astero¶
astero is a Python library for writing compilers, transpilers and code analysers. You declare your language's syntax tree once; astero computes the name handling and code generation that such a program otherwise writes, and maintains, by hand.
Python's grammar ships with the library, so for Python source there is nothing to declare.
The distribution is called abilian-astero, because PyPI already had astero. The import name is astero:
Python 3.11 through 3.15. No runtime dependencies. Apache-2.0.
The problem, in one example¶
Rename a variable throughout a module. This is the version that gets written:
import ast
NEW = {"x": "y"}
SOURCE = "def f(x):\n import os as x\n try: pass\n except E as x: g(x)\n return x\n"
class Renamer(ast.NodeTransformer):
def visit_Name(self, node):
node.id = NEW.get(node.id, node.id)
return node
def visit_arg(self, node):
node.arg = NEW.get(node.arg, node.arg)
return node
print(ast.unparse(Renamer().visit(ast.parse(SOURCE))))
def f(y):
import os as x # not renamed
try:
pass
except E as x: # not renamed
g(y) # now reads a name nothing binds
return y
Two of the four binding positions were missed. The result still parses. It means something else.
The same job with astero:
from astero.hygiene import rename
from astero.lang_py import PY, VARS
print(ast.unparse(rename(ast.parse(SOURCE), NEW, PY, VARS)))
Fifteen positions in Python can hold a variable name. The set moves between releases: 3.12 added three. rename reaches all of them because a declaration lists them, not because anyone remembered to.
Why that generalises¶
Renaming is the smallest visible case. The same fact, which fields introduce a name, is needed in several other places. Hand-writing it means several enumerations that have to agree with each other and with the language:
| what needs it | what a missed position does there |
|---|---|
| the scope or symbol table | a name that is never bound, so a parameter silently disappears |
| capture-avoiding substitution, in an inliner | inlining produces [99 for 99 in xs], which does not parse |
ctx on a rewritten tree |
b[1] += 5 compiles to JavaScript computing NaN where Python gives 5 |
| a test that every binding position is exercised | nothing, which is why the other three went unnoticed |
The first three are defects that shipped, in three different compilers: **kwargs never bound, an inliner substituting at every Name, and an AugAssign rewrite setting the context by hand. None of them crashed.
In astero they are one query, positions(Kind.DEF, ns), read by four modules: scopes, hygiene, rewriting and generate. Add a binding form to the language, edit the declaration; the four follow.
That is the shape of the library. Structural facts are declared once and consumed many times. Names are the clearest case; the same holds for which fields a traversal must enter, which productions can appear in a tree, and how tightly each operator binds.
What a declaration buys¶
You declare a role for each field of each production: whether it introduces a name, refers to one, or merely holds a subtree. For Python that declaration ships with the library; for your own IR it is one table, read off your dataclasses.
What reads it:
| module | what you get |
|---|---|
grammar |
which fields bind a name, which read one, which hold a bare identifier, which a traversal must enter, which productions can appear |
rewriting |
tree rewriting from pattern => result rules, with each Name's ctx recomputed for you |
scopes |
the scope tree of a module, and the names each scope binds |
hygiene |
renaming, and substitution that does not capture |
emit_rules |
a code generator written as templates, bracketed from a precedence table |
coverage |
a test that fails when your dispatch table misses a production |
Two worked compilers ship with the docs: TinyPy, a Python subset with two back ends, and PL/0, Wirth's textbook language, built on a tree of plain dataclasses with no relationship to ast.
What astero is not¶
- Not a parser. Bring your own AST. astero starts from a tree.
- Not a code generator you run. There is no
astero build; nothing is generated on disk. It is a library you import; every entry point is a function call at run time. - Not a framework. It does not own your pipeline, your IR, or your
main. You can adopt one query in one function and leave everything else alone.
This is a narrower scope than compiler toolkits such as Cocktail or Eli, which generate a compiler's components from declarations ahead of time. astero answers questions about a declaration at run time, which is why adoption can be incremental.
Examples¶
Each of these runs as written. The outputs in the comments are asserted by tests/a_unit/test_tutorial.py.
Ask the grammar for a table¶
from astero.lang_py import PY, VARS
PY.ident_slots(VARS)["arg"] # ('arg',) — every slot a renamer must reach
PY.definitions(VARS)["For"] # ('target',) — positions that introduce a name
PY.definitions(VARS)["FunctionDef"] # ('name',)
ident_slots(VARS) returns the full mapping: on Python 3.12 and later, 15 productions holding a variable name.
Rewrite a tree without touching ctx¶
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,
)
ast.unparse(DESUGAR(ast.parse("b[1] += 5"))) # 'b[1] = b[1] + 5'
In Python's AST every Name and Subscript carries ctx=Load() or ctx=Store(). A rewrite that moves a node between positions has to update it. The rule above does not mention ctx: astero reads each field's role from the grammar and sets the context to match the position the node ends up in. The same rule therefore handles x += 5 and b[1] += 5, where the target must become Store in one place and Load in the other.
eliminates= is a postcondition. If any AugAssign survives the pass, it raises and will not pass a half-desugared tree downstream.
Substitute without capturing a name¶
from astero.hygiene import Fresh, substitute
body = ast.parse("[n + k for k in xs]", mode="eval").body
substitute(body, {"n": ast.parse("k", mode="eval").body}, PY, VARS, fresh=Fresh())
# [k + _t1 for _t1 in xs]
Replacing n with the expression k would put a free k inside a comprehension that binds its own k, silently changing what the code means. substitute detects the collision and, given a Fresh name supply, renames the comprehension's binder to _t1 first. It also replaces only uses of n: a position that binds n is left alone, because substituting there produces something that is not a program.
Write a code generator as a table¶
from astero.emit_rules import All, Both, Emitter, OpIs
def type_of(node):
"""Your type inference. A stub, so the example runs."""
return int if isinstance(node, ast.Constant) else None
js = Emitter(PY, typer=type_of)
js.projection("name", str)
js.projection("lit", str)
js.rule("BinOp", "{left} + {right}", All((OpIs((ast.Add,)), Both((int,)))))
js.rule("BinOp", "add({left}, {right})", OpIs((ast.Add,)))
js.rule("Name", "{id:name}")
js.rule("Constant", "{value:lit}")
js.to_text(ast.parse("1 + 2", mode="eval").body) # '1 + 2'
js.to_text(ast.parse("a + b", mode="eval").body) # 'add(a, b)'
A rule is a production name, a template, and an optional guard. Rules are tried in order and the first whose guard holds wins, so the specialised case sits above the general one. {left} emits that field and brackets it if the precedence table says it needs brackets.
typer is how the emitter asks your type inference a question; it is the only hook into host analysis.
Fail the build when a production has no handler¶
from functools import singledispatch
from astero.coverage import dispatch
@singledispatch
def gen(node: ast.AST) -> str:
raise NotImplementedError
@gen.register
def _(node: ast.Assign) -> str:
return "assign"
@gen.register
def _(node: ast.Return) -> str:
return "return"
cover = dispatch(
PY,
(gen.registry,),
bases=("stmt",),
accounted={"desugared": {"AugAssign", "AnnAssign"}},
)
cover.explain().splitlines()[0] # '2 of 28 productions handled'
sorted(cover.missing)[:3] # ['Assert', 'AsyncFor', 'AsyncFunctionDef']
dispatch compares the productions your singledispatch registries handle against the productions the grammar declares. In a real project you would assert not cover.missing, cover.explain() in a test.
accounted names the productions you deliberately do not handle, with the reason as the key. A stale reason, one whose production no longer exists or has since gained a handler, shows up in cover.absent and cover.redundant, not in cover.missing.
Where variable names appear in Python's AST¶
Take one concrete job: renaming a variable throughout a Python module. You need every position where a variable name can appear. On Python 3.12 and later there are fifteen:
AsyncFunctionDef.name Global.names Name.id TypeVar.name
ClassDef.name MatchAs.name Nonlocal.names TypeVarTuple.name
ExceptHandler.name MatchMapping.rest ParamSpec.name alias.name, alias.asname
FunctionDef.name MatchStar.name arg.arg
Three observations a hand-written list tends to miss:
- The list is version-dependent. Python 3.11 has twelve of these.
ParamSpec,TypeVarandTypeVarTuplearrived with PEP 695 in 3.12. Code that hard-codes the list is correct for one interpreter. - The unobvious entries are the ones omitted.
ExceptHandler.nameis theeinexcept E as e.alias.asnameis theyinimport x as y.Global.namesholds a list of names. The fourMatch*patterns bind too. - Namespaces matter.
ident_slots()without one returns two more productions,Attribute.attrandkeyword.arg. Those are identifiers, but renaming them changesx.foointo a different attribute andf(key=1)into a different call. PassingVARSasks the narrower question.
PY.ident_slots(VARS) computes the answer from the interpreter you are running on, so all three problems go away together.
How a grammar is declared¶
A grammar is a set of productions, each with named fields. Every field carries three things:
- a shape:
ONE,OPTorSEQ, i.e. whether the slot holds one node, an optional node, or a list; - a sort: what the slot holds, another production, a bare identifier, or plain data;
- a role: what the position does with what it holds.
Roles have no equivalent in ASDL, the notation Python's own AST definition is written in. Every query above is computed from them:
child structural containment; a traversal descends into it
attr plain data; never traversed
def(ns) introduces a name in namespace `ns`
use(ns) refers to a name in `ns`
defuse(ns) both, for read-modify-write positions
del(ns) removes a name
declare(ns) `global` and `nonlocal`: names a binding that lives elsewhere
Two rules govern how roles are written, both explained in Roles. A role belongs to the parent's field, describing a position the child node occupies. Namespaces then distinguish positions that hold the same type but mean different things.
You can declare a grammar three ways: lang_py.build() if your AST subclasses Python's, grammar.from_dataclasses() if your IR is annotated dataclasses, or GrammarBuilder by hand. All three are in the API reference.
How the derivations are checked¶
Each derivation is compared against an independent source of truth over the Python standard library:
| derived | compared against | result |
|---|---|---|
ctx values |
what CPython's parser produces | every position in the standard library, no disagreements |
| scopes and their names | CPython's symtable module |
99.98% of blocks agree (77,900 of 77,915 on 3.12) |
| emitted source | reparsing the emitted text | all 1,797 modules and all 2,266,043 expressions |
| operand positions of an SSA IR | that compiler's own declaration | exact match |
These are integration tests, run on Python 3.11 through 3.15. The exact counts depend on which standard library you measure, so the tests hold the ratio. A regression fails; an improvement does not have to be chased.
Where to go next¶
- Getting started: install it and run three queries against real code.
- Tutorial: build a working compiler for a Python subset, with two back ends, in about 170 lines.
- User guide: every module, its API, and when to use it.
- Adopting astero: fitting it into a compiler you already have.