[ty] add support for nonlocal statements

This commit is contained in:
Jack O'Connor 2025-07-02 19:08:32 -07:00
parent 110765154f
commit 78bd73f25a
11 changed files with 625 additions and 44 deletions

View file

@ -1304,7 +1304,7 @@ scope of the name that was declared `global`, can add a symbol to the global nam
def f():
global g, h
g: bool = True
g = True
f()
```

View file

@ -83,7 +83,7 @@ def f():
x = 1
def g() -> None:
nonlocal x
global x # TODO: error: [invalid-syntax] "name 'x' is nonlocal and global"
global x # error: [invalid-syntax] "name `x` is nonlocal and global"
x = None
```
@ -209,5 +209,18 @@ x: int = 1
def f():
global x
x: str = "foo" # TODO: error: [invalid-syntax] "annotated name 'x' can't be global"
x: str = "foo" # error: [invalid-syntax] "annotated name `x` can't be global"
```
## Global declarations affect the inferred type of the binding
Even if the `global` declaration isn't used in an assignment, we conservatively assume it could be:
```py
x = 1
def f():
global x
# TODO: reveal_type(x) # revealed: Unknown | Literal["1"]
```

View file

@ -43,3 +43,321 @@ def f():
def h():
reveal_type(x) # revealed: Unknown | Literal[1]
```
## The `nonlocal` keyword
Without the `nonlocal` keyword, bindings in an inner scope shadow variables of the same name in
enclosing scopes. This example isn't a type error, because the inner `x` shadows the outer one:
```py
def f():
x: int = 1
def g():
x = "hello" # allowed
```
With `nonlocal` it is a type error, because `x` refers to the same place in both scopes:
```py
def f():
x: int = 1
def g():
nonlocal x
x = "hello" # error: [invalid-assignment] "Object of type `Literal["hello"]` is not assignable to `int`"
```
## Local variable bindings "look ahead" to any assignment in the current scope
The binding `x = 2` in `g` causes the earlier read of `x` to refer to `g`'s not-yet-initialized
binding, rather than to `x = 1` in `f`'s scope:
```py
def f():
x = 1
def g():
if x == 1: # error: [unresolved-reference] "Name `x` used when not defined"
x = 2
```
The `nonlocal` keyword makes this example legal (and makes the assignment `x = 2` affect the outer
scope):
```py
def f():
x = 1
def g():
nonlocal x
if x == 1:
x = 2
```
For the same reason, using the `+=` operator in an inner scope is an error without `nonlocal`
(unless you shadow the outer variable first):
```py
def f():
x = 1
def g():
x += 1 # error: [unresolved-reference] "Name `x` used when not defined"
def f():
x = 1
def g():
x = 1
x += 1 # allowed, but doesn't affect the outer scope
def f():
x = 1
def g():
nonlocal x
x += 1 # allowed, and affects the outer scope
```
## `nonlocal` declarations must match an outer binding
`nonlocal x` isn't allowed when there's no binding for `x` in an enclosing scope:
```py
def f():
def g():
nonlocal x # error: [invalid-syntax] "no binding for nonlocal `x` found"
def f():
x = 1
def g():
nonlocal x, y # error: [invalid-syntax] "no binding for nonlocal `y` found"
```
A global `x` doesn't work. The target must be in a function-like scope:
```py
x = 1
def f():
def g():
nonlocal x # error: [invalid-syntax] "no binding for nonlocal `x` found"
def f():
global x
def g():
nonlocal x # error: [invalid-syntax] "no binding for nonlocal `x` found"
```
A class-scoped `x` also doesn't work:
```py
class Foo:
x = 1
@staticmethod
def f():
nonlocal x # error: [invalid-syntax] "no binding for nonlocal `x` found"
```
However, class-scoped bindings don't break the `nonlocal` chain the way `global` declarations do:
```py
def f():
x: int = 1
class Foo:
x: str = "hello"
@staticmethod
def g():
# Skips the class scope and reaches the outer function scope.
nonlocal x
x = 2 # allowed
x = "goodbye" # error: [invalid-assignment]
```
## `nonlocal` uses the closest binding
```py
def f():
x = 1
def g():
x = 2
def h():
nonlocal x
reveal_type(x) # revealed: Unknown | Literal[2]
```
## `nonlocal` "chaining"
Multiple `nonlocal` statements can "chain" through nested scopes:
```py
def f():
x = 1
def g():
nonlocal x
def h():
nonlocal x
reveal_type(x) # revealed: Unknown | Literal[1]
```
And the `nonlocal` chain can skip over a scope that doesn't bind the variable:
```py
def f1():
x = 1
def f2():
nonlocal x
def f3():
# No binding; this scope gets skipped.
def f4():
nonlocal x
reveal_type(x) # revealed: Unknown | Literal[1]
```
But a `global` statement breaks the chain:
```py
def f():
x = 1
def g():
global x
def h():
nonlocal x # error: [invalid-syntax] "no binding for nonlocal `x` found"
```
## `nonlocal` bindings respect declared types from the defining scope, even without a binding
```py
def f():
x: int
def g():
nonlocal x
x = "string" # error: [invalid-assignment] "Object of type `Literal["string"]` is not assignable to `int`"
```
## A complicated mixture of `nonlocal` chaining, empty scopes, class scopes, and the `global` keyword
```py
def f1():
# The original bindings of `x`, `y`, and `z` with type declarations.
x: int = 1
y: int = 2
z: int = 3
def f2():
# This scope doesn't touch `x`, `y`, or `z` at all.
class Foo:
# This class scope is totally ignored.
x: str = "a"
y: str = "b"
z: str = "c"
@staticmethod
def f3():
# This scope declares `x` nonlocal and `y` as global, and it shadows `z` without
# giving it a type declaration.
nonlocal x
x = 4
y = 5
global z
z = 6
def f4():
# This scope sees `x` from `f1` and `y` from `f3`. It *can't* declare `z`
# nonlocal, because of the global statement above, but it *can* load `z` as a
# "free" variable, in which case it sees the global value.
nonlocal x, y, z # error: [invalid-syntax] "no binding for nonlocal `z` found"
x = "string" # error: [invalid-assignment]
y = "string" # allowed, because `f3`'s `y` is untyped
reveal_type(z) # revealed: Unknown | Literal[6]
```
## TODO: `nonlocal` affects the inferred type in the outer scope
Without `nonlocal`, `g` can't write to `x`, and the inferred type of `x` in `f`'s scope isn't
affected by `g`:
```py
def f():
x = 1
def g():
reveal_type(x) # revealed: Unknown | Literal[1]
reveal_type(x) # revealed: Literal[1]
```
But with `nonlocal`, `g` could write to `x`, and that affects its inferred type in `f`. That's true
regardless of whether `g` actually writes to `x`. With a write:
```py
def f():
x = 1
def g():
nonlocal x
reveal_type(x) # revealed: Unknown | Literal[1]
x += 1
reveal_type(x) # revealed: Unknown | Literal[2]
# TODO: should be `Unknown | Literal[1]`
reveal_type(x) # revealed: Literal[1]
```
Without a write:
```py
def f():
x = 1
def g():
nonlocal x
reveal_type(x) # revealed: Unknown | Literal[1]
# TODO: should be `Unknown | Literal[1]`
reveal_type(x) # revealed: Literal[1]
```
## Annotating a `nonlocal` binding is a syntax error
```py
def f():
x: int = 1
def g():
nonlocal x
x: str = "foo" # error: [invalid-syntax] "annotated name `x` can't be nonlocal"
```
## Use before `nonlocal`
Using a name prior to its `nonlocal` declaration in the same scope is a syntax error:
```py
def f():
x = 1
def g():
x = 2
nonlocal x # error: [invalid-syntax] "name `x` is used prior to nonlocal declaration"
```
This is true even if there are multiple `nonlocal` declarations of the same variable, as long as any
of them come after the usage:
```py
def f():
x = 1
def g():
nonlocal x
x = 2
nonlocal x # error: [invalid-syntax] "name `x` is used prior to nonlocal declaration"
def f():
x = 1
def g():
nonlocal x
nonlocal x
x = 2 # allowed
```
## `nonlocal` before outer initialization
`nonlocal x` works even if `x` isn't bound in the enclosing scope until afterwards:
```py
def f():
def g():
# This is allowed, because of the subsequent definition of `x`.
nonlocal x
x = 1
```

View file

@ -147,8 +147,7 @@ def nonlocal_use():
X: Final[int] = 1
def inner():
nonlocal X
# TODO: this should be an error
X = 2
X = 2 # error: [invalid-assignment] "Reassignment of `Final` symbol `X` is not allowed: Reassignment of `Final` symbol"
```
`main.py`: