Search code examples
pythonpycharmtype-hintingsupertypechecking

type hinting super().__init__ call with concrete types


Below are two class trees. Each with a base class eating the base class of its neighbor class.

Once I derivate a concrete class, I also use the neighbor type as a dependency. The code works as expected, however, the typechecker complains. (I'm using pycharm)

I think the downcast from concrete to base type is okay, as the base class will always only work with the downcasted type and is not relying on the concrecte aspects of the concrete type.

from typing import reveal_type


class BaseView:
    ...


class BaseController:
    def __init__(self, view: BaseView):
        self.view = view


class ConcreteView(BaseView):
    attr = "42"


class ConcreteController(BaseController):
    def __init__(self, view: ConcreteView):
        super().__init__(view)

    def meth(self):
        # gives an
        # Unresolved attribute reference 'attr' for class 'BaseView'
        # warning
        print(self.view.attr)
        # okay
        print(reveal_type(self.view))


if __name__ == '__main__':
    concrete_view = ConcreteView()
    controller = ConcreteController(concrete_view)
    controller.meth()

A half solution is to use factories that convince the typchecker However, the linter still is not able to resolve. Additional the factory pattern is not what I like and it makes things complicated just to satisfy the typchecker

from typing import reveal_type, Callable


class BaseView:
    ...


class BaseController:
    def __init__(self, view_factory: Callable):
        self.view = view_factory()


class ConcreteView(BaseView):
    attr = "42"

    def __init__(self):
        self.b = 10


class ConcreteController(BaseController):
    def __init__(self, view_factory: Callable[[], ConcreteView]):
        super().__init__(view_factory)

    def meth(self):
        # warning gone but lint still not working
        print(self.view.attr)

        print(self.view.b)
        # okay
        print(reveal_type(self.view))


if __name__ == '__main__':
    controller = ConcreteController(lambda: ConcreteView())
    controller.meth()

Additionally, I already played around with generic.

from typing import reveal_type, Generic, TypeVar

T = TypeVar("")


class BaseView(Generic[T]):
    ...


class BaseController(Generic[T]):
    def __init__(self, view: T):
        self.view = view


class ConcreteView(BaseView):
    attr = "42"


class ConcreteController(BaseController):
    def __init__(self, view: BaseView[ConcreteView]):
        super().__init__(view)

    def meth(self):
        # no warning typchecker
        print(self.view.attr)
        # but no lint
        self.view
        # okay
        print(reveal_type(self.view))


if __name__ == '__main__':
    concrete_view = ConcreteView()
    controller = ConcreteController(concrete_view)
    controller.meth()

Further inheritance

Based on the answer of mr_mo To keep further inheritance on sub concrete class generic, I need to add a generic to the controller

from typing import Generic, TypeVar

T = TypeVar("T")


class BaseView:
    ...


class BaseController(Generic[T]):
    def __init__(self, view: T) -> None:
        self.view = view


class ConcreteView(BaseView):
    attr = "Concrete View"


class MoreConcreteView(ConcreteView):
    attr = "More concrete view"
    foo = "another attribute of more concrete view"


class ConcreteController(Generic[T], BaseController[T | ConcreteView]):
    def __init__(self, view: ConcreteView) -> None:
        super().__init__(view)

    def meth(self) -> None:
        print(self.view.attr)
        print(type(self.view))


class MoreConcreteController(Generic[T], ConcreteController[T | MoreConcreteView]):
    def __init__(self, view):
        super().__init__(view)

    def meth(self):
        print(self.view.foo)
        print(self.view.attr)
        print(type(self.view))


if __name__ == "__main__":
    concrete_view = ConcreteView()
    controller = ConcreteController(concrete_view)
    controller.meth()
    mcv = MoreConcreteView()
    mcc = MoreConcreteController(mcv)
    mcc.meth()

looks like I still have not understood the conecpt of generics in python...


Solution

  • Your last attempt is close. However, you weren't really using the generic to type the view in the concrete class.

    Solution

    from typing import Generic, TypeVar
    
    T = TypeVar("T")
    
    
    class BaseView:
        ...
    
    
    class BaseController(Generic[T]):
        def __init__(self, view: T) -> None:
            self.view = view
    
    
    class ConcreteView(BaseView):
        attr = "42"
    
    
    class ConcreteController(BaseController[ConcreteView]):
        def __init__(self, view: ConcreteView) -> None:
            super().__init__(view)
    
        def meth(self) -> None:
            # no warning typchecker
            # now lint is working!
            print(self.view.attr)
            self.view
            # okay
            print(type(self.view))
    
    
    if __name__ == "__main__":
        concrete_view = ConcreteView()
        controller = ConcreteController(concrete_view)
        controller.meth()
    
    

    Notes

    • Full type hinting includes return types for all methods
    • Generic is a template you can provide the type in the concrete
    • In concrete controller class, you implement the base controller with a concrete view. Thus, you can use the type in the BaseController template.
    • In heritage (subclasses), if you type an input argument as the ancestor (base) of the class, you can still pass the class.
    • In your example, the linting was the thing to address. Checked in PyCharm (builtin) and Pyre. Both supply linting correctly (having the attr attribute).

    Edit: generic subclass of a generic class

    This is added due to the addition to the original question - how do I keep advanced implementations of generic classes, generic themselves. More precisely, I'll try to TL;DR how generics are expected to be used according to official documentations in common linters (e.g. MyPy).

    Generic types have one or more type parameters, which can be arbitrary types. For example, dict[int, str] has the type parameters int and str, and list[int] has a type parameter int.

    • Generics are a way to pass create a type A that uses another type - B or C, without knowing which one in advance.
    • Subclasses inherit their ancestor's attributes, method and types, etc.
    • In the first example, BaseController is a Generic, that takes parameter type T and can use it across the class.
    • T is a TypeVar, which means it's just a type. It can be bounded to a type to match any of it's subclasses or be exactly one of listed types (see https://docs.python.org/3/library/typing.html#typing.TypeVar)
    • The ConcreteController subclass of BaseController, is using [ConcreteView] to tell the ancestor which type was eventually used.
    • The type ConcreteView is checked against T and is a legit type (in this case, T can be any type, since nothing else is specified).

    Additionally to the typing logic, we should consider how subclasses are being used. Since python basically allows you to do almost anything, there is not obligation of typing correctly. Running strict type checkers e.g. MyPy or Pyre can help with that.

    Consider the following statements, assuming these are our rules of how things should be

    • A base class implies to the subclass what should be implemented (see abc about abstraction)
    • A subclass uses the base class as a template and implements only what it should.
    • There can be multiple implementation to the same base class
    • If a class can handle a parent as an argument, I can handle the child, but not vise versa.
    • This implies that the class can also handle the "grand child" (applying this rule twice).

    Going with that spirit, one needs to decide if the base class can be a generic class, that can handle objects of type BaseView and all of it's childs. That is BaseController.

    Then, ConcreteController can be an implementation that can handle ConcreteView and all of his childs, or even the parent. That really depends on what you are trying to achieve. For that to be generic, you need to make the ConcreteController generic again.

    This is implemented in the following example:

    from typing import Generic, TypeVar
    
    
    class BaseView:
        ...
    
    
    # We want to create a class typed with any subclass of BaseView
    T = TypeVar("T", bound=BaseView)
    
    
    class BaseController(Generic[T]):  # this class is generic
        def __init__(self, view: T) -> None:  # on init, gets the type that was used by the subclass
            self.view = view
    
    
    class ConcreteView(BaseView):  # here we want a new class (and type) - a concrete view, with attr
        attr = "42"
    
    
    # again, we want a concrete implementation, that is generic to any subtype of this class
    TC = TypeVar("TC", bound=ConcreteView)
    
    
    # so we implement based controller, with a type of and subclass of concrete view
    # but this class is also generic
    class ConcreteController(Generic[TC], BaseController[TC]):
        def __init__(self, view: TC) -> None:
            super().__init__(view)
    
        def meth(self) -> None:
            # no warning typchecker
            # lint is working now!
            print(self.view.attr)
            print(self.view)
            # okay
            print(type(self.view))
    
    
    class MoreConcreteView(ConcreteView):
        attr = "More concrete view"
        foo = "another attribute of more concrete view"
    
    
    # finally, this is the most concrete implementation.
    class MoreConcreteController(ConcreteController[MoreConcreteView]):
        def __init__(self, view: MoreConcreteView) -> None:
            super().__init__(view)
    
        def meth(self) -> None:
            # no warning typchecker
            # lint is working now!
            print(self.view.attr)  # linter finds attr
            print(self.view.foo)  # linter finds foo
            print(type(self.view))
    
    
    if __name__ == "__main__":
        concrete_view = ConcreteView()
        controller = ConcreteController(concrete_view)
        controller.meth()