Tests for attribute access on various kinds of types.
Variables only declared and/or bound in __init__ are pure instance variables. They cannot be
accessed on the class itself.
class C:
def __init__(self, param: int | None, flag: bool = False) -> None:
value = 1 if flag else "a"
self.inferred_from_value = value
self.inferred_from_other_attribute = self.inferred_from_value
self.inferred_from_param = param
self.declared_only: bytes
self.declared_and_bound: bool = True
if flag:
self.possibly_undeclared_unbound: str = "possibly set in __init__"
c_instance = C(1)
reveal_type(c_instance.inferred_from_value) # revealed: int | str
reveal_type(c_instance.inferred_from_other_attribute) # revealed: int | str
reveal_type(c_instance.inferred_from_param) # revealed: int | None
reveal_type(c_instance.declared_only) # revealed: bytes
reveal_type(c_instance.declared_and_bound) # revealed: bool
reveal_type(c_instance.possibly_undeclared_unbound) # revealed: str
# This assignment is fine, as we infer `int | str` for `inferred_from_value`.
c_instance.inferred_from_value = "value set on instance"
# This assignment is also fine:
c_instance.declared_and_bound = False
# error: [invalid-assignment] "Object of type `Literal["incompatible"]` is not assignable to attribute `declared_and_bound` of type `bool`"
c_instance.declared_and_bound = "incompatible"
# mypy shows no error here, but pyright raises "reportAttributeAccessIssue"
# error: [unresolved-attribute] "Attribute `inferred_from_value` can only be accessed on instances, not on the class object `<class 'C'>` itself."
reveal_type(C.inferred_from_value) # revealed: Unknown
# error: [unresolved-attribute]
reveal_type(C.declared_and_bound) # revealed: Unknown
# mypy shows no error here, but pyright raises "reportAttributeAccessIssue"
# error: [invalid-attribute-access] "Cannot assign to instance attribute `inferred_from_value` from the class object `<class 'C'>`"
C.inferred_from_value = "overwritten on class"
# This assignment is fine:
c_instance.declared_and_bound = False
# Strictly speaking, inferring this as `Literal[False]` rather than `bool` is unsound in general
# (we don't know what else happened to `c_instance` between the assignment and the use here),
# but mypy and pyright support this.
reveal_type(c_instance.declared_and_bound) # revealed: Literal[False]The same rule applies even if the variable is declared (not bound!) in the class body: it is still a pure instance variable.
class C:
declared_and_bound: str | None
def __init__(self) -> None:
self.declared_and_bound = "value set in __init__"
c_instance = C()
reveal_type(c_instance.declared_and_bound) # revealed: str | None
reveal_type(C.declared_and_bound) # revealed: str | None
C.declared_and_bound = "overwritten on class"
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `declared_and_bound` of type `str | None`"
c_instance.declared_and_bound = 1Assignments to ordinary annotated instance attributes should remain valid even when the annotation
is Never/NoReturn; they should not be mistaken for non-returning descriptors.
from typing import NoReturn
class ClassA:
x: NoReturn
y: list[NoReturn]
def __init__(self, x: NoReturn, y: list[NoReturn]) -> None:
self.x = x
self.y = yIf a variable is declared in the class body but not bound anywhere, we consider it to be accessible
on instances and the class itself. It would be more consistent to treat this as a pure instance
variable (and require the attribute to be annotated with ClassVar if it should be accessible on
the class as well), but other type checkers allow this as well. This is also heavily relied on in
the Python ecosystem:
class C:
only_declared: str
c_instance = C()
reveal_type(c_instance.only_declared) # revealed: str
reveal_type(C.only_declared) # revealed: str
C.only_declared = "overwritten on class"class C:
only_declared_in_body: str | None
declared_in_body_and_init: str | None
declared_in_body_defined_in_init: str | None
bound_in_body_declared_in_init = "a"
bound_in_body_and_init = 1
def __init__(self, flag) -> None:
self.only_declared_in_init: str | None
self.declared_in_body_and_init: str | None = None
self.declared_in_body_defined_in_init = "a"
self.bound_in_body_declared_in_init: str | None
if flag:
self.bound_in_body_and_init = "a"
c_instance = C(True)
reveal_type(c_instance.only_declared_in_body) # revealed: str | None
reveal_type(c_instance.only_declared_in_init) # revealed: str | None
reveal_type(c_instance.declared_in_body_and_init) # revealed: str | None
reveal_type(c_instance.declared_in_body_defined_in_init) # revealed: str | None
reveal_type(c_instance.bound_in_body_declared_in_init) # revealed: str | None
reveal_type(c_instance.bound_in_body_and_init) # revealed: int | str
c_instance.only_declared_in_body = b"invalid" # error: [invalid-assignment]
c_instance.only_declared_in_init = b"invalid" # error: [invalid-assignment]
c_instance.declared_in_body_and_init = b"invalid" # error: [invalid-assignment]
c_instance.declared_in_body_defined_in_init = b"invalid" # error: [invalid-assignment]
c_instance.bound_in_body_declared_in_init = b"invalid" # error: [invalid-assignment]
c_instance.bound_in_body_and_init = b"invalid" # error: [invalid-assignment]We also recognize pure instance variables if they are defined in a method that is not __init__.
class C:
def __init__(self, param: int | None, flag: bool = False) -> None:
self.initialize(param, flag)
def initialize(self, param: int | None, flag: bool) -> None:
value = 1 if flag else "a"
self.inferred_from_value = value
self.inferred_from_other_attribute = self.inferred_from_value
self.inferred_from_param = param
self.declared_only: bytes
self.declared_and_bound: bool = True
c_instance = C(1)
reveal_type(c_instance.inferred_from_value) # revealed: int | str
reveal_type(c_instance.inferred_from_other_attribute) # revealed: int | str
reveal_type(c_instance.inferred_from_param) # revealed: int | None
reveal_type(c_instance.declared_only) # revealed: bytes
reveal_type(c_instance.declared_and_bound) # revealed: bool
# error: [unresolved-attribute] "Attribute `inferred_from_value` can only be accessed on instances, not on the class object `<class 'C'>` itself."
reveal_type(C.inferred_from_value) # revealed: Unknown
# error: [invalid-attribute-access] "Cannot assign to instance attribute `inferred_from_value` from the class object `<class 'C'>`"
C.inferred_from_value = "overwritten on class"If we see multiple un-annotated assignments to a single attribute (self.x below), we build the
union of all inferred types. If we see multiple conflicting declarations of the same attribute, that
should be an error.
def get_int() -> int:
return 0
def get_str() -> str:
return "a"
class C:
z: int
def __init__(self) -> None:
self.x = get_int()
self.y: int = 1
def other_method(self):
self.x = get_str()
# TODO: this redeclaration should be an error
self.y: str = "a"
# TODO: this redeclaration should be an error
self.z: str = "a"
c_instance = C()
reveal_type(c_instance.x) # revealed: int | str
reveal_type(c_instance.y) # revealed: int
reveal_type(c_instance.z) # revealed: intclass C:
def __init__(self, flag: bool = False) -> None:
if flag:
self.x = None
else:
self.x = 1
reveal_type(C().x) # revealed: None | intclass C:
def __init__(self) -> None:
self.a = self.b = 1
c_instance = C()
reveal_type(c_instance.a) # revealed: int
reveal_type(c_instance.b) # revealed: intclass Weird:
def __iadd__(self, other: None) -> str:
return "a"
class C:
def __init__(self) -> None:
self.w = Weird()
self.w += None
# TODO: Mypy and pyright do not support this, but it would be great if we could
# infer `str` here (`Weird` is not a possible type for the `w` attribute).
reveal_type(C().w) # revealed: WeirdAugmented assignments to nested attributes (e.g., self.inner.value += ...) should work correctly
after narrowing away None from the intermediate attribute. This is a regression test for a case
where the combination of narrowing and augmented assignment on a nested attribute caused a false
positive.
from unknown_module import unknown # error: [unresolved-import]
class Inner:
value: int = 0
class Outer:
def __init__(self) -> None:
self.inner = None
self.load()
def load(self) -> None:
self.inner = Inner() if unknown else unknown
def update(self) -> None:
if self.inner is None:
return
self.inner.value += unknowndef returns_tuple() -> tuple[int, str]:
return (1, "a")
class C:
a1, b1 = (1, "a")
c1, d1 = returns_tuple()
def __init__(self) -> None:
self.a2, self.b2 = (1, "a")
self.c2, self.d2 = returns_tuple()
c_instance = C()
reveal_type(c_instance.a1) # revealed: int
reveal_type(c_instance.b1) # revealed: str
reveal_type(c_instance.c1) # revealed: int
reveal_type(c_instance.d1) # revealed: str
reveal_type(c_instance.a2) # revealed: int
reveal_type(c_instance.b2) # revealed: str
reveal_type(c_instance.c2) # revealed: int
reveal_type(c_instance.d2) # revealed: strclass C:
def __init__(self) -> None:
# error: [invalid-assignment] "Object of type `Literal[2]` is not assignable to attribute `b` of type `list[Literal[2, 3]]`"
self.a, *self.b = (1, 2, 3)
c_instance = C()
reveal_type(c_instance.a) # revealed: int
reveal_type(c_instance.b) # revealed: list[Literal[2, 3]]class TupleIterator:
def __next__(self) -> tuple[int, str]:
return (1, "a")
class TupleIterable:
def __iter__(self) -> TupleIterator:
return TupleIterator()
class NonIterable: ...
class C:
def __init__(self):
for self.x in range(3):
pass
for _, self.y in TupleIterable():
pass
# TODO: We should emit a diagnostic here
for self.z in NonIterable():
pass
reveal_type(C().x) # revealed: int
reveal_type(C().y) # revealed: strclass ContextManager:
def __enter__(self) -> int | None:
return 1
def __exit__(self, exc_type, exc_value, traceback) -> None:
pass
class C:
def __init__(self) -> None:
with ContextManager() as self.x:
pass
c_instance = C()
reveal_type(c_instance.x) # revealed: int | Noneclass ContextManager:
def __enter__(self) -> tuple[int | None, int]:
return 1, 2
def __exit__(self, exc_type, exc_value, traceback) -> None:
pass
class C:
def __init__(self) -> None:
with ContextManager() as (self.x, self.y):
pass
c_instance = C()
reveal_type(c_instance.x) # revealed: int | None
reveal_type(c_instance.y) # revealed: int[environment]
python-version = "3.12"class TupleIterator:
def __next__(self) -> tuple[int, str]:
return (1, "a")
class TupleIterable:
def __iter__(self) -> TupleIterator:
return TupleIterator()
class C:
def __init__(self) -> None:
[... for self.a in range(3)]
[... for (self.b, self.c) in TupleIterable()]
[... for self.d in range(3) for self.e in range(3)]
[[... for self.f in range(3)] for _ in range(3)]
[[... for self.g in range(3)] for self in [D()]]
class D:
g: int
c_instance = C()
reveal_type(c_instance.a) # revealed: int
reveal_type(c_instance.b) # revealed: int
reveal_type(c_instance.c) # revealed: str
reveal_type(c_instance.d) # revealed: int
reveal_type(c_instance.e) # revealed: int
reveal_type(c_instance.f) # revealed: int
# This one is correctly not resolved as an attribute:
# error: [unresolved-attribute]
reveal_type(c_instance.g) # revealed: UnknownIt does not matter how much the comprehension is nested.
Similarly attributes defined by the comprehension in a generic method are recognized.
class C:
def f[T](self):
[... for self.a in [1]]
[[... for self.b in [1]] for _ in [1]]
c_instance = C()
reveal_type(c_instance.a) # revealed: int
reveal_type(c_instance.b) # revealed: intIf the comprehension is inside another scope like function then that attribute is not inferred.
class C:
def __init__(self):
def f():
# error: [unresolved-attribute]
[... for self.a in [1]]
def g():
# error: [unresolved-attribute]
[... for self.b in [1]]
g()
c_instance = C()
# This attribute is in the function f and is not reachable
# error: [unresolved-attribute]
reveal_type(c_instance.a) # revealed: Unknown
# error: [unresolved-attribute]
reveal_type(c_instance.b) # revealed: UnknownIf the comprehension is nested in any other eager scope it still can assign attributes.
class C:
def __init__(self):
class D:
[[... for self.a in [1]] for _ in [1]]
reveal_type(C().a) # revealed: intWe currently treat implicit instance attributes to be bound, even if they are only conditionally defined:
def flag() -> bool:
return True
class C:
def f(self) -> None:
if flag():
self.a1: str | None = "a"
self.b1 = 1
if flag():
def f(self) -> None:
self.a2: str | None = "a"
self.b2 = 1
c_instance = C()
reveal_type(c_instance.a1) # revealed: str | None
reveal_type(c_instance.a2) # revealed: str | None
reveal_type(c_instance.b1) # revealed: int
reveal_type(c_instance.b2) # revealed: intclass C:
# This might trigger a stylistic lint like `invalid-first-argument-name-for-method`, but
# it should be supported in general:
def __init__(this) -> None:
this.declared_and_bound: str | None = "a"
reveal_type(C().declared_and_bound) # revealed: str | Noneclass C:
def __init__(self) -> None:
this = self
this.declared_and_bound: str | None = "a"
# This would ideally be `str | None`, but mypy/pyright don't support this either,
# so `Unknown` + a diagnostic is also fine.
# error: [unresolved-attribute]
reveal_type(C().declared_and_bound) # revealed: Unknownclass Other:
x: int
class C:
@staticmethod
def f(other: Other) -> None:
other.x = 1
# error: [unresolved-attribute]
reveal_type(C.x) # revealed: Unknown
# error: [unresolved-attribute]
reveal_type(C().x) # revealed: Unknown
# This also works if `staticmethod` is aliased:
my_staticmethod = staticmethod
class D:
@my_staticmethod
def f(other: Other) -> None:
other.x = 1
# error: [unresolved-attribute]
reveal_type(D.x) # revealed: Unknown
# error: [unresolved-attribute]
reveal_type(D().x) # revealed: UnknownIf staticmethod is something else, that should not influence the behavior:
def staticmethod(f):
return f
class C:
@staticmethod
def f(self) -> None:
self.x = 1
reveal_type(C().x) # revealed: intAnd if staticmethod is fully qualified, that should also be recognized:
import builtins
class Other:
x: int
class C:
@builtins.staticmethod
def f(other: Other) -> None:
other.x = 1
# error: [unresolved-attribute]
reveal_type(C.x) # revealed: Unknown
# error: [unresolved-attribute]
reveal_type(C().x) # revealed: Unknownclass C:
def __init__(self) -> None:
# We use a "significantly complex" condition here (instead of just `False`)
# for a proper comparison with mypy and pyright, which distinguish between
# conditions that can be resolved from a simple pattern matching and those
# that need proper type inference.
if (2 + 3) < 4:
self.x: str = "a"
# TODO: this would ideally raise an `unresolved-attribute` error
reveal_type(C().x) # revealed: strclass C:
def __init__(self, cond: bool) -> None:
if True:
self.a = 1
else:
self.a = "a"
if False:
self.b = 2
if cond:
return
self.c = 3
self.d = 4
self.d = "d"
def set_c(self, c: str) -> None:
self.c = c
if False:
def set_e(self, e: str) -> None:
self.e = e
# TODO: this would ideally be `int`
reveal_type(C(True).a) # revealed: int | str
# TODO: this would ideally raise an `unresolved-attribute` error
reveal_type(C(True).b) # revealed: int
reveal_type(C(True).c) # revealed: int | str
# TODO: ideally this would be `str`, but we don't attempt to analyze control flow within methods
# that closely; all reachable attribute assignments are included.
reveal_type(C(True).d) # revealed: int | str
# error: [unresolved-attribute]
reveal_type(C(True).e) # revealed: Unknownclass C:
def __init__(self, cond: bool):
self.x = 1
if cond:
raise ValueError("Something went wrong")
# We consider this attribute is always bound.
# This is because, it is not possible to access a partially-initialized object by normal means.
self.y = 2
reveal_type(C(False).x) # revealed: int
reveal_type(C(False).y) # revealed: int
class C:
def __init__(self, b: bytes) -> None:
self.b = b
try:
s = b.decode()
except UnicodeDecodeError:
raise ValueError("Invalid UTF-8 sequence")
self.s = s
reveal_type(C(b"abc").b) # revealed: bytes
reveal_type(C(b"abc").s) # revealed: str
class C:
def __init__(self, iter) -> None:
self.x = 1
for _ in iter:
pass
# The for-loop may not stop,
# but we consider the subsequent attributes to be definitely-bound.
self.y = 2
reveal_type(C([]).x) # revealed: int
reveal_type(C([]).y) # revealed: intclass C:
def __init__(self) -> None:
# error: [too-many-positional-arguments]
# error: [invalid-argument-type]
self.x: int = len(1, 2, 3)Class variables annotated with the typing.ClassVar type qualifier are pure class variables. They
cannot be overwritten on instances, but they can be accessed on instances.
For more details, see the typing spec on ClassVar.
from typing import ClassVar
class C:
pure_class_variable1: ClassVar[str] = "value in class body"
pure_class_variable2: ClassVar = 1
def method(self):
# error: [invalid-attribute-access] "Cannot assign to ClassVar `pure_class_variable1` from an instance of type `Self@method`"
self.pure_class_variable1 = "value set through instance"
reveal_type(C.pure_class_variable1) # revealed: str
reveal_type(C.pure_class_variable2) # revealed: Unknown | Literal[1]
c_instance = C()
# It is okay to access a pure class variable on an instance.
reveal_type(c_instance.pure_class_variable1) # revealed: str
reveal_type(c_instance.pure_class_variable2) # revealed: Unknown | Literal[1]
# error: [invalid-attribute-access] "Cannot assign to ClassVar `pure_class_variable1` from an instance of type `C`"
c_instance.pure_class_variable1 = "value set on instance"
C.pure_class_variable1 = "overwritten on class"
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `pure_class_variable1` of type `str`"
C.pure_class_variable1 = 1
class Subclass(C):
pure_class_variable1: ClassVar[str] = "overwritten on subclass"
reveal_type(Subclass.pure_class_variable1) # revealed: strIf a class variable is additionally qualified as Final, we do not union with Unknown for bare
ClassVars:
from typing import Final
class D:
# error: [redundant-final-classvar] "Combining `ClassVar` and `Final` is redundant"
final1: Final[ClassVar] = 1
# error: [redundant-final-classvar] "Combining `ClassVar` and `Final` is redundant"
final2: ClassVar[Final] = 1
# error: [redundant-final-classvar] "Combining `ClassVar` and `Final` is redundant"
final3: ClassVar[Final[int]] = 1
# error: [redundant-final-classvar] "Combining `ClassVar` and `Final` is redundant"
final4: Final[ClassVar[int]] = 1
reveal_type(D.final1) # revealed: Literal[1]
reveal_type(D.final2) # revealed: Literal[1]
reveal_type(D.final3) # revealed: int
reveal_type(D.final4) # revealed: intWe also consider a class variable to be a pure class variable if it is only mentioned in a class method.
class C:
@classmethod
def class_method(cls):
cls.pure_class_variable = "value set in class method"
# for a more realistic example, let's actually call the method
C.class_method()
reveal_type(C.pure_class_variable) # revealed: str
C.pure_class_variable = "overwritten on class"
reveal_type(C.pure_class_variable) # revealed: Literal["overwritten on class"]
c_instance = C()
reveal_type(c_instance.pure_class_variable) # revealed: str
# error: [possibly-missing-attribute]
c_instance.pure_class_variable = "value set on instance"These are instance attributes, but the fact that we can see that they have a binding (not a declaration) in the class body means that reading the value from the class directly is also permitted. This is the only difference for these attributes as opposed to "pure" instance attributes.
class C:
variable_with_class_default1: str = "value in class body"
variable_with_class_default2 = 1
def instance_method(self):
self.variable_with_class_default1 = "value set in instance method"
reveal_type(C.variable_with_class_default1) # revealed: str
reveal_type(C.variable_with_class_default2) # revealed: int
c_instance = C()
reveal_type(c_instance.variable_with_class_default1) # revealed: str
reveal_type(c_instance.variable_with_class_default2) # revealed: int
c_instance.variable_with_class_default1 = "value set on instance"
reveal_type(C.variable_with_class_default1) # revealed: str
reveal_type(c_instance.variable_with_class_default1) # revealed: Literal["value set on instance"]
C.variable_with_class_default1 = "overwritten on class"
reveal_type(C.variable_with_class_default1) # revealed: Literal["overwritten on class"]
reveal_type(c_instance.variable_with_class_default1) # revealed: Literal["value set on instance"]Whether they are explicitly qualified as ClassVar, or just have a class level default, we treat
descriptor attributes as class variables. This test mainly makes sure that we do not treat them as
instance variables. This would lead to a different outcome, since the __get__ method would not be
called (the descriptor protocol is not invoked for instance variables).
from typing import ClassVar
class Descriptor:
def __get__(self, instance, owner) -> int:
return 42
class C:
a: ClassVar[Descriptor]
b: Descriptor = Descriptor()
c: ClassVar[Descriptor] = Descriptor()
reveal_type(C().a) # revealed: int
reveal_type(C().b) # revealed: int
reveal_type(C().c) # revealed: intclass Base:
attribute: int | None = 1
redeclared_with_same_type: str | None
redeclared_with_narrower_type: str | None
redeclared_with_wider_type: str | None
redeclared_in_method_with_same_type: str | None
redeclared_in_method_with_narrower_type: str | None
redeclared_in_method_with_wider_type: str | None
overwritten_in_subclass_body: str
overwritten_in_subclass_method: str
undeclared = "base"
def __init__(self) -> None:
self.pure_attribute: str | None = "value in base"
self.pure_overwritten_in_subclass_body: str = "value in base"
self.pure_overwritten_in_subclass_method: str = "value in base"
self.pure_undeclared = "base"
class Intermediate(Base):
# Redeclaring base class attributes with the *same *type is fine:
redeclared_with_same_type: str | None = None
# Redeclaring them with a *narrower type* is unsound, because modifications
# through a `Base` reference could violate that constraint.
#
# Mypy does not report an error here, but pyright does: "… overrides symbol
# of same name in class "Base". Variable is mutable so its type is invariant"
#
# We should introduce a diagnostic for this. Whether or not that should be
# enabled by default can still be discussed.
#
# TODO: This should be an error
redeclared_with_narrower_type: str
# Redeclaring attributes with a *wider type* directly violates LSP.
#
# In this case, both mypy and pyright report an error.
#
# TODO: This should be an error
redeclared_with_wider_type: str | int | None
# TODO: This should be an `invalid-assignment` error
overwritten_in_subclass_body = 1
# TODO: This should be an `invalid-assignment` error
pure_overwritten_in_subclass_body = 1
undeclared = "intermediate"
def set_attributes(self) -> None:
self.redeclared_in_method_with_same_type: str | None = None
# TODO: This should be an error (violates Liskov)
self.redeclared_in_method_with_narrower_type: str = "foo"
# TODO: This should be an error (violates Liskov)
self.redeclared_in_method_with_wider_type: object = object()
self.overwritten_in_subclass_method = None # error: [invalid-assignment]
self.pure_overwritten_in_subclass_method = None # error: [invalid-assignment]
self.pure_undeclared = "intermediate"
class Derived(Intermediate): ...
reveal_type(Derived.attribute) # revealed: int | None
reveal_type(Derived().attribute) # revealed: int | None
reveal_type(Derived.redeclared_with_same_type) # revealed: str | None
reveal_type(Derived().redeclared_with_same_type) # revealed: str | None
# We infer the narrower type here, despite the Liskov violation,
# for compatibility with other type checkers (and to reflect the clear user intent)
reveal_type(Derived.redeclared_with_narrower_type) # revealed: str
reveal_type(Derived().redeclared_with_narrower_type) # revealed: str
# We infer the wider type here, despite the Liskov violation,
# for compatibility with other type checkers (and to reflect the clear user intent)
reveal_type(Derived.redeclared_with_wider_type) # revealed: str | int | None
reveal_type(Derived().redeclared_with_wider_type) # revealed: str | int | None
# TODO: Both of these should be `str`
reveal_type(Derived.overwritten_in_subclass_body) # revealed: int
reveal_type(Derived().overwritten_in_subclass_body) # revealed: int | str
reveal_type(Derived.redeclared_in_method_with_same_type) # revealed: str | None
reveal_type(Derived().redeclared_in_method_with_same_type) # revealed: str | None
# TODO: both of these should be `str`, despite the Liskov violation,
# for compatibility with other type checkers (and to reflect the clear user intent)
reveal_type(Derived.redeclared_in_method_with_narrower_type) # revealed: str | None
reveal_type(Derived().redeclared_in_method_with_narrower_type) # revealed: str | None
# TODO: both of these should be `object`, despite the Liskov violation,
# for compatibility with other type checkers (and to reflect the clear user intent)
reveal_type(Derived.redeclared_in_method_with_wider_type) # revealed: str | None
reveal_type(Derived().redeclared_in_method_with_wider_type) # revealed: object
reveal_type(Derived.overwritten_in_subclass_method) # revealed: str
reveal_type(Derived().overwritten_in_subclass_method) # revealed: str
reveal_type(Derived().pure_attribute) # revealed: str | None
# TODO: This should be `str`
reveal_type(Derived().pure_overwritten_in_subclass_body) # revealed: int | str
reveal_type(Derived().pure_overwritten_in_subclass_method) # revealed: str
reveal_type(Derived.undeclared) # revealed: str
reveal_type(Derived().undeclared) # revealed: str
reveal_type(Derived().pure_undeclared) # revealed: strWhen accessing attributes on class objects, they are always looked up on the type of the class object first, i.e. on the metaclass:
from typing import Literal
class Meta1:
attr: Literal["metaclass value"] = "metaclass value"
class C1(metaclass=Meta1): ...
reveal_type(C1.attr) # revealed: Literal["metaclass value"]However, the metaclass attribute only takes precedence over a class-level attribute if it is a data descriptor. If it is a non-data descriptor or a normal attribute, the class-level attribute is used instead (see the descriptor protocol tests for data/non-data descriptor attributes):
class Meta2:
attr: str = "metaclass value"
class C2(metaclass=Meta2):
attr: Literal["class value"] = "class value"
reveal_type(C2.attr) # revealed: Literal["class value"]If the class-level attribute is only partially defined, we union the metaclass attribute with the class-level attribute:
def _(flag: bool):
class Meta3:
attr1 = "metaclass value"
attr2: Literal["metaclass value"] = "metaclass value"
class C3(metaclass=Meta3):
if flag:
attr1 = "class value"
# TODO: Neither mypy nor pyright show an error here, but we could consider emitting a conflicting-declaration diagnostic here.
attr2: Literal["class value"] = "class value"
reveal_type(C3.attr1) # revealed: str
reveal_type(C3.attr2) # revealed: Literal["metaclass value", "class value"]If the metaclass attribute is only partially defined, we emit a possibly-missing-attribute
diagnostic:
def _(flag: bool):
class Meta4:
if flag:
attr1: str = "metaclass value"
class C4(metaclass=Meta4): ...
# error: [possibly-missing-attribute]
reveal_type(C4.attr1) # revealed: strFinally, if both the metaclass attribute and the class-level attribute are only partially defined,
we union them and emit a possibly-missing-attribute diagnostic:
def _(flag1: bool, flag2: bool):
class Meta5:
if flag1:
attr1 = "metaclass value"
class C5(metaclass=Meta5):
if flag2:
attr1 = "class value"
# error: [possibly-missing-attribute]
reveal_type(C5.attr1) # revealed: strIf an undefined variable is used in a method, and an attribute with the same name is defined and
accessible, then we emit a subdiagnostic suggesting the use of self..
class Foo:
x: int
def method(self):
y = x # snapshoterror[unresolved-reference]: Name `x` used when not defined
--> src/mdtest_snippet.py:5:13
|
5 | y = x # snapshot
| ^
|
info: An attribute `x` is available: consider using `self.x`
class Foo:
x: int = 1
def method(self):
y = x # snapshoterror[unresolved-reference]: Name `x` used when not defined
--> src/mdtest_snippet.py:10:13
|
10 | y = x # snapshot
| ^
|
info: An attribute `x` is available: consider using `self.x`
class Foo:
def __init__(self):
self.x = 1
def method(self):
# error: [unresolved-reference] "Name `x` used when not defined"
y = xIn a staticmethod, we don't suggest that it might be an attribute.
class Foo:
def __init__(self):
self.x = 42
@staticmethod
def static_method():
# error: [unresolved-reference] "Name `x` used when not defined"
y = xIn a classmethod, if the name matches a class attribute, we suggest cls..
from typing import ClassVar
class Foo:
x: ClassVar[int] = 42
@classmethod
def class_method(cls):
# error: [unresolved-reference] "Name `x` used when not defined"
y = xIn a classmethod, if the name matches an instance-only attribute, we don't suggest anything.
class Foo:
def __init__(self):
self.x = 42
@classmethod
def class_method(cls):
# error: [unresolved-reference] "Name `x` used when not defined"
y = xWe also don't suggest anything if the method is (invalidly) decorated with both @classmethod and
@staticmethod:
class Foo:
x: ClassVar[int]
@classmethod
@staticmethod
def class_method(cls):
# error: [unresolved-reference] "Name `x` used when not defined"
y = xIn an instance method that uses some other parameter name in place of self, we use that parameter
name in the sub-diagnostic.
class Foo:
def __init__(self):
self.x = 42
def method(other):
# error: [unresolved-reference] "Name `x` used when not defined"
y = xIn a classmethod that uses some other parameter name in place of cls, we use that parameter name
in the sub-diagnostic.
from typing import ClassVar
class Foo:
x: ClassVar[int] = 42
@classmethod
def class_method(c_other):
# error: [unresolved-reference] "Name `x` used when not defined"
y = xWe don't suggest anything if an instance method or a classmethod only has variadic arguments, or if the first parameter is keyword-only:
from typing import ClassVar
class Foo:
x: ClassVar[int] = 42
def instance_method(*args, **kwargs):
# error: [unresolved-reference] "Name `x` used when not defined"
print(x)
@classmethod
def class_method(*, cls):
# error: [unresolved-reference] "Name `x` used when not defined"
y = xIf the (meta)class is a union type or if the attribute on the (meta) class has a union type, we infer those union types accordingly:
def _(flag: bool):
if flag:
class C1:
x = 1
y: int = 1
else:
class C1:
x = "b"
y: int | str = "b"
reveal_type(C1.x) # revealed: int | str
reveal_type(C1.y) # revealed: int | str
C1.y = 100
# error: [invalid-assignment] "Object of type `Literal["problematic"]` is not assignable to attribute `y` on type `<class 'mdtest_snippet.<locals of function '_'>.C1 @ src/mdtest_snippet.py:3:15'> | <class 'mdtest_snippet.<locals of function '_'>.C1 @ src/mdtest_snippet.py:8:15'>`"
C1.y = "problematic"
class C2:
if flag:
x = 3
y: int = 3
else:
x = "d"
y: int | str = "d"
reveal_type(C2.x) # revealed: int | str
reveal_type(C2.y) # revealed: int | str
C2.y = 100
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `y` of type `int | str`"
C2.y = None
# TODO: should be an error, needs more sophisticated union handling in `validate_attribute_assignment`
C2.y = "problematic"
if flag:
class Meta3(type):
x = 5
y: int = 5
else:
class Meta3(type):
x = "f"
y: int | str = "f"
class C3(metaclass=Meta3): ...
reveal_type(C3.x) # revealed: int | str
reveal_type(C3.y) # revealed: int | str
C3.y = 100
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `y` of type `int | str`"
C3.y = None
# TODO: should be an error, needs more sophisticated union handling in `validate_attribute_assignment`
C3.y = "problematic"
class Meta4(type):
if flag:
x = 7
y: int = 7
else:
x = "h"
y: int | str = "h"
class C4(metaclass=Meta4): ...
reveal_type(C4.x) # revealed: int | str
reveal_type(C4.y) # revealed: int | str
C4.y = 100
# error: [invalid-assignment] "Object of type `None` is not assignable to attribute `y` of type `int | str`"
C4.y = None
# TODO: should be an error, needs more sophisticated union handling in `validate_attribute_assignment`
C4.y = "problematic"In this example, the x attribute is not defined in the C2 element of the union:
def _(flag1: bool, flag2: bool):
class C1:
x = 1
class C2: ...
class C3:
x = "a"
C = C1 if flag1 else C2 if flag2 else C3
# error: [unresolved-attribute] "Attribute `x` is not defined on `<class 'C2'>` in union `<class 'C1'> | <class 'C2'> | <class 'C3'>`"
reveal_type(C.x) # revealed: int | str
# error: [invalid-assignment] "Object of type `Literal[100]` is not assignable to attribute `x` on type `<class 'C1'> | <class 'C2'> | <class 'C3'>`"
C.x = 100
# error: [unresolved-attribute] "Attribute `x` is not defined on `C2` in union `C1 | C2 | C3`"
reveal_type(C().x) # revealed: int | str
# error: [invalid-assignment] "Object of type `Literal[100]` is not assignable to attribute `x` on type `C1 | C2 | C3`"
# error: [possibly-missing-attribute]
C().x = 100We raise the same diagnostic if the attribute is possibly-unbound in at least one element of the union:
def _(flag: bool, flag1: bool, flag2: bool):
class C1:
x = 1
class C2:
if flag:
x = "a"
class C3:
x = b"b"
C = C1 if flag1 else C2 if flag2 else C3
# error: [possibly-missing-attribute] "Attribute `x` may be missing on object of type `<class 'C1'> | <class 'C2'> | <class 'C3'>`"
reveal_type(C.x) # revealed: int | str | bytes
# error: [possibly-missing-attribute]
# error: [invalid-assignment]
C.x = 100
# Note: we might want to consider ignoring possibly-missing diagnostics for instance attributes eventually,
# see the "Possibly unbound/undeclared instance attribute" section below.
# error: [possibly-missing-attribute] "Attribute `x` may be missing on object of type `C1 | C2 | C3`"
reveal_type(C().x) # revealed: int | str | bytes
# error: [possibly-missing-attribute]
# error: [possibly-missing-attribute]
# error: [possibly-missing-attribute]
C().x = 100from typing import Any
def _(flag: bool):
class Base:
x: Any
class Derived(Base):
if flag:
# Redeclaring `x` with a more static type is okay in terms of LSP.
x: int
reveal_type(Derived().x) # revealed: int | Any
Derived().x = 1
# TODO
# The following assignment currently fails, because we first check if "a" is assignable to the
# attribute on the meta-type of `Derived`, i.e. `<class 'Derived'>`. When accessing the class
# member `x` on `Derived`, we only see the `x: int` declaration and do not union it with the
# type of the base class attribute `x: Any`. This could potentially be improved. Note that we
# see a type of `int | Any` above because we have the full union handling of possibly-unbound
# *instance* attributes.
# error: [invalid-assignment] "Object of type `Literal["a"]` is not assignable to attribute `x` of type `int`"
Derived().x = "a"def _(flag: bool):
class Foo:
x = 1
class Bar(Foo):
if flag:
x = 2
reveal_type(Bar.x) # revealed: int
Bar.x = 3
reveal_type(Bar().x) # revealed: int
# error: [possibly-missing-attribute]
Bar().x = 3def _(flag: bool):
class Foo:
if flag:
x = 1
class Bar(Foo):
if flag:
x = 2
# error: [possibly-missing-attribute]
reveal_type(Bar.x) # revealed: int
# error: [possibly-missing-attribute]
Bar.x = 3
# error: [possibly-missing-attribute]
reveal_type(Bar().x) # revealed: int
# error: [possibly-missing-attribute]
Bar().x = 3We currently treat implicit instance attributes to be bound, even if they are only conditionally
defined within a method. If the class-level definition or the whole method is only conditionally
available, we emit a possibly-missing-attribute diagnostic.
def _(flag: bool):
class Foo:
if flag:
x: int
def __init(self):
if flag:
self.x = 1
reveal_type(Foo().x) # revealed: int
Foo().x = 1def _(flag: bool):
class Foo:
def __init(self):
if flag:
self.x = 1
self.y = "a"
else:
self.y = b"b"
reveal_type(Foo().x) # revealed: int
Foo().x = 2
reveal_type(Foo().y) # revealed: str | bytes
Foo().y = "c"If the symbol is unbound in all elements of the union, we detect that:
def _(flag: bool):
class C1: ...
class C2: ...
C = C1 if flag else C2
# error: [unresolved-attribute] "Object of type `<class 'C1'> | <class 'C2'>` has no attribute `x`"
reveal_type(C.x) # revealed: Unknown
# TODO: This should ideally be a `unresolved-attribute` error. We need better union
# handling in `validate_attribute_assignment` for this.
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `x` on type `<class 'C1'> | <class 'C2'>`"
C.x = 1If the symbol is unbound in some elements of the union, that's also an error:
def f(x: list[int], y: list[int] | None, z: None):
x.index
# error: [unresolved-attribute] "Attribute `index` is not defined on `None` in union `list[int] | None`"
y.index
# error: [unresolved-attribute] "Object of type `None` has no attribute `index`"
z.indexThis is also true of type aliases of unions, and of special-case NewTypes that have a union as a
base type:
[environment]
python-version = "3.12"from typing import NewType
type MaybeList = list[int] | None
FloatNT = NewType("FloatNT", float)
def g(x: MaybeList, y: FloatNT):
# error: [unresolved-attribute] "Attribute `index` is not defined on `None` in union `MaybeList`"
x.index
# error: [unresolved-attribute] "Attribute `hex` is not defined on `int` in union `FloatNT`"
y.hexclass A:
X = "foo"
class B(A): ...
class C(B): ...
reveal_type(C.X) # revealed: str
C.X = "bar"from ty_extensions import reveal_mro
class O: ...
class F(O):
X = 56
class E(O):
X = 42
class D(O): ...
class C(D, F): ...
class B(E, D): ...
class A(B, C): ...
# revealed: (<class 'A'>, <class 'B'>, <class 'E'>, <class 'C'>, <class 'D'>, <class 'F'>, <class 'O'>, <class 'object'>)
reveal_mro(A)
# `E` is earlier in the MRO than `F`, so we should use the type of `E.X`
reveal_type(A.X) # revealed: int
A.X = 100from ty_extensions import Intersection
class A:
x: int = 1
class B: ...
def _(a_and_b: Intersection[A, B]):
reveal_type(a_and_b.x) # revealed: int
a_and_b.x = 2
# Same for class objects
def _(a_and_b: Intersection[type[A], type[B]]):
reveal_type(a_and_b.x) # revealed: int
a_and_b.x = 2from ty_extensions import Intersection
class P: ...
class Q: ...
class R(P, Q): ...
class A:
x: P = P()
class B:
x: Q = Q()
def _(a_and_b: Intersection[A, B]):
reveal_type(a_and_b.x) # revealed: P & Q
a_and_b.x = R()
# Same for class objects
def _(a_and_b: Intersection[type[A], type[B]]):
reveal_type(a_and_b.x) # revealed: P & Q
a_and_b.x = R()Make sure that attributes accessible on object are also accessible on a negation type like ~P,
which is equivalent to object & ~P:
class P: ...
def _(obj: object):
if not isinstance(obj, P):
reveal_type(obj) # revealed: ~P
reveal_type(obj.__dict__) # revealed: dict[str, Any]from ty_extensions import Intersection
class P: ...
class Q: ...
class R(P, Q): ...
def _(flag: bool):
class A1:
if flag:
x: P = P()
class B1: ...
def inner1(a_and_b: Intersection[A1, B1]):
# error: [possibly-missing-attribute]
reveal_type(a_and_b.x) # revealed: P
# error: [possibly-missing-attribute]
a_and_b.x = R()
# Same for class objects
def inner1_class(a_and_b: Intersection[type[A1], type[B1]]):
# error: [possibly-missing-attribute]
reveal_type(a_and_b.x) # revealed: P
# error: [possibly-missing-attribute]
a_and_b.x = R()
class A2:
if flag:
x: P = P()
class B1:
x: Q = Q()
def inner2(a_and_b: Intersection[A2, B1]):
reveal_type(a_and_b.x) # revealed: P & Q
# TODO: this should not be an error, we need better intersection
# handling in `validate_attribute_assignment` for this
# error: [possibly-missing-attribute]
a_and_b.x = R()
# Same for class objects
def inner2_class(a_and_b: Intersection[type[A2], type[B1]]):
reveal_type(a_and_b.x) # revealed: P & Q
class A3:
if flag:
x: P = P()
class B3:
if flag:
x: Q = Q()
def inner3(a_and_b: Intersection[A3, B3]):
# error: [possibly-missing-attribute]
reveal_type(a_and_b.x) # revealed: P & Q
# error: [possibly-missing-attribute]
a_and_b.x = R()
# Same for class objects
def inner3_class(a_and_b: Intersection[type[A3], type[B3]]):
# error: [possibly-missing-attribute]
reveal_type(a_and_b.x) # revealed: P & Q
# error: [possibly-missing-attribute]
a_and_b.x = R()
class A4: ...
class B4: ...
def inner4(a_and_b: Intersection[A4, B4]):
# error: [unresolved-attribute]
reveal_type(a_and_b.x) # revealed: Unknown
# error: [invalid-assignment]
a_and_b.x = R()
# Same for class objects
def inner4_class(a_and_b: Intersection[type[A4], type[B4]]):
# error: [unresolved-attribute]
reveal_type(a_and_b.x) # revealed: Unknown
# error: [invalid-assignment]
a_and_b.x = R()from ty_extensions import Intersection
class P: ...
class Q: ...
class A:
def __init__(self):
self.x: P = P()
class B:
def __init__(self):
self.x: Q = Q()
def _(a_and_b: Intersection[A, B]):
reveal_type(a_and_b.x) # revealed: P & QThe union of the set of types that Any could materialise to is equivalent to object. It follows
from this that attribute access on Any resolves to Any if the attribute does not exist on
object -- but if the attribute does exist on object, the type of the attribute is
<type as it exists on object> & Any.
from typing import Any
class Foo(Any): ...
reveal_type(Foo.bar) # revealed: Any
reveal_type(Foo.__repr__) # revealed: (def __repr__(self) -> str) & AnySimilar principles apply if Any appears in the middle of an inheritance hierarchy:
from typing import ClassVar, Literal
from ty_extensions import reveal_mro
class A:
x: ClassVar[Literal[1]] = 1
class B(Any): ...
class C(B, A): ...
reveal_mro(C) # revealed: (<class 'C'>, <class 'B'>, Any, <class 'A'>, <class 'object'>)
reveal_type(C.x) # revealed: Literal[1] & AnyIf a type provides a custom __getattr__ method, we use the return type of that method as the type
for unknown attributes. Consider the following CustomGetAttr class:
from typing import Literal
def flag() -> bool:
return True
class GetAttrReturnType: ...
class CustomGetAttr:
class_attr: int = 1
if flag():
possibly_unbound: bytes = b"a"
def __init__(self) -> None:
self.instance_attr: str = "a"
def __getattr__(self, name: str) -> GetAttrReturnType:
return GetAttrReturnType()We can access arbitrary attributes on instances of this class, and the type of the attribute will be
GetAttrReturnType:
c = CustomGetAttr()
reveal_type(c.whatever) # revealed: GetAttrReturnTypeIf an attribute is defined on the class, it takes precedence over the __getattr__ method:
reveal_type(c.class_attr) # revealed: intIf the class attribute is possibly missing, we union the type of the attribute with the fallback
type of the __getattr__ method:
reveal_type(c.possibly_unbound) # revealed: bytes | GetAttrReturnTypeInstance attributes also take precedence over the __getattr__ method:
# Note: we could attempt to union with the fallback type of `__getattr__` here, as we currently do not
# attempt to determine if instance attributes are always bound or not. Neither mypy nor pyright do this,
# so it's not a priority.
reveal_type(c.instance_attr) # revealed: strImportantly, __getattr__ is only called if attributes are accessed on instances, not if they are
accessed on the class itself:
# error: [unresolved-attribute]
CustomGetAttr.whateverIf the name parameter of the __getattr__ method is annotated with a (union of) literal type(s),
we only consider the attribute access to be valid if the accessed attribute is one of them:
from typing import Literal
class Date:
def __getattr__(self, name: Literal["day", "month", "year"]) -> int:
return 0
date = Date()
reveal_type(date.day) # revealed: int
reveal_type(date.month) # revealed: int
reveal_type(date.year) # revealed: int
# error: [unresolved-attribute] "Object of type `Date` has no attribute `century`"
reveal_type(date.century) # revealed: UnknownA standard library example of a class with a custom __getattr__ method is argparse.Namespace:
import argparse
def _(ns: argparse.Namespace):
reveal_type(ns.whatever) # revealed: AnyIf a type provides a custom __getattribute__, we use its return type as the type for unknown
attributes. Note that this behavior differs from runtime, where __getattribute__ is called
unconditionally, even for known attributes. The rationale for doing this is that it allows users to
specify more precise types for specific attributes, such as x: str in the example below. This
behavior matches other type checkers such as mypy and pyright.
from typing import Any
class Foo:
x: str
def __getattribute__(self, attr: str) -> Any:
return 42
reveal_type(Foo().x) # revealed: str
reveal_type(Foo().y) # revealed: AnyA standard library example for a class with a custom __getattribute__ method is SimpleNamespace:
from types import SimpleNamespace
sn = SimpleNamespace(a="a")
reveal_type(sn.a) # revealed: Any__getattribute__ takes precedence over __getattr__:
class C:
def __getattribute__(self, name: str) -> int:
return 1
def __getattr__(self, name: str) -> str:
return "a"
c = C()
reveal_type(c.x) # revealed: intLike all dunder methods, __getattribute__ is not looked up on instances:
def external_getattribute(name) -> int:
return 1
class ThisFails:
def __init__(self):
# error: [invalid-assignment]
self.__getattribute__ = external_getattribute
# error: [unresolved-attribute]
ThisFails().xA class is an instance of its metaclass. When attribute lookup on a class fails, Python falls back
to type(cls).__getattr__, the metaclass's __getattr__ method. This is analogous to how instance
attribute access falls back to the class's __getattr__.
class Meta(type):
def __getattr__(cls, name: str) -> int:
return 1
class Foo(metaclass=Meta): ...
reveal_type(Foo.whatever) # revealed: intIf the class defines the attribute directly, it takes precedence over the metaclass __getattr__:
class Meta(type):
def __getattr__(cls, name: str) -> int:
return 1
class Foo(metaclass=Meta):
x: str = "hello"
reveal_type(Foo.x) # revealed: str
reveal_type(Foo.unknown) # revealed: intA __getattr__ defined on the class itself applies to instance attribute access, not class
attribute access. A __getattr__ on the metaclass applies to class attribute access:
class Meta(type):
def __getattr__(cls, name: str) -> int:
return 1
class Foo(metaclass=Meta):
def __getattr__(self, name: str) -> str:
return "a"
# Class access uses the metaclass __getattr__
reveal_type(Foo.unknown) # revealed: int
# Instance access uses the class __getattr__
reveal_type(Foo().unknown) # revealed: strIf a class attribute is possibly unbound, the type is unioned with the metaclass __getattr__
return type:
def flag() -> bool:
return True
class Meta(type):
def __getattr__(cls, name: str) -> int:
return 1
class Foo(metaclass=Meta):
if flag():
maybe: str = "hello"
reveal_type(Foo.maybe) # revealed: str | int__getattr__ defined on a base metaclass is found via the metaclass MRO:
class BaseMeta(type):
def __getattr__(cls, name: str) -> int:
return 1
class Meta(BaseMeta): ...
class Foo(metaclass=Meta): ...
reveal_type(Foo.whatever) # revealed: intIf a metaclass provides a custom __getattribute__, we use its return type for unknown attributes
on the class. Known class attributes still take precedence, matching the behavior of type checkers
like mypy and pyright.
class Meta(type):
def __getattribute__(cls, name: str, /) -> int:
return 1
class Foo(metaclass=Meta): ...
reveal_type(Foo.whatever) # revealed: intclass Meta(type):
def __getattribute__(cls, name: str) -> int:
return 1
class Foo(metaclass=Meta):
x: str = "hello"
reveal_type(Foo.x) # revealed: str
reveal_type(Foo.unknown) # revealed: intclass Meta(type):
def __getattribute__(cls, name: str) -> int:
return 1
def __getattr__(cls, name: str) -> str:
return "a"
class Foo(metaclass=Meta): ...
reveal_type(Foo.x) # revealed: intIf a type provides a custom __setattr__ method, we use the parameter type of that method as the
type to validate attribute assignments. Consider the following CustomSetAttr class:
class CustomSetAttr:
def __setattr__(self, name: str, value: int) -> None:
passWe can set arbitrary attributes on instances of this class:
c = CustomSetAttr()
c.whatever = 42If the name parameter of the __setattr__ method is annotated with a (union of) literal type(s),
we only consider the attribute assignment to be valid if the assigned attribute is one of them:
from typing import Literal
class Date:
# error: [invalid-method-override]
def __setattr__(self, name: Literal["day", "month", "year"], value: int) -> None:
pass
date = Date()
date.day = 8
date.month = 4
date.year = 2025
# error: [unresolved-attribute] "Cannot assign object of type `Literal["UTC"]` to attribute `tz` on type `Date` with custom `__setattr__` method."
date.tz = "UTC"If the return type of the __setattr__ method is Never, we do not allow any attribute assignments
on instances of that class:
from typing_extensions import Never
class Frozen:
existing: int = 1
def __setattr__(self, name, value) -> Never:
raise AttributeError("Attributes cannot be modified")
instance = Frozen()
instance.non_existing = 2 # error: [invalid-assignment] "Cannot assign to unresolved attribute `non_existing` on type `Frozen`"
instance.existing = 2 # error: [invalid-assignment] "Cannot assign to attribute `existing` on type `Frozen` whose `__setattr__` method returns `Never`/`NoReturn`"object has a custom __setattr__ implementation, but we still emit an error if a non-existing
attribute is assigned on an object instance.
obj = object()
obj.non_existing = 1 # error: [unresolved-attribute]Setting attributes on Never itself should be allowed (even though it has a __setattr__ attribute
of type Never):
from typing_extensions import Never, Any
def _(never: Never):
reveal_type(never.__setattr__) # revealed: Never
def _(never: Never):
# No error:
never.non_existing = 1And similarly for Any:
def _(a: Any):
reveal_type(a.__setattr__) # revealed: Any
# No error:
a.non_existing = 1If a __setattr__ method is only partially bound, the behavior is still the same:
from typing_extensions import Never
def flag() -> bool:
return True
class Frozen:
if flag():
def __setattr__(self, name, value) -> Never:
raise AttributeError("Attributes cannot be modified")
instance = Frozen()
instance.non_existing = 2 # error: [invalid-assignment]
instance.existing = 2 # error: [invalid-assignment]A standard library example of a class with a custom __setattr__ method is argparse.Namespace:
import argparse
def _(ns: argparse.Namespace):
ns.whatever = 42When a class has both a custom __setattr__ method and explicitly defined attributes, the
__setattr__ method is treated as a fallback. The type of the explicit attribute takes precedence
over the __setattr__ parameter type.
This matches the behavior of other type checkers and reflects the common pattern in libraries like
PyTorch, where __setattr__ may have a narrow type signature but forwards to
super().__setattr__() for attributes that don't match.
from typing import Union
class Tensor: ...
class Module:
def __setattr__(self, name: str, value: Union[Tensor, "Module"]) -> None:
super().__setattr__(name, value)
class MyModule(Module):
some_param: int # Explicit attribute with type `int`
def use_module(m: MyModule, param: int) -> None:
# This is allowed because `some_param` is explicitly defined with type `int`,
# even though `__setattr__` only accepts `Union[Tensor, Module]`.
m.some_param = param
# But assigning to an attribute that's not explicitly defined will still
# use `__setattr__` for validation.
# error: [unresolved-attribute] "Cannot assign object of type `int` to attribute `undefined_param` on type `MyModule` with custom `__setattr__` method."
m.undefined_param = paramWhen __setattr__ returns Never (indicating an immutable class), all attribute assignments are
blocked, even if the value type doesn't match __setattr__'s parameter type.
from typing import NoReturn
class Immutable:
x: float
def __setattr__(self, name: str, value: int) -> NoReturn:
raise AttributeError("Immutable")
def _(obj: Immutable) -> None:
# Even though `"foo"` doesn't match `__setattr__`'s `value: int` parameter,
# we still detect that `__setattr__` returns `Never` and block the assignment.
# error: [invalid-assignment] "Cannot assign to attribute `x` on type `Immutable` whose `__setattr__` method returns `Never`/`NoReturn`"
obj.x = "foo"
# Same for assignments that would match `__setattr__`'s parameter type.
# error: [invalid-assignment] "Cannot assign to attribute `x` on type `Immutable` whose `__setattr__` method returns `Never`/`NoReturn`"
obj.x = 42The type of x.__class__ is the same as x's meta-type. x.__class__ is always the same value as
type(x).
import typing_extensions
reveal_type(typing_extensions.__class__) # revealed: <class 'ModuleType'>
reveal_type(type(typing_extensions)) # revealed: <class 'ModuleType'>
a = 42
reveal_type(a.__class__) # revealed: <class 'int'>
reveal_type(type(a)) # revealed: <class 'int'>
b = "42"
reveal_type(b.__class__) # revealed: <class 'str'>
c = b"42"
reveal_type(c.__class__) # revealed: <class 'bytes'>
d = True
reveal_type(d.__class__) # revealed: <class 'bool'>
e = (42, 42)
reveal_type(e.__class__) # revealed: type[tuple[Literal[42], Literal[42]]]
def f(a: int, b: typing_extensions.LiteralString, c: int | str, d: type[str]):
reveal_type(a.__class__) # revealed: type[int]
reveal_type(type(a)) # revealed: type[int]
reveal_type(b.__class__) # revealed: <class 'str'>
reveal_type(type(b)) # revealed: <class 'str'>
reveal_type(c.__class__) # revealed: type[int | str]
reveal_type(type(c)) # revealed: type[int | str]
# `type[type]`, a.k.a., either the class `type` or some subclass of `type`.
# It would be incorrect to infer `Literal[type]` here,
# as `c` could be some subclass of `str` with a custom metaclass.
# All we know is that the metaclass must be a (non-strict) subclass of `type`.
reveal_type(d.__class__) # revealed: type[type]
reveal_type(f.__class__) # revealed: <class 'FunctionType'>
class Foo: ...
reveal_type(Foo.__class__) # revealed: <class 'type'>mod.py:
global_symbol: str = "a"import mod
reveal_type(mod.global_symbol) # revealed: str
mod.global_symbol = "b"
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `global_symbol` of type `str`"
mod.global_symbol = 1
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `global_symbol` of type `str`"
_, mod.global_symbol = (..., 1)
# TODO: this should be an error, but we do not understand list unpackings yet.
[_, mod.global_symbol] = [1, 2]
# error: [invalid-assignment] "Object of type `int` is not assignable to attribute `global_symbol` of type `str`"
for mod.global_symbol in range(3):
passouter/__init__.py:
outer/nested/__init__.py:
outer/nested/inner.py:
class Outer:
class Nested:
class Inner:
attr: int = 1import outer.nested.inner
reveal_type(outer.nested.inner.Outer.Nested.Inner.attr) # revealed: int
# error: [invalid-assignment]
outer.nested.inner.Outer.Nested.Inner.attr = "a"mod1.py:
global_symbol: str = "a"mod2.py:
global_symbol: str = "a"import mod1
import mod2
def _(flag: bool):
if flag:
mod = mod1
else:
mod = mod2
mod.global_symbol = "b"
# error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to attribute `global_symbol` on type `<module 'mod1'> | <module 'mod2'>`"
mod.global_symbol = 1Most attribute accesses on function-literal types are delegated to types.FunctionType, since all
functions are instances of that class:
def f(): ...
reveal_type(f.__defaults__) # revealed: tuple[Any, ...] | None
reveal_type(f.__kwdefaults__) # revealed: dict[str, Any] | NoneSome attributes are special-cased, however:
import types
from ty_extensions import static_assert, TypeOf, is_subtype_of
reveal_type(f.__get__) # revealed: <method-wrapper '__get__' of function 'f'>
reveal_type(f.__call__) # revealed: <method-wrapper '__call__' of function 'f'>
static_assert(is_subtype_of(TypeOf[f.__get__], types.MethodWrapperType))
static_assert(is_subtype_of(TypeOf[f.__call__], types.MethodWrapperType))Most attribute accesses on int-literal types are delegated to builtins.int, since all literal
integers are instances of that class:
reveal_type((2).bit_length) # revealed: bound method Literal[2].bit_length() -> int
reveal_type((2).denominator) # revealed: Literal[1]Some attributes are special-cased, however:
reveal_type((2).numerator) # revealed: Literal[2]
reveal_type((2).real) # revealed: Literal[2]Most attribute accesses on bool-literal types are delegated to builtins.bool, since all literal
bools are instances of that class:
# revealed: Overload[(value: bool, /) -> bool, (value: int, /) -> int]
reveal_type(True.__and__)
# revealed: Overload[(value: bool, /) -> bool, (value: int, /) -> int]
reveal_type(False.__or__)Some attributes are special-cased, however:
reveal_type(True.numerator) # revealed: Literal[1]
reveal_type(False.real) # revealed: Literal[0]All attribute access on literal bytes types is currently delegated to builtins.bytes:
# revealed: bound method Literal[b"foo"].join(iterable_of_bytes: Iterable[Buffer], /) -> bytes
reveal_type(b"foo".join)
# revealed: bound method Literal[b"foo"].endswith(suffix: Buffer | tuple[Buffer, ...], start: SupportsIndex | None = None, end: SupportsIndex | None = None, /) -> bool
reveal_type(b"foo".endswith)class Other:
x: int = 1
class C:
def __init__(self, other: Other) -> None:
other.x = 1
def f(c: C):
# error: [unresolved-attribute]
reveal_type(c.x) # revealed: Unknownclass Outer:
def __init__(self):
self.x: int = 1
class Middle:
# has no 'x' attribute
class Inner:
def __init__(self):
self.x: str = "a"
reveal_type(Outer().x) # revealed: int
# error: [unresolved-attribute]
Outer.Middle().x
reveal_type(Outer.Middle.Inner().x) # revealed: strclass Other:
x: int = 1
class C:
def __init__(self) -> None:
# Redeclaration of self. `self` does not refer to the instance anymore.
self: Other = Other()
self.x: int = 1
# TODO: this should be an error
C().xclass Other:
x: int = 1
class C:
def __init__(self, other: Other) -> None:
(self := other).x = 1
# error: [unresolved-attribute]
reveal_type(C(Other()).x) # revealed: Unknownclass Other:
x: str = "a"
class C:
def __init__(self) -> None:
def nested_function(self: Other):
self.x = "b"
self.x: int = 1
reveal_type(C().x) # revealed: intclass C:
def __init__(self) -> None:
def set_attribute(value: str):
self.x: str = value
set_attribute("a")
# TODO: ideally, this would be `str`. Mypy supports this, pyright does not.
# error: [unresolved-attribute]
reveal_type(C().x) # revealed: UnknownArbitrary attributes can be accessed on Never without emitting any errors:
from typing_extensions import Never
def f(never: Never):
reveal_type(never.arbitrary_attribute) # revealed: Never
# Assigning `Never` to an attribute on `Never` is also allowed:
never.another_attribute = neverInferring types for undeclared implicit attributes can be cyclic:
class C:
def __init__(self):
self.x = 1
def copy(self, other: "C"):
self.x = other.x
reveal_type(C().x) # revealed: intIf the only assignment to a name is cyclic, we infer Divergent for that attribute:
class D:
def copy(self, other: "D"):
self.x = other.x
reveal_type(D().x) # revealed: DivergentIf there is an annotation for a name, we don't try to infer any type from the RHS of assignments to that name, so these cases don't trigger any cycle:
class E:
def __init__(self):
self.x: int = 1
def copy(self, other: "E"):
self.x = other.x
reveal_type(E().x) # revealed: int
class F:
def __init__(self):
self.x = 1
def copy(self, other: "F"):
self.x: int = other.x
reveal_type(F().x) # revealed: int
class G:
def copy(self, other: "G"):
self.x: int = other.x
reveal_type(G().x) # revealed: intWe can even handle cycles involving multiple classes:
class A:
def __init__(self):
self.x = 1
def copy(self, other: "B"):
self.x = other.x
class B:
def copy(self, other: "A"):
self.x = other.x
reveal_type(B().x) # revealed: int
reveal_type(A().x) # revealed: int
class Base:
def flip(self) -> "Sub":
return Sub()
class Sub(Base):
# error: [invalid-method-override]
def flip(self) -> "Base":
return Base()
class C2:
def __init__(self, x: Sub):
self.x = x
def replace_with(self, other: "C2"):
self.x = other.x.flip()
reveal_type(C2(Sub()).x) # revealed: Base
class C3:
def __init__(self, x: Sub):
self.x = [x]
def replace_with(self, other: "C3"):
self.x = [self.x[0].flip()]
# TODO: should be `list[Sub] | list[Base]`
reveal_type(C3(Sub()).x) # revealed: list[Sub] | list[Divergent]And cycles between many attributes:
class ManyCycles:
def __init__(self: "ManyCycles"):
self.x1 = 0
self.x2 = 0
self.x3 = 0
self.x4 = 0
self.x5 = 0
self.x6 = 0
self.x7 = 1
def f1(self: "ManyCycles"):
self.x1 = self.x2 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7
self.x2 = self.x1 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7
self.x3 = self.x1 + self.x2 + self.x4 + self.x5 + self.x6 + self.x7
self.x4 = self.x1 + self.x2 + self.x3 + self.x5 + self.x6 + self.x7
self.x5 = self.x1 + self.x2 + self.x3 + self.x4 + self.x6 + self.x7
self.x6 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x7
self.x7 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x6
def f2(self: "ManyCycles"):
self.x1 = self.x2 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7
self.x2 = self.x1 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7
self.x3 = self.x1 + self.x2 + self.x4 + self.x5 + self.x6 + self.x7
self.x4 = self.x1 + self.x2 + self.x3 + self.x5 + self.x6 + self.x7
self.x5 = self.x1 + self.x2 + self.x3 + self.x4 + self.x6 + self.x7
self.x6 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x7
self.x7 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x6
def f3(self: "ManyCycles"):
self.x1 = self.x2 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7
self.x2 = self.x1 + self.x3 + self.x4 + self.x5 + self.x6 + self.x7
self.x3 = self.x1 + self.x2 + self.x4 + self.x5 + self.x6 + self.x7
self.x4 = self.x1 + self.x2 + self.x3 + self.x5 + self.x6 + self.x7
self.x5 = self.x1 + self.x2 + self.x3 + self.x4 + self.x6 + self.x7
self.x6 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x7
self.x7 = self.x1 + self.x2 + self.x3 + self.x4 + self.x5 + self.x6
reveal_type(self.x1) # revealed: int
reveal_type(self.x2) # revealed: int
reveal_type(self.x3) # revealed: int
reveal_type(self.x4) # revealed: int
reveal_type(self.x5) # revealed: int
reveal_type(self.x6) # revealed: int
reveal_type(self.x7) # revealed: int
class ManyCycles2:
def __init__(self: "ManyCycles2"):
self.x1 = [0]
self.x2 = [1]
self.x3 = [1]
def f1(self: "ManyCycles2"):
reveal_type(self.x3) # revealed: list[int] | list[Divergent] | Unknown | list[Unknown]
self.x1 = [self.x2] + [self.x3]
self.x2 = [self.x1] + [self.x3]
self.x3 = [self.x1] + [self.x2]
def f2(self: "ManyCycles2"):
self.x1 = self.x2 + self.x3
self.x2 = self.x1 + self.x3
self.x3 = self.x1 + self.x2
def f3(self: "ManyCycles2"):
self.x1 = self.x2 + self.x3
self.x2 = self.x1 + self.x3
self.x3 = self.x1 + self.x2This case additionally tests our union/intersection simplification logic:
class H:
def __init__(self):
self.x = 1
def copy(self, other: "H"):
self.x = other.x or self.xAn attribute definition can be guarded by a condition involving that attribute. This is a regression test for astral-sh/ty#692:
from typing import Literal
def check(x) -> Literal[False]:
return False
class Toggle:
def __init__(self: "Toggle"):
if not self.x:
self.x: Literal[True] = True
if check(self.y):
self.y = True
# Literal[True] or undefined
reveal_type(Toggle().x) # revealed: Unknown | Literal[True]
reveal_type(Toggle().y) # revealed: boolMake sure that the growing union of literals Literal[0, 1, 2, ...] collapses to int during
fixpoint iteration. This is a regression test for astral-sh/ty#660.
class Counter:
def __init__(self: "Counter"):
self.count = 0
def increment(self: "Counter"):
self.count = self.count + 1
reveal_type(Counter().count) # revealed: intWe also handle infinitely nested generics:
class NestedLists:
def __init__(self: "NestedLists"):
self.x = 1
def f(self: "NestedLists"):
self.x = [self.x]
reveal_type(NestedLists().x) # revealed: int | list[Divergent]
class NestedMixed:
def f(self: "NestedMixed"):
self.x = [self.x]
def g(self: "NestedMixed"):
self.x = {self.x}
reveal_type(NestedMixed().x) # revealed: list[Divergent] | set[Divergent]And cases where the types originate from annotations:
from typing import TypeVar
T = TypeVar("T")
def make_list(value: T) -> list[T]:
return [value]
class NestedLists2:
def f(self: "NestedLists2"):
self.x = make_list(self.x)
reveal_type(NestedLists2().x) # revealed: list[Divergent]This test can probably be removed eventually, but we currently include it because we do not yet
understand generic bases and protocols, and we want to make sure that we can still use builtin types
in our tests in the meantime. See the corresponding TODO in Type::static_member for more
information.
class C:
a_int: int = 1
a_str: str = "a"
a_bytes: bytes = b"a"
a_bool: bool = True
a_float: float = 1.0
a_complex: complex = 1 + 1j
a_tuple: tuple[int] = (1,)
a_range: range = range(1)
a_slice: slice = slice(1)
a_type: type = int
a_none: None = None
reveal_type(C.a_int) # revealed: int
reveal_type(C.a_str) # revealed: str
reveal_type(C.a_bytes) # revealed: bytes
reveal_type(C.a_bool) # revealed: bool
reveal_type(C.a_float) # revealed: int | float
reveal_type(C.a_complex) # revealed: int | float | complex
reveal_type(C.a_tuple) # revealed: tuple[int]
reveal_type(C.a_range) # revealed: range
# TODO: revealed: slice[Any, Literal[1], Any]
reveal_type(C.a_slice) # revealed: slice[Any, Any, Any]
reveal_type(C.a_type) # revealed: type
reveal_type(C.a_none) # revealed: NoneWe also detect implicit instance attributes on methods that are themselves generic. We have an extra
test for this because generic functions have an extra type-params scope in between the function body
scope and the outer scope, so we need to make sure that our implementation can still recognize f
as a method of C here:
[environment]
python-version = "3.12"class C:
def f[T](self, t: T) -> T:
self.x: int = 1
return t
reveal_type(C().x) # revealed: intWhen an attribute is defined in a method that is decorated with an unknown decorator, the method is
treated as a regular instance method. The attribute is accessible on instances but not on the class
itself. We don't assume that an unknown decorator might be an alias for @classmethod, because that
would cause the attribute to pollute class-level lookups and potentially override instance-level
declarations.
# error: [unresolved-import]
from unknown_library import unknown_decorator
class C:
@unknown_decorator
def f(self):
self.x: int = 1
# error: [unresolved-attribute]
reveal_type(C.x) # revealed: Unknown
reveal_type(C().x) # revealed: int
class D:
def __init__(self):
self.x: int = 1
@unknown_decorator
def f(self):
self.x = 2
reveal_type(D().x) # revealed: intWhen an attribute is declared with a type annotation in __init__, and then assigned (without
annotation) in a method decorated with a known decorator like @cache, we should respect the
declared type from __init__. The decorated method's assignment should not cause the type to
include Unknown.
from functools import cache
class C:
def __init__(self) -> None:
self.x: int = 0
@cache
def method(self) -> None:
self.x = 1
def f(c: C) -> None:
reveal_type(c.x) # revealed: intInstance attributes can be defined in a @property getter. Properties are still instance methods
that take self, so attribute assignments in them should be recognized.
class C:
@property
def prop(self) -> int:
self.x: int = 1
return self.x
def f(c: C) -> None:
reveal_type(c.x) # revealed: intimport enum
reveal_type(enum.Enum.__members__) # revealed: MappingProxyType[str, Enum]
class Answer(enum.Enum):
NO = 0
YES = 1
reveal_type(Answer.NO) # revealed: Literal[Answer.NO]
reveal_type(Answer.NO.value) # revealed: Literal[0]
reveal_type(Answer.__members__) # revealed: MappingProxyType[str, Answer]If an implicit attribute is defined recursively and type inference diverges, the divergent part is
filled in with the dynamic type Divergent. Types containing Divergent can be seen as "cheap"
recursive types: they are not true recursive types based on recursive type theory, so no unfolding
is performed when you use them.
class C:
def f(self, other: "C"):
self.x = (other.x, 1)
reveal_type(C().x) # revealed: tuple[Divergent, int]
reveal_type(C().x[0]) # revealed: DivergentThis also works if the tuple is not constructed directly:
from typing import TypeVar, Literal
T = TypeVar("T")
def make_tuple(x: T) -> tuple[T, Literal[1]]:
return (x, 1)
class D:
def f(self, other: "D"):
self.x = make_tuple(other.x)
reveal_type(D().x) # revealed: tuple[Divergent, Literal[1]]The tuple type may also expand exponentially "in breadth":
def duplicate(x: T) -> tuple[T, T]:
return (x, x)
class E:
def f(self: "E"):
self.x = duplicate(self.x)
reveal_type(E().x) # revealed: tuple[Divergent, Divergent]And it also works for homogeneous tuples:
def make_homogeneous_tuple(x: T) -> tuple[T, ...]:
return (x, x)
class F:
def f(self, other: "F"):
self.x = make_homogeneous_tuple(other.x)
reveal_type(F().x) # revealed: tuple[Divergent, ...]For attributes of stdlib modules that exist in future versions, we can give better diagnostics.
[environment]
python-version = "3.10"main.py:
import datetime
# snapshot: unresolved-attribute
reveal_type(datetime.UTC) # revealed: Unknownerror[unresolved-attribute]: Module `datetime` has no member `UTC`
--> src/main.py:4:13
|
4 | reveal_type(datetime.UTC) # revealed: Unknown
| ^^^^^^^^^^^^
|
info: The member may be available on other Python versions or platforms
info: Python 3.10 was assumed when resolving the `UTC` attribute because it was specified on the command line
If an attribute doesn't exist at all, we still give the same error as before:
wrong.py:
import datetime
# snapshot: unresolved-attribute
reveal_type(datetime.fakenotreal) # revealed: Unknownerror[unresolved-attribute]: Module `datetime` has no member `fakenotreal`
--> src/wrong.py:4:13
|
4 | reveal_type(datetime.fakenotreal) # revealed: Unknown
| ^^^^^^^^^^^^^^^^^^^^
|
We give special diagnostics for this common case too:
foo/__init__.py:
foo/bar.py:
baz/bar.py:
foo_importer.py:
import foo
# snapshot: possibly-missing-submodule
reveal_type(foo.bar) # revealed: Unknownwarning[possibly-missing-submodule]: Submodule `bar` might not have been imported
--> src/foo_importer.py:4:13
|
4 | reveal_type(foo.bar) # revealed: Unknown
| ^^^^^^^
|
help: Consider explicitly importing `foo.bar`
baz_importer.py:
import baz
# snapshot: possibly-missing-submodule
reveal_type(baz.bar) # revealed: Unknownwarning[possibly-missing-submodule]: Submodule `bar` might not have been imported
--> src/baz_importer.py:4:13
|
4 | reveal_type(baz.bar) # revealed: Unknown
| ^^^^^^^
|
help: Consider explicitly importing `baz.bar`
We show a special help message here that explains that not all callables are functions.
[environment]
python-version = "3.14"from typing import Callable
def f(x: Callable):
x.__name__ # snapshot: unresolved-attributeerror[unresolved-attribute]: Object of type `(...) -> Unknown` has no attribute `__name__`
--> src/mdtest_snippet.py:4:5
|
4 | x.__name__ # snapshot: unresolved-attribute
| ^^^^^^^^^^
|
help: Function objects have a `__name__` attribute, but not all callable objects are functions
help: See this FAQ for more information: <https://docs.astral.sh/ty/reference/typing-faq/#why-does-ty-say-callable-has-no-attribute-__name__>
def g(x: Callable):
x.__annotate__ # snapshot: unresolved-attributeerror[unresolved-attribute]: Object of type `(...) -> Unknown` has no attribute `__annotate__`
--> src/mdtest_snippet.py:6:5
|
6 | x.__annotate__ # snapshot: unresolved-attribute
| ^^^^^^^^^^^^^^
|
help: Function objects have an `__annotate__` attribute, but not all callable objects are functions
help: See this FAQ for more information: <https://docs.astral.sh/ty/reference/typing-faq/#why-does-ty-say-callable-has-no-attribute-__name__>
Some of the tests in the Class and instance variables section draw inspiration from pyright's documentation on this topic.