Skip to content

Commit 852defe

Browse files
committed
[ty] Support walrus operator scope leaking from comprehensions
1 parent a96fc4e commit 852defe

File tree

4 files changed

+230
-54
lines changed

4 files changed

+230
-54
lines changed

crates/ty_python_semantic/resources/mdtest/assignment/walrus.md

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,104 @@ x = 0
1515
(x := x + 1)
1616
reveal_type(x) # revealed: Literal[1]
1717
```
18+
19+
## Walrus in comprehensions
20+
21+
PEP 572: Named expressions in comprehensions bind the target in the first enclosing scope that is
22+
not a comprehension.
23+
24+
### List comprehension element
25+
26+
```py
27+
class Iterator:
28+
def __next__(self) -> int:
29+
return 42
30+
31+
class Iterable:
32+
def __iter__(self) -> Iterator:
33+
return Iterator()
34+
35+
[(a := b * 2) for b in Iterable()]
36+
reveal_type(a) # revealed: int
37+
```
38+
39+
### Comprehension filter
40+
41+
```py
42+
class Iterator:
43+
def __next__(self) -> int:
44+
return 42
45+
46+
class Iterable:
47+
def __iter__(self) -> Iterator:
48+
return Iterator()
49+
50+
[c for d in Iterable() if (c := d - 10) > 0]
51+
reveal_type(c) # revealed: int
52+
```
53+
54+
### Dict comprehension
55+
56+
```py
57+
class Iterator:
58+
def __next__(self) -> int:
59+
return 42
60+
61+
class Iterable:
62+
def __iter__(self) -> Iterator:
63+
return Iterator()
64+
65+
{(e := f * 2): (g := f * 3) for f in Iterable()}
66+
reveal_type(e) # revealed: int
67+
reveal_type(g) # revealed: int
68+
```
69+
70+
### Generator expression
71+
72+
```py
73+
class Iterator:
74+
def __next__(self) -> int:
75+
return 42
76+
77+
class Iterable:
78+
def __iter__(self) -> Iterator:
79+
return Iterator()
80+
81+
list(((h := i * 2) for i in Iterable()))
82+
reveal_type(h) # revealed: int
83+
```
84+
85+
### Nested comprehension
86+
87+
```py
88+
[[(x := y) for y in range(3)] for _ in range(3)]
89+
reveal_type(x) # revealed: int
90+
```
91+
92+
### Honoring `global` declaration
93+
94+
PEP 572: the walrus honors a `global` declaration in the enclosing scope.
95+
96+
```py
97+
x: int = 0
98+
99+
def f() -> None:
100+
global x
101+
[(x := y) for y in range(3)]
102+
reveal_type(x) # revealed: int
103+
```
104+
105+
### Honoring `nonlocal` declaration
106+
107+
PEP 572: the walrus honors a `nonlocal` declaration in the enclosing scope.
108+
109+
```py
110+
def outer() -> None:
111+
x = "hello"
112+
113+
def inner() -> None:
114+
nonlocal x
115+
[(x := y) for y in range(3)]
116+
reveal_type(x) # revealed: int
117+
inner()
118+
```

crates/ty_python_semantic/resources/mdtest/import/star.md

Lines changed: 7 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -491,22 +491,13 @@ reveal_type(s) # revealed: Unknown
491491
# error: [unresolved-reference]
492492
reveal_type(t) # revealed: Unknown
493493

494-
# TODO: these should all reveal `Unknown | int` and should not emit errors.
495-
# (We don't generally model elsewhere in ty that bindings from walruses
496-
# "leak" from comprehension scopes into outer scopes, but we should.)
497-
# See https://github.com/astral-sh/ruff/issues/16954
498-
# error: [unresolved-reference]
499-
reveal_type(g) # revealed: Unknown
500-
# error: [unresolved-reference]
501-
reveal_type(i) # revealed: Unknown
502-
# error: [unresolved-reference]
503-
reveal_type(k) # revealed: Unknown
504-
# error: [unresolved-reference]
505-
reveal_type(m) # revealed: Unknown
506-
# error: [unresolved-reference]
507-
reveal_type(o) # revealed: Unknown
508-
# error: [unresolved-reference]
509-
reveal_type(q) # revealed: Unknown
494+
# PEP 572: walrus targets in comprehensions leak into the enclosing scope.
495+
reveal_type(g) # revealed: int
496+
reveal_type(i) # revealed: int
497+
reveal_type(k) # revealed: int
498+
reveal_type(m) # revealed: int
499+
reveal_type(o) # revealed: int
500+
reveal_type(q) # revealed: int
510501
```
511502

512503
### An annotation without a value is a definition in a stub but not a `.py` file

crates/ty_python_semantic/src/semantic_index/builder.rs

Lines changed: 93 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,23 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
251251
false
252252
}
253253

254+
/// Returns the enclosing non-comprehension scope for walrus operator targets,
255+
/// per [PEP 572]. Named expressions in comprehensions bind in the first
256+
/// enclosing scope that is *not* a comprehension.
257+
///
258+
/// Returns `None` if the current scope is not a comprehension.
259+
///
260+
/// [PEP 572]: https://peps.python.org/pep-0572/#scope-of-the-target
261+
fn enclosing_scope_for_walrus(&self) -> Option<FileScopeId> {
262+
if self.scopes[self.current_scope()].kind() != ScopeKind::Comprehension {
263+
return None;
264+
}
265+
self.scope_stack.iter().rev().skip(1).find_map(|info| {
266+
(self.scopes[info.file_scope_id].kind() != ScopeKind::Comprehension)
267+
.then_some(info.file_scope_id)
268+
})
269+
}
270+
254271
/// Push a new loop, returning the outer loop, if any.
255272
fn push_loop(&mut self) -> Option<Loop> {
256273
self.current_scope_info_mut()
@@ -668,8 +685,7 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
668685
definition
669686
}
670687

671-
fn delete_associated_bindings(&mut self, place: ScopedPlaceId) {
672-
let scope = self.current_scope();
688+
fn delete_associated_bindings_in_scope(&mut self, scope: FileScopeId, place: ScopedPlaceId) {
673689
// Don't delete associated bindings if the scope is a class scope & place is a name (it's never visible to nested scopes)
674690
if self.scopes[scope].kind() == ScopeKind::Class && place.is_symbol() {
675691
return;
@@ -687,19 +703,18 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
687703
self.current_use_def_map_mut().delete_binding(place);
688704
}
689705

690-
/// Push a new [`Definition`] onto the list of definitions
691-
/// associated with the `definition_node` AST node.
706+
/// Create a [`Definition`] for `definition_node` in the given `scope`, recording it in
707+
/// that scope's place table and use-def map.
692708
///
693-
/// Returns a 2-element tuple, where the first element is the newly created [`Definition`]
694-
/// and the second element is the number of definitions that are now associated with
695-
/// `definition_node`.
709+
/// Returns a 2-element tuple: the newly created [`Definition`] and the total number of
710+
/// definitions now associated with the AST node.
696711
///
697-
/// This method should only be used when adding a definition associated with a `*` import.
698-
/// All other nodes can only ever be associated with exactly 1 or 0 [`Definition`]s.
699-
/// For any node other than an [`ast::Alias`] representing a `*` import,
700-
/// prefer to use `self.add_definition()`, which ensures that this invariant is maintained.
701-
fn push_additional_definition(
712+
/// This is the low-level helper; callers that add definitions to the **current** scope
713+
/// should normally use [`Self::push_additional_definition`] or [`Self::add_definition`],
714+
/// which also update lazy snapshots and the try-node context stack.
715+
fn add_definition_in_scope(
702716
&mut self,
717+
scope: FileScopeId,
703718
place: ScopedPlaceId,
704719
definition_node: impl Into<DefinitionNodeRef<'ast, 'db>>,
705720
) -> (Definition<'db>, usize) {
@@ -711,14 +726,8 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
711726
let category = kind.category(self.source_type.is_stub(), self.module);
712727
let is_reexported = kind.is_reexported();
713728

714-
let definition: Definition<'db> = Definition::new(
715-
self.db,
716-
self.file,
717-
self.current_scope(),
718-
place,
719-
kind,
720-
is_reexported,
721-
);
729+
let definition: Definition<'db> =
730+
Definition::new(self.db, self.file, scope, place, kind, is_reexported);
722731

723732
let num_definitions = {
724733
let definitions = self.add_entry_for_definition_key(definition_node.key());
@@ -727,25 +736,53 @@ impl<'db, 'ast> SemanticIndexBuilder<'db, 'ast> {
727736
};
728737

729738
if category.is_binding() {
730-
self.mark_place_bound(place);
739+
self.place_tables[scope].mark_bound(place);
731740
}
732741
if category.is_declaration() {
733-
self.mark_place_declared(place);
742+
self.place_tables[scope].mark_declared(place);
734743
}
735744

736-
let use_def = self.current_use_def_map_mut();
737745
match category {
738746
DefinitionCategory::DeclarationAndBinding => {
739-
use_def.record_declaration_and_binding(place, definition);
740-
self.delete_associated_bindings(place);
747+
self.use_def_maps[scope].record_declaration_and_binding(place, definition);
748+
self.delete_associated_bindings_in_scope(scope, place);
749+
}
750+
DefinitionCategory::Declaration => {
751+
self.use_def_maps[scope].record_declaration(place, definition);
741752
}
742-
DefinitionCategory::Declaration => use_def.record_declaration(place, definition),
743753
DefinitionCategory::Binding => {
744-
use_def.record_binding(place, definition);
745-
self.delete_associated_bindings(place);
754+
self.use_def_maps[scope].record_binding(place, definition);
755+
self.delete_associated_bindings_in_scope(scope, place);
746756
}
747757
}
748758

759+
(definition, num_definitions)
760+
}
761+
762+
/// Push a new [`Definition`] onto the list of definitions
763+
/// associated with the `definition_node` AST node in the **current** scope.
764+
///
765+
/// Returns a 2-element tuple, where the first element is the newly created [`Definition`]
766+
/// and the second element is the number of definitions that are now associated with
767+
/// `definition_node`.
768+
///
769+
/// This method should only be used when adding a definition associated with a `*` import.
770+
/// All other nodes can only ever be associated with exactly 1 or 0 [`Definition`]s.
771+
/// For any node other than an [`ast::Alias`] representing a `*` import,
772+
/// prefer to use `self.add_definition()`, which ensures that this invariant is maintained.
773+
fn push_additional_definition(
774+
&mut self,
775+
place: ScopedPlaceId,
776+
definition_node: impl Into<DefinitionNodeRef<'ast, 'db>>,
777+
) -> (Definition<'db>, usize) {
778+
let scope = self.current_scope();
779+
let (definition, num_definitions) =
780+
self.add_definition_in_scope(scope, place, definition_node);
781+
782+
let category = definition
783+
.kind(self.db)
784+
.category(self.source_type.is_stub(), self.module);
785+
749786
if category.is_binding() {
750787
if let Some(id) = place.as_symbol() {
751788
self.update_lazy_snapshots(id);
@@ -2632,10 +2669,28 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
26322669
);
26332670
}
26342671
Some(CurrentAssignment::Named(named)) => {
2635-
// TODO(dhruvmanila): If the current scope is a comprehension, then the
2636-
// named expression is implicitly nonlocal. This is yet to be
2637-
// implemented.
2638-
self.add_definition(place_id, named);
2672+
if let Some(enclosing_scope) = self.enclosing_scope_for_walrus() {
2673+
// PEP 572: walrus in comprehension binds in enclosing scope.
2674+
let target_name = named
2675+
.target
2676+
.as_name_expr()
2677+
.expect("target should be a Name expression")
2678+
.id
2679+
.clone();
2680+
let (symbol_id, added) = self.place_tables[enclosing_scope]
2681+
.add_symbol(Symbol::new(target_name));
2682+
if added {
2683+
self.use_def_maps[enclosing_scope]
2684+
.add_place(symbol_id.into());
2685+
}
2686+
self.add_definition_in_scope(
2687+
enclosing_scope,
2688+
symbol_id.into(),
2689+
named,
2690+
);
2691+
} else {
2692+
self.add_definition(place_id, named);
2693+
}
26392694
}
26402695
Some(CurrentAssignment::Comprehension {
26412696
unpack,
@@ -2690,11 +2745,16 @@ impl<'ast> Visitor<'ast> for SemanticIndexBuilder<'_, 'ast> {
26902745
walk_expr(self, expr);
26912746
}
26922747
ast::Expr::Named(node) => {
2693-
// TODO walrus in comprehensions is implicitly nonlocal
26942748
self.visit_expr(&node.value);
26952749

26962750
// See https://peps.python.org/pep-0572/#differences-between-assignment-expressions-and-assignment-statements
26972751
if node.target.is_name_expr() {
2752+
// PEP 572: walrus in comprehension binds in the enclosing scope.
2753+
// Make the value a standalone expression so inference can evaluate
2754+
// it in the comprehension scope where the iteration variables are visible.
2755+
if self.enclosing_scope_for_walrus().is_some() {
2756+
self.add_standalone_expression(&node.value);
2757+
}
26982758
self.push_assignment(node.into());
26992759
self.visit_expr(&node.target);
27002760
self.pop_assignment();

crates/ty_python_semantic/src/types/infer/builder.rs

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11290,10 +11290,24 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
1129011290
fn infer_named_expression(&mut self, named: &ast::ExprNamed) -> Type<'db> {
1129111291
// See https://peps.python.org/pep-0572/#differences-between-assignment-expressions-and-assignment-statements
1129211292
if named.target.is_name_expr() {
11293-
let definition = self.index.expect_single_definition(named);
11294-
let result = infer_definition_types(self.db(), definition);
11295-
self.extend_definition(result);
11296-
result.binding_type(definition)
11293+
let db = self.db();
11294+
let file_scope_id = self.scope().file_scope_id(db);
11295+
11296+
if self.index.scope(file_scope_id).kind() == ScopeKind::Comprehension {
11297+
// PEP 572: walrus in comprehension binds in the enclosing scope.
11298+
// Infer the value via its standalone expression in this scope;
11299+
// the definition lives in the enclosing scope and will be
11300+
// inferred when that scope needs it.
11301+
let expression = self.index.expression(named.value.as_ref());
11302+
let result = infer_expression_types(db, expression, TypeContext::default());
11303+
self.extend_expression(result);
11304+
result.expression_type(named.value.as_ref())
11305+
} else {
11306+
let definition = self.index.expect_single_definition(named);
11307+
let result = infer_definition_types(db, definition);
11308+
self.extend_definition(result);
11309+
result.binding_type(definition)
11310+
}
1129711311
} else {
1129811312
// For syntactically invalid targets, we still need to run type inference:
1129911313
self.infer_expression(&named.target, TypeContext::default());
@@ -11316,7 +11330,17 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> {
1131611330

1131711331
let add = self.add_binding(named.target.as_ref().into(), definition);
1131811332

11319-
let ty = self.infer_expression(value, add.type_context());
11333+
// PEP 572: walrus in a comprehension binds in the enclosing scope, but
11334+
// the value references comprehension-scoped variables. The builder
11335+
// registers the value as a standalone expression in the comprehension
11336+
// scope so we can infer it there. We must not `extend` the result
11337+
// because expression IDs are only meaningful within their own scope.
11338+
let ty = if let Some(expression) = self.index.try_expression(value.as_ref()) {
11339+
let result = infer_expression_types(self.db(), expression, add.type_context());
11340+
result.expression_type(value.as_ref())
11341+
} else {
11342+
self.infer_expression(value, add.type_context())
11343+
};
1132011344
self.store_expression_type(target, ty);
1132111345
add.insert(self, ty)
1132211346
}

0 commit comments

Comments
 (0)