Skip to content

Commit e9a64e5

Browse files
oconnor663carljm
andauthored
[ty] make del x force local resolution of x in the current scope (astral-sh#19389)
Fixes astral-sh/ty#769. **Updated:** The preferred approach here is to keep the SemanticIndex simple (`del` of any name marks that name "bound" in the current scope) and to move complexity to type inference (free variable resolution stops when it finds a binding, unless that binding is declared `nonlocal`). As part of this change, free variable resolution will now union the types it finds as it walks in enclosing scopes. This approach is still incomplete, because it doesn't consider inner scopes or sibling scopes, but it improves the common case. --------- Co-authored-by: Carl Meyer <[email protected]>
1 parent 360eb70 commit e9a64e5

File tree

4 files changed

+187
-18
lines changed

4 files changed

+187
-18
lines changed

crates/ty_python_semantic/resources/mdtest/del.md

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,22 +40,92 @@ else:
4040
# error: [possibly-unresolved-reference]
4141
reveal_type(c) # revealed: Literal[2]
4242

43-
d = 1
43+
d = [1, 2, 3]
4444

4545
def delete():
46-
# TODO: this results in `UnboundLocalError`; we should emit `unresolved-reference`
47-
del d
46+
del d # error: [unresolved-reference] "Name `d` used when not defined"
4847

4948
delete()
50-
reveal_type(d) # revealed: Literal[1]
49+
reveal_type(d) # revealed: list[Unknown]
50+
51+
def delete_element():
52+
# When the `del` target isn't a name, it doesn't force local resolution.
53+
del d[0]
54+
print(d)
5155

5256
def delete_global():
5357
global d
5458
del d
59+
# We could lint that `d` is unbound in this trivial case, but because it's global we'd need to
60+
# be careful about false positives if `d` got reinitialized somehow in between the two `del`s.
61+
del d
5562

5663
delete_global()
57-
# The variable should have been removed, but we won't check it for now.
58-
reveal_type(d) # revealed: Literal[1]
64+
# Again, the variable should have been removed, but we don't check it.
65+
reveal_type(d) # revealed: list[Unknown]
66+
67+
def delete_nonlocal():
68+
e = 2
69+
70+
def delete_nonlocal_bad():
71+
del e # error: [unresolved-reference] "Name `e` used when not defined"
72+
73+
def delete_nonlocal_ok():
74+
nonlocal e
75+
del e
76+
# As with `global` above, we don't track that the nonlocal `e` is unbound.
77+
del e
78+
```
79+
80+
## `del` forces local resolution even if it's unreachable
81+
82+
Without a `global x` or `nonlocal x` declaration in `foo`, `del x` in `foo` causes `print(x)` in an
83+
inner function `bar` to resolve to `foo`'s binding, in this case an unresolved reference / unbound
84+
local error:
85+
86+
```py
87+
x = 1
88+
89+
def foo():
90+
print(x) # error: [unresolved-reference] "Name `x` used when not defined"
91+
if False:
92+
# Assigning to `x` would have the same effect here.
93+
del x
94+
95+
def bar():
96+
print(x) # error: [unresolved-reference] "Name `x` used when not defined"
97+
```
98+
99+
## But `del` doesn't force local resolution of `global` or `nonlocal` variables
100+
101+
However, with `global x` in `foo`, `print(x)` in `bar` resolves in the global scope, despite the
102+
`del` in `foo`:
103+
104+
```py
105+
x = 1
106+
107+
def foo():
108+
global x
109+
def bar():
110+
# allowed, refers to `x` in the global scope
111+
reveal_type(x) # revealed: Unknown | Literal[1]
112+
bar()
113+
del x # allowed, deletes `x` in the global scope (though we don't track that)
114+
```
115+
116+
`nonlocal x` has a similar effect, if we add an extra `enclosing` scope to give it something to
117+
refer to:
118+
119+
```py
120+
def enclosing():
121+
x = 2
122+
def foo():
123+
nonlocal x
124+
def bar():
125+
# allowed, refers to `x` in `enclosing`
126+
reveal_type(x) # revealed: Unknown | Literal[2]
127+
bar()
128+
del x # allowed, deletes `x` in `enclosing` (though we don't track that)
59129
```
60130

61131
## Delete attributes

crates/ty_python_semantic/resources/mdtest/scopes/nonlocal.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,52 @@ def f():
8484
x = "hello" # error: [invalid-assignment] "Object of type `Literal["hello"]` is not assignable to `int`"
8585
```
8686

87+
## The types of `nonlocal` binding get unioned
88+
89+
Without a type declaration, we union the bindings in enclosing scopes to infer a type. But name
90+
resolution stops at the closest binding that isn't declared `nonlocal`, and we ignore bindings
91+
outside of that one:
92+
93+
```py
94+
def a():
95+
# This binding is shadowed in `b`, so we ignore it in inner scopes.
96+
x = 1
97+
98+
def b():
99+
x = 2
100+
101+
def c():
102+
nonlocal x
103+
x = 3
104+
105+
def d():
106+
nonlocal x
107+
reveal_type(x) # revealed: Unknown | Literal[3, 2]
108+
x = 4
109+
reveal_type(x) # revealed: Literal[4]
110+
111+
def e():
112+
reveal_type(x) # revealed: Unknown | Literal[4, 3, 2]
113+
```
114+
115+
However, currently the union of types that we build is incomplete. We walk parent scopes, but not
116+
sibling scopes, child scopes, second-cousin-once-removed scopes, etc:
117+
118+
```py
119+
def a():
120+
x = 1
121+
def b():
122+
nonlocal x
123+
x = 2
124+
125+
def c():
126+
def d():
127+
nonlocal x
128+
x = 3
129+
# TODO: This should include 2 and 3.
130+
reveal_type(x) # revealed: Unknown | Literal[1]
131+
```
132+
87133
## Local variable bindings "look ahead" to any assignment in the current scope
88134

89135
The binding `x = 2` in `g` causes the earlier read of `x` to refer to `g`'s not-yet-initialized
@@ -390,3 +436,13 @@ def f():
390436
nonlocal x
391437
x = 1
392438
```
439+
440+
## Narrowing nonlocal types to `Never` doesn't make them unbound
441+
442+
```py
443+
def foo():
444+
x: int = 1
445+
def bar():
446+
if isinstance(x, str):
447+
reveal_type(x) # revealed: Never
448+
```

crates/ty_python_semantic/src/semantic_index/builder.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1994,8 +1994,26 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
19941994
walk_stmt(self, stmt);
19951995
for target in targets {
19961996
if let Ok(target) = PlaceExpr::try_from(target) {
1997+
let is_name = target.is_name();
19971998
let place_id = self.add_place(PlaceExprWithFlags::new(target));
1998-
self.current_place_table_mut().mark_place_used(place_id);
1999+
let place_table = self.current_place_table_mut();
2000+
if is_name {
2001+
// `del x` behaves like an assignment in that it forces all references
2002+
// to `x` in the current scope (including *prior* references) to refer
2003+
// to the current scope's binding (unless `x` is declared `global` or
2004+
// `nonlocal`). For example, this is an UnboundLocalError at runtime:
2005+
//
2006+
// ```py
2007+
// x = 1
2008+
// def foo():
2009+
// print(x) # can't refer to global `x`
2010+
// if False:
2011+
// del x
2012+
// foo()
2013+
// ```
2014+
place_table.mark_place_bound(place_id);
2015+
}
2016+
place_table.mark_place_used(place_id);
19992017
self.delete_binding(place_id);
20002018
}
20012019
}

crates/ty_python_semantic/src/types/infer.rs

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5978,6 +5978,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
59785978
// definition of this name visible to us (would be `LOAD_DEREF` at runtime.)
59795979
// Note that we skip the scope containing the use that we are resolving, since we
59805980
// already looked for the place there up above.
5981+
let mut nonlocal_union_builder = UnionBuilder::new(db);
5982+
let mut found_some_definition = false;
59815983
for (enclosing_scope_file_id, _) in self.index.ancestor_scopes(file_scope_id).skip(1) {
59825984
// Class scopes are not visible to nested scopes, and we need to handle global
59835985
// scope differently (because an unbound name there falls back to builtins), so
@@ -6063,21 +6065,25 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
60636065
let Some(enclosing_place) = enclosing_place_table.place_by_expr(expr) else {
60646066
continue;
60656067
};
6068+
6069+
// Reads of "free" variables terminate at any enclosing scope that marks the
6070+
// variable `global`, whether or not that scope actually binds the variable. If we
6071+
// see a `global` declaration, stop walking scopes and proceed to the global
6072+
// handling below. (If we're walking from a prior/inner scope where this variable
6073+
// is `nonlocal`, then this is a semantic syntax error, but we don't enforce that
6074+
// here. See `infer_nonlocal_statement`.)
60666075
if enclosing_place.is_marked_global() {
6067-
// Reads of "free" variables can terminate at an enclosing scope that marks the
6068-
// variable `global` but doesn't actually bind it. In that case, stop walking
6069-
// scopes and proceed to the global handling below. (But note that it's a
6070-
// semantic syntax error for the `nonlocal` keyword to do this. See
6071-
// `infer_nonlocal_statement`.)
60726076
break;
60736077
}
6078+
6079+
// If the name is declared or bound in this scope, figure out its type. This might
6080+
// resolve the name and end the walk. But if the name is declared `nonlocal` in
6081+
// this scope, we'll keep walking enclosing scopes and union this type with the
6082+
// other types we find. (It's a semantic syntax error to declare a type for a
6083+
// `nonlocal` variable, but we don't enforce that here. See the
6084+
// `ast::Stmt::AnnAssign` handling in `SemanticIndexBuilder::visit_stmt`.)
60746085
if enclosing_place.is_bound() || enclosing_place.is_declared() {
6075-
// We can return early here, because the nearest function-like scope that
6076-
// defines a name must be the only source for the nonlocal reference (at
6077-
// runtime, it is the scope that creates the cell for our closure.) If the name
6078-
// isn't bound in that scope, we should get an unbound name, not continue
6079-
// falling back to other scopes / globals / builtins.
6080-
return place(
6086+
let local_place_and_qualifiers = place(
60816087
db,
60826088
enclosing_scope_id,
60836089
expr,
@@ -6086,6 +6092,25 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
60866092
.map_type(|ty| {
60876093
self.narrow_place_with_applicable_constraints(expr, ty, &constraint_keys)
60886094
});
6095+
// We could have Place::Unbound here, despite the checks above, for example if
6096+
// this scope contains a `del` statement but no binding or declaration.
6097+
if let Place::Type(type_, boundness) = local_place_and_qualifiers.place {
6098+
nonlocal_union_builder.add_in_place(type_);
6099+
// `ConsideredDefinitions::AllReachable` never returns PossiblyUnbound
6100+
debug_assert_eq!(boundness, Boundness::Bound);
6101+
found_some_definition = true;
6102+
}
6103+
6104+
if !enclosing_place.is_marked_nonlocal() {
6105+
// We've reached a function-like scope that marks this name bound or
6106+
// declared but doesn't mark it `nonlocal`. The name is therefore resolved,
6107+
// and we won't consider any scopes outside of this one.
6108+
return if found_some_definition {
6109+
Place::Type(nonlocal_union_builder.build(), Boundness::Bound).into()
6110+
} else {
6111+
Place::Unbound.into()
6112+
};
6113+
}
60896114
}
60906115
}
60916116

0 commit comments

Comments
 (0)