Skip to content

Commit f76d3f8

Browse files
authored
[ty] Allow declared-only class-level attributes to be accessed on the class (astral-sh#19071)
## Summary Allow declared-only class-level attributes to be accessed on the class: ```py class C: attr: int C.attr # this is now allowed ``` closes astral-sh/ty#384 closes astral-sh/ty#553 ## Ecosystem analysis * We see many removed `unresolved-attribute` false-positives for code that makes use of sqlalchemy, as expected (see changes for `prefect`) * We see many removed `call-non-callable` false-positives for uses of `pytest.skip` and similar, as expected * Most new diagnostics seem to be related to cases like the following, where we previously inferred `int` for `Derived().x`, but now we infer `int | None`. I think this should be a conflicting-declarations/bad-override error anyway? The new behavior may even be preferred here? ```py class Base: x: int | None class Derived(Base): def __init__(self): self.x: int = 1 ```
1 parent 5f426b9 commit f76d3f8

File tree

6 files changed

+37
-68
lines changed

6 files changed

+37
-68
lines changed

crates/ty_python_semantic/resources/mdtest/attributes.md

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,8 @@ c_instance = C()
8787

8888
reveal_type(c_instance.declared_and_bound) # revealed: str | None
8989

90-
# Note that both mypy and pyright show no error in this case! So we may reconsider this in
91-
# the future, if it turns out to produce too many false positives. We currently emit:
92-
# error: [unresolved-attribute] "Attribute `declared_and_bound` can only be accessed on instances, not on the class object `<class 'C'>` itself."
93-
reveal_type(C.declared_and_bound) # revealed: Unknown
90+
reveal_type(C.declared_and_bound) # revealed: str | None
9491

95-
# Same as above. Mypy and pyright do not show an error here.
96-
# error: [invalid-attribute-access] "Cannot assign to instance attribute `declared_and_bound` from the class object `<class 'C'>`"
9792
C.declared_and_bound = "overwritten on class"
9893

9994
# 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
10297

10398
#### Variable declared in class body and not bound anywhere
10499

105-
If a variable is declared in the class body but not bound anywhere, we still consider it a pure
106-
instance variable and allow access to it via instances.
100+
If a variable is declared in the class body but not bound anywhere, we consider it to be accessible
101+
on instances and the class itself. It would be more consistent to treat this as a pure instance
102+
variable (and require the attribute to be annotated with `ClassVar` if it should be accessible on
103+
the class as well), but other type checkers allow this as well. This is also heavily relied on in
104+
the Python ecosystem:
107105

108106
```py
109107
class C:
@@ -113,11 +111,8 @@ c_instance = C()
113111

114112
reveal_type(c_instance.only_declared) # revealed: str
115113

116-
# Mypy and pyright do not show an error here. We treat this as a pure instance variable.
117-
# error: [unresolved-attribute] "Attribute `only_declared` can only be accessed on instances, not on the class object `<class 'C'>` itself."
118-
reveal_type(C.only_declared) # revealed: Unknown
114+
reveal_type(C.only_declared) # revealed: str
119115

120-
# error: [invalid-attribute-access] "Cannot assign to instance attribute `only_declared` from the class object `<class 'C'>`"
121116
C.only_declared = "overwritten on class"
122117
```
123118

@@ -1235,6 +1230,16 @@ def _(flag: bool):
12351230
reveal_type(Derived().x) # revealed: int | Any
12361231

12371232
Derived().x = 1
1233+
1234+
# TODO
1235+
# The following assignment currently fails, because we first check if "a" is assignable to the
1236+
# attribute on the meta-type of `Derived`, i.e. `<class 'Derived'>`. When accessing the class
1237+
# member `x` on `Derived`, we only see the `x: int` declaration and do not union it with the
1238+
# type of the base class attribute `x: Any`. This could potentially be improved. Note that we
1239+
# see a type of `int | Any` above because we have the full union handling of possibly-unbound
1240+
# *instance* attributes.
1241+
1242+
# error: [invalid-assignment] "Object of type `Literal["a"]` is not assignable to attribute `x` of type `int`"
12381243
Derived().x = "a"
12391244
```
12401245

@@ -1299,10 +1304,8 @@ def _(flag: bool):
12991304
if flag:
13001305
self.x = 1
13011306

1302-
# error: [possibly-unbound-attribute]
13031307
reveal_type(Foo().x) # revealed: int | Unknown
13041308

1305-
# error: [possibly-unbound-attribute]
13061309
Foo().x = 1
13071310
```
13081311

crates/ty_python_semantic/resources/mdtest/call/dunder.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,19 +120,16 @@ def _(flag: bool):
120120

121121
### Dunder methods as class-level annotations with no value
122122

123-
Class-level annotations with no value assigned are considered instance-only, and aren't available as
124-
dunder methods:
123+
Class-level annotations with no value assigned are considered to be accessible on the class:
125124

126125
```py
127126
from typing import Callable
128127

129128
class C:
130129
__call__: Callable[..., None]
131130

132-
# error: [call-non-callable]
133131
C()()
134132

135-
# error: [invalid-assignment]
136133
_: Callable[..., None] = C()
137134
```
138135

crates/ty_python_semantic/resources/mdtest/dataclasses.md

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -810,21 +810,6 @@ D(1) # OK
810810
D() # error: [missing-argument]
811811
```
812812

813-
### Accessing instance attributes on the class itself
814-
815-
Just like for normal classes, accessing instance attributes on the class itself is not allowed:
816-
817-
```py
818-
from dataclasses import dataclass
819-
820-
@dataclass
821-
class C:
822-
x: int
823-
824-
# error: [unresolved-attribute] "Attribute `x` can only be accessed on instances, not on the class object `<class 'C'>` itself."
825-
C.x
826-
```
827-
828813
### Return type of `dataclass(...)`
829814

830815
A call like `dataclass(order=True)` returns a callable itself, which is then used as the decorator.

crates/ty_python_semantic/resources/mdtest/protocols.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,13 @@ class FooSubclassOfAny:
533533
x: SubclassOfAny
534534

535535
static_assert(not is_subtype_of(FooSubclassOfAny, HasX))
536-
static_assert(not is_assignable_to(FooSubclassOfAny, HasX))
536+
537+
# `FooSubclassOfAny` is assignable to `HasX` for the following reason. The `x` attribute on `FooSubclassOfAny`
538+
# is accessible on the class itself. When accessing `x` on an instance, the descriptor protocol is invoked, and
539+
# `__get__` is looked up on `SubclassOfAny`. Every member access on `SubclassOfAny` yields `Any`, so `__get__` is
540+
# also available, and calling `Any` also yields `Any`. Thus, accessing `x` on an instance of `FooSubclassOfAny`
541+
# yields `Any`, which is assignable to `int` and vice versa.
542+
static_assert(is_assignable_to(FooSubclassOfAny, HasX))
537543

538544
class FooWithY(Foo):
539545
y: int
@@ -1586,11 +1592,7 @@ def g(a: Truthy, b: FalsyFoo, c: FalsyFooSubclass):
15861592
reveal_type(bool(c)) # revealed: Literal[False]
15871593
```
15881594

1589-
It is not sufficient for a protocol to have a callable `__bool__` instance member that returns
1590-
`Literal[True]` for it to be considered always truthy. Dunder methods are looked up on the class
1591-
rather than the instance. If a protocol `X` has an instance-attribute `__bool__` member, it is
1592-
unknowable whether that attribute can be accessed on the type of an object that satisfies `X`'s
1593-
interface:
1595+
The same works with a class-level declaration of `__bool__`:
15941596

15951597
```py
15961598
from typing import Callable
@@ -1599,7 +1601,7 @@ class InstanceAttrBool(Protocol):
15991601
__bool__: Callable[[], Literal[True]]
16001602

16011603
def h(obj: InstanceAttrBool):
1602-
reveal_type(bool(obj)) # revealed: bool
1604+
reveal_type(bool(obj)) # revealed: Literal[True]
16031605
```
16041606

16051607
## Callable protocols
@@ -1832,7 +1834,8 @@ def _(r: Recursive):
18321834
reveal_type(r.direct) # revealed: Recursive
18331835
reveal_type(r.union) # revealed: None | Recursive
18341836
reveal_type(r.intersection1) # revealed: C & Recursive
1835-
reveal_type(r.intersection2) # revealed: C & ~Recursive
1837+
# revealed: @Todo(map_with_boundness: intersections with negative contributions) | (C & ~Recursive)
1838+
reveal_type(r.intersection2)
18361839
reveal_type(r.t) # revealed: tuple[int, tuple[str, Recursive]]
18371840
reveal_type(r.callable1) # revealed: (int, /) -> Recursive
18381841
reveal_type(r.callable2) # revealed: (Recursive, /) -> int

crates/ty_python_semantic/resources/mdtest/type_qualifiers/classvar.md

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -64,24 +64,6 @@ c = C()
6464
c.a = 2
6565
```
6666

67-
and similarly here:
68-
69-
```py
70-
class Base:
71-
a: ClassVar[int] = 1
72-
73-
class Derived(Base):
74-
if flag():
75-
a: int
76-
77-
reveal_type(Derived.a) # revealed: int
78-
79-
d = Derived()
80-
81-
# error: [invalid-attribute-access]
82-
d.a = 2
83-
```
84-
8567
## Too many arguments
8668

8769
```py

crates/ty_python_semantic/src/place.rs

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -235,29 +235,28 @@ pub(crate) fn class_symbol<'db>(
235235
) -> PlaceAndQualifiers<'db> {
236236
place_table(db, scope)
237237
.place_id_by_name(name)
238-
.map(|symbol| {
239-
let symbol_and_quals = place_by_id(
238+
.map(|place| {
239+
let place_and_quals = place_by_id(
240240
db,
241241
scope,
242-
symbol,
242+
place,
243243
RequiresExplicitReExport::No,
244244
ConsideredDefinitions::EndOfScope,
245245
);
246246

247-
if symbol_and_quals.is_class_var() {
248-
// For declared class vars we do not need to check if they have bindings,
249-
// we just trust the declaration.
250-
return symbol_and_quals;
247+
if !place_and_quals.place.is_unbound() {
248+
// Trust the declared type if we see a class-level declaration
249+
return place_and_quals;
251250
}
252251

253252
if let PlaceAndQualifiers {
254253
place: Place::Type(ty, _),
255254
qualifiers,
256-
} = symbol_and_quals
255+
} = place_and_quals
257256
{
258257
// Otherwise, we need to check if the symbol has bindings
259258
let use_def = use_def_map(db, scope);
260-
let bindings = use_def.end_of_scope_bindings(symbol);
259+
let bindings = use_def.end_of_scope_bindings(place);
261260
let inferred = place_from_bindings_impl(db, bindings, RequiresExplicitReExport::No);
262261

263262
// TODO: we should not need to calculate inferred type second time. This is a temporary

0 commit comments

Comments
 (0)