Files
codegraph/__tests__/python-module-resolution.test.ts
T
Colby MchenryandClaude Opus 5.5 23a80a847a fix(python): a from-import of a module with no dot in its name links the module's file (#2823)
In Python, `from loader import X` with loader.py at a package root left
the module reference `loader` unresolved, so the importing file had no
imports edge to loader.py. `import loader` and `from pkg.loader import X`
both link their module's file. The from-import's names (X) linked, but
`codegraph affected` and impact could miss the file's dependency on the
module. v1.6.2 behaves the same.

Root cause: resolvePythonAbsoluteModule resolves only dotted module refs.
It leaves bare names to resolveModuleImportToFile, because a bare
imported name like `authentication` in `from app.api.routes import
authentication` is ambiguous on its own. But resolveModuleImportToFile
matches an import by its local name, which for `from loader import X` is
X, never `loader`. So the module of an undotted from-import was never
looked up by its path. When the file also bound the module's name some
other way, the generic import loop resolved it through that binding:
next to `from django.core import mail`, the module of `from mail import
...` became django/core/mail/__init__.py. Django's tests/mail/__init__.py
is the module meant, and the statement's own names already resolve
there.

Fix: resolvePythonAbsoluteModule also takes the module a from-import
names (isPythonFromImportModule), dotted or not. It resolves it from the
package roots, as `import loader` is resolved. Imported names keep their
path, and a module from outside the project still links nothing.

Validation:
- New __tests__/python-from-import-module-file.test.ts, 7 tests: a
  root-level module, a top-level package, a src layout, a module named on
  a continuation line, a submodule imported by name, sync after the module
  file is added (matching a fresh index), and an outside module with a
  same-named nested file (links nothing). With main's import-resolver.ts
  the 6 positive tests fail and the guard passes. Seven assertions in
  python-module-resolution.test.ts listed a file's import edges exactly
  while the module edge was missing; they now include the module's file.
  What they test is unchanged: where the imported name binds, and no
  cycle.
- A/B against main d5fc1fbb. Both arms were built from their own sources
  with the kernel. Rebased onto 628f69e2 (PR #2822, Kotlin only), the fix
  arm's graphs are byte-identical to before the rebase:
  - requests: +2 edges;
  - sqlalchemy: +3,189;
  - django's 2,938 Python files: +103 edges and 2 retargeted;
  - every change is a from-import's module linking its file, and nothing
    else changed;
  - express and gin (controls): byte-identical.
  Triage of all 3,296 added edges:
  - 3,284 point at the file, or the package folder, where the same
    statement's imported names already resolve;
  - the other 12 were read by hand: multi-line statements, re-exported
    constants the name resolver doesn't follow, and a deliberately broken
    name in a Django test fixture, each on the right module file;
  - the 2 django retargets are the mail case above.
- Cost: index CPU medians of 3 interleaved runs, main vs fix: sqlalchemy
  100.7 s vs 100.3 s, django 98.4 s vs 97.8 s.

Found while verifying another Python issue in the sweep; no issue was
filed for it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 02:38:35 +00:00

1011 lines
36 KiB
TypeScript

/**
* Python module-specifier resolution: one resolver for every Python import.
*
* An absolute dotted specifier (`pkg.sub.mod`) used to reach a file only
* through a suffix match against ANY `…/pkg/sub/mod.py` in the repo. That
* bound `import json` to a project's `app/utils/json.py`, and picked an
* arbitrary twin when two services both carry `core/models.py`. Named imports
* that an `__init__.py` re-exports got no edge at all. Each case below plants
* a same-named decoy, so only the import can pick the right target.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { CodeGraph } from '../src';
import { extractImportMappings } from '../src/resolution/import-resolver';
describe('Python module resolution', () => {
let tempDir: string;
let cg: CodeGraph | undefined;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pymod-'));
cg = undefined;
});
afterEach(() => {
if (cg) cg.destroy();
if (fs.existsSync(tempDir)) fs.rmSync(tempDir, { recursive: true, force: true });
});
function write(files: Record<string, string>): void {
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(tempDir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content);
}
}
/** `kind` edges leaving `fnName` in `file`, as `name@file` of each target. */
function targets(fnName: string, file: string, kind: 'calls' | 'imports'): string[] {
const src = cg!
.getNodesInFile(file)
.find((n) => n.name === fnName && (n.kind === 'function' || n.kind === 'file'));
expect(src, `${fnName} in ${file}`).toBeDefined();
return cg!
.getOutgoingEdges(src!.id)
.filter((e) => e.kind === kind)
.map((e) => cg!.getNode(e.target))
.map((n) => `${n!.name}@${n!.filePath.replace(/\\/g, '/')}`);
}
it('resolves an absolute dotted specifier to the module at a package root, not a same-named nested module', async () => {
write({
'pkg/__init__.py': '',
'pkg/sub/__init__.py': '',
'pkg/sub/mod.py': 'def run():\n return 1\n',
// `aaa.pkg.sub.mod` — ends in `pkg/sub/mod.py` but is not `pkg.sub.mod`.
'aaa/__init__.py': '',
'aaa/pkg/__init__.py': '',
'aaa/pkg/sub/__init__.py': '',
'aaa/pkg/sub/mod.py': 'def run():\n return 2\n',
'app/__init__.py': '',
'app/main.py': `import pkg.sub.mod
from pkg.sub.mod import run
def qualified():
pkg.sub.mod.run()
def named():
run()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('qualified', 'app/main.py', 'calls')).toEqual(['run@pkg/sub/mod.py']);
expect(targets('named', 'app/main.py', 'calls')).toEqual(['run@pkg/sub/mod.py']);
expect(targets('main.py', 'app/main.py', 'imports')).toContain('mod.py@pkg/sub/mod.py');
expect(targets('main.py', 'app/main.py', 'imports')).not.toContain('mod.py@aaa/pkg/sub/mod.py');
});
it('finds packages under a src/ layout from the __init__.py chain', async () => {
write({
'src/shop/__init__.py': '',
'src/shop/cart.py': 'def total():\n return 1\n',
'legacy/__init__.py': '',
'legacy/shop/__init__.py': '',
'legacy/shop/cart.py': 'def total():\n return 2\n',
'tests/test_cart.py': `from shop.cart import total
def test_total():
total()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('test_total', 'tests/test_cart.py', 'calls')).toEqual(['total@src/shop/cart.py']);
});
it('prefers the monorepo service that contains the importer', async () => {
write({
'svc_a/core/__init__.py': '',
'svc_a/core/models.py': 'def save():\n return 1\n',
'svc_b/core/__init__.py': '',
'svc_b/core/models.py': 'def save():\n return 2\n',
'svc_b/core/views.py': `import core.models
def view():
core.models.save()
`,
'svc_a/core/views.py': `import core.models
def view():
core.models.save()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('views.py', 'svc_b/core/views.py', 'imports')).toContain('models.py@svc_b/core/models.py');
expect(targets('views.py', 'svc_b/core/views.py', 'imports')).not.toContain('models.py@svc_a/core/models.py');
expect(targets('views.py', 'svc_a/core/views.py', 'imports')).toContain('models.py@svc_a/core/models.py');
expect(targets('views.py', 'svc_a/core/views.py', 'imports')).not.toContain('models.py@svc_b/core/models.py');
expect(targets('view', 'svc_b/core/views.py', 'calls')).toEqual(['save@svc_b/core/models.py']);
expect(targets('view', 'svc_a/core/views.py', 'calls')).toEqual(['save@svc_a/core/models.py']);
});
it('draws no edge when two package roots both provide the module and neither contains the importer', async () => {
write({
'svc_a/core/__init__.py': '',
'svc_a/core/models.py': 'def save():\n return 1\n',
'svc_b/core/__init__.py': '',
'svc_b/core/models.py': 'def save():\n return 2\n',
'tools/check.py': `import core.models
def check():
core.models.save()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
// The import names no module this file can prove. (The `save()` call is
// emitted as a bare name, which the name matcher still answers on its own.)
const intoServices = (t: string) => t.includes('@svc_');
expect(targets('check.py', 'tools/check.py', 'imports').filter(intoServices)).toEqual([]);
});
it('reads package roots declared in pyproject.toml, for namespace packages without __init__.py', async () => {
write({
'pyproject.toml': '[tool.setuptools.packages.find]\nwhere = ["lib"]\n',
'lib/nsp/tools.py': 'def go():\n return 1\n',
'vendor/__init__.py': '',
'vendor/nsp/tools.py': 'def go():\n return 2\n',
'tests/test_tools.py': `import nsp.tools
def test_go():
nsp.tools.go()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('test_go', 'tests/test_tools.py', 'calls')).toEqual(['go@lib/nsp/tools.py']);
});
it('resolves a namespace package from inside itself', async () => {
write({
'backend/myapp/models.py': 'def fetch():\n return 1\n',
'backend/myapp/views.py': `from myapp.models import fetch
def index():
fetch()
`,
'other/__init__.py': '',
'other/myapp/__init__.py': '',
'other/myapp/models.py': 'def fetch():\n return 2\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('index', 'backend/myapp/views.py', 'calls')).toEqual(['fetch@backend/myapp/models.py']);
});
it('resolves a package import to its __init__.py', async () => {
write({
'shop/__init__.py': 'def checkout():\n return 1\n',
'misc/__init__.py': '',
'misc/tools.py': 'def checkout():\n return 2\n',
'main.py': `import shop
def buy():
shop.checkout()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('buy', 'main.py', 'calls')).toEqual(['checkout@shop/__init__.py']);
expect(targets('main.py', 'main.py', 'imports')).toContain('__init__.py@shop/__init__.py');
});
it('follows __init__.py re-exports: relative, absolute, aliased, parenthesised and star', async () => {
write({
'shop/__init__.py': `from .billing.invoice import make_invoice
from shop.billing.tax import compute_tax as tax
from .cart import (
add_item, # the common multi-line form
remove_item,
)
from .money import *
`,
'shop/billing/__init__.py': '',
'shop/billing/invoice.py': 'def make_invoice():\n return 1\n',
'shop/billing/tax.py': 'def compute_tax():\n return 1\n',
'shop/cart.py': 'def add_item():\n return 1\n\n\ndef remove_item():\n return 1\n',
'shop/money.py': 'def to_cents():\n return 1\n',
// A same-named decoy for every re-exported name.
'decoys/__init__.py': '',
'decoys/all.py': `def make_invoice():
return 2
def compute_tax():
return 2
def tax():
return 2
def add_item():
return 2
def remove_item():
return 2
def to_cents():
return 2
`,
'main.py': `from shop import make_invoice, tax, add_item, remove_item, to_cents
def order():
make_invoice()
tax()
add_item()
remove_item()
to_cents()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('order', 'main.py', 'calls').sort()).toEqual([
'add_item@shop/cart.py',
'compute_tax@shop/billing/tax.py',
'make_invoice@shop/billing/invoice.py',
'remove_item@shop/cart.py',
'to_cents@shop/money.py',
]);
});
it('terminates on a re-export cycle and draws no edge', async () => {
write({
'a/__init__.py': 'from b import thing\n',
'b/__init__.py': 'from a import thing\n',
'main.py': `from a import thing
def use():
thing()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('use', 'main.py', 'calls')).toEqual([]);
});
it('never binds a stdlib or third-party import to a same-named module nested in the project', async () => {
write({
'app/__init__.py': '',
'app/utils/__init__.py': '',
'app/utils/json.py': 'def loads(s):\n return s\n',
'app/utils/yaml.py': 'def safe_load(s):\n return s\n',
'app/views.py': `import json
import yaml
def view():
json.loads("1")
yaml.safe_load("x")
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('view', 'app/views.py', 'calls')).toEqual([]);
expect(targets('views.py', 'app/views.py', 'imports')).toEqual([]);
});
it('never binds a name imported from a third-party package to a copy of it nested in a test fixture', async () => {
write({
'tests/fixtures/proj/pyproject.toml': '[project]\nname = "proj"\n',
'tests/fixtures/proj/requests/__init__.py': 'def get(u):\n return u\n',
'app/__init__.py': '',
'app/client.py': `from requests import get
def fetch():
return get("x")
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('fetch', 'app/client.py', 'calls')).toEqual([]);
expect(targets('client.py', 'app/client.py', 'imports')).toEqual([]);
});
// #2566: `from scipy import stats` bound `stats` by exact name to a
// top-level `stats` in another project file, and when that file imports
// this one, a cycle that does not exist. The stdlib form (`from os import
// path`) did the same. A module import (`import scipy.stats as stats`) never did.
it.each([
['a third-party module, against a variable', 'from scipy import stats', 'stats', 'stats = summary([1, 2, 3])\n'],
['a third-party module, against a function', 'from scipy import stats', 'stats', 'def stats():\n return 1\n'],
['a stdlib module, against a variable', 'from os import path', 'path', 'path = summary([1])\n'],
['a third-party module under an alias, against a variable', 'from scipy import stats as st', 'st', 'st = summary([1])\n'],
])('never binds a name imported from %s to a same-named project symbol (#2566)', async (_label, importLine, name, decl) => {
write({
'analysis.py': `${importLine}\n\n\ndef summary(values):\n return ${name}.describe(values)\n`,
'report.py': `from analysis import summary\n\n${decl}`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('analysis.py', 'analysis.py', 'imports')).toEqual([]);
// 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([]);
});
it('still binds a name imported from a package the project provides at a root', async () => {
write({
'requests/__init__.py': 'def get(u):\n return u\n',
'app/__init__.py': '',
'app/client.py': `from requests import get
def fetch():
return get("x")
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('fetch', 'app/client.py', 'calls')).toEqual(['get@requests/__init__.py']);
});
it('binds a top-level module the project really provides at a package root', async () => {
write({
'yaml.py': 'def safe_load(s):\n return s\n',
'app/__init__.py': '',
'app/views.py': `import yaml
def view():
yaml.safe_load("x")
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('view', 'app/views.py', 'calls')).toEqual(['safe_load@yaml.py']);
});
it('keeps script-directory imports: a sibling module of a file outside any package', async () => {
write({
'scripts/helpers.py': 'def helper():\n return 1\n',
'lib/__init__.py': '',
'lib/helpers.py': 'def helper():\n return 2\n',
'scripts/run.py': `import helpers
def main():
helpers.helper()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('main', 'scripts/run.py', 'calls')).toEqual(['helper@scripts/helpers.py']);
});
it('does not resolve a relative import that climbs above the top-level package', async () => {
write({
'helpers.py': 'def helper():\n return 1\n',
'pkg/__init__.py': '',
'pkg/mod.py': `from .. import helpers
def use():
helpers.helper()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('mod.py', 'pkg/mod.py', 'imports')).not.toContain('helpers.py@helpers.py');
expect(targets('use', 'pkg/mod.py', 'calls')).toEqual([]);
});
// Before this change a Python named import never reached the import
// resolver's member branch, so `send_welcome.delay()` stayed unresolved.
// Now that it does, it must keep that answer rather than land on the task.
it('keeps an attribute of an imported function unresolved, as before', async () => {
write({
'api/tasks.py': 'def send_welcome(item_id):\n return item_id\n',
'api/items.py': `from .tasks import send_welcome
def create():
send_welcome.delay(1)
send_welcome(2)
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
// `send_welcome.delay()` enqueues a Celery job; only the direct call is a call.
const edges = cg
.getOutgoingEdges(cg.getNodesInFile('api/items.py').find((n) => n.name === 'create')!.id)
.filter((e) => e.kind === 'calls');
expect(edges.map((e) => e.line)).toEqual([6]);
});
it('never lets a package nested in a test fixture capture an import from outside it', async () => {
write({
'app/__init__.py': '',
'app/client.py': `import requests
def fetch():
requests.get("x")
`,
// A fixture project's own copy of a PyPI name — importable only by the
// fixture's own code, never by the application.
'tests/fixtures/proj/requests/__init__.py': 'def get(u):\n return u\n',
'tests/fixtures/proj/pyproject.toml': '[project]\nname = "proj"\n',
'tests/fixtures/proj/use.py': `import requests
def use():
requests.get("y")
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
const intoFixture = (t: string) => t.includes('@tests/fixtures/');
expect(targets('client.py', 'app/client.py', 'imports').filter(intoFixture)).toEqual([]);
expect(targets('fetch', 'app/client.py', 'calls').filter(intoFixture)).toEqual([]);
// Inside its own subtree the fixture still resolves its own package.
expect(targets('use', 'tests/fixtures/proj/use.py', 'calls')).toEqual(['get@tests/fixtures/proj/requests/__init__.py']);
});
it('never lets a package nested inside another package capture an import from outside it', async () => {
write({
'mylib/__init__.py': '',
'mylib/_vendor/__init__.py': '',
'mylib/_vendor/six.py': 'def ensure_str(s):\n return s\n',
'app/__init__.py': '',
'app/main.py': `import six
def run():
six.ensure_str("x")
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('run', 'app/main.py', 'calls')).toEqual([]);
});
it('treats src/ as a package root for a namespace package with no __init__.py and no build config', async () => {
write({
'src/shop/cart.py': 'def total():\n return 0\n',
'legacy/cart.py': 'def total():\n return 9\n',
'tests/test_cart.py': `from shop.cart import total
def test_it():
total()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('test_cart.py', 'tests/test_cart.py', 'imports')).toContain('cart.py@src/shop/cart.py');
expect(targets('test_it', 'tests/test_cart.py', 'calls')).toEqual(['total@src/shop/cart.py']);
});
it("reads a service's own src/ layout and nested build configs, scoped to their tables", async () => {
write({
// A monorepo service with a src/ layout and nothing at the repo root.
'services/billing/pyproject.toml': '[project]\nname = "billing"\n',
'services/billing/src/billing/__init__.py': '',
'services/billing/src/billing/invoice.py': 'def make():\n return 1\n',
// A nested setup.cfg declaring its package directory.
'services/auth/setup.cfg': '[metadata]\nname = auth\n\n[options]\npackage_dir =\n =lib\n',
'services/auth/lib/authn/tokens.py': 'def issue():\n return 1\n',
// `where` under an unrelated table must not declare a root.
'pyproject.toml': '[tool.other]\nwhere = ["decoy"]\n',
'decoy/authn/tokens.py': 'def issue():\n return 2\n',
'tools/run.py': `from billing.invoice import make
from authn.tokens import issue
def go():
make()
issue()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('go', 'tools/run.py', 'calls').sort()).toEqual([
'issue@services/auth/lib/authn/tokens.py',
'make@services/billing/src/billing/invoice.py',
]);
});
it('prefers a regular package over a same-named module, and a module over a namespace directory', async () => {
write({
'pkg.py': 'def f():\n return "module"\n',
'pkg/__init__.py': 'def f():\n return "package"\n',
'mod.py': 'def g():\n return "module"\n',
'mod/g.py': 'def g():\n return "namespace"\n',
'main.py': `from pkg import f
from mod import g
def go():
f()
g()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('go', 'main.py', 'calls').sort()).toEqual(['f@pkg/__init__.py', 'g@mod.py']);
});
it('honours __all__ on a star re-export, and skips _private names without one', async () => {
write({
'pkg/__init__.py': `from .impl import *
from .noall import *
from .priv import _priv
`,
'pkg/impl.py': `__all__ = ["pub"]
def pub():
return 1
def hidden():
return 2
def _priv():
return 3
`,
'pkg/noall.py': `def hidden():
return 4
def _priv():
return 5
def util():
return 6
`,
'pkg/priv.py': 'def _priv():\n return 7\n',
'main.py': `from pkg import pub, hidden, _priv, util
def go():
pub()
hidden()
_priv()
util()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('go', 'main.py', 'calls').sort()).toEqual([
'_priv@pkg/priv.py',
'hidden@pkg/noall.py',
'pub@pkg/impl.py',
'util@pkg/noall.py',
]);
});
it('does not trust an __all__ assembled from other names, as django.db.models builds its own', async () => {
write({
'shop/__init__.py': 'from shop.models import *\n',
'shop/models/__init__.py': `from shop.models.fields import *
from shop.models.fields import __all__ as fields_all
__all__ = fields_all + ["Model"]
__all__ += ["Manager"]
`,
'shop/models/fields.py': '__all__ = ["CharField"]\n\n\nclass CharField:\n pass\n',
'decoy.py': 'class CharField:\n pass\n',
'main.py': `from shop import CharField
def build():
CharField()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(cg.getOutgoingEdges(cg.getNodesInFile('main.py').find((n) => n.name === 'build')!.id)
.map((e) => cg!.getNode(e.target)!.filePath)).toEqual(['shop/models/fields.py']);
});
it("lets the importer's own tree answer before a same-named module next to it", async () => {
// A script run from `demo/` imports `utils.helpers`; `demo/utils/` also
// holds a `utils.py`, which must not shadow the package it sits in.
write({
'demo/run.py': 'from utils.helpers import go\n',
'demo/utils/helpers.py': 'def go():\n return 1\n',
'demo/utils/utils.py': 'def x():\n return 1\n',
'demo/utils/runner.py': `from utils.helpers import go
def main():
go()
`,
'pkg/__init__.py': '',
'pkg/utils.py': 'def go():\n return 2\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('runner.py', 'demo/utils/runner.py', 'imports')).toContain('helpers.py@demo/utils/helpers.py');
expect(targets('main', 'demo/utils/runner.py', 'calls')).toEqual(['go@demo/utils/helpers.py']);
});
describe('from P import N: the package attribute before the submodule', () => {
// CPython's `_handle_fromlist` looks N up on the package first and imports
// the submodule P.N only when the package has no such attribute.
it('binds a name the package __init__.py defines, not a same-named submodule', async () => {
write({
'pkg/__init__.py': 'def f():\n return 0\n',
'pkg/f.py': 'X = 1\n',
'main.py': 'from pkg import f\n\n\ndef main():\n f()\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
// 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 () => {
write({
'pkg/__init__.py': '',
'pkg/sub/__init__.py': 'def f():\n return 0\n',
'pkg/sub/f.py': 'X = 1\n',
'pkg/a/__init__.py': '',
'pkg/a/m.py': 'from ..sub import f\n\n\ndef main():\n f()\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('m.py', 'pkg/a/m.py', 'imports')).toContain('f@pkg/sub/__init__.py');
expect(targets('m.py', 'pkg/a/m.py', 'imports')).not.toContain('f.py@pkg/sub/f.py');
});
it('binds a name the package re-exports, not a same-named subpackage', async () => {
write({
'pkg/__init__.py': 'from .impl import thing\n',
'pkg/impl.py': 'def thing():\n return 1\n',
'pkg/thing/__init__.py': '',
'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').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 () => {
write({
'pkg/__init__.py': 'X = 1\n',
'pkg/f.py': 'def g():\n return 1\n',
'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').sort()).toEqual(['__init__.py@pkg/__init__.py', 'f.py@pkg/f.py']);
expect(targets('main', 'main.py', 'calls')).toEqual(['g@pkg/f.py']);
});
});
describe('service roots under a shared test config', () => {
const services = (extra: Record<string, string>) => ({
'services/billing/pyproject.toml': '[project]\nname = "billing"\n',
'services/billing/src/billing/__init__.py': '',
'services/billing/src/billing/models.py': 'def charge():\n return 1\n',
'services/api/pyproject.toml': '[project]\nname = "api"\n',
'services/api/src/api/__init__.py': '',
'services/api/src/api/views.py': 'from billing.models import charge\n\n\ndef view():\n charge()\n',
...extra,
});
const linked = () =>
expect(targets('views.py', 'services/api/src/api/views.py', 'imports')).toContain(
'models.py@services/billing/src/billing/models.py'
);
it('keeps a service a project root when a shared conftest.py sits above it', async () => {
write(services({ 'services/conftest.py': 'import pytest\n' }));
cg = await CodeGraph.init(tempDir, { index: true });
linked();
});
it('keeps a service a project root when a smoke_test.py script sits above it', async () => {
write(services({ 'services/smoke_test.py': 'print(1)\n' }));
cg = await CodeGraph.init(tempDir, { index: true });
linked();
});
it('lets sibling projects under examples/ import each other, but not the code outside', async () => {
const moved = Object.fromEntries(
Object.entries(services({})).map(([k, v]) => [k.replace('services/', 'examples/'), v])
);
write({
...moved,
'app/__init__.py': '',
'app/main.py': 'from billing.models import charge\n\n\ndef run():\n charge()\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(targets('views.py', 'examples/api/src/api/views.py', 'imports')).toContain(
'models.py@examples/billing/src/billing/models.py'
);
expect(targets('main.py', 'app/main.py', 'imports')).not.toContain('models.py@examples/billing/src/billing/models.py');
});
});
describe('sync', () => {
/** Every Python calls/imports edge, as a sorted list of readable triples. */
function pythonEdges(graph: CodeGraph): string[] {
const out: string[] = [];
for (const n of graph.getNodesByKind('function').concat(graph.getNodesByKind('file'))) {
if (!n.filePath.endsWith('.py')) continue;
for (const e of graph.getOutgoingEdges(n.id)) {
if (e.kind !== 'calls' && e.kind !== 'imports') continue;
const t = graph.getNode(e.target)!;
out.push(`${e.kind} ${n.name}@${n.filePath} -> ${t.kind}:${t.name}@${t.filePath}`);
}
}
return out.sort();
}
async function freshEdges(): Promise<string[]> {
const copy = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pymod-fresh-'));
try {
fs.cpSync(tempDir, copy, { recursive: true, filter: (src) => !src.includes('.codegraph') });
const fresh = await CodeGraph.init(copy, { index: true });
const edges = pythonEdges(fresh);
fresh.destroy();
return edges;
} finally {
fs.rmSync(copy, { recursive: true, force: true });
}
}
it('re-resolves unchanged importers when an __init__.py changes where a package starts', async () => {
write({
'a/b/__init__.py': '',
'a/helpers.py': 'def h():\n return 1\n',
'a/b/mod.py': `from ..helpers import h
def use():
h()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
// `a/b` is the top-level package, so `..helpers` climbs out of it.
const before = pythonEdges(cg);
expect(before).not.toContain('imports mod.py@a/b/mod.py -> file:helpers.py@a/helpers.py');
write({ 'a/__init__.py': '' });
await cg.sync();
const fresh = await freshEdges();
expect(fresh).toContain('imports mod.py@a/b/mod.py -> file:helpers.py@a/helpers.py');
expect(pythonEdges(cg)).toEqual(fresh);
});
it('re-resolves when pyproject.toml declares a new package root', async () => {
write({
'lib/nsp/tools.py': 'def go():\n return 1\n',
'tests/test_tools.py': `import nsp.tools
def test_go():
nsp.tools.go()
`,
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(pythonEdges(cg)).not.toContain('imports test_tools.py@tests/test_tools.py -> file:tools.py@lib/nsp/tools.py');
write({ 'pyproject.toml': '[tool.setuptools.packages.find]\nwhere = ["lib"]\n' });
await cg.sync();
const fresh = await freshEdges();
expect(fresh).toContain('imports test_tools.py@tests/test_tools.py -> file:tools.py@lib/nsp/tools.py');
expect(pythonEdges(cg)).toEqual(fresh);
});
/** Spies on both re-open paths of the orchestrator. */
function spyReopen(graph: CodeGraph) {
const orchestrator = (graph as unknown as { orchestrator: object }).orchestrator;
return {
all: vi.spyOn(orchestrator as { resurrectResolutionEdgesForLanguage: () => number }, 'resurrectResolutionEdgesForLanguage'),
narrow: vi.spyOn(orchestrator as { resurrectResolutionEdgesTouching: () => number }, 'resurrectResolutionEdgesTouching'),
};
}
const subpackageRepo = {
'pkg/__init__.py': '',
'pkg/core.py': 'def run():\n return 1\n',
'app/__init__.py': '',
'app/main.py': `import pkg.core
from pkg.fresh.mod import go
def main():
pkg.core.run()
go()
`,
};
it('re-resolves only what a new subpackage touches, and matches a fresh index', async () => {
write(subpackageRepo);
cg = await CodeGraph.init(tempDir, { index: true });
const spy = spyReopen(cg);
write({ 'pkg/fresh/__init__.py': '', 'pkg/fresh/mod.py': 'def go():\n return 1\n' });
await cg.sync();
const fresh = await freshEdges();
expect(fresh).toContain('imports main.py@app/main.py -> file:mod.py@pkg/fresh/mod.py');
expect(pythonEdges(cg)).toEqual(fresh);
expect(spy.all).not.toHaveBeenCalled();
expect(spy.narrow).toHaveBeenCalledTimes(1);
});
it('converges when a subpackage loses its __init__.py and a same-named module takes over', async () => {
write({
'pkg/__init__.py': '',
'pkg/sub.py': 'def f():\n return 1\n',
'pkg/sub/__init__.py': '',
'pkg/sub/x.py': 'def g():\n return 2\n',
'main.py': 'import pkg.sub.x\n\n\ndef main():\n pkg.sub.x.g()\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(pythonEdges(cg)).toContain('imports main.py@main.py -> file:x.py@pkg/sub/x.py');
fs.rmSync(path.join(tempDir, 'pkg/sub/__init__.py'));
await cg.sync();
// `pkg.sub` is now the module `pkg/sub.py`, which has no `x`.
const fresh = await freshEdges();
expect(fresh).not.toContain('imports main.py@main.py -> file:x.py@pkg/sub/x.py');
expect(pythonEdges(cg)).toEqual(fresh);
});
it('converges when an __init__.py makes a package shadow the same-named module', async () => {
write({
'pkg/__init__.py': '',
'pkg/sub.py': 'def f():\n return 1\n',
'pkg/sub/g.py': 'def g():\n return 2\n',
'main.py': 'import pkg.sub\n\n\ndef main():\n pkg.sub.f()\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
expect(pythonEdges(cg)).toContain('imports main.py@main.py -> file:sub.py@pkg/sub.py');
write({ 'pkg/sub/__init__.py': '' });
await cg.sync();
const fresh = await freshEdges();
expect(fresh).toContain('imports main.py@main.py -> file:__init__.py@pkg/sub/__init__.py');
expect(pythonEdges(cg)).toEqual(fresh);
});
it('keeps the package binding when a same-named submodule is added, and matches a fresh index', async () => {
write({
'pkg/__init__.py': 'def f():\n return 0\n',
'main.py': 'from pkg import f\n\n\ndef main():\n f()\n',
});
cg = await CodeGraph.init(tempDir, { index: true });
const before = pythonEdges(cg);
write({ 'pkg/f.py': 'X = 1\n' });
await cg.sync();
const fresh = await freshEdges();
expect(fresh).toEqual(before);
expect(pythonEdges(cg)).toEqual(fresh);
});
describe('an edited __init__.py moves what its importers bind', () => {
const SUB = 'def run():\n return "sub"\n';
const MAIN = 'from pkg import N\n\n\ndef main():\n N()\n N.run()\n';
const IMPL = 'class N:\n def run(self):\n return 1\n';
async function editAndCompare(initial: Record<string, string>, edit: Record<string, string>, expected: string) {
write(initial);
cg = await CodeGraph.init(tempDir, { index: true });
write(edit);
await cg.sync();
const fresh = await freshEdges();
expect(fresh).toContain(expected);
expect(pythonEdges(cg)).toEqual(fresh);
}
it('adding a def of N moves the import off the submodule (E1)', () =>
editAndCompare(
{ 'pkg/__init__.py': 'X = 1\n', 'pkg/N.py': SUB, 'main.py': MAIN },
{ 'pkg/__init__.py': 'X = 1\n\n\ndef N():\n return 0\n' },
'imports main.py@main.py -> function:N@pkg/__init__.py'
));
it('removing a def of N hands the import back to the submodule (E2)', () =>
editAndCompare(
{ 'pkg/__init__.py': 'X = 1\n\n\ndef N():\n return 0\n', 'pkg/N.py': SUB, 'main.py': MAIN },
{ 'pkg/__init__.py': 'X = 1\n' },
'calls main@main.py -> function:run@pkg/N.py'
));
it('removing a re-export of N hands the import back to the submodule (E4)', () =>
editAndCompare(
{ 'pkg/__init__.py': 'X = 1\nfrom .impl import N\n', 'pkg/N.py': SUB, 'pkg/impl.py': IMPL, 'main.py': MAIN },
{ 'pkg/__init__.py': 'X = 1\n' },
'imports main.py@main.py -> file:N.py@pkg/N.py'
));
it('repointing a re-export moves the importer with it (B1)', () =>
editAndCompare(
{
'pkg/__init__.py': 'from .impl import thing\n',
'pkg/impl.py': 'def thing():\n return 1\n',
'pkg/impl2.py': 'def thing():\n return 2\n',
'main.py': 'from pkg import thing\n\n\ndef main():\n thing()\n',
},
{ 'pkg/__init__.py': 'from .impl2 import thing\n' },
'calls main@main.py -> function:thing@pkg/impl2.py'
));
});
it('re-opens nothing on a sync that changes nothing', async () => {
write(subpackageRepo);
cg = await CodeGraph.init(tempDir, { index: true });
const spy = spyReopen(cg);
await cg.sync();
expect(spy.all).not.toHaveBeenCalled();
expect(spy.narrow).not.toHaveBeenCalled();
});
it('records a fingerprint for an index built before one existed, without re-resolving', async () => {
write(subpackageRepo);
cg = await CodeGraph.init(tempDir, { index: true });
const queries = (cg as unknown as {
queries: { getMetadata(k: string): string | null; db: { prepare(sql: string): { run(...a: unknown[]): unknown } } };
}).queries;
queries.db.prepare('DELETE FROM project_metadata WHERE key = ?').run('python_root_fingerprint');
const spy = spyReopen(cg);
write({ 'pyproject.toml': '[tool.setuptools.packages.find]\nwhere = ["lib"]\n' });
await cg.sync();
expect(spy.all).not.toHaveBeenCalled();
expect(queries.getMetadata('python_root_fingerprint')).toMatch(/^[0-9a-f]{40}$/);
});
});
describe('import parsing', () => {
const names = (code: string) =>
extractImportMappings('m.py', code, 'python').map((m) => `${m.localName}<-${m.source}:${m.exportedName}`);
it('reads a parenthesised multi-line list, with comments and a trailing comma', () => {
expect(names('from .cart import (\n add_item, # note\n remove_item as rm,\n)\n')).toEqual([
'add_item<-.cart:add_item',
'rm<-.cart:remove_item',
]);
});
it('reads backslash continuations and comma-separated plain imports, at module level', () => {
// A function's own `import lazy` is not the file's: the mappings apply to
// every function, and a function-local `import a.b as X` binds X in its
// function only (python-local-import-alias.test.ts).
expect(names('from a import b, \\\n c\nimport os, pkg.sub as s\ndef f():\n import lazy\n')).toEqual([
'b<-a:b',
'c<-a:c',
'os<-os:*',
's<-pkg.sub:*',
]);
});
it('ignores imports written inside a docstring or a comment', () => {
expect(names('"""\nfrom fake import thing\nimport other\n"""\n# from nope import x\nimport real\n')).toEqual([
'real<-real:*',
]);
});
});
});