Skip to content

Commit 0858896

Browse files
mtshibasharkdpcarljm
authored
[ty] type narrowing by attribute/subscript assignments (#18041)
## Summary This PR partially solves astral-sh/ty#164 (derived from #17643). Currently, the definitions we manage are limited to those for simple name (symbol) targets, but we expand this to track definitions for attribute and subscript targets as well. This was originally planned as part of the work in #17643, but the changes are significant, so I made it a separate PR. After merging this PR, I will reflect this changes in #17643. There is still some incomplete work remaining, but the basic features have been implemented, so I am publishing it as a draft PR. Here is the TODO list (there may be more to come): * [x] Complete rewrite and refactoring of documentation (removing `Symbol` and replacing it with `Place`) * [x] More thorough testing * [x] Consolidation of duplicated code (maybe we can consolidate the handling related to name, attribute, and subscript) This PR replaces the current `Symbol` API with the `Place` API, which is a concept that includes attributes and subscripts (the term is borrowed from Rust). ## Test Plan `mdtest/narrow/assignment.md` is added. --------- Co-authored-by: David Peter <sharkdp@users.noreply.github.com> Co-authored-by: Carl Meyer <carl@astral.sh>
1 parent ce8b744 commit 0858896

38 files changed

+3427
-2399
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# This is a regression test for `infer_expression_types`.
2+
# ref: https://github.com/astral-sh/ruff/pull/18041#discussion_r2094573989
3+
4+
class C:
5+
def f(self, other: "C"):
6+
if self.a > other.b or self.b:
7+
return False
8+
if self:
9+
return True
10+
11+
C().a

crates/ty_python_semantic/resources/mdtest/attributes.md

Lines changed: 52 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ reveal_type(c_instance.inferred_from_other_attribute) # revealed: Unknown
3737
# See https://github.com/astral-sh/ruff/issues/15960 for a related discussion.
3838
reveal_type(c_instance.inferred_from_param) # revealed: Unknown | int | None
3939

40-
reveal_type(c_instance.declared_only) # revealed: bytes
40+
# TODO: Should be `bytes` with no error, like mypy and pyright?
41+
# error: [unresolved-attribute]
42+
reveal_type(c_instance.declared_only) # revealed: Unknown
4143

4244
reveal_type(c_instance.declared_and_bound) # revealed: bool
4345

@@ -64,12 +66,10 @@ C.inferred_from_value = "overwritten on class"
6466
# This assignment is fine:
6567
c_instance.declared_and_bound = False
6668

67-
# TODO: After this assignment to the attribute within this scope, we may eventually want to narrow
68-
# the `bool` type (see above) for this instance variable to `Literal[False]` here. This is unsound
69-
# in general (we don't know what else happened to `c_instance` between the assignment and the use
70-
# here), but mypy and pyright support this. In conclusion, this could be `bool` but should probably
71-
# be `Literal[False]`.
72-
reveal_type(c_instance.declared_and_bound) # revealed: bool
69+
# Strictly speaking, inferring this as `Literal[False]` rather than `bool` is unsound in general
70+
# (we don't know what else happened to `c_instance` between the assignment and the use here),
71+
# but mypy and pyright support this.
72+
reveal_type(c_instance.declared_and_bound) # revealed: Literal[False]
7373
```
7474

7575
#### Variable declared in class body and possibly bound in `__init__`
@@ -149,14 +149,16 @@ class C:
149149
c_instance = C(True)
150150

151151
reveal_type(c_instance.only_declared_in_body) # revealed: str | None
152-
reveal_type(c_instance.only_declared_in_init) # revealed: str | None
152+
# TODO: should be `str | None` without error
153+
# error: [unresolved-attribute]
154+
reveal_type(c_instance.only_declared_in_init) # revealed: Unknown
153155
reveal_type(c_instance.declared_in_body_and_init) # revealed: str | None
154156

155157
reveal_type(c_instance.declared_in_body_defined_in_init) # revealed: str | None
156158

157159
# TODO: This should be `str | None`. Fixing this requires an overhaul of the `Symbol` API,
158160
# which is planned in https://github.com/astral-sh/ruff/issues/14297
159-
reveal_type(c_instance.bound_in_body_declared_in_init) # revealed: Unknown | str | None
161+
reveal_type(c_instance.bound_in_body_declared_in_init) # revealed: Unknown | Literal["a"]
160162

161163
reveal_type(c_instance.bound_in_body_and_init) # revealed: Unknown | None | Literal["a"]
162164
```
@@ -187,7 +189,9 @@ reveal_type(c_instance.inferred_from_other_attribute) # revealed: Unknown
187189

188190
reveal_type(c_instance.inferred_from_param) # revealed: Unknown | int | None
189191

190-
reveal_type(c_instance.declared_only) # revealed: bytes
192+
# TODO: should be `bytes` with no error, like mypy and pyright?
193+
# error: [unresolved-attribute]
194+
reveal_type(c_instance.declared_only) # revealed: Unknown
191195

192196
reveal_type(c_instance.declared_and_bound) # revealed: bool
193197

@@ -260,8 +264,8 @@ class C:
260264
self.w += None
261265

262266
# TODO: Mypy and pyright do not support this, but it would be great if we could
263-
# infer `Unknown | str` or at least `Unknown | Weird | str` here.
264-
reveal_type(C().w) # revealed: Unknown | Weird
267+
# infer `Unknown | str` here (`Weird` is not a possible type for the `w` attribute).
268+
reveal_type(C().w) # revealed: Unknown
265269
```
266270

267271
#### Attributes defined in tuple unpackings
@@ -410,14 +414,41 @@ class C:
410414
[... for self.a in IntIterable()]
411415
[... for (self.b, self.c) in TupleIterable()]
412416
[... for self.d in IntIterable() for self.e in IntIterable()]
417+
[[... for self.f in IntIterable()] for _ in IntIterable()]
418+
[[... for self.g in IntIterable()] for self in [D()]]
419+
420+
class D:
421+
g: int
413422

414423
c_instance = C()
415424

416-
reveal_type(c_instance.a) # revealed: Unknown | int
417-
reveal_type(c_instance.b) # revealed: Unknown | int
418-
reveal_type(c_instance.c) # revealed: Unknown | str
419-
reveal_type(c_instance.d) # revealed: Unknown | int
420-
reveal_type(c_instance.e) # revealed: Unknown | int
425+
# TODO: no error, reveal Unknown | int
426+
# error: [unresolved-attribute]
427+
reveal_type(c_instance.a) # revealed: Unknown
428+
429+
# TODO: no error, reveal Unknown | int
430+
# error: [unresolved-attribute]
431+
reveal_type(c_instance.b) # revealed: Unknown
432+
433+
# TODO: no error, reveal Unknown | str
434+
# error: [unresolved-attribute]
435+
reveal_type(c_instance.c) # revealed: Unknown
436+
437+
# TODO: no error, reveal Unknown | int
438+
# error: [unresolved-attribute]
439+
reveal_type(c_instance.d) # revealed: Unknown
440+
441+
# TODO: no error, reveal Unknown | int
442+
# error: [unresolved-attribute]
443+
reveal_type(c_instance.e) # revealed: Unknown
444+
445+
# TODO: no error, reveal Unknown | int
446+
# error: [unresolved-attribute]
447+
reveal_type(c_instance.f) # revealed: Unknown
448+
449+
# This one is correctly not resolved as an attribute:
450+
# error: [unresolved-attribute]
451+
reveal_type(c_instance.g) # revealed: Unknown
421452
```
422453

423454
#### Conditionally declared / bound attributes
@@ -721,10 +752,7 @@ reveal_type(C.pure_class_variable) # revealed: Unknown
721752
# error: [invalid-attribute-access] "Cannot assign to instance attribute `pure_class_variable` from the class object `<class 'C'>`"
722753
C.pure_class_variable = "overwritten on class"
723754

724-
# TODO: should be `Unknown | Literal["value set in class method"]` or
725-
# Literal["overwritten on class"]`, once/if we support local narrowing.
726-
# error: [unresolved-attribute]
727-
reveal_type(C.pure_class_variable) # revealed: Unknown
755+
reveal_type(C.pure_class_variable) # revealed: Literal["overwritten on class"]
728756

729757
c_instance = C()
730758
reveal_type(c_instance.pure_class_variable) # revealed: Unknown | Literal["value set in class method"]
@@ -762,19 +790,12 @@ reveal_type(c_instance.variable_with_class_default2) # revealed: Unknown | Lite
762790
c_instance.variable_with_class_default1 = "value set on instance"
763791

764792
reveal_type(C.variable_with_class_default1) # revealed: str
765-
766-
# TODO: Could be Literal["value set on instance"], or still `str` if we choose not to
767-
# narrow the type.
768-
reveal_type(c_instance.variable_with_class_default1) # revealed: str
793+
reveal_type(c_instance.variable_with_class_default1) # revealed: Literal["value set on instance"]
769794

770795
C.variable_with_class_default1 = "overwritten on class"
771796

772-
# TODO: Could be `Literal["overwritten on class"]`, or still `str` if we choose not to
773-
# narrow the type.
774-
reveal_type(C.variable_with_class_default1) # revealed: str
775-
776-
# TODO: should still be `Literal["value set on instance"]`, or `str`.
777-
reveal_type(c_instance.variable_with_class_default1) # revealed: str
797+
reveal_type(C.variable_with_class_default1) # revealed: Literal["overwritten on class"]
798+
reveal_type(c_instance.variable_with_class_default1) # revealed: Literal["value set on instance"]
778799
```
779800

780801
#### Descriptor attributes as class variables

crates/ty_python_semantic/resources/mdtest/descriptor_protocol.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -699,9 +699,7 @@ class C:
699699
descriptor = Descriptor()
700700

701701
C.descriptor = "something else"
702-
703-
# This could also be `Literal["something else"]` if we support narrowing of attribute types based on assignments
704-
reveal_type(C.descriptor) # revealed: Unknown | int
702+
reveal_type(C.descriptor) # revealed: Literal["something else"]
705703
```
706704

707705
### Possibly unbound descriptor attributes

0 commit comments

Comments
 (0)