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 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-01 04:29:55 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c38aa9c973
commit 6d8802cef8
3 changed files with 107 additions and 3 deletions
+1
View File
@@ -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`.
@@ -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<string, string> = {
'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']);
});
});
+45 -3
View File
@@ -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<ResolutionContext, Map<string, { module: Node[]; pkg: Node[] }>>();
/**
* 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<ResolutionContext, Map<string, Node | null>>();
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,