Skip to content

Variable Scoping Rules

No global or nonlocal Keywords

Sharpy does not support Python's global or nonlocal keywords. This aligns with C# scoping semantics:

# ❌ Invalid - these keywords don't exist in Sharpy
global x       # ERROR: unexpected 'global'
nonlocal y     # ERROR: unexpected 'nonlocal'

Assignment to a name bound in an enclosing scope — including an enclosing function's local — writes through to that binding; use let (let x = ...) or a new name to shadow instead. In Stage 1 an annotated declaration (x: T = ...) also shadows (#1974). This applies uniformly to blocks and nested functions (C# closure semantics).

Block Scoping

Sharpy uses C#-style block scoping: all compound statement bodies introduce a new scope. Variables declared inside a block are not visible outside it. This is a deliberate departure from Python, where variables leak out of most blocks.

Block-Scoped Compound Statements (variables declared inside don't leak): - if / elif / else bodies - while body - for body (including the loop variable itself) - try body - except body (including the as binding) - else body (in try/except/else) - finally body - with body - Comprehensions (including walrus assignments inside comprehensions)

Note on try/except/else/finally: Variables declared in the try body are not visible in except, else, or finally handlers. If a variable must be accessible across all clauses, declare it before the try statement:

def risky_operation() -> int:
    return int("42")

result: int = 0
try:
    result = risky_operation()
except ValueError as e:
    print(f"Failed: {e}")
finally:
    print(f"Result was: {result}")

Containing-Scope Constructs (variable persists): - Declarations in a function body or module top-level (outside any compound statement) - Walrus operator (x := value) in non-block contexts - see Walrus Operator

Walrus Operator Scoping:

The walrus operator (:=) assigns to the containing scope. In most cases this is the enclosing function or module. However, inside block-scoped constructs like comprehensions, the walrus variable is scoped to that block:

import re

def main() -> None:
    pattern = re.compile("[0-9]+")
    text = "order 66"
    # Walrus in if-statement: variable persists in containing scope
    if (m := pattern.search(text)) is not None:
        print(m)  # OK
    print(m)      # OK - walrus assigned in containing scope
def transform(v: int) -> int:
    return v - 1

def main() -> None:
    items = [1, 2, 3]
    # Walrus in comprehension: variable is comprehension-local
    results = [y * 2 for x in items if (y := transform(x)) > 0]
    print(y)          # ERROR: 'y' does not exist in this scope

Note: This differs from Python 3.8+, where walrus in comprehensions leaks to the outer scope. In Sharpy, the syntactic boundary equals the semantic boundary—comprehension delimiters ([...], {...}) which fully contain all variables declared within.

Example

x = "outer"

for x in range(5):      # New 'x' shadows outer, block-scoped
    print(x)            # Prints 0, 1, 2, 3, 4

print(x)                # Prints "outer", 'x' was shadowed only
                        # in the for-loop, and not modified.

Write-Through Assignment

Assignment to a name that already exists in an enclosing scope writes through to it — no nonlocal keyword needed:

x = 0
for i in range(5):      # 'i' is block-scoped
    x += i              # Modifies outer 'x'
print(x)                # 10
for i in range(5):
    print(i)
print(i)                # ERROR: 'i' is block-scoped

This applies to nested functions too (C# closure semantics — captured by reference):

def main():
    n: int = 1
    def double_it():
        n = n * 2        # Writes through to outer 'n'
    double_it()
    print(n)             # 2

    count: int = 0
    adder = lambda: count + 1  # Reads outer 'count'
    count = 10
    print(adder())       # 11 — lambda sees the updated value

To create a new local that shadows an outer name, use let:

def main():
    let x = 1
    def shadow_it():
        let x = 2         # New local — does NOT modify outer 'x'
        print(x)          # 2
    shadow_it()
    print(x)              # 1 — outer 'x' unchanged

A bare x = 2 in shadow_it would instead write through and print 2 twice. In Stage 1 an annotated declaration (x: int = 2) also creates the new local (#1974).

Class-Body Names Are Not Visible by Bare Name Inside Methods

Class and struct bodies define their own scope, but this scope is not a closure scope for methods. Methods cannot read or write class-body names by bare name — they must use self.name, or ClassName.name for a const or @static member. This matches Python's class-scope semantics. The rule is about members, not about the lines of the body in front of you: a field or property the class inherits, and a property the body declares, are members too and get the same treatment (see Inherited members and properties).

class Counter:
    count: int = 0

    def increment(self) -> None:
        count = count + 1     # ERROR (SPY0606) — bare store to class attribute
        print(count)          # ERROR (SPY0200) — bare read of class attribute

    def correct(self) -> None:
        self.count += 1       # OK — instance attribute via self
        let count = 99        # OK — 'let' creates a new local
        print(count)          # 99

The SPY0606 refusal steers to the member spelling first (self.count = ... for an instance attribute, ClassName.count = ... for a const or @static one), then to let count = ... for a new local, then to the Stage 1 annotated count: <type> = ....

ClassName.name is the spelling for a type-level member — a const or an @static field. An instance field reached through the type name is a different error (SPY0290), so the diagnostic offers ClassName.name only when it compiles:

class Registry:
    @static
    total: int = 0

    @static
    def bump() -> None:
        total += 1            # ERROR (SPY0606) — "Use 'Registry.total = ...'"
        Registry.total += 1   # OK — @static field via the class name

Every store form is refused, not only the plain one

A bare name in a class body is a member in every write position, so each is refused by name rather than silently declaring a local:

class Counter:
    count: int = 0

    def forms(self) -> None:
        count = 1             # SPY0606 — plain store
        count += 1            # SPY0606 — augmented store
        print(count := 1)     # SPY0606 — walrus
        count, n = 1, 2       # SPY0606 — tuple-unpacking element
        count ??= 1           # SPY0606 — null-coalescing store

A bare store to a class const says so, and offers only the shadowing local — a constant cannot be assigned through any spelling:

class C:
    const K: int = 1

    def m(self) -> None:
        K = 2    # SPY0606 — "'C.K' is a constant and cannot be assigned"

Which bodies the rule applies to

Every function-like body: methods, property accessors, event accessors, property observers, nested defs, and lambdas — including a lambda in a class-field initializer, which has no self at all.

class Config:
    name: str = "default"

    property get label(self) -> str:
        return self.name      # OK
        # return name         # SPY0200 — bare read of a class attribute

    def make_greeter(self) -> () -> str:
        return lambda: self.name   # OK — the lambda reaches the field through self

Inherited members and properties are members too

A member is a member whether the enclosing body declares it or a base class's body does, and whether it is a field or a property. The bare read is SPY0200 with the same self. steer, and the bare store is SPY0606 in every store form — the refusal names the inheritance so the reader is not sent looking for a declaration in the body in front of them. In C# a bare v = 7 in a method writes the inherited field; in Python it declares a local; a Sharpy program that spells it is a mistake either way, and the shadowing local is still one let away:

class Base:
    v: int = 5

    property get label(self) -> str:
        return "base"

class Derived(Base):
    def m(self) -> int:
        v = 7                 # SPY0606 — "Cannot assign to inherited class attribute 'v' by bare name"
        label = "x"           # SPY0606 — a property is a member too
        print(v, label)       # SPY0200 — bare reads, steered to 'self.v' and 'self.label'
        return self.v         # OK

    def shadow(self) -> int:
        let v = 7             # OK — 'let' declares a new local
        print(v)              # 7
        return self.v         # 5 — the inherited field is untouched

An inherited const or @static field is steered to the declaring class's name, which is the spelling that compiles: Base.K, not Derived.K.

Parameter defaults still see the class body

A parameter default belongs to the signature, which is resolved in the scope that declares the method — the class body — not in the method body:

class Grid:
    const SIZE: int = 8

    def resize(self, n: int = SIZE) -> None:   # OK — the default is resolved in class scope
        print(n)

The same holds for decorator arguments and type annotations. Names in the method's body are resolved by the rule above.

Pattern heads are not reads

A case head naming a class const matches that constant; it does not degrade into a capture:

class C:
    const A: str = "x"

    def m(self, v: str) -> None:
        match v:
            case A:           # matches C.A, with SPY0468 noting the constant
                print("hit")
            case _:
                print("miss")

Nested types and module names remain visible inside methods — the rule applies only to variable and constant bindings in a class or struct body, not to type declarations or to names from the enclosing module or global scope. When a module-level variable and a class attribute share a name, a bare use inside a method binds the module variable, exactly as Python does.

Assignment Statement

# Simple assignment
x = 10
count = 1

# Multiple assignment (unpacking)
x, y = 10, 20

# Augmented assignment
x += 5
count *= 2

Sibling-Block Redeclaration

Each block scope is independent — the same name can be reused across sibling blocks without collision:

def main():
    try:
        x = 1
        print(x)          # 1
    except Exception:
        x = 2
        print(x)          # 2 (different 'x')
    finally:
        x = 3
        print(x)          # 3 (different 'x')

    # After the try, 'x' from any block is not visible:
    x = 99                 # New outer declaration
    print(x)               # 99

Variable Shadowing

let always introduces a new variable, so a name can be redeclared in the same scope, with the same or a different type; the new variable shadows the old one for the rest of the scope:

def main():
    let x = 5               # Initial declaration
    x = 10                  # Assignment (same type)
    let x = "hello"         # Shadowing: a new variable of type str
    print(x)                # hello

Stage 1 also accepts the keywordless shadowing forms, which Stage 2 retires (#1974): an annotated declaration shadows, and x: auto = e is the deprecated spelling of let x = e:

def main():
    x: int = 5
    x: str = "hello"        # Stage 1: an annotated declaration shadows
    x: auto = [1, 2, 3]     # Deprecated: write 'let x = [1, 2, 3]'
    print(x)                # [1, 2, 3]

Implementation: - 🔄 Lowered — LocalNameAllocator assigns C# spellings with monotonic integer versioning (x, x_1, x_2) during CodeGenInfoComputer.ComputeForModule. Rebinding chains link each redefinition to its predecessor; chain members share the root's spelling. A let target never joins a chain: let re-points the trigger that starts a new binding (formerly only the annotation), and the allocator is unchanged — a let shadow is versioned exactly like an annotated one. The emitter reads CodeGenInfo and TargetBinding and owns no local-slot state.