Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In JavaScript and TypeScript, react-dom's `root.render(<Popup />)` no longer links to your own class component's `render()`, such as an error boundary's, when `root` is declared outside the function making the call, like a module's `let root: Root | null`, or holds what `createRoot(el)` returned. A call on a variable declared outside the calling function now goes by the type that declaration gives it, so one declared with your own class, like `const store = new Store()` at the top of a file, links to that class's method, and a class component's `render()` and lifecycle methods, which only React calls, are no longer a guess for an untyped value. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report. (#2589)
- In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600)
- In Python, `from app import email` no longer links the importing file to a project variable named `app`, such as a FastAPI or Flask `app = FastAPI()`. (#2584)
- In Python, a from-import of a module whose name has no dot, like `from loader import Registry` with `loader.py` at the repository root, or `from mypackage import helper` in a `src/` layout, now links the importing file to that module's file, as `import loader` and `from pkg.loader import Registry` already did. Before, the file's dependency on the module itself was left out, so `codegraph affected` and impact could miss the files that import it. When the file also imported something else under the module's name, like `from django.core import mail` beside `from mail import …`, the module was linked to that instead. Re-index Python projects after upgrading.
- In Python, a call through a module-level instance, like `_svc.delete(obj)` after `_svc = SceneService()`, now links to the method its class has or inherits, even when other classes have a method of that name. (#2582)
- In Python, a call of your own class's method named like a dictionary, list or file method, such as `get`, `update`, `items`, `pop` or `close`, now links to that method when the function says what the variable holds: `reg = SystemPackRegistry()` then `reg.get(…)`, `registry = load_registry()` with `def load_registry() -> SystemPackRegistry`, or a parameter written `reg: SystemPackRegistry`. Before, these calls were dropped as built-in methods unless the variable was named after the class. A variable the function also sets to something else, like `{}` or what `json.load` returns, a class imported from another package, like `queue.Queue`, and a union type still link nothing. Re-index Python projects after upgrading. Thanks @inth3shadows for the report. (#2583)
- In Python, a call on a module imported from outside your project, like `subprocess.run(…)` after `import subprocess`, no longer links to a project method that happens to share its name. Thanks @Egrn for the report. (#2599)
Expand Down
129 changes: 129 additions & 0 deletions __tests__/python-from-import-module-file.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/**
* The module a Python `from X import y` names is a file of the project when X
* is one, dotted or not: `from loader import X` with `loader.py` at a package
* root links the importing file to it, as `import loader` and `from
* pkg.loader import X` already did. Before, an undotted module of a
* from-import was left unresolved, so the file lost its dependency on the
* module and `codegraph affected` missed it. A module from outside the
* project still links nothing.
*/

import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';

let tempDir: string;
let cg: CodeGraph | null = null;

function write(files: Record<string, string>): void {
for (const [rel, source] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(tempDir, rel)), { recursive: true });
fs.writeFileSync(path.join(tempDir, rel), source);
}
}

/** `kind:name@file` of every target of an `imports` edge from `fromFile`'s file node, sorted. */
function importEdges(fromFile: string): string[] {
const file = cg!.getNodesByKind('file').find((n) => n.filePath === fromFile)!;
expect(file).toBeDefined();
return cg!
.getOutgoingEdges(file.id)
.filter((e) => e.kind === 'imports')
.map((e) => cg!.getNode(e.target))
.filter((n): n is NonNullable<typeof n> => !!n)
.map((n) => `${n.kind}:${n.name}@${n.filePath}`)
.sort();
}

async function indexed(files: Record<string, string>): Promise<void> {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-from-module-'));
write(files);
cg = await CodeGraph.init(tempDir, { index: true });
}

afterEach(() => {
cg?.close();
cg = null;
fs.rmSync(tempDir, { recursive: true, force: true });
});

const LOADER = 'class Registry:\n def get(self, k):\n return k\n\n\ndef load_registry():\n return Registry()\n';

describe('the module of a Python from-import links its file', () => {
it('links a top-level module at the project root, as `import loader` does', async () => {
await indexed({
'loader.py': LOADER,
'packs.py': 'from loader import Registry, load_registry\n\n\ndef use():\n return load_registry()\n',
'plain.py': 'import loader\n\n\ndef use():\n return loader.load_registry()\n',
});
expect(importEdges('packs.py')).toEqual([
'class:Registry@loader.py',
'file:loader.py@loader.py',
'function:load_registry@loader.py',
]);
expect(importEdges('plain.py')).toEqual(['file:loader.py@loader.py']);
});

it('links a top-level package to its __init__.py, as a dotted from-import links its package', async () => {
await indexed({
'pkg/__init__.py': 'def helper():\n return 1\n',
'pkg/sub/__init__.py': 'def deep():\n return 2\n',
'app.py': 'from pkg import helper\nfrom pkg.sub import deep\n',
});
expect(importEdges('app.py')).toEqual([
'file:__init__.py@pkg/__init__.py',
'file:__init__.py@pkg/sub/__init__.py',
'function:deep@pkg/sub/__init__.py',
'function:helper@pkg/__init__.py',
]);
});

it('finds the module from the package roots, as in a src layout', async () => {
await indexed({
'pyproject.toml': '[project]\nname = "app"\n',
'src/app/__init__.py': 'def helper():\n return 1\n',
'tests/test_app.py': 'from app import helper\n\n\ndef test_helper():\n assert helper() == 1\n',
});
expect(importEdges('tests/test_app.py')).toEqual([
'file:__init__.py@src/app/__init__.py',
'function:helper@src/app/__init__.py',
]);
});

it('links a module named on a continuation line of the statement', async () => {
await indexed({ 'loader.py': LOADER, 'packs.py': 'from \\\n loader import Registry\n' });
expect(importEdges('packs.py')).toEqual(['class:Registry@loader.py', 'file:loader.py@loader.py']);
});

it('links nothing for a module from outside the project, even one a nested file is named like', async () => {
await indexed({
'tests/fixtures/requests.py': 'class Session:\n pass\n',
'client.py': 'from requests import Session\nimport requests\n',
});
expect(importEdges('client.py')).toEqual([]);
});

it('keeps a submodule the statement imports by name', async () => {
await indexed({
'pkg/__init__.py': '',
'pkg/loader.py': LOADER,
'app.py': 'from pkg import loader\n',
});
expect(importEdges('app.py')).toEqual(['file:__init__.py@pkg/__init__.py', 'file:loader.py@pkg/loader.py']);
});

it('links the module once it is added, on sync as on a fresh index', async () => {
await indexed({ 'packs.py': 'from loader import Registry\n' });
expect(importEdges('packs.py')).toEqual([]);
write({ 'loader.py': LOADER });
await cg!.sync();
const synced = importEdges('packs.py');
cg!.close();
fs.rmSync(path.join(tempDir, '.codegraph'), { recursive: true, force: true });
cg = await CodeGraph.init(tempDir, { index: true });
expect(synced).toEqual(importEdges('packs.py'));
expect(synced).toEqual(['class:Registry@loader.py', 'file:loader.py@loader.py']);
});
});
10 changes: 6 additions & 4 deletions __tests__/python-module-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,8 @@ def fetch():
cg = await CodeGraph.init(tempDir, { index: true });

expect(targets('analysis.py', 'analysis.py', 'imports')).toEqual([]);
expect(targets('report.py', 'report.py', 'imports')).toEqual(['summary@analysis.py']);
// The from-import's module is analysis.py itself; the name is its `summary`.
expect(targets('report.py', 'report.py', 'imports').sort()).toEqual(['analysis.py@analysis.py', 'summary@analysis.py']);
expect(cg.findCircularDependencies()).toEqual([]);
});

Expand Down Expand Up @@ -651,7 +652,8 @@ def main():
'main.py': 'from pkg import f\n\n\ndef main():\n f()\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('main.py', 'main.py', 'imports')).toEqual(['f@pkg/__init__.py']);
// The package itself is the from-import's module; `f` is its attribute.
expect(targets('main.py', 'main.py', 'imports').sort()).toEqual(['__init__.py@pkg/__init__.py', 'f@pkg/__init__.py']);
});

it('binds it through a relative import too', async () => {
Expand All @@ -675,7 +677,7 @@ def main():
'main.py': 'from pkg import thing\n\n\ndef main():\n thing()\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('main.py', 'main.py', 'imports')).toEqual(['thing@pkg/impl.py']);
expect(targets('main.py', 'main.py', 'imports').sort()).toEqual(['__init__.py@pkg/__init__.py', 'thing@pkg/impl.py']);
});

it('still binds the submodule when the package does not bind the name', async () => {
Expand All @@ -685,7 +687,7 @@ def main():
'main.py': 'from pkg import f\n\n\ndef main():\n f.g()\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('main.py', 'main.py', 'imports')).toEqual(['f.py@pkg/f.py']);
expect(targets('main.py', 'main.py', 'imports').sort()).toEqual(['__init__.py@pkg/__init__.py', 'f.py@pkg/f.py']);
expect(targets('main', 'main.py', 'calls')).toEqual(['g@pkg/f.py']);
});
});
Expand Down
14 changes: 9 additions & 5 deletions src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
tsImportedTypeFile,
reExportSource,
pythonFunctionModuleAlias,
isPythonFromImportModule,
isRustAssociatedItem,
tsGlobalScope,
} from './name-matcher';
Expand Down Expand Up @@ -3661,11 +3662,14 @@ function resolvePythonAbsoluteModule(
context: ResolutionContext
): ResolvedRef | null {
if (ref.referenceKind !== 'imports') return null;
// Only a DOTTED `import a.b.c` ref carries its full module path. A bare leaf
// (`from app.api.routes import authentication`) is ambiguous on its own — three
// `authentication.py` files may exist — so leave it to resolveModuleImportToFile,
// which uses the import's source (`app.api.routes`) to build the full path.
if (!ref.referenceName.includes('.')) return null;
// Only a DOTTED `import a.b.c` ref carries its full module path, and so does
// the module a from-import names, dotted or not: `from loader import X` is
// the top-level `loader`, found from the package roots as `import loader`
// finds it. A bare leaf it imports (`from app.api.routes import
// authentication`) is ambiguous on its own — three `authentication.py` files
// may exist — so leave it to resolveModuleImportToFile, which uses the
// import's source (`app.api.routes`) to build the full path.
if (!ref.referenceName.includes('.') && !isPythonFromImportModule(ref, context)) return null;
const hit = pythonModuleFileNode(ref.referenceName, ref.filePath, context);
return hit ? { original: ref, targetNodeId: hit.id, confidence: 0.9, resolvedBy: 'import' } : null;
}
Expand Down