Attribute Python import names with ty's canonical types - #8408
Merged
knutwannheden merged 2 commits intoAug 6, 2026
Conversation
ty attributes a re-exported symbol at its definition site, so a name imported through its public path was unreachable from type attribution: usages of `from os.path import join` are attributed as `posixpath.join`, and `from collections.abc import Iterable` as `typing.Iterable`. Import qualids carried no type at all, so nothing bridged the two spellings and a recipe asking to remove `posixpath.join` left the `os.path` import in place. The ty `getTypes` response already carries this: for each import name it emits an `Alias` node at the name's byte range whose descriptor names the canonical location. The parser now consumes it and attributes each import name, and the import machinery matches a requested (module, name) against both the written path and the canonical FQN.
A module is the canonical home of every symbol re-exported through it, so testing a member's canonical parent against the requested module made `RemoveImport(module='typing')` delete `from collections.abc import Iterable`. Whole-module removal now matches only an import that binds the module itself, which still covers `from os import path` for `os.path`. Name-level matching is unaffected: it compares an exact `module.name` FQN. The one in-tree caller is ChangeImport, which schedules a whole-module removal for the old module after rewriting qualified references; a regression test covers an unrelated import surviving that path.
knutwannheden
deleted the
canonical-type-attribution-for-python-import-names
branch
August 6, 2026 10:22
5 tasks
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.
Motivation
ty attributes a re-exported symbol at its definition site, not at the path a file imports it through. Usages of
from os.path import joinare attributed asposixpath.join, andfrom collections.abc import Iterableastyping.Iterable. Import qualids, meanwhile, carried no type at all, so nothing connected the two spellings: an FQN derived from type attribution never matched the import statement's written path, and a recipe askingRemoveImportto dropposixpath.joinleft theos.pathimport sitting there unused. The same gap runs the other way — a recipe pattern written against the public path could not be compared to what attribution reports.The fix is entirely parser-side. ty's
getTypesresponse already carries what is needed: for each import name it emits anAliasnode at that name's byte range whose descriptor names the canonical location, e.g.{"nodeKind": "Alias", "start": 20, "end": 24, "typeId": 1}resolving to{"kind": "function", "moduleName": "posixpath", "name": "join"}. The parser now consumes those nodes, and the Python import machinery matches a requested(module, name)against both the written path and the canonical FQN.Examples
Import names carry the canonical type of the symbol they bind:
RemoveImportaccepts either spelling, so a Java-side recipe that only knows the canonical FQN can clean up after itself:AddImporttreats an existing public-path import as already satisfying a canonical request, rather than adding a redundant second import:Summary
PythonTypeMapping.import_alias_type(ast.alias)maps ty'sAliasdescriptor to aJavaType: module kinds become the module's class, function kinds a wholeJavaType.Method(not the return type that expression positions use, so callers can derive an FQN from declaring type plus name), and everything else resolves normally.ParserVisitor.visit_aliasattaches that type to the import's qualid and to anyasalias identifier. For dotted names only the outermostFieldAccess— the one naming the full path — carries it.import os.pathis typed by ty as the bound root packageos; the qualid keeps the full dotted path, since that is what the name denotes.import_utils.get_canonical_fqn(imp)reads the canonical FQN back off an attributed import, joining declaring type and name for methods.RemoveImportmatches a member when the written module and name match or its canonical FQN equals the requestedmodule.name. Whole-module removal additionally matches an import that binds the module itself (from os import pathforos.path). Membership deliberately does not count: a module is the canonical home of every symbol re-exported through it, so matching members would sweep up imports written against other modules —RemoveImport(module='typing')would reachfrom collections.abc import Iterable.AddImport's duplicate check accepts a canonically matching member, guarded on it binding the same name so references to the requested name still resolve. Merging a new name into an existingfromstatement stays purely syntactic — merging a canonical request into a differently-written statement would need a canonical-to-public reverse mapping, which is unbounded.Test plan
tests/python/test_type_attribution.py::TestImportNameAttribution— 8 new cases covering re-exported functions (with and without an alias), re-exported classes, plain and dotted module imports,from X import <module>, and an unresolvable module staying untyped.tests/python/test_remove_import.py::TestCanonicalRemoveImport— 6 new cases: removal by canonical FQN for functions and classes, canonical removal leaving sibling names intact, whole-module removal by module binding, a negative case (os.path.existsis canonicallygenericpath.exists, so aposixpath.existsrequest must not match), and a whole-module request sparing canonically-related members of another module.tests/recipes/test_change_import.py::TestChangeImportLeavesUnrelatedImports— ChangeImport's whole-module removal of the old module leaves an unrelated, canonically-related import in place.tests/python/test_add_import.py::TestCanonicalAddImportDedup— 4 new cases: canonical requests deduplicating against written from-imports for functions and classes, an aliased binding not satisfying a plain-name request, and a canonical mismatch still adding its own import.rewrite-pythonsuite green: 1863 passed, 7 skipped.