ruff/crates/ty_python_semantic/resources/mdtest/scopes/nonlocal.md
Jack O'Connor 5f2e855c29 allow reads of "free" variables to refer to a global declaration
Previously this worked if there was also a binding in the same scope as
the `global` declaration (probably almost always the case), but CPython
doesn't require this.

This change surfaced an error in an existing test, where a global
variable was only ever declared and bound using the `global` keyword,
and never mentioned explicitly in the global scope. @AlexWaygood
suggested we probably want to keep that requirement, so I'm adding an a
new test for that on top of fixing the failing test.
2025-07-16 08:30:42 -07:00

9 KiB

Nonlocal references

One level up

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

Two levels up

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

Skips class scope

def f():
    x = 1

    class C:
        x = 2
        def g():
            reveal_type(x)  # revealed: Unknown | Literal[1]

Reads respect annotation-only declarations

def f():
    x: int = 1
    def g():
        # TODO: This example should actually be an unbound variable error. However to avoid false
        # positives, we'd need to analyze `nonlocal x` statements in other inner functions.
        x: str
        def h():
            reveal_type(x)  # revealed: str

Reads terminate at the global keyword in an enclosing scope, even if there's no binding in that scope

Unlike variables that are explicitly declared nonlocal (below), implicitly nonlocal ("free") reads can come from a variable that's declared global in an enclosing scope. It doesn't matter whether the variable is bound in that scope:

x: int = 1

def f():
    x: str = "hello"
    def g():
        global x
        def h():
            # allowed: this loads the global `x` variable due to the `global` declaration in the immediate enclosing scope
            y: int = x

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:

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:

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:

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):

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):

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:

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:

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"

def f():
    # A *use* of `x` in an enclosing scope isn't good enough. There needs to be a binding.
    print(x)
    def g():
        nonlocal x  # error: [invalid-syntax] "no binding for nonlocal `x` found"

A class-scoped x also doesn't work:

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:

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

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:

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:

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:

x = 1

def f():
    x = 2
    def g():
        global x
        def h():
            nonlocal x  # error: [invalid-syntax] "no binding for nonlocal `x` found"

Assigning to a nonlocal respects the declared type from its defining scope, even without a binding in that scope

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

# Global definitions of `x`, `y`, and `z`.
x: bool = True
y: bool = True
z: bool = True

def f1():
    # Local definitions of `x`, `y`, and `z`.
    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, shadows `y` without a type declaration, and
                # declares `z` global.
                nonlocal x
                x = 4
                y = 5
                global z

                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

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:

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:

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:

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

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:

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:

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:

def f():
    def g():
        # This is allowed, because of the subsequent definition of `x`.
        nonlocal x
    x = 1