Python: an import in a module-scope if binds at module scope - #8767
Merged
knutwannheden merged 1 commit intoSep 3, 2026
Merged
Conversation
A call to a symbol ty cannot type is attributed from the file's own import bindings. That index read `tree.body` only, and `_rebound_names` then excluded every alias outside it, so a version-guarded `from gettext import lgettext` lost its declaring type. Walk `if` bodies with no `else` too, which is the rule `import_utils.unconditional_body` already states for `RemoveImport`.
knutwannheden
force-pushed
the
attribution-excludes-imports-in-a-module-scope-if
branch
from
September 3, 2026 16:36
fafddbd to
3879ed2
Compare
knutwannheden
deleted the
attribution-excludes-imports-in-a-module-scope-if
branch
September 3, 2026 16:39
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.
A call to a removed API gets no declaring type when the import binding it sits inside a module-scope
if— the shape a codebase mid-migration actually writes:Measured on
gettext.lgettext, which 3.14's typeshed does not declare. Position is not the problem: ty resolves a live symbol from every one of these.The mechanism
A symbol ty cannot type is attributed from the file's own import bindings instead.
_import_bindingsindexedtree.bodyonly, so a nested alias never entered the index;_rebound_namesthen added every alias outside that index to the shadowed set, so the name was actively excluded rather than merely absent. That exclusion is deliberate — a name bound twice cannot be read off an import — and right for a function-local orelse-guarded import.The fix
_module_scope_statementswalks a module body plus the bodies ofifs with noelseorelif: those bind into the enclosing scope, and nothing else binds them. Only that shape widens.import_utils.unconditional_body(Python: imports in anif TYPE_CHECKING:block are module scope #8739), whichRemoveImportandPythonRemoveImportVisitorread. Attribution runs during parse, before any LST orCursorexists, so the rule is now stated in two layers — overasthere, overJ.Ifthere. That is the parser-layer boundary Python: a call names the member its import binds #8758 ran into; the new helper's docstring points at its LST twin so the next person changing either finds the other.The two other failing rows keep failing, and need no new predicate. A
try's handler can bind the name to a shim (gone = None), a genuine second binding_rebound_namesalready excludes on its own merits. A function-local import is scoped, and a module-level reference is not that binding.Tests
TestSymbolTheStubsDoNotDeclare, which calls a symbol its fixture module does not define. A fixture using a symbol the typeshed still declares passes whether or not the binding path was reached — the confound behind the wrong conclusions on Python: let a project's declared version pick the typeshed #8759.test_an_import_under_an_unconditional_if_binds_at_module_scopecovers one guard and nested guards;..._with_an_else_branch_...and..._in_a_try_...pin the declines. Each mutation-checked against the line it claims: dropping theorelseguard fails theelsetest, widening the helper toast.Tryfails thetrytest, and dropping its recursion fails the nested row.Downstream
In
moderneinc/rewrite-migrate-pythonthis is necessary but not sufficient. Run against the same guarded fixture per recipe, on this branch:FindSslMatchHostnameandFindLocaleGetdefaultlocaleact on it. The first gates on_is_ssl_method(...)beforemember_called(...); the second is a bareMethodMatcher.create("locale getdefaultlocale(..)"). Both read the declaring type directly.gettext lgettext,locale resetlocaleandplatform popenstill decline. They reach the receiver-less form only throughFromImports.member_called, whose index is built fromcu.statements— top-level only, exactly as this one was.That helper's docstring gives the limit
try/except's reasoning, applied to the unconditionaliftoo: the same conflation, one layer down. It widens separately, after this merges.Full downstream suite on this branch: 668 passed, no test changed outcome in either direction.