diff --git a/crates/ty_python_semantic/resources/mdtest/attributes.md b/crates/ty_python_semantic/resources/mdtest/attributes.md index 12bab34fb8..9697310d6f 100644 --- a/crates/ty_python_semantic/resources/mdtest/attributes.md +++ b/crates/ty_python_semantic/resources/mdtest/attributes.md @@ -87,13 +87,8 @@ c_instance = C() reveal_type(c_instance.declared_and_bound) # revealed: str | None -# Note that both mypy and pyright show no error in this case! So we may reconsider this in -# the future, if it turns out to produce too many false positives. We currently emit: -# error: [unresolved-attribute] "Attribute `declared_and_bound` can only be accessed on instances, not on the class object `` itself." -reveal_type(C.declared_and_bound) # revealed: Unknown +reveal_type(C.declared_and_bound) # revealed: str | None -# Same as above. Mypy and pyright do not show an error here. -# error: [invalid-attribute-access] "Cannot assign to instance attribute `declared_and_bound` from the class object ``" 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`" @@ -102,8 +97,11 @@ c_instance.declared_and_bound = 1 #### Variable declared in class body and not bound anywhere -If a variable is declared in the class body but not bound anywhere, we still consider it a pure -instance variable and allow access to it via instances. +If 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: ```py class C: @@ -113,11 +111,8 @@ c_instance = C() reveal_type(c_instance.only_declared) # revealed: str -# Mypy and pyright do not show an error here. We treat this as a pure instance variable. -# error: [unresolved-attribute] "Attribute `only_declared` can only be accessed on instances, not on the class object `` itself." -reveal_type(C.only_declared) # revealed: Unknown +reveal_type(C.only_declared) # revealed: str -# error: [invalid-attribute-access] "Cannot assign to instance attribute `only_declared` from the class object ``" C.only_declared = "overwritten on class" ``` @@ -1235,6 +1230,16 @@ def _(flag: bool): 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. ``. 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" ``` @@ -1299,10 +1304,8 @@ def _(flag: bool): if flag: self.x = 1 - # error: [possibly-unbound-attribute] reveal_type(Foo().x) # revealed: int | Unknown - # error: [possibly-unbound-attribute] Foo().x = 1 ``` diff --git a/crates/ty_python_semantic/resources/mdtest/call/dunder.md b/crates/ty_python_semantic/resources/mdtest/call/dunder.md index ed846ea8df..7eadb96dae 100644 --- a/crates/ty_python_semantic/resources/mdtest/call/dunder.md +++ b/crates/ty_python_semantic/resources/mdtest/call/dunder.md @@ -120,8 +120,7 @@ def _(flag: bool): ### Dunder methods as class-level annotations with no value -Class-level annotations with no value assigned are considered instance-only, and aren't available as -dunder methods: +Class-level annotations with no value assigned are considered to be accessible on the class: ```py from typing import Callable @@ -129,10 +128,8 @@ from typing import Callable class C: __call__: Callable[..., None] -# error: [call-non-callable] C()() -# error: [invalid-assignment] _: Callable[..., None] = C() ``` diff --git a/crates/ty_python_semantic/resources/mdtest/dataclasses.md b/crates/ty_python_semantic/resources/mdtest/dataclasses.md index 41c7cb7078..7ed6ea8962 100644 --- a/crates/ty_python_semantic/resources/mdtest/dataclasses.md +++ b/crates/ty_python_semantic/resources/mdtest/dataclasses.md @@ -810,21 +810,6 @@ D(1) # OK D() # error: [missing-argument] ``` -### Accessing instance attributes on the class itself - -Just like for normal classes, accessing instance attributes on the class itself is not allowed: - -```py -from dataclasses import dataclass - -@dataclass -class C: - x: int - -# error: [unresolved-attribute] "Attribute `x` can only be accessed on instances, not on the class object `` itself." -C.x -``` - ### Return type of `dataclass(...)` A call like `dataclass(order=True)` returns a callable itself, which is then used as the decorator. diff --git a/crates/ty_python_semantic/resources/mdtest/protocols.md b/crates/ty_python_semantic/resources/mdtest/protocols.md index 6f18000bb3..f507b963a5 100644 --- a/crates/ty_python_semantic/resources/mdtest/protocols.md +++ b/crates/ty_python_semantic/resources/mdtest/protocols.md @@ -533,7 +533,13 @@ class FooSubclassOfAny: x: SubclassOfAny static_assert(not is_subtype_of(FooSubclassOfAny, HasX)) -static_assert(not is_assignable_to(FooSubclassOfAny, HasX)) + +# `FooSubclassOfAny` is assignable to `HasX` for the following reason. The `x` attribute on `FooSubclassOfAny` +# is accessible on the class itself. When accessing `x` on an instance, the descriptor protocol is invoked, and +# `__get__` is looked up on `SubclassOfAny`. Every member access on `SubclassOfAny` yields `Any`, so `__get__` is +# also available, and calling `Any` also yields `Any`. Thus, accessing `x` on an instance of `FooSubclassOfAny` +# yields `Any`, which is assignable to `int` and vice versa. +static_assert(is_assignable_to(FooSubclassOfAny, HasX)) class FooWithY(Foo): y: int @@ -1586,11 +1592,7 @@ def g(a: Truthy, b: FalsyFoo, c: FalsyFooSubclass): reveal_type(bool(c)) # revealed: Literal[False] ``` -It is not sufficient for a protocol to have a callable `__bool__` instance member that returns -`Literal[True]` for it to be considered always truthy. Dunder methods are looked up on the class -rather than the instance. If a protocol `X` has an instance-attribute `__bool__` member, it is -unknowable whether that attribute can be accessed on the type of an object that satisfies `X`'s -interface: +The same works with a class-level declaration of `__bool__`: ```py from typing import Callable @@ -1599,7 +1601,7 @@ class InstanceAttrBool(Protocol): __bool__: Callable[[], Literal[True]] def h(obj: InstanceAttrBool): - reveal_type(bool(obj)) # revealed: bool + reveal_type(bool(obj)) # revealed: Literal[True] ``` ## Callable protocols @@ -1832,7 +1834,8 @@ def _(r: Recursive): reveal_type(r.direct) # revealed: Recursive reveal_type(r.union) # revealed: None | Recursive reveal_type(r.intersection1) # revealed: C & Recursive - reveal_type(r.intersection2) # revealed: C & ~Recursive + # revealed: @Todo(map_with_boundness: intersections with negative contributions) | (C & ~Recursive) + reveal_type(r.intersection2) reveal_type(r.t) # revealed: tuple[int, tuple[str, Recursive]] reveal_type(r.callable1) # revealed: (int, /) -> Recursive reveal_type(r.callable2) # revealed: (Recursive, /) -> int diff --git a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/classvar.md b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/classvar.md index 6b86ab5fab..52b117ab5b 100644 --- a/crates/ty_python_semantic/resources/mdtest/type_qualifiers/classvar.md +++ b/crates/ty_python_semantic/resources/mdtest/type_qualifiers/classvar.md @@ -64,24 +64,6 @@ c = C() c.a = 2 ``` -and similarly here: - -```py -class Base: - a: ClassVar[int] = 1 - -class Derived(Base): - if flag(): - a: int - -reveal_type(Derived.a) # revealed: int - -d = Derived() - -# error: [invalid-attribute-access] -d.a = 2 -``` - ## Too many arguments ```py diff --git a/crates/ty_python_semantic/src/place.rs b/crates/ty_python_semantic/src/place.rs index 385bc60b94..97cb7c3673 100644 --- a/crates/ty_python_semantic/src/place.rs +++ b/crates/ty_python_semantic/src/place.rs @@ -235,29 +235,28 @@ pub(crate) fn class_symbol<'db>( ) -> PlaceAndQualifiers<'db> { place_table(db, scope) .place_id_by_name(name) - .map(|symbol| { - let symbol_and_quals = place_by_id( + .map(|place| { + let place_and_quals = place_by_id( db, scope, - symbol, + place, RequiresExplicitReExport::No, ConsideredDefinitions::EndOfScope, ); - if symbol_and_quals.is_class_var() { - // For declared class vars we do not need to check if they have bindings, - // we just trust the declaration. - return symbol_and_quals; + if !place_and_quals.place.is_unbound() { + // Trust the declared type if we see a class-level declaration + return place_and_quals; } if let PlaceAndQualifiers { place: Place::Type(ty, _), qualifiers, - } = symbol_and_quals + } = place_and_quals { // Otherwise, we need to check if the symbol has bindings let use_def = use_def_map(db, scope); - let bindings = use_def.end_of_scope_bindings(symbol); + let bindings = use_def.end_of_scope_bindings(place); let inferred = place_from_bindings_impl(db, bindings, RequiresExplicitReExport::No); // TODO: we should not need to calculate inferred type second time. This is a temporary