Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[red-knot] Resolve references in eager nested scopes eagerly #16079

Merged
merged 53 commits into from
Feb 19, 2025
Merged
Show file tree
Hide file tree
Changes from 35 commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
9d101d6
Improve encapsulation of `Scope`
AlexWaygood Nov 14, 2024
2789cd0
add a failing test
AlexWaygood Nov 14, 2024
23ed82d
existing tests pass
AlexWaygood Nov 15, 2024
5f566cd
Get a naive version working
AlexWaygood Nov 21, 2024
6ea3655
another passing test
AlexWaygood Nov 21, 2024
1e5ae86
another passing test
AlexWaygood Nov 21, 2024
16b63ba
add a failing test
AlexWaygood Nov 21, 2024
f4904b1
wip
AlexWaygood Nov 21, 2024
b8ffa78
Merge branch 'main' into alex/eager-scopes
dcreager Feb 10, 2025
e5eaab9
Fix merge conflicts
dcreager Feb 10, 2025
a3ea477
Fix type inference merge conflicts
dcreager Feb 10, 2025
4cd5ae0
This unknown is because there is no declared type
dcreager Feb 11, 2025
0c394ab
Add more commentary
dcreager Feb 11, 2025
df91210
Look for eager references in any enclosing scope, not just innermost
dcreager Feb 11, 2025
06f76ae
clippy
dcreager Feb 11, 2025
cf78c49
linter
dcreager Feb 11, 2025
54efbbe
Add a bunch of nested examples
dcreager Feb 11, 2025
7e0ef53
Merge branch 'main' into alex/eager-scopes
dcreager Feb 11, 2025
be8f9eb
lint
dcreager Feb 11, 2025
908d3e2
doc typo
dcreager Feb 11, 2025
a63c6e6
comment
dcreager Feb 11, 2025
5db470e
Merge branch 'main' into alex/eager-scopes
dcreager Feb 18, 2025
28d6513
Don't reexport UseDefMap
dcreager Feb 18, 2025
f72c268
Don't impl Deref
dcreager Feb 18, 2025
f647032
Expect all eager scopes to have nested ID
dcreager Feb 18, 2025
4eb38fd
Extract eager symbol lookup into separate function
dcreager Feb 18, 2025
de73d08
Name nit
dcreager Feb 18, 2025
dfb7384
Rework eager bindings storage
dcreager Feb 18, 2025
95a6bb8
Remove unneeded mem::take
dcreager Feb 18, 2025
e13ac91
Move more lookup logic into the semantic index builder
dcreager Feb 18, 2025
71194ba
Only consider bindings
dcreager Feb 18, 2025
3b5277e
Add test for class definition bindings
dcreager Feb 18, 2025
fc93843
Revert AstIds changes
dcreager Feb 18, 2025
4556f4d
Clarify expect message
dcreager Feb 18, 2025
03ce733
Move clone
dcreager Feb 18, 2025
fc6c009
Don't snapshot eager bindings for bound symbols
dcreager Feb 18, 2025
40d5be2
Add tests for deferred annotations
dcreager Feb 18, 2025
3cf2308
Add failing tests for eager scopes in global scope
dcreager Feb 18, 2025
8a2449f
Handle eager lookups in global scope
dcreager Feb 18, 2025
576f54d
Remove unneeded track_caller
dcreager Feb 18, 2025
c344871
Deferred expressions are always lazy
dcreager Feb 18, 2025
260756b
Remove stale TODO
dcreager Feb 18, 2025
b24cf07
Remove another stale track_caller
dcreager Feb 18, 2025
4931bd4
Remove stale clone/copy
dcreager Feb 18, 2025
1a7cbe8
Another track_caller
dcreager Feb 18, 2025
a55a01a
Merge branch 'main' into alex/eager-scopes
dcreager Feb 19, 2025
d76d362
Don't save eager bindings in class definitions
dcreager Feb 19, 2025
c65011f
Make test iterables non-empty
dcreager Feb 19, 2025
889a64b
Verify that eager bindings are used in public types of class attrs
dcreager Feb 19, 2025
888ba7b
Add technically incorrect test for generators evaluated later
dcreager Feb 19, 2025
cb22523
Add link to generator spec
dcreager Feb 19, 2025
86a243c
lint
dcreager Feb 19, 2025
f474b43
fix heading
dcreager Feb 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ class IntIterable:
def __iter__(self) -> IntIterator:
return IntIterator()

# TODO: This could be a `tuple[int, int]` if we model that `y` can not be modified in the outer comprehension scope
# revealed: tuple[int, Unknown | int]
# revealed: tuple[int, int]
[[reveal_type((x, y)) for x in IntIterable()] for y in IntIterable()]
```

Expand All @@ -67,8 +66,7 @@ class IterableOfIterables:
def __iter__(self) -> IteratorOfIterables:
return IteratorOfIterables()

# TODO: This could be a `tuple[int, int]` (see above)
# revealed: tuple[int, Unknown | IntIterable]
# revealed: tuple[int, IntIterable]
[[reveal_type((x, y)) for x in y] for y in IterableOfIterables()]
```

Expand Down
203 changes: 203 additions & 0 deletions crates/red_knot_python_semantic/resources/mdtest/scopes/eager.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# Eager scopes

Some scopes are executed eagerly: references to variables defined in enclosing scopes are resolved
_immediately_. This is in constrast to (for instance) function scopes, where those references are
resolved when the function is called.

## Function definitions

Function definitions are evaluated lazily.

```py
x = 1

def f():
reveal_type(x) # revealed: Unknown | Literal[2]

x = 2
```

## Class definitions

Class definitions are evaluated eagerly.

```py
def _():
x = 1

class A:
reveal_type(x) # revealed: Literal[1]

x = 2
```

## List comprehensions

List comprehensions are evaluated eagerly.

```py
def _():
x = 1

# revealed: Literal[1]
[reveal_type(x) for a in range(0)]

x = 2
```

## Set comprehensions

Set comprehensions are evaluated eagerly.

```py
def _():
x = 1

# revealed: Literal[1]
{reveal_type(x) for a in range(0)}

x = 2
```

## Dict comprehensions

Dict comprehensions are evaluated eagerly.

```py
def _():
x = 1

# revealed: Literal[1]
{a: reveal_type(x) for a in range(0)}

x = 2
```

## Generator expressions

Generator expressions don't necessarily run eagerly, but in practice usually they do, so assuming
they do is the better default.
Comment on lines +82 to +83
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if it might be good to explicitly include an example of a generator-expression scope that does not run eagerly? It might be good to document that yes, this causes some incorrect assumptions from us in some edge cases, but that this is a cost we accept because (as you say) generator expressions nearly always run eagerly in practice. One example might be something like this: we report the revealed type of z is Literal[42]:

from typing_extensions import reveal_type

def _(flag: bool):
    y = (0,)
    z = 42
    gen = (x + reveal_type(z) for x in y)
    if flag:
        z = 56
    print(next(gen))

but at runtime, the print() call reveals that the runtime value of z can actually be 56 when the scope of gen() is actually evaluated:

>>> from typing import reveal_type
... 
... def _(flag: bool):
...     y = (0,)
...     z = 42
...     gen = (x + reveal_type(z) for x in y)
...     if flag:
...         z = 56
...     print(next(gen))
...     
>>> _(True)
Runtime type is 'int'
56

(Again, I'm not saying we should try to account for this -- generator expressions are almost always evaluated eagerly, and it would be hard to detect cases like this when they're not! Just suggesting we could add a test to explicitly document the shortcoming.)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. I also added a test for how the "first iterable" is evaluated eagerly, even if the generator itself is not used immediately.

This example tells me that we'd want to handle generators the same as functions, if we ever start tracking where they're called as part of the public types work (#16079 (review)). After all, that's precisely the weirdness that's happening here — the generator includes a __next__ method that might be called at any arbitrary place, just like the f function in your linked comment might be called at any arbitrary place. If we start tracking that, we should use the same mechanism for both.


```py
def _():
x = 1

# revealed: Literal[1]
list(reveal_type(x) for a in range(0))

x = 2
```

## Lazy scopes are "sticky"

As we look through each enclosing scope when resolving a reference, lookups become lazy as soon as
we encounter any lazy scope, even if there are other eager scopes that enclose it.

### Eager scope within eager scope

If we don't encounter a lazy scope, lookup remains eager. The resolved binding is not necessarily in
the immediately enclosing scope. Here, the list comprehension and class definition are both eager
scopes, and we immediately resolve the use of `x` to (only) the `x = 1` binding.

```py
def _():
x = 1

class A:
# revealed: Literal[1]
[reveal_type(x) for a in range(0)]

x = 2
```

### Class definition bindings are not visible in nested scopes

Class definitions are eager scopes, but any bindings in them are explicitly not visible to any
nested scopes. (Those nested scopes are typically (lazy) function definitions, but the rule also
applies to nested eager scopes like comprehensions and other class definitions.)

```py
def _():
x = 1

class A:
x = 4

# revealed: Literal[1]
[reveal_type(x) for a in range(0)]

class B:
# revealed: Literal[1]
[reveal_type(x) for a in range(0)]

x = 2
```

### Eager scope within a lazy scope

The list comprehension is an eager scope, and it is enclosed within a function definition, which is
a lazy scope. Because we pass through this lazy scope before encountering any bindings or
definitions, the lookup is lazy.

```py
def _():
x = 1

def f():
# revealed: Unknown | Literal[2]
[reveal_type(x) for a in range(0)]
x = 2
```

### Lazy scope within an eager scope

The function definition is a lazy scope, and it is enclosed within a class definition, which is an
eager scope. Even though we pass through an eager scope before encountering any bindings or
definitions, the lookup remains lazy.

```py
def _():
x = 1

class A:
def f():
# revealed: Unknown | Literal[2]
reveal_type(x)

x = 2
```

### Lazy scope within a lazy scope

No matter how many lazy scopes we pass through before encountering a binding or definition, the
lookup remains lazy.

```py
def _():
x = 1

def f():
def g():
# revealed: Unknown | Literal[2]
reveal_type(x)
x = 2
```

## Eager scope within a lazy scope within another eager scope

We have a list comprehension (eager scope), enclosed within a function definition (lazy scope),
enclosed within a class definition (eager scope), all of which we must pass through before
encountering any binding of `x`. Even though the last scope we pass through is eager, the lookup is
lazy, since we encountered a lazy scope on the way.

```py
def _():
x = 1

class A:
def f():
# revealed: Unknown | Literal[2]
[reveal_type(x) for a in range(0)]

x = 2
```
29 changes: 24 additions & 5 deletions crates/red_knot_python_semantic/src/semantic_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use crate::semantic_index::expression::Expression;
use crate::semantic_index::symbol::{
FileScopeId, NodeWithScopeKey, NodeWithScopeRef, Scope, ScopeId, ScopedSymbolId, SymbolTable,
};
use crate::semantic_index::use_def::UseDefMap;
use crate::semantic_index::use_def::{EagerBindingsKey, ScopedEagerBindingsId, UseDefMap};
use crate::Db;

pub mod ast_ids;
Expand Down Expand Up @@ -165,6 +165,9 @@ pub(crate) struct SemanticIndex<'db> {
/// Maps from class body scopes to attribute assignments that were found
/// in methods of that class.
attribute_assignments: FxHashMap<FileScopeId, Arc<AttributeAssignments<'db>>>,

/// Map of all of the eager bindings that appear in this file.
eager_bindings: FxHashMap<EagerBindingsKey, ScopedEagerBindingsId>,
}

impl<'db> SemanticIndex<'db> {
Expand Down Expand Up @@ -220,7 +223,7 @@ impl<'db> SemanticIndex<'db> {
/// Returns the id of the parent scope.
pub(crate) fn parent_scope_id(&self, scope_id: FileScopeId) -> Option<FileScopeId> {
let scope = self.scope(scope_id);
scope.parent
scope.parent()
}

/// Returns the parent scope of `scope_id`.
Expand Down Expand Up @@ -290,6 +293,22 @@ impl<'db> SemanticIndex<'db> {
pub(super) fn has_future_annotations(&self) -> bool {
self.has_future_annotations
}

/// Returns an iterator of bindings for a particular nested eager scope reference.
pub(crate) fn eager_bindings(
&self,
enclosing_scope: FileScopeId,
enclosing_symbol: ScopedSymbolId,
nested_scope: FileScopeId,
) -> Option<BindingWithConstraintsIterator<'_, 'db>> {
let key = EagerBindingsKey {
enclosing_scope,
enclosing_symbol,
nested_scope,
};
let id = self.eager_bindings.get(&key)?;
self.use_def_maps[enclosing_scope].eager_bindings(*id)
}
}

pub struct AncestorsIter<'a> {
Expand All @@ -312,7 +331,7 @@ impl<'a> Iterator for AncestorsIter<'a> {
fn next(&mut self) -> Option<Self::Item> {
let current_id = self.next_id?;
let current = &self.scopes[current_id];
self.next_id = current.parent;
self.next_id = current.parent();

Some((current_id, current))
}
Expand All @@ -328,7 +347,7 @@ pub struct DescendentsIter<'a> {
impl<'a> DescendentsIter<'a> {
fn new(symbol_table: &'a SemanticIndex, scope_id: FileScopeId) -> Self {
let scope = &symbol_table.scopes[scope_id];
let scopes = &symbol_table.scopes[scope.descendents.clone()];
let scopes = &symbol_table.scopes[scope.descendents()];

Self {
next_id: scope_id + 1,
Expand Down Expand Up @@ -378,7 +397,7 @@ impl<'a> Iterator for ChildrenIter<'a> {

fn next(&mut self) -> Option<Self::Item> {
self.descendents
.find(|(_, scope)| scope.parent == Some(self.parent))
.find(|(_, scope)| scope.parent() == Some(self.parent))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ impl AstIds {
})
}

#[track_caller]
fn use_id(&self, key: impl Into<ExpressionNodeKey>) -> ScopedUseId {
self.uses_map[&key.into()]
}
Expand Down
Loading
Loading