From 6d8802cef8b3abc2ca8238af97cb63cb9d19d0e5 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Thu, 1 Oct 2026 04:29:55 +0000 Subject: [PATCH] fix(python): an imported name follows the package's re-exports to its definition (#2247) pythonModuleSymbol: a name imported from a module is its top-level definition, or one the module re-exports by name (`from .tokens import Token`) or through a star import (`from .users import *`, read from source), three packages deep, memoized. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/python-package-reexports.test.ts | 61 ++++++++++++++++++++++ src/resolution/import-resolver.ts | 48 +++++++++++++++-- 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 __tests__/python-package-reexports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f05f15e31..aa68868b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- A Python name imported from a package now follows the package's re-exports to where it's defined. That covers both `from .users import *` and `from .tokens import Token` in its `__init__.py`. Before, httpx's own tests' `httpx.Client(…)` and `httpx.URL(…)`, pytest's `pytest.raises(…)` and `pytest.param(…)`, and netbox's `from users.models import User` went unresolved or to a same-named class elsewhere, such as a test's `User` or an Alembic migration's copy of a model. - In NestJS projects, the provider naming convention (`…Service`, `…Controller`, …) now picks a class only from the reference's own file. Another file's provider is reached through its import, or by ordinary name matching that prefers the nearest declaration. Before, the convention's preference for `*.service.ts` files sent Nest's own specs' `TransientService` and `TestService` to same-named classes in unrelated sample apps. - In Astro projects, the component-name convention now applies only inside `.astro` files, where components are used. Before, a TypeScript type with a component's name was linked to that component: astrowind's `image?: Image` and `callToAction?: CallToAction` in its type declarations, and starlight's Playwright `page: Page` in its tests. - In JavaScript and TypeScript, the Express conventions for middleware (`validate…`, `auth`, `logger`, …), controllers and services now pick only the calling file's own declaration. Another file's is reached only through an `import` or `require`. Before, SvelteKit's remote functions linked `validate(arg)` to its config loader's `validate` in another package, and Express's examples linked `logger('dev')` to one example's `logger`. diff --git a/__tests__/python-package-reexports.test.ts b/__tests__/python-package-reexports.test.ts new file mode 100644 index 000000000..45a97dd41 --- /dev/null +++ b/__tests__/python-package-reexports.test.ts @@ -0,0 +1,61 @@ +/** + * A Python name imported from a package resolves through the package's + * re-exports — `from .users import *`, `from .tokens import Token` in its + * `__init__.py` — to where it is defined. netbox's `from users.models import + * User` names `users/models/__init__.py`, which star-imports `.users`; + * `httpx.Client(…)` names `httpx/__init__.py`, which imports `._client`. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-py-reexport-')); + const files: Record = { + 'netbox/users/__init__.py': '', + 'netbox/users/models/__init__.py': `from .tokens import Token +from .users import * +`, + 'netbox/users/models/users.py': `class User: + pass +`, + 'netbox/users/models/tokens.py': `class Token: + pass +`, + 'netbox/extras/tests/test_templatetags.py': `class User: + pass +`, + 'netbox/alembic/versions/0001_tokens.py': `class Token: + pass +`, + 'netbox/core/views.py': `from users.models import Token, User + + +def make(): + return User(), Token() +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('Python package re-exports', () => { + it('lead an imported name to its definition, through star and named re-exports', () => { + const ids = cg.getNodesInFile('netbox/core/views.py').map((n) => n.id); + const targets = cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'instantiates').map((e) => cg.getNode(e.target)!.filePath).sort(); + expect(targets).toEqual(['netbox/users/models/tokens.py', 'netbox/users/models/users.py']); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index eacde7ae1..09544b839 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -206,6 +206,7 @@ export function clearImportResolverMemos(context: ResolutionContext): void { luaFileBasenameIndexes.delete(context); cobolCopybookIndexes.delete(context); pythonModuleFileMemos.delete(context); + PY_MODULE_SYMBOLS.delete(context); } export function resolveImportPath( @@ -1763,9 +1764,7 @@ export function resolveViaImport( context, new Set() ) ?? (ref.language === 'python' - ? context.getNodesInFile(resolvedPath).find(n => - n.name === (memberName ?? exportedName) && !n.qualifiedName.includes('::') && - (n.kind === 'class' || n.kind === 'function' || n.kind === 'variable' || n.kind === 'constant')) + ? pythonModuleSymbol(resolvedPath, memberName ?? exportedName, context, 0) : undefined); if (targetNode) { @@ -2077,6 +2076,49 @@ function resolveModuleImportToFile( */ const pythonModuleFileMemos = new WeakMap>(); +/** + * A top-level class / function / value named `name` in a Python module, or + * one the module re-exports — `from .users import *`, `from .users import + * User` — a few packages deep. netbox's `from users.models import User` names + * `users/models/__init__.py`, which star-imports `.users`, where `User` is. + */ +const PY_MODULE_SYMBOLS = new WeakMap>(); + +function pythonModuleSymbol(file: string, name: string, context: ResolutionContext, depth: number): Node | undefined { + let memo = PY_MODULE_SYMBOLS.get(context); + if (!memo) PY_MODULE_SYMBOLS.set(context, (memo = new Map())); + const key = `${file}\0${name}`; + const hit = memo.get(key); + if (hit !== undefined) return hit ?? undefined; + // (A cycle of star imports reads as "not here" while it is being walked.) + memo.set(key, null); + const found = pythonModuleSymbolUncached(file, name, context, depth); + memo.set(key, found ?? null); + return found; +} + +function pythonModuleSymbolUncached(file: string, name: string, context: ResolutionContext, depth: number): Node | undefined { + const own = context.getNodesInFile(file).find((n) => + n.name === name && !n.qualifiedName.includes('::') && + (n.kind === 'class' || n.kind === 'function' || n.kind === 'variable' || n.kind === 'constant')); + if (own || depth >= 3) return own; + // Re-exported by name (`from .users import User`), else through a star import + // (`from .users import *` — not among the import mappings, so read here). + const sources: Array<{ source: string; exported: string }> = context.getImportMappings(file, 'python') + .filter((imp) => !imp.isNamespace && imp.localName === name) + .map((imp) => ({ source: imp.source, exported: imp.exportedName })); + for (const m of (context.readFile(file) ?? '').matchAll(/^\s*from\s+([\w.]+)\s+import\s+\*/gm)) { + sources.push({ source: m[1]!, exported: name }); + } + for (const { source, exported } of sources) { + const target = resolveImportPath(source, file, 'python', context) ?? findPythonModuleFile(source, context, file)?.filePath ?? null; + if (!target || target === file) continue; + const found = pythonModuleSymbol(target, exported, context, depth + 1); + if (found) return found; + } + return undefined; +} + function findPythonModuleFile( mod: string, context: ResolutionContext,