Skip to content

Getting started

Install

uv add astero          # or: pip install astero

Python 3.11 or newer. astero has no runtime dependencies.

The first useful answer

Ask Python's grammar which slots hold a variable name:

from astero.lang_py import PY, VARS

slots = PY.ident_slots(VARS)
print(len(slots))                 # every production that has one
print(slots["FunctionDef"])       # ('name',)
print(slots["arg"])               # ('arg',)

That is the table a renamer needs. Written by hand it is usually four or five entries and it is usually wrong: people miss vararg, kwarg, lambda parameters and async def parameters.

Note the namespace. PY.ident_slots() without one reports 17 productions and 18 slots, spanning every identifier, including Attribute.attr and keyword.arg, so a renamer built on it rewrites x.foo into a different attribute. VARS narrows it to the 15 productions and 16 slots that hold a variable.

Rewrite something

import ast
from astero import Pass, rules
from astero.lang_py import PY

DESUGAR = Pass(
    "desugar",
    rules("ast.AugAssign(_t, _o, _v) => ast.Assign([_t], ast.BinOp(_t, _o, _v))"),
    eliminates=(ast.AugAssign,),
    grammar=PY,
)

tree = DESUGAR(ast.parse("b[1] += 5"))
print(ast.unparse(tree))          # b[1] = b[1] + 5

The rule never mentions ctx. astero recomputes it from the shape of the result, using the roles, so the subscripted target and the plain one go through the same rule and neither can get it wrong.

eliminates is a postcondition: if any AugAssign survives, the pass raises, and the problem goes no further.

Ask what a scope binds

from astero.lang_py import BINDING_SCOPES
from astero.scopes import scope_tree

tree = ast.parse("def f(a, *rest, **kw):\n    x = 1\n    return x\n")

def show(block, depth=0):
    print(" " * depth, block.kind, block.name, sorted(block.bound))
    for child in block.children:
        show(child, depth + 2)

show(scope_tree(tree, PY, BINDING_SCOPES, VARS))
 module top ['f']
   function f ['a', 'kw', 'rest', 'x']

rest and kw are there because the slots come from the grammar. A hand-written binder that enumerated args and forgot vararg and kwarg (the common shape) would report two of the four.

scope_tree takes the scope table explicitly because there are two, and they are not interchangeable. SCOPES says what CPython's symtable calls a block; BINDING_SCOPES says what a name binds over. PEP 709 separated them at 3.12, when comprehensions stopped opening a scope while still binding their target locally. Pass the one that matches your question.

Checked against CPython's own symtable over the standard library: 73,973 of 73,991 blocks agree.

Where to go next

  • The tutorial builds a complete small compiler and shows where astero stops.
  • The user guide covers each module and the question it answers.
  • If you have a compiler already, Adopting astero is the path that has been walked three times.