I have a class structure that looks something like this:
class Base:
class Nested:
pass
def __init__(self):
self.nestedInstance = self.Nested()
where subclasses of Base each have their own Nested class extending the original, like this:
class Sub(Base):
class Nested(Base.Nested):
pass
This works perfectly, and instances of Sub have their nestedInstance attributes set to instances of Sub.Nested.
However, in my IDE the nestedInstance attribute is always treated as an instance of Base.Nested, not the inherited Sub.Nested. How can I make it so that nestedInstance will be inferred to be Sub.Nested rather than Base.Nested? (Without having to add extra code to every subclass; preferably, this would all be done in Base.)
(By the way, I'm aware that this is an odd structure, and I can go into detail about why I chose it if necessary, but I think it's pretty elegant for my situation, and I'm hoping there's a solution to this problem.)
I don't agree with the statement that you were trying to violate the Liskov substitution principle. You were merely looking for a way to let a static type checker infer the type of nested_instance for classes inheriting from Base to be their respective Nested class. Obviously this wasn't possible with the code you had; otherwise there would be no question.
There actually is a way to minimize repetition and accomplish what you want.
You can define your Base class as generic over a type variable with the upper bound of Base.Nested. When you define Sub as a subclass Base, you provide a reference to Sub.Nested as the concrete type argument. Here is the setup:
from typing import Generic, TypeVar, cast
N = TypeVar("N", bound="Base.Nested")
class Base(Generic[N]):
nested_instance: N
class Nested:
pass
def __init__(self) -> None:
self.nested_instance = cast(N, self.Nested())
class Sub(Base["Sub.Nested"]):
class Nested(Base.Nested):
pass
This is actually all you need. For more info about generics I recommend the relevant section of PEP 484. A few things to note:
bound?If we were to just use N = TypeVar("N"), the type checker would have no problem if we wanted do define a subclass like this:
class Broken(Base[int]):
class Nested(Base.Nested):
pass
But this would be a problem since now the nested_instance attribute would be expected to be of the type int, which is not what we want. That upper bound on N will prevent this causing mypy to complain:
error: Type argument "int" of "Base" must be a subtype of "Nested" [type-var]
nested_instance?The whole point of making a class generic is to bind some type variable (like N) to it and then indicate that some associated type inside that class is in fact N (or even multiple). We essentially tell the type checker to expect nested_instance to always be of the type N, which must be provided, whenever Base is used to annotate something.
However, now the type checker will always complain, if we ever omit the type argument for Base and tried an annotation like this: x: Base. Again, mypy would tell us:
error: Missing type parameters for generic type "Base" [type-arg]
This may be the only "downside" to the use of generics in this fashion.
cast?The problem is that inside Base, the nested_instance attribute is declared as a generic type N, whereas in Base.__init__, we assign an instance of the specific type Base.Nested. Even though it may seem like this should work, it does not. Omitting the cast call results in the following mypy error:
error: Incompatible types in assignment (expression has type "Nested", variable has type "N") [assignment]
Yes, and importing __future__.annotations does not help here. I am not entirely sure why that is, but I believe in case of the Base[...] usage the reason is that __class_getitem__ is actually called and you cannot provide Sub.Nested to it because it is not even defined at that point.
from typing import Generic, TypeVar, cast
N = TypeVar("N", bound="Base.Nested")
class Base(Generic[N]):
nested_instance: N
class Nested:
pass
def __init__(self) -> None:
self.nested_instance = cast(N, self.Nested())
class Sub(Base["Sub.Nested"]):
class Nested(Base.Nested):
pass
def get_nested(obj: Base[N]) -> N:
return obj.nested_instance
def assert_instance_of_nested(nested_obj: N, cls: type[Base[N]]) -> None:
assert isinstance(nested_obj, cls.Nested)
if __name__ == '__main__':
sub = Sub()
nested = get_nested(sub)
assert_instance_of_nested(nested, Sub)
This script works "as is" and mypy is perfectly happy with it.
The two functions are just for demonstration purposes, so that you see how you could leverage the generic Base.
To assure you even more, you can for example add reveal_type(sub.nested_instance) at the bottom and mypy will tell you:
note: Revealed type is "[...].Sub.Nested"
This is what we wanted.
If we declare a new subclass
class AnotherSub(Base["AnotherSub.Nested"]):
class Nested(Base.Nested):
pass
and try this
a: AnotherSub.Nested
a = Sub().nested_instance
we are again correctly reprimanded by mypy:
error: Incompatible types in assignment (expression has type "[...].Sub.Nested", variable has type "[...].AnotherSub.Nested") [assignment]
Hope this helps.
To be clear, you can still inherit from Base without specifying the type argument. This has no runtime implications either way. It's just that a strict type checker will complain about it because it is generic, just as it would complain if you annotate something with list without specifying the type argument. (Yes, list is generic.)
Also, whether or not your IDE actually infers this correctly of course depends on how consistent their internal type checker is with the typing rules in Python. PyCharm for example seems to deal with this setup as expected.