Skip to content

astero.python.hygiene

Substitution and renaming that do not capture or corrupt names.

astero.python.hygiene

Fresh names, renaming, and capture-avoiding substitution.

A pass that moves code from one place to another has to answer three questions about names, and every one of them is a question the grammar already answers.

Which occurrences of a name may be replaced? Only the ones that use it. p2w's inliner substituted at every Name node, so inlining f(99) into a body containing [x for x in ...] produced [99 for 99 in ...], which is not a program. Whether a position uses or binds is what roles say.

Which slots does renaming have to reach? Every slot holding an identifier, not the ones a pass happens to remember. latexify's renamer listed them by hand and missed vararg, kwarg, lambda parameters and async def parameters, so it renamed references to parameters it left alone.

When is a substitution unsafe? When the expression being moved has a free name that the destination binds, so moving it would capture that name. That is computable from the same two answers.

Nothing here decides policy. A caller that cannot supply fresh names gets a refusal naming the obstruction, which is what p2w's inliner does today by declining to inline at all.

CaptureError

Bases: Exception

A substitution could not be made without capturing a name.

Fresh dataclass

A supply of names that avoids a set of names already in use.

Every name handed out is added to taken, so two calls never collide even when the caller does not record the first one.

avoiding classmethod

avoiding(
    node: Any, grammar: Grammar, prefix: str = "_t"
) -> Fresh

A supply avoiding every name that appears anywhere in node.

binding_occurrences

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

id() of every node that binds rather than uses a name.

A binding field may hold a target that is not itself a binding: b[i] = v binds nothing, and reads both b and i. bound_names already draws that line, so this walks the same shapes it does.

all_names

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

Every identifier appearing anywhere in node, bound or used.

No namespace argument: an identifier slot is a slot whatever namespace its role names, and renaming has to reach all of them.

bound_here

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

Names node introduces anywhere inside itself.

Accepts a node or a list of them, so a caller holding one field's value does not have to wrap it.

free_names

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

Names node uses without introducing.

An approximation that ignores where a binding takes effect, so a name both bound and used in node counts as bound. That is the safe direction for capture checking: it never reports a free name that is not one.

rename

rename(
    node: Any,
    mapping: Mapping[str, str],
    grammar: Grammar,
    ns: str,
) -> Any

Rename identifiers of namespace ns, in every slot the grammar declares.

Both binding and using occurrences, because a rename that reaches only one of them changes what the program means.

The namespace matters. Attribute.attr and keyword.arg are identifier slots too, in a different namespace, and renaming a variable foo must not rewrite x.foo into a different attribute.

A dotted identifier is skipped. import a.b as c holds a module path in alias.name, and while that is an identifier position the renaming surface has to know about, it names a module rather than a local. Renaming a variable a must not rewrite the import it happens to share a spelling with. Renaming the binding of a bare import a is a different operation, since it has to become import a as ... to keep meaning the same thing.

shadowed_at

shadowed_at(
    node: Any,
    grammar: Grammar,
    ns: str,
    scopes: Mapping[str, tuple[Scope, ...]],
) -> dict[int, frozenset[str]]

id() of every node, mapped to the names shadowed around it.

A name bound by an inner scope hides the outer one inside that scope's own fields and nowhere else, which is why this cannot be a flat set. A function's parameters shadow inside its body, and its decorators are evaluated outside where they do not.

substitute

substitute(
    node: Any,
    mapping: Mapping[str, AST],
    grammar: Grammar,
    ns: str,
    *,
    scopes: Mapping[str, tuple[Scope, ...]] | None = None,
    fresh: Fresh | None = None,
) -> Any

Replace uses of the names in mapping, avoiding capture.

node is copied, so the argument is left alone. Each replacement is copied too, so two occurrences of one name do not share a subtree.

Raises CaptureError when the substitution cannot be made safely and fresh was not supplied:

  • node binds one of the names being substituted, by assigning it or by shadowing it in an inner scope. Not every occurrence then means the same thing, so replacing them all is wrong.
  • a replacement has a free name that node binds, so moving it in would capture that name.

With fresh, the second is repaired by renaming the offending binders. The first is always refused.

With scopes, shadowing stops counting as rebinding: a name bound by an inner scope hides the outer one inside that scope and is left alone there, rather than making the whole substitution impossible. Pass the table that says what a name binds over, astero.python.BINDING_SCOPES for Python, which is not the same as the one saying what symtable calls a block.

taken_names

taken_names(
    nodes: Iterable[Any], grammar: Grammar
) -> set[str]

Every name appearing in any of nodes, for seeding a Fresh.