Search code examples
pythonpycharmmypypython-typingmetaclass

PyCharm gives me a type warning about my metaclass; mypy disagrees


I was trying to write a metaclass named Singleton, that, of course, implement the singleton design pattern:

class Singleton(type):
    
  def __new__(cls, name, bases = None, attrs = None):
    if bases is None:
      bases = ()
        
    if attrs is None:
      attrs = {}
        
    new_class = type.__new__(cls, name, bases, attrs)
    new_class._instance = None
    return new_class
    
  def __call__(cls, *args, **kwargs):
    if cls._instance is None:
      cls._instance = cls.__new__(cls, *args, **kwargs)
      cls.__init__(cls._instance, *args, **kwargs)
        
    return cls._instance

This seems to work correctly:

class Foo(metaclass = Singleton):
  pass

foo1 = Foo()
foo2 = Foo()

print(foo1 is foo2)  # True

However, PyCharm gave me this warning for cls._instance = cls.__new__(cls, *args, **kwargs):

Expected type 'Type[Singleton]', got 'Singleton' instead

...and this for cls.__init__(cls._instance, *args, **kwargs):

Expected type 'str', got 'Singleton' instead

I ran mypy on the same file, and here's what it said:

# mypy test.py
Success: no issues found in 1 source file

I'm using Python 3.11, PyCharm 2023.1.1 and mypy 1.3.0 if that makes a difference.

So what exactly is the problem here? Am I doing this correctly? Is this a bug with PyCharm, with mypy or something else? If the error is on me, how can I fix it?


Solution

  • Since this is an XY Problem, I'll start with the solution to X. The answers for Y are further down.


    Solution

    There is no need for manually calling cls.__new__ and then cls.__init__ in Singleton.__call__. You can just call super().__call__ instead, just like @Grismar did in his answer.

    There is also no need for a custom Singleton.__new__ method at all, if all you want to do is set up the singleton pattern in a type safe manner.

    And to have a _instance = None fallback in your classes, you can just define and assign that attribute on the meta class.

    Here is the minimal setup required:

    from __future__ import annotations
    from typing import Any
    
    
    class Singleton(type):
        _instance: Singleton | None = None
    
        def __call__(cls, *args: Any, **kwargs: Any) -> Singleton:
            if cls._instance is None:
                cls._instance = super().__call__(*args, **kwargs)
            return cls._instance
    
    
    class Foo(metaclass=Singleton):
        pass
    

    This passes mypy --strict and causes no PyCharm warnings. You said you wanted _instance to be saved in each class not in the meta class. This is the case here. Try the following:

    foo1 = Foo()
    foo2 = Foo()
    
    print(foo1 is foo2)           # True
    print(foo1 is Foo._instance)  # True
    print(Singleton._instance)    # None
    

    If you do want a custom meta class __new__ method, it requires a lot more boilerplate to set up in a type safe manner, due to the overloaded type constructor. But here is a template that should work in all situations:

    from __future__ import annotations
    from typing import Any, TypeVar, overload
    
    T = TypeVar("T", bound=type)
    
    
    class Singleton(type):
        _instance: Singleton | None = None
    
        @overload
        def __new__(mcs, o: object, /) -> type: ...
    
        @overload
        def __new__(
            mcs: type[T],
            name: str,
            bases: tuple[type, ...],
            namespace: dict[str, Any],
            /,
            **kwargs: Any,
        ) -> T: ...
    
        def __new__(
            mcs,
            name: Any,
            bases: Any = (),
            namespace: Any = None,
            /,
            **kwargs: Any,
        ) -> type:
            if namespace is None:
                namespace = {}
            # do other things here...
            return type.__new__(mcs, name, bases, namespace, **kwargs)
    
        def __call__(cls, *args: Any, **kwargs: Any) -> Singleton:
            if cls._instance is None:
                cls._instance = super().__call__(*args, **kwargs)
            return cls._instance
    

    The __new__ overloads closely resemble those in the typeshed stubs.

    But again, that is not necessary for the singleton-via-metaclass pattern.

    Digging deeper into your type inference questions sent me down a rabbit hole, so the explanations got a little longer. But I thought it more useful to present the solution to your actual problem up front (because it circumvents the errors). So if you are interested in the explanations, keep reading.


    Why does cls.__new__ expect type[Singleton]?

    I get the same error from Mypy for your code:

    Argument 1 to "__new__" of "Singleton" has incompatible type "Singleton"; expected "Type[Singleton]"
    

    Recall that the __call__ method is an instance method. In the context of Singleton.__call__, the type of the first argument (in this case named cls) is inferred as Singleton.

    Since you defined your own Singleton.__new__ method, its (implicit) signature will be reflected in cls.__new__. You did not annotate Singleton.__new__, but type checkers typically fall back to "standard" inferences for special cases like the first parameter of a method.

    __new__ is a static method that takes a class as the first argument and returns an instance of it. The first argument to Singleton.__new__ is therefore expected to be the type type[Singleton], not an instance of Singleton.

    So from the point of view of the type checker, by calling cls.__new__(cls, ...) you are passing an instance of Singleton as an argument, where a the type Singleton itself (or a subtype) is expected.


    Side note:

    This distinction can be quite confusing, which is one of the reasons why it is best practice to name the first parameter to __new__ differently than the first parameter to a "normal" method.

    In regular classes (not inheriting from type) the first parameter to normal methods should be called self, while the first parameter to __new__ should be called cls.

    In meta classes however, the conventions are not as ubiquitous, but common sense suggests that the normal methods' first parameter should be called cls, while the first parameter to __new__ should be called mcs (or mcls or something like that). It is just very useful to highlight the distinction in the nature of that first argument. But these are all conventions of course and the interpreter doesn't care either way.


    Whether or not this inference of cls.__new__ as Singleton.__new__ is justified or sensible is debatable.

    Since cls in that context will always be an instance of the surrounding (meta) class, I would argue that it does not make sense to expect cls.__new__ to ever resolve to the __new__ method of said (meta) class.

    In fact, unless the class cls itself defines a custom __new__ method, it will fall back to object.__new__, not to Singleton.__new__:

    class Singleton(type):
        def __call__(cls, *args, **kwargs):
            print(cls.__new__)
            print(cls.__new__ is object.__new__)
            print(cls.__new__ is Singleton.__new__)
    
    class Foo(metaclass=Singleton):
        pass
    
    foo1 = Foo()
    

    Output:

    <built-in method __new__ of type object at 0x...>
    True
    False
    

    object.__new__ indeed does accept Singleton as its first argument because it is a class and the return type would be an instance of it. So there is nothing wrong or unsafe about the way you called cls.__new__ as far as I can tell.

    We can see the wrong type inference by the type checker even more clearly, if we add a custom __new__ to Singleton and run it through the type checker:

    from __future__ import annotations
    from typing import Any
    
    
    class Singleton(type):
        def __new__(mcs, name: str, bases: Any = None, attrs: Any = None) -> Singleton:
            return type.__new__(mcs, name, bases, attrs)
    
        def __call__(cls, *args: Any, **kwargs: Any) -> Any:
            reveal_type(cls.__new__)
            ...
    

    Mypy wrongly infers the type as follows:

    Revealed type is "def (mcs: Type[Singleton], name: builtins.str, bases: Any =, attrs: Any =) -> Singleton"
    

    So it clearly expects cls.__new__ to be Singleton.__new__, even though it is actually object.__new__.

    As far as I understand it, the discrepancy between the actual method resolution and the one inferred by the type checker is due to the special-cased behavior for the __new__ method. It may also just have to do with meta classes being poorly supported by type checkers. But maybe someone more knowledgeable can clear this up. (Or I'll consult the issue tracker.)


    That pesky __init__ call

    The PyCharm message is nonsense of course. The problem seems to come down to the same faulty inference of cls.__init__ as type.__init__, as opposed to object.__init__.

    Mypy has an entirely different problem, complaining about the explicit usage of __init__ on an instance with the following error:

    Accessing "__init__" on an instance is unsound, since instance.__init__ could be from an incompatible subclass
    

    The __init__ method is intentionally excluded from LSP conformity requirements by mypy, which means that explicitly calling it is technically unsafe.

    Not much else to say. Avoid that call, unless you are sure the overrides all the way up the MRO chain for __init__ are type safe; then use # type: ignore[misc].


    So to summarize, I believe these two warnings/errors are both false positives.