I have this small piece of code which is supposed to be a decorator/descriptor that passes the outer instance to the inner class as the first positional argument when it is accessed from an instance. The runtime behaviour is of no concern; I'm asking specifically about the behaviour of the two type checkers, mypy (playground) and pyright (playground).
from __future__ import annotationsfrom typing import TypeVar, Generic, ParamSpec, Concatenate, overload, castfrom collections.abc import Callablefrom functools import partialO = TypeVar('O')I = TypeVar('I')P = ParamSpec('P')class InnerClass(Generic[O, I, P]): _cls: Callable[Concatenate[O, P], I] def __init__(self, inner_class: Callable[Concatenate[O, P], I], /) -> None: self._cls = inner_class @overload def __get__(self, instance: None, owner: type[O] | None = None) -> Callable[Concatenate[O, P], I]: ... @overload def __get__(self, instance: O, owner: type[O] | None = None) -> Callable[P, I]: ... def __get__( self, instance: O | None, owner: type[O] | None = None ) -> Callable[Concatenate[O, P], I] | Callable[P, I]: if instance is None: return self._cls return cast(Callable[P, I], partial(self._cls, instance))
Examples
The first three examples make use of inner classes whose expected first argument is not an instance of the corresponding outer classes. I expected that the type checkers would fail right when the decorator is used.
For the first and second, it appears that pyright got to the __get__
method's @overload
-ed signatures and eventually failed. mypy was more lenient/carefree and resolved to the original signature in both accesses, from class and from instance.
class _1: @InnerClass # mypy => fine # pyright => fine # Expected => error class FirstArgIsNotOuter: def __init__(self, not_a: int, foo: str, *, bar: float) -> None: ...reveal_type(_1.FirstArgIsNotOuter)reveal_type(_1().FirstArgIsNotOuter)# mypy => (not_a: int, foo: str, *, bar: float) -> _1.FirstArgIsNotOuter# pyright => error:# Cannot access member "FirstArgIsNotOuter" for type "_1"/"type[_1]"# Failed to call method "__get__" for descriptor class "InnerClass[int, FirstArgIsNotOuter, (foo: str, *, bar: float)]"
class _2: @InnerClass # mypy => fine # pyright => fine class FirstArgIsNotOuterAndPositionalOnly: def __init__(self, not_a: int, /, foo: str, *, bar: float) -> None: ...reveal_type(_2.FirstArgIsNotOuterAndPositionalOnly)reveal_type(_2().FirstArgIsNotOuterAndPositionalOnly)# mypy => (int, foo: str, *, bar: float) -> _2.FirstArgIsNotOuterAndPositionalOnly# pyright => error:# Cannot access member "FirstArgIsNotOuterAndPositionalOnly" for type "_2"/"type[_2]"# Failed to call method "__get__" for descriptor class "InnerClass[int, FirstArgIsNotOuterAndPositionalOnly, (foo: str, *, bar: float)]"
The third example, with keyword-only arguments, shows that the type checkers did determine that the signatures conflict with Concatenate[]
which cannot represent keyword arguments:
class _3: @InnerClass # mypy => Argument 1 to "InnerClass" has incompatible type "type[FirstArgIsNotOuterAndKeywordOnly]"; expected "Callable[[Never, NamedArg(int, 'not_a'), NamedArg(str, 'foo'), NamedArg(float, 'bar')], FirstArgIsNotOuterAndKeywordOnly]" # pyright => error: # Argument of type "type[FirstArgIsNotOuterAndKeywordOnly]" cannot be assigned to parameter "inner_class" of type "(O@InnerClass, **P@InnerClass) -> I@InnerClass" in function "__init__" # Type "type[FirstArgIsNotOuterAndKeywordOnly]" cannot be assigned to type "(O@InnerClass, **P@InnerClass) -> I@InnerClass" class FirstArgIsNotOuterAndKeywordOnly: def __init__(self, *, not_a: int, foo: str, bar: float) -> None: ...
On the other hand, the results for reveal_type()
calls were diverged: at first pyright correctly determined that there is an error for the class access, but somehow it let the instance access passed.
reveal_type(_3.FirstArgIsNotOuterAndKeywordOnly)reveal_type(_3().FirstArgIsNotOuterAndKeywordOnly)# mypy => (*, not_a: int, foo: str, bar: float) -> _3.FirstArgIsNotOuterAndKeywordOnly# pyright => error:# Type of "FirstArgIsNotOuterAndKeywordOnly" is partially unknown# (Unknown, foo: str, bar: float) -> FirstArgIsNotOuterAndKeywordOnly# => no error:# (foo: str, bar: float) -> FirstArgIsNotOuterAndKeywordOnly
The last three examples is pretty much the same as those above, except for that the expected first arguments have the correct, outer type. mypy still failed to detect the correct signatures, but pyright's were good (except for the last example, where it made the same mistake as in example 3).
class _4: @InnerClass # mypy + pyright => fine class FirstArgIsOuter: def __init__(self, c: _4, foo: str, *, bar: float) -> None: ...reveal_type(_4.FirstArgIsOuter)reveal_type(_4().FirstArgIsOuter)# mypy => (c: _4, foo: str, *, bar: float) -> _4.FirstArgIsOuter# pyright => (_4, foo: str, *, bar: float) -> FirstArgIsOuter# => (foo: str, *, bar: float) -> FirstArgIsOuter
class _5: @InnerClass class FirstArgIsOuterAndPositionalOnly: def __init__(self, c: _5, /, foo: str, *, bar: float) -> None: ...reveal_type(_5.FirstArgIsOuterAndPositionalOnly)reveal_type(_5().FirstArgIsOuterAndPositionalOnly)# mypy => (_5, foo: str, *, bar: float) -> _5.FirstArgIsOuterAndPositionalOnly# pyright => (_5, foo: str, *, bar: float) -> FirstArgIsOuterAndPositionalOnly# => (foo: str, *, bar: float) -> FirstArgIsOuterAndPositionalOnly
class _6: @InnerClass # mypy + pyright => error class FirstArgIsOuterAndKeywordOnly: def __init__(self, *, c: _6, foo: str, bar: float) -> None: ...reveal_type(_6.FirstArgIsOuterAndKeywordOnly)reveal_type(_6().FirstArgIsOuterAndKeywordOnly)# mypy => (*, c: _6, foo: str, bar: float) -> _6.FirstArgIsOuterAndKeywordOnly# pyright => error:# Type of "FirstArgIsOuterAndKeywordOnly" is partially unknown# (Unknown, foo: str, bar: float) -> FirstArgIsOuterAndKeywordOnly# => no error:# (foo: str, bar: float) -> FirstArgIsOuterAndKeywordOnly
The question
Is there a way to improve this piece of code, type-hinting-wise? I would like the type checkers to fail early, as stated. If that is not possible, then at the very least I want better results from mypy. An explanation for the mistake of pyright in examples 3 and 6 would be a plus, but not necessary.
I'm using Python 3.12 (with old-style generic class syntax since mypy has yet to support PEP 695), with --strict
flags for both mypy 1.8.0 and pyright 1.1.344, if that matters.