Python: imports in an if TYPE_CHECKING: block are module scope - #8739
Merged
Conversation
`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
force-pushed
the
sdk-nested-import-handling
branch
from
September 2, 2026 07:56
594c08e to
f4f0219
Compare
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ChangeImportandRemoveImportdecided what a file imports by iteratingcu.statements. An import nested inif TYPE_CHECKING:is a child of aJ.Ifat that level, not a statement of the compilation unit, so neither visitor ever saw it.org.openrewrite.python.migrate.ReplaceTypingCallableWithCollectionsAbcCallableisChangeImport(old_module="typing", old_name="Callable", new_module="collections.abc")and nothing else. It reported no change at all on this file:The typing-alias moves in
UpgradeToPython39are 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
ChangeImportbinds the new import inside the block it took the old one from, rather than lettingmaybe_add_importplace 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. FourUpgradeToPython39moves overleave
A block can hold more than one match —
from typing import Callableandimport typingtogether — and each gets its binding.What counts as module scope
unconditional_bodyis the rule: anifcarrying anelseis 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_importandvisit_multi_importboth require the statement's parent to be the compilation unit:try:,while:orwith:body, or anelse/elifbranch, where pruning empties the suite and the file stops parsingReferences follow the same line.
visit_identifierrenames wherever it goes, and a suite is not a scope, so anelse-branch import reads as a module global thatLocalBindingswill 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
RemoveImportabandons a removal rather than lose a comment, at three points:ChangeImportskips the merge when the replaced statement carries a comment, so the comment stays with the import it describes.Java side
PythonRemoveImportVisitor.mightChangetranscribes 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 anifcarrying anelse, matchingmodule_scope_blocks.Tests
Across
tests/python/test_remove_import.pyandtests/recipes/test_change_import.py, both arms of the existingnative/javafixture. Each guard is mutation-pinned: reverting it fails the test named for it.Verification
rewrite-migrate-pythoninstalled against this branch: its suite passes onmain(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._pruneis 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 itsmaybe_remove_typing_importwrapper.