Skip to content

astero.scopes

The scope tree, and the names each block binds.

astero.scopes

Scopes, derived from a declared grammar.

The grammar already says which fields bind a name. This adds the second half: which productions open a scope, and which of their fields are evaluated inside it. Between them, the scope tree and the names bound in each block follow, with nothing about lexical structure written out per production.

The declaration is small. What it buys is that Python's awkward cases become statements rather than code: a function's name binds outside while its args bind inside, a class body is a scope that nested functions cannot see, and a comprehension is a scope on some versions and inlined on others.

CPython ships the oracle for this in symtable, which is what tests/b_integration/test_scopes_symtable.py checks against.

Scope dataclass

A scope a production opens.

A production may open several, nested. def f[T]() opens a type-parameter scope wrapping the function scope, and only when it has type parameters, so a layer carries a condition and the layers are listed outermost first.

Block dataclass

A resolved scope: what it is, what it binds, what it contains.

owns

owns() -> set[str]

Names this block actually binds, less those declared elsewhere.

shape

shape() -> tuple

A comparable summary: kind, name, and the shape of each child.

names_bound_by

names_bound_by(
    node: Any, grammar: Grammar, ns: str
) -> set[str]

Every identifier node itself introduces in ns, conditions applied.

bound_names answers for one field, so a caller had to find the production, fetch the Field and pass its sort — a five-line dance that every consumer wrote. This is the identifier twin of Grammar.binds, which answers the same question for slot contents.

The node only, not its children, which is the same rule Grammar.binds follows. So a FunctionDef answers with its own name and not its parameters, and a With answers with nothing: the binding lives on the withitem, as import a.b as c's lives on the alias and except E as err's on the ExceptHandler. A pass that wants everything a statement binds walks, and asks this of each node it reaches.

target_nodes

target_nodes(value: object) -> Iterator[Name]

Every Name a Python binding position actually binds.

Python's, not every language's: a target may nest ((a, b), *c = ...) and these four shapes are how it nests. It lives here rather than in astero.python because astero.scopes may not import that package — the Python grammar imports Scope from here, and the cycle only fires when a consumer imports astero.scopes first, which is what examples/pl0 does.

bound_names yields the identifiers and hygiene the nodes; before this they were the same walk written twice, the second copy captioned "mirroring bound_names".

bound_names

bound_names(
    value: object, sort: str | None = None
) -> Iterator[str]

The identifiers a binding position introduces.

A binding field holds either a name outright (arg.arg) or an expression that is a target (Assign.targets), and a target may nest.

binds_in_scope

binds_in_scope(
    node: Any,
    grammar: Grammar,
    ns: str,
    scopes: Mapping[str, tuple[Scope, ...]],
    *,
    stop: Collection[str] = (),
) -> set[str]

Every name node introduces into the scope that contains it.

names_bound_by answers for one node, and a pass that wants everything a statement binds has to walk. The walk is the part that goes wrong, because it has to stop at a scope boundary: a function's parameters and body bind inside the function, so def f(a, b): ... contributes f to the scope around it and nothing else. scopes is the layer table that says which fields those are, astero.python.BINDING_SCOPES for Python.

Use BINDING_SCOPES rather than SCOPES here. They differ from 3.12, where PEP 709 stopped comprehensions opening a symtable block while leaving their targets local, and this is the binding question.

stop names productions the walk will not enter, as a set of production names. A flow analysis visiting one statement at a time passes grammar.concrete("stmt"), so an if reports what its test binds without reporting what its branches bind::

binds_in_scope(stmt, PY, VARS, BINDING_SCOPES, stop=PY.concrete("stmt"))

node itself is entered whatever stop says, since it is the thing being asked about.

Reaches through anything that is not a node of its own: with a as b binds b through a withitem, import a.b as c through an alias, except E as err through an ExceptHandler, and case [x] through a pattern. None of those are named here.

walk

walk(node: Any, grammar: Grammar) -> Iterator[Any]

Every declared node under node, itself included, in no fixed order.

ast.walk reads node._fields, so it answers nothing for a tree the grammar declares off dataclasses — not an error, an empty iterator, which is how hygiene.taken_names came to report that no name was taken in a PL/0 program. _fields_of and _is_node are what make this the same walk for every grammar; see their notes for the same mistake made twice before.

A list is walked as a root, so a body can be passed directly.

scope_tree

scope_tree(
    tree: Any,
    grammar: Grammar,
    scopes: Mapping[str, tuple[Scope, ...]],
    namespace: str,
    *,
    extra: Mapping[str, ExtraBinder] | None = None,
    mangle: Mangler | None = None,
    siblings: SiblingBlocks | None = None,
    root_kind: str = "module",
    root_name: str = "top",
) -> Block

Resolve tree into a block tree with the names each block binds.

tree is any node the grammar declares, not only an ast.AST: the walk reads its fields from grammar, which is what lets a tree of plain dataclasses resolve. The annotation said ast.AST for as long as the implementation was Python-only, and outlived it.