Skip to content

Python: a spliced template's context reaches the file it lands in - #8790

Merged
knutwannheden merged 12 commits into
mainfrom
python-wire-imports-when-a-template-is-spliced
Oct 2, 2026
Merged

knutwannheden merged 12 commits into
mainfrom
python-wire-imports-when-a-template-is-spliced

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Sep 6, 2026 •

Copy link
Copy Markdown
Contributor

A Python template's context parsed the template and nothing else, so a recipe splicing a name the target file did not import emitted code that raises NameError when it runs. It parses, it prints, and it reads as correct in a diff: two recipes in a downstream package shipped that way, their docstrings showing an import the code never emitted and their tests asserting the broken output as expected. Both were found by reading.

A template's context is now also what it needs bound where it lands:

_arg = capture('arg')
_tmpl = template(f"subprocess.run({_arg}, shell=True)", context=["import subprocess"])

class Visitor(PythonVisitor[ExecutionContext]):
    def visit_method_invocation(self, method, p):
        match = _pat.match(method, self.cursor)
        return _tmpl.apply(self.cursor, visitor=self, values=match) if match else method

Applied to out = os.popen('ls'), what the file already binds decides what it gets:

the file binds the splice reads imports added
nothing subprocess.run('ls', shell=True) import subprocess
import subprocess as sp sp.run('ls', shell=True) none
from subprocess import run subprocess.run('ls', shell=True) import subprocess, a member import not binding the module
if TYPE_CHECKING: import subprocess subprocess.run('ls', shell=True) import subprocess, a conditional import binding nothing at runtime
import subprocess inside the enclosing function subprocess.run('ls', shell=True) none, the lazy import binds it where the splice lands
the name, to anything else, in a scope the splice sits in — refused, no import reaching a shadowed name

A context binding counts only where the template's own code reads it, judged per occurrence, so a name the template binds for itself is neither renamed nor imported, and context that exists only to type a capture stays out of the file. What does get imported joins the file's existing import of the same module, from subprocess import Popen becoming Popen, run as r, under whichever name the context or the file aliases it to. A relative context import stays relative.

visitor= is what reaches the file's imports; the cursor is where the result lands, and the two differ for a recipe splicing somewhere other than where it stands. Omitting the visitor still works for a template whose context binds no module, and raises otherwise, naming the modules and the call to make.

Where the bindings come from

The engine already parses the context statements ahead of the template, so import_bindings reads them there, as it does for the file being edited. One model answers for both sides, which is what lets a binding be compared rather than merely matched by name: whether it is guarded, whether it is the same member, whether two dotted modules share a root.

Why automatic

TypeScript treats this as the template's job. Template.resolveBindings(visitor) binds each module the context declares and returns the local name it actually bound; apply({bindings}) renames the template's references to those names; rewrite(...).tryOn(cursor, node, {visitor: this}) does the whole thing once a pattern matches, throwing if given neither a visitor nor bindings (templating/rewrite.ts:65). Python has no tryOn — apply() is the splice path — so the wiring and the refusal both land there.

Where it refuses rather than deconflicts

TS's maybeBind picks another name when the one it wants is taken. This has no such machinery, so it raises where emitting the reference would read the wrong object: a scope at the splice site binding the name, a module-scope import binding it to another module, or a rename landing on a name the context binds elsewhere. Nothing is registered until every binding holds, so a refusal leaves the file's imports alone. from subprocess import run as _run in the context is the way out of the second.

Tests

tests/python/template/test_template_imports.py, one per decision: the table's six rows, context the template never references, a name the template binds for itself, an aliased member merging into the file's import, a splice inside a function whose import still lands at module scope, a splice landing where the visitor does not stand, and the call that omits the visitor.

Reconciling the two import styles, so a spliced subprocess.run(...) collapses to run(...) where the file already imports the member, is #8791: it belongs to a normalizer that sees the whole file, since binding is per member and a template reading two members of a partly imported module can be collapsed for neither use or for one.

The Java peer's isReferenced also took a dotted module's last segment, so import os.path was only ever added to a file already using a name path. import a.b.c binds a. The Python half of that was fixed separately in #8831.

A template's `context` parsed the template and nothing else, so a recipe
splicing `subprocess.run(...)` under `context=["import subprocess"]` emitted
code the target file never imported: it parsed, printed, and raised NameError
when run. Recipes shipped that way, with docstrings showing an import the code
never emitted.

`apply()` now takes the visitor as well as its cursor, and binds each module the
context declares in the file being edited — reusing the file's own name where it
already binds the module, adding an import where it does not, and leaving alone
context the template does not reference. Passing only the cursor raises, since
that path cannot reach the file's imports.

Also fixes `AddImport._is_referenced`, which looked for the last segment of a
dotted module; `import a.b.c` binds `a`.
…taken name

Review of the previous commit found three ways the wiring could still emit code
that reads the wrong object.

A context binding now counts only where the template's own code reads it, judged
per occurrence: a name the template binds for itself — a comprehension variable,
a parameter — is not the context's, so it is neither renamed nor imported. That
also drops context that exists only to type a capture before it can collide with
a file that spells the same name.

Where the name the spliced code would read is held by something else, applying
raises instead of emitting the reference: a scope at the splice site binding it,
or a module-scope import of another module under it. Neither is reachable by any
import, and the second silently rebinds what the file already reads.

`apply()` accepts any `TreeVisitor`, the cursor and after-visit list being all it
needs, so a non-`PythonVisitor` no longer falls into the cursor branch.

`PythonAddImportVisitor.isReferenced` gets the dotted-module fix its Python half
already had; the two are one predicate across the RPC boundary.
Merging a member into a file's existing import of that module is AddImport's,
but the alias it merges under comes from the context binding.
A file importing lazily — `def f(): import subprocess` — binds the name where
the splice lands, so the refusal for a shadowed name was firing on code the
splice would have read correctly. A scope between the splice and the module now
satisfies the binding when what it imports is what the context names, and the
module scope is left alone; anything else it binds still refuses, since no
import reaches past it.

Both refusals name what the template reads through the name — the module, or a
member of it — rather than reporting that 'subprocess' is not 'subprocess', and
the alias they suggest takes the shape of the import it would replace.
A recipe can splice where it is not standing — rewriting a statement of the file
it visits, or assembling several results before formatting the subtree once — and
until now that meant choosing between the splice site and the imports, since
passing the visitor took its cursor for both. `apply(at, visitor=self)` names
them separately.

Which names are in scope is then read at the splice site rather than at the
visitor, so a local import covering the template binds it where the two differ.
The first parameter accepted either a cursor or a visitor standing for its own,
which left two spellings for one call and a parameter named for one of the two
things it held. It is a cursor; `visitor=` names the visitor, as TS spells the
same pair in `tryOn(cursor, node, {visitor: this})`.
@jkschneider

Copy link
Copy Markdown
Member

@copilot resolve the merge conflicts in this pull request

…hen-a-template-is-spliced

# Conflicts:
#	rewrite-python/rewrite/src/rewrite/python/add_import.py

Co-authored-by: jkschneider <1697736+jkschneider@users.noreply.github.com>

Copilot AI commented Sep 26, 2026

Copy link
Copy Markdown
Contributor

@copilot resolve the merge conflicts in this pull request

Resolved and pushed in 43106a4.

jkschneider and others added 5 commits September 26, 2026 12:50
…hen-a-template-is-spliced

# Conflicts:
#	rewrite-python/rewrite/src/rewrite/python/add_import.py
Four ways the binding could read the wrong thing, or refuse a splice that was
fine:

A conditional import binds nothing at runtime. That held for `import x` at
module scope but not for `from x import y`, which refused the splice instead of
importing the module again, and not for either inside the function holding the
splice, where it was taken as covering the context and the spliced call found no
name at all.

A function-local import binds its name for the whole call, so one below the
splice leaves the name unbound where the splice reads it. Only an import the
splice is reached after covers it.

Which name the splice reads is decided before its scope is judged: where the
file imports the module under its own name, that name is what lands, and the
template's own being shadowed costs it nothing.

A rename onto a name the context binds elsewhere now refuses rather than
spelling two symbols the same, and no import is registered until every binding
holds, so a refusal leaves the file's imports alone.
…ate-is-spliced' into python-wire-imports-when-a-template-is-spliced

# Conflicts:
#	rewrite-python/rewrite/src/rewrite/python/add_import.py
…does

The template engine parses the context statements ahead of the template and
discards them, so the bindings were recovered by parsing the same strings a
second time with `ast` and recording them in a second dataclass. The two models
disagreed: `ast` sees only top-level statements, so a conditional import in a
template's own context was invisible, and it has no notion of a guarded binding
for the file side to compare against.

The engine now keeps those statements beside the extracted tree, and
`import_bindings` reads them, as it already does for the file being edited.
`ContextBinding` and its parser are gone, a wildcard context needs no special
case because nothing reads the name it binds, and `get_alias_name` recovers
what the fourth field used to hold.

Also: refusals read as two plain sentences, the scope lookup compares `Tree.id`
rather than CPython object identity, `RenameBindings` delegates to its super,
and the Java peer takes a module's root package with `indexOf` rather than by
compiling a regex.
@knutwannheden
knutwannheden merged commit 3290ff9 into main Oct 2, 2026
1 check passed
@knutwannheden
knutwannheden deleted the python-wire-imports-when-a-template-is-spliced branch October 2, 2026 15:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

3 participants