Search code examples
pythonmypypython-typingmetaclass

Python typing for a metaclass Singleton


I have a Python (3.8) metaclass for a singleton as seen here

I've tried to add typings like so:

from typing import Dict, Any, TypeVar, Type

_T = TypeVar("_T", bound="Singleton")


class Singleton(type):
    _instances: Dict[Any, _T] = {}

    def __call__(cls: Type[_T], *args: Any, **kwargs: Any) -> _T:
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

In the line:

_instances: Dict[Any, _T] = {}

MyPy warns:

Mypy: Type variable "utils.singleton._T" is unbound

I've tried different iterations of this to no avail; it's very hard for me to figure out how to type this dict.

Further, the line:

def __call__(cls: Type[_T], *args: Any, **kwargs: Any) -> _T:

Produces:

Mypy: The erased type of self "Type[golf_ml.utils.singleton.Singleton]" is not a supertype of its class "golf_ml.utils.singleton.Singleton"

How could I correctly type this?


Solution

  • This should work:

    from __future__ import annotations
    
    import typing as t
    
    
    _T = t.TypeVar("_T")
    
    
    class Singleton(type, t.Generic[_T]):
    
        _instances: dict[Singleton[_T], _T] = {}
    
        def __call__(cls, *args: t.Any, **kwargs: t.Any) -> _T:
            if cls not in cls._instances:
                cls._instances[cls] = super().__call__(*args, **kwargs)
            return cls._instances[cls]
    

    Rough explanations:

    1. _T = TypeVar("_T", bound="Singleton") is not correct - Singleton is type(type(obj)) where obj: _T = Singleton.__call__(...). In proper usage, the argument to bound= can only be type(obj) or some union typing construct, not type(type(obj).
    2. Type variable "_T" is unbound indicates that you need to make Singleton generic with respect to _T to bind _T.
    3. The erased type of self ... error message is telling you that you've "erased" the type checker's inferred type* of cls. Technically speaking, __call__ is the same on a metaclass as any other instance method - the first argument is simply the type of the owning class. In the current static typing system, however, a metaclass's instance method's first argument is not in concordance with type[...].

    *The inferred type is explicitly Self in the following:

    import typing as t
    
    Self = t.TypeVar("Self", bound="A")
    
    class A:
        def instancemethod(arg: Self) -> None:
            pass
        @classmethod
        def classmethod_(arg: type[Self]) -> None:
            pass
    

    Runtime is important too, so the final sanity check is to make sure you've actually implemented a singleton using this metaclass:

    class Logger(metaclass=Singleton):
        pass
    
    >>> print(Logger() is Logger())
    True