Files
codegraph/__tests__/python-pep695-bases.test.ts
T
Josh66andClaude Opus 5.5 bea5e205f4 fix(python): a class with PEP 695 type parameters reads its base list
pythonBaseList, the resolver's reader of a Python base list behind module-global,
self. and class-name dispatch, expected the ( of the list right after the class
name. In class Repo[T](Base[T]): it found the [ of the type parameters, so the
class seemed to have no bases there, though extraction gave it its extends edge:
current = Repo() then current.run(), and Repo.run(x), reached nothing.

The reader now skips a type parameter list between the name and the bases, with
brackets balanced, so bounds like T: dict[str, int], *Ts and **P go whole, and a
constraint tuple like [T: (int, str)] isn't taken for the base list. Either list
can wrap over several lines. Anything else after the name and its parameters,
such as the : of class NoBases[T]:, still means no base list.

Validation:
- The test file: 4 of 7 fail on main (the controls pass), 7 pass here, and the
  multiline, generic-base and type-argument suites still pass.
- A fixture of ours on the built CLI, kernel on: Repo.run(None), a wrapped
  list, a constraint tuple and a module global reach no method on main and
  Base::run here. class NoBases[T] reaches neither run, and
  Typed[T](Base[User]) does not reach User.save.
- A/B against main on 11 Python repos, 486,377 edges (requests, flask,
  litestar, mypy, sqlalchemy, polar's and django's packages, and four small
  apps that use PEP 695 bases): nothing changes. Neither do express and gin,
  whose full dumps are byte-identical. polar has 39 such classes, and none is
  reached by a call this reader decides.
- Index cost on sqlalchemy, median of 3 interleaved: 23.4 s on main and here.

Original PR #3105 by @JosefAschauer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 17:48:17 -05:00

172 lines
4.2 KiB
TypeScript

/**
* A Python class with PEP 695 type parameters, `class Repo[T](Base[T]):`,
* names its bases after the parameter list. The resolver's base-list reader
* (`pythonBaseList`, behind module-global, `self.` and class-name dispatch)
* expected `(` right after the name, so such a class had no bases there.
*/
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-pep695-bases-'));
fs.mkdirSync(path.join(root, 'app'));
fs.writeFileSync(path.join(root, 'app/__init__.py'), '');
fs.writeFileSync(path.join(root, 'app/m.py'), `class Base[T]:
def run(self):
pass
class Other:
def run(self):
pass
class Mixin:
pass
class Classic(Mixin, Base):
pass
class Repo[T](Base[T]):
pass
class Bounded[T: dict[str, int], *Ts, **P](Mixin, Base[T]):
pass
class Constrained[T: (int, str)](Mixin, Base[T]):
pass
class WrappedParams[
K,
V: (int, str),
](
Mixin,
Base[V],
):
pass
class NoBases[T]:
pass
current_a = Classic()
current_b = Repo()
current_c = Bounded()
current_d = Constrained()
current_e = WrappedParams()
def global_classic():
current_a.run()
def global_repo():
current_b.run()
def global_bounded():
current_c.run()
def global_constrained():
current_d.run()
def global_wrapped_params():
current_e.run()
def class_classic(x):
Classic.run(x)
def class_repo(x):
Repo.run(x)
def class_bounded(x):
Bounded.run(x)
def class_constrained(x):
Constrained.run(x)
def class_wrapped_params(x):
WrappedParams.run(x)
def class_no_bases(x):
NoBases.run(x)
`);
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => {
cg?.close();
if (root) fs.rmSync(root, { recursive: true, force: true });
});
/** Qualified names of the methods `fn` calls. */
const calls = (fn: string): string[] => {
const node = cg.getNodesInFile('app/m.py').find((n) => n.name === fn && n.kind === 'function')!;
expect(node, fn).toBeDefined();
return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'calls')
.map((e) => cg.getNode(e.target)!).filter((n) => n.kind === 'method').map((n) => n.qualifiedName);
};
// Base and Other both declare `run`, and the globals are not named after their
// classes, so only the class's own bases can pick one.
describe('a Python class with PEP 695 type parameters', () => {
it('resolves through a classic base list (control)', () => {
expect(calls('global_classic')).toEqual(['Base::run']);
expect(calls('class_classic')).toEqual(['Base::run']);
});
it('reads the bases after a type parameter list', () => {
expect(calls('global_repo')).toEqual(['Base::run']);
expect(calls('class_repo')).toEqual(['Base::run']);
});
it('skips bounds with brackets, *Ts and **P in the parameter list', () => {
expect(calls('global_bounded')).toEqual(['Base::run']);
expect(calls('class_bounded')).toEqual(['Base::run']);
});
it('does not take a constraint tuple for the base list', () => {
expect(calls('global_constrained')).toEqual(['Base::run']);
expect(calls('class_constrained')).toEqual(['Base::run']);
});
it('reads a parameter list and a base list both wrapped over several lines', () => {
expect(calls('global_wrapped_params')).toEqual(['Base::run']);
expect(calls('class_wrapped_params')).toEqual(['Base::run']);
});
it('links each such class to its base with an extends edge', () => {
const base = cg.getNodesInFile('app/m.py').find((n) => n.name === 'Base' && n.kind === 'class')!;
for (const name of ['Repo', 'Bounded', 'Constrained', 'WrappedParams']) {
const cls = cg.getNodesInFile('app/m.py').find((n) => n.name === name && n.kind === 'class')!;
const targets = cg.getOutgoingEdges(cls.id).filter((e) => e.kind === 'extends').map((e) => e.target);
expect(targets, name).toContain(base.id);
}
});
it('gives a parameterized class without bases none', () => {
expect(calls('class_no_bases')).not.toContain('Base::run');
expect(calls('class_no_bases')).not.toContain('Other::run');
});
});