[ty] more precise lazy scope place lookup (#19932)

## Summary

This is a follow-up to https://github.com/astral-sh/ruff/pull/19321.

Now lazy snapshots are updated to take into account new bindings on
every symbol reassignment.

```python
def outer(x: A | None):
    if x is None:
        x = A()

    reveal_type(x)  # revealed: A

    def inner() -> None:
        # lazy snapshot: {x: A}
        reveal_type(x)  # revealed: A
    inner()

def outer() -> None:
    x = None

    x = 1

    def inner() -> None:
        # lazy snapshot: {x: Literal[1]} -> {x: Literal[1, 2]}
        reveal_type(x)  # revealed: Literal[1, 2]
    inner()

    x = 2
```

Closes astral-sh/ty#559.

## Test Plan

Some TODOs in `public_types.md` now work properly.

---------

Co-authored-by: Carl Meyer <carl@astral.sh>
This commit is contained in:
Shunsuke Shibayama 2025-09-09 06:08:35 +09:00 committed by GitHub
parent aa5d665d52
commit 08a561fc05
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 208 additions and 84 deletions

View file

@ -248,6 +248,13 @@ def f(x: str | None):
reveal_type(x) # revealed: str | None
x = None
def f(x: str | None):
def _(x: str | None):
if x is not None:
def closure():
reveal_type(x) # revealed: str
x = None
def f(x: str | None):
class C:
def _():
@ -303,13 +310,17 @@ no longer valid in the inner lazy scope.
def f(l: list[str | None]):
if l[0] is not None:
def _():
reveal_type(l[0]) # revealed: str | None
# TODO: should be `str | None`
reveal_type(l[0]) # revealed: str | None | @Todo(list literal element type)
# TODO: should be of type `list[None]`
l = [None]
def f(l: list[str | None]):
l[0] = "a"
def _():
reveal_type(l[0]) # revealed: str | None
# TODO: should be `str | None`
reveal_type(l[0]) # revealed: str | None | @Todo(list literal element type)
# TODO: should be of type `list[None]`
l = [None]
def f(l: list[str | None]):

View file

@ -83,6 +83,18 @@ def outer(flag: bool) -> None:
x = C()
inner()
def outer(flag: bool) -> None:
if flag:
x = A()
else:
x = B() # this binding of `x` is invisible to `inner`
return
def inner() -> None:
reveal_type(x) # revealed: A | C
x = C()
inner()
```
If a symbol is only conditionally bound, we do not raise any errors:
@ -186,6 +198,59 @@ def outer(x: A | None):
inner()
```
"Reassignment" here refers to a thing that happens after the closure is defined that can actually
change the inferred type of a captured symbol. Something done before the closure definition is more
of a shadowing, and doesn't actually invalidate narrowing.
```py
def outer() -> None:
x = None
def inner() -> None:
# In this scope, `x` may refer to `x = None` or `x = 1`.
reveal_type(x) # revealed: None | Literal[1]
inner()
x = 1
inner()
def inner2() -> None:
# In this scope, `x = None` appears as being shadowed by `x = 1`.
reveal_type(x) # revealed: Literal[1]
inner2()
def outer() -> None:
x = None
x = 1
def inner() -> None:
reveal_type(x) # revealed: Literal[1, 2]
inner()
x = 2
def outer(x: A | None):
if x is None:
x = A()
reveal_type(x) # revealed: A
def inner() -> None:
reveal_type(x) # revealed: A
inner()
def outer(x: A | None):
x = x or A()
reveal_type(x) # revealed: A
def inner() -> None:
reveal_type(x) # revealed: A
inner()
```
## At module level
The behavior is the same if the outer scope is the global scope of a module:
@ -265,37 +330,17 @@ def outer() -> None:
inner()
```
This is currently even true if the `inner` function is only defined after the second assignment to
`x`:
And, in the current implementation, shadowing of module symbols (i.e., symbols exposed to other
modules) cannot be recognized from lazy scopes.
```py
def outer() -> None:
x = None
class A: ...
class A: ...
# [additional code here]
x = 1
def inner() -> None:
# TODO: this should be `Literal[1]`. Mypy and pyright support this.
reveal_type(x) # revealed: None | Literal[1]
inner()
```
A similar case derived from an ecosystem example, involving declared types:
```py
class C: ...
def outer(x: C | None):
x = x or C()
reveal_type(x) # revealed: C
def inner() -> None:
# TODO: this should ideally be `C`
reveal_type(x) # revealed: C | None
inner()
def f(x: A):
# TODO: no error
# error: [invalid-assignment] "Object of type `A | A` is not assignable to `A`"
x = A()
```
### Assignments to nonlocal variables