Skip to content

Python: imports in an if TYPE_CHECKING: block are module scope - #8739

Merged
knutwannheden merged 4 commits into
mainfrom
sdk-nested-import-handling
Sep 2, 2026
Merged

Python: imports in an if TYPE_CHECKING: block are module scope#8739
knutwannheden merged 4 commits into
mainfrom
sdk-nested-import-handling

Conversation

@knutwannheden

@knutwannheden knutwannheden commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

ChangeImport and RemoveImport decided what a file imports by iterating cu.statements. An import nested in if TYPE_CHECKING: is a child of a J.If at that level, not a statement of the compilation unit, so neither visitor ever saw it.

org.openrewrite.python.migrate.ReplaceTypingCallableWithCollectionsAbcCallable is ChangeImport(old_module="typing", old_name="Callable", new_module="collections.abc") and nothing else. It reported no change at all on this file:

from __future__ import annotations
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from typing import Callable

def f(x: Callable[[int], str]) -> None: ...

The typing-alias moves in UpgradeToPython39 are the same recipe under different arguments and skipped the same files. A file that defers its annotations keeps its typing imports exactly here, so this is not a corner of the input space.

Where the replacement import goes

ChangeImport binds the new import inside the block it took the old one from, rather than letting maybe_add_import place a replacement at module level. Hoisting it would turn an import the file deliberately deferred into one that runs at import time.

A nested match is spliced where it stands: the reduced old statement keeps its place and the new import follows it, or merges into a sibling from <new_module> import … already in the block. That merge is what keeps a chain of alias moves from emitting one line per alias. Four UpgradeToPython39 moves over

if TYPE_CHECKING:
    from typing import Deque, Iterable, Pattern, Sequence

leave

if TYPE_CHECKING:
    from re import Pattern
    from collections.abc import Iterable, Sequence
    from collections import deque

A block can hold more than one match — from typing import Callable and import typing together — and each gets its binding.

What counts as module scope

unconditional_body is the rule: an if carrying an else is not module scope. Its branches are alternative bindings of the same name, and honouring one rewrites the binding the other was there to provide.

Everything below that boundary is left exactly as it stands, and visit_import and visit_multi_import both require the statement's parent to be the compilation unit:

  • a function or class body, where pruning would strip a binding and put nothing back
  • a try:, while: or with: body, or an else/elif branch, where pruning empties the suite and the file stops parsing

References follow the same line. visit_identifier renames wherever it goes, and a suite is not a scope, so an else-branch import reads as a module global that LocalBindings will not shadow. When the recipe changes the bound name and such a match survives, the file is left whole rather than renamed onto a name nothing binds.

Comments

RemoveImport abandons a removal rather than lose a comment, at three points:

  • nothing follows the removed statement to carry one
  • the statement that would inherit its prefix has one of its own
  • a second removal has one the first prefix cannot also hold

ChangeImport skips the merge when the replaced statement carries a comment, so the comment stays with the import it describes.

Java side

PythonRemoveImportVisitor.mightChange transcribes the Python visitor's early returns so the host can skip a round trip. Left alone it would answer "no" for precisely the files this fixes, and the RPC path would keep the old behaviour. It excludes an if carrying an else, matching module_scope_blocks.

Tests

Across tests/python/test_remove_import.py and tests/recipes/test_change_import.py, both arms of the existing native/java fixture. Each guard is mutation-pinned: reverting it fails the test named for it.

Verification

rewrite-migrate-python installed against this branch: its suite passes on main (602) and on the open PR #89 (612), and the reported no-op above now rewrites in place.

Note for whoever bumps that repo: RemoveImport._prune is now _prune_statements. PR #89's _RemoveImportInBlocks — a caller-side subclass that existed only because the SDK could not do this — overrides the old name, and should be deleted in the same bump along with its maybe_remove_typing_import wrapper.

`ChangeImport` and `RemoveImport` found a file's imports by iterating
`cu.statements`, so an import nested in a module-scope `if` was invisible to
them — it is a child of a `J.If` at that level, not a statement of the
compilation unit.

`ChangeImport` binds the replacement inside the block it took the old import
from. `maybe_add_import` would put it at module level, which turns an import
the file deferred into one that runs at import time.

An `if` carrying an `else` is not module scope: its branches bind the same
name differently, so honouring one would rewrite the other's binding too.
`RemoveImport` also abandons a removal rather than lose a comment that has
nowhere left to go.
@knutwannheden
knutwannheden force-pushed the sdk-nested-import-handling branch from 594c08e to f4f0219 Compare September 2, 2026 07:56
Scanning `if TYPE_CHECKING:` bodies made `has_old_import` true from a block
alone, which handed `ChangeImport.visit_multi_import` files it used to return
early on. That path prunes a match anywhere in the tree, so an import in an
`else` branch, a `try:`, a class or a function body was removed with no
replacement placed — an empty suite (`SyntaxError`) in the first three, an
unresolved name in the last. Both visit methods now require the statement's
parent to be the compilation unit; `_rewrite_block` covers the `if` bodies.

`_rewrite_block` matched once per block, so a block holding both
`from typing import Callable` and `import typing` bound only one of them:
the scheduled `RemoveImport` dropped `import typing` while the references
this recipe had just rewritten to `collections.abc` had nothing to resolve
through. Bindings accumulate in a list and are placed back to front.

`_bound_by_another_import` counted a `TYPE_CHECKING` binding as one that
shadows a module-level import, so `RemoveImport` removed the only binding
that exists at run time. It reads `cu.statements` again.

`_place_import` dropped the replaced statement's comment when it merged into
a sibling; the merge is now skipped when the prefix carries one. `RemoveImport`
abandoned a removal on a comment collision only for the first removal, letting
a later one discard its comment silently. `PythonRemoveImportVisitor` gained
the `else` exclusion its javadoc claimed.

Tests pin each guard: reverting any one of the six fails exactly the test
named for it.
`visit_identifier` renames references wherever they appear, but a match in a
module-level suite the recipe declines to rewrite — an `else` branch, a `try:`
body — goes on binding the old name. The reference was renamed to a name
nothing bound:

    if TYPE_CHECKING:
        from collections import Mapping
    if sys.version_info >= (3, 10):
        pass
    else:
        from collections import Mapping
        m = Mapping()          # became m = Map()

A function-local import is already covered, since `LocalBindings` shadows it;
a suite is not a scope, so those bindings read as module globals. When the
recipe changes the bound name and such a match exists, the file is left whole.
…dling

# Conflicts:
#	rewrite-python/rewrite/src/rewrite/python/import_utils.py
#	rewrite-python/rewrite/src/rewrite/python/remove_import.py
#	rewrite-python/rewrite/tests/python/test_remove_import.py
@knutwannheden
knutwannheden merged commit da3ebab into main Sep 2, 2026
1 check passed
@knutwannheden
knutwannheden deleted the sdk-nested-import-handling branch September 2, 2026 14:38
@github-project-automation github-project-automation Bot moved this from In Progress to Done in OpenRewrite Sep 2, 2026
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.

1 participant