mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-19 06:22:38 +08:00
fix(python): a pytest test's parameter is the class its fixture returns (#2228)
A receiver that is the test's own parameter takes the type its in-reach fixture returns (`return Cls(…)` or a local assigned `Cls(…)`), so calls on it resolve on that class, or on nothing when it is a library's; fixtures a conftest.py star-imports or lists in pytest_plugins are in reach. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- In pytest tests, a parameter now takes the class its fixture returns, so a call on it reaches that class's method. If the class comes from a library, the call no longer reaches the project's only method of that name. Fixtures a `conftest.py` pulls in with `from … import *` or `pytest_plugins` now count as in reach. Before, flask's `runner.invoke(…)` on click's `CliRunner()` linked to the project's `FlaskCliRunner.invoke`, and `@app.get(…)` on a `Flask(…)` fixture linked to an unrelated `get`.
|
||||
- What a Svelte component's instance `<script>` or a Vue component's `<script setup>` declares is now private to that component. Only a Svelte `<script module>`, a plain Vue `<script>`, or a type a `<script setup>` exports can be reached from another file. Before, shadcn-svelte's `<Item.Root>` linked to a `type Item` that one example component declares for itself, and halo's `isActive` imported from its editor core linked to a tab component's local value.
|
||||
- In JavaScript and TypeScript, a name the calling function binds itself, as a parameter or a plain `var`/`let`/`const` above the reference, now means that local instead of a same-named function elsewhere in the file. Before, every lodash helper's `object` parameter linked to a `function object() {}` declared inside `runInContext`, and zod's `new Class(…)` linked to an unrelated `Class` when `Class` was the function's parameter. Destructured hook results like `const { t } = useI18n()` still link to the function they come from.
|
||||
- Framework name conventions for Django, FastAPI, Flask, ASP.NET, Gin, SwiftUI and Vapor now only pick a class the reference can see. Examples of these conventions are "a `…View` is a view", "a `…Service` is a service" and "a capitalized name is a model". The pick starts from the reference's own file and then its package, and never takes a class declared inside some function or nested in another file's class. Before, Django REST framework's tests linked each file's own `MockView` and `Serializer` to another file's, and netbox's tests linked `TestForm()` to a form declared inside a different test function.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* A pytest test's parameter is what its fixture returns — `return Cls(…)`, or
|
||||
* a local assigned `Cls(…)` — so a call on it is that class's method, and
|
||||
* none of the project's when the class comes from outside: flask's
|
||||
* `runner.invoke(cli, …)` on click's `CliRunner()` is not `FlaskCliRunner.invoke`.
|
||||
* A fixture is in reach from its own module, a `conftest.py` above the test,
|
||||
* or a module such a conftest star-imports or lists in `pytest_plugins`.
|
||||
*/
|
||||
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-pytest-fixture-'));
|
||||
const files: Record<string, string> = {
|
||||
'app/cli.py': `class AlembicGroup:
|
||||
def invoke(self, ctx):
|
||||
return ctx
|
||||
`,
|
||||
'app/models.py': `class Forum:
|
||||
def save(self):
|
||||
return self
|
||||
`,
|
||||
'tests/conftest.py': `from tests.fixtures.cli import * # noqa
|
||||
from tests.fixtures.forum import * # noqa
|
||||
`,
|
||||
'tests/fixtures/cli.py': `import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
`,
|
||||
'tests/fixtures/forum.py': `import pytest
|
||||
from app.models import Forum
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def forum():
|
||||
forum = Forum()
|
||||
return forum
|
||||
`,
|
||||
'tests/test_cli.py': `def test_routes(runner):
|
||||
result = runner.invoke(cli, ["routes"])
|
||||
return result
|
||||
|
||||
|
||||
def test_forum(forum):
|
||||
forum.save()
|
||||
`,
|
||||
};
|
||||
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 });
|
||||
});
|
||||
|
||||
const targetsFrom = (qualifiedName: string) => {
|
||||
const ids = cg.getNodesInFile('tests/test_cli.py').filter((n) => n.qualifiedName === qualifiedName).map((n) => n.id);
|
||||
return cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind !== 'contains').map((e) => cg.getNode(e.target)!).map((t) => `${t.filePath}:${t.qualifiedName}`);
|
||||
};
|
||||
|
||||
describe('pytest fixture parameters', () => {
|
||||
// Both fixtures live in modules `tests/conftest.py` star-imports.
|
||||
it('are the class their fixture returns', () => {
|
||||
expect(targetsFrom('test_routes')).not.toContain('app/cli.py:AlembicGroup::invoke');
|
||||
expect(targetsFrom('test_forum')).toContain('app/models.py:Forum::save');
|
||||
});
|
||||
});
|
||||
@@ -1709,7 +1709,76 @@ function isPytestFixture(n: Node, context: ResolutionContext): boolean {
|
||||
function isFixtureInReach(n: Node, filePath: string, context: ResolutionContext): boolean {
|
||||
if (n.filePath === filePath) return isPytestFixture(n, context);
|
||||
const conftest = /^(.*?)(?:^|\/)conftest\.py$/.exec(n.filePath);
|
||||
return conftest !== null && (conftest[1] === '' || filePath.startsWith(`${conftest[1]}/`));
|
||||
if (conftest !== null) return conftest[1] === '' || filePath.startsWith(`${conftest[1]}/`);
|
||||
// A fixture module a `conftest.py` above the test pulls in — `from
|
||||
// tests.fixtures.cli import *`, or `pytest_plugins = ["tests.fixtures.cli"]`.
|
||||
if (!isPytestFixture(n, context)) return false;
|
||||
return pluggedFixtureModules(filePath, context).some((m) => n.filePath === m || n.filePath.endsWith(`/${m}`));
|
||||
}
|
||||
|
||||
const PY_PLUGGED_MODULES = new WeakMap<ResolutionContext, Map<string, string[]>>();
|
||||
|
||||
/** The module files (`tests/fixtures/cli.py`) the `conftest.py` files above `filePath` star-import or list in `pytest_plugins`. */
|
||||
function pluggedFixtureModules(filePath: string, context: ResolutionContext): string[] {
|
||||
let memo = PY_PLUGGED_MODULES.get(context);
|
||||
if (!memo) PY_PLUGGED_MODULES.set(context, (memo = new Map()));
|
||||
const dir = filePath.includes('/') ? filePath.slice(0, filePath.lastIndexOf('/')) : '';
|
||||
const hit = memo.get(dir);
|
||||
if (hit) return hit;
|
||||
const modules: string[] = [];
|
||||
for (let d = dir; ; d = d.includes('/') ? d.slice(0, d.lastIndexOf('/')) : '') {
|
||||
const text = context.readFile(d ? `${d}/conftest.py` : 'conftest.py');
|
||||
if (text) {
|
||||
for (const m of text.matchAll(/^\s*from\s+([\w.]+)\s+import\s+\*/gm)) modules.push(`${m[1]!.replace(/\./g, '/')}.py`);
|
||||
const plugins = /^\s*pytest_plugins\s*=\s*[[(]([^\])]*)[\])]/m.exec(text)?.[1] ?? '';
|
||||
for (const m of plugins.matchAll(/["']([\w.]+)["']/g)) modules.push(`${m[1]!.replace(/\./g, '/')}.py`);
|
||||
}
|
||||
if (!d) break;
|
||||
}
|
||||
memo.set(dir, modules);
|
||||
return modules;
|
||||
}
|
||||
|
||||
const PY_FIXTURE_TYPES = new WeakMap<ResolutionContext, Map<string, string | null>>();
|
||||
|
||||
/**
|
||||
* The class a pytest fixture returns, for a test parameter of its name — the
|
||||
* fixture in reach (the test's module, else the nearest `conftest.py` above
|
||||
* it) whose body returns or yields `Cls(…)`, directly or through a local
|
||||
* assigned `Cls(…)`. Null for anything else (a parameter that is no fixture's,
|
||||
* a fixture returning a call of a function).
|
||||
*/
|
||||
function pythonFixtureReturnType(receiver: string, ref: UnresolvedRef, context: ResolutionContext): string | null {
|
||||
if (!/^[a-z_]\w*$/.test(receiver) || receiver === 'self' || receiver === 'cls') return null;
|
||||
let memo = PY_FIXTURE_TYPES.get(context);
|
||||
if (!memo) PY_FIXTURE_TYPES.set(context, (memo = new Map()));
|
||||
const key = `${ref.fromNodeId}\0${receiver}`;
|
||||
const hit = memo.get(key);
|
||||
if (hit !== undefined) return hit;
|
||||
let type: string | null = null;
|
||||
const caller = context.getNodeById?.(ref.fromNodeId);
|
||||
const lines = caller ? context.getFileLines?.(caller.filePath) ?? context.readFile(caller.filePath)?.split(/\r?\n/) ?? [] : [];
|
||||
// The receiver must be the test's own parameter.
|
||||
const signature = caller && (caller.kind === 'function' || caller.kind === 'method')
|
||||
? lines.slice(caller.startLine - 1, caller.startLine + 4).join(' ').split(/\)\s*(?:->[^:]*)?:/)[0] ?? '' : '';
|
||||
if (new RegExp(`[(,]\\s*${receiver}\\s*(?:[:=,)]|$)`).test(signature)) {
|
||||
const fixtures = context.getNodesByName(receiver)
|
||||
.filter((n) => n.kind === 'function' && n.language === 'python' && isFixtureInReach(n, ref.filePath, context))
|
||||
.sort((a, b) => (a.filePath === ref.filePath ? -1 : 0) - (b.filePath === ref.filePath ? -1 : 0) || b.filePath.length - a.filePath.length);
|
||||
const fixture = fixtures[0];
|
||||
if (fixture) {
|
||||
const body = (context.getFileLines?.(fixture.filePath) ?? context.readFile(fixture.filePath)?.split(/\r?\n/) ?? [])
|
||||
.slice(fixture.startLine, fixture.endLine).join('\n');
|
||||
const returned = /^\s*(?:return|yield)\s+([A-Za-z_][\w.]*)\s*(\()?/m.exec(body);
|
||||
if (returned) {
|
||||
const direct = returned[2] ? returned[1]! : new RegExp(`^\\s*${returned[1]!.replace(/\./g, '\\.')}\\s*=\\s*([A-Za-z_][\\w.]*)\\s*\\(`, 'm').exec(body)?.[1];
|
||||
const cls = direct?.split('.').pop();
|
||||
if (cls && /^[A-Z]/.test(cls)) type = cls;
|
||||
}
|
||||
}
|
||||
}
|
||||
memo.set(key, type);
|
||||
return type;
|
||||
}
|
||||
|
||||
function isDecoratedFixture(n: Node, context: ResolutionContext): boolean {
|
||||
@@ -5751,6 +5820,8 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
CSHARP_PROJECT_USINGS.delete(context);
|
||||
CSHARP_GLOBAL_USINGS.delete(context);
|
||||
CSHARP_ANCESTORS.delete(context);
|
||||
PY_FIXTURE_TYPES.delete(context);
|
||||
PY_PLUGGED_MODULES.delete(context);
|
||||
SCALA_SUPERS.delete(context);
|
||||
SCALA_IMPORTS.delete(context);
|
||||
ESM_EXPORT_LISTS.delete(context);
|
||||
@@ -6811,6 +6882,9 @@ export function matchMethodCall(
|
||||
ref.language === 'cpp'
|
||||
? inferCppReceiverType(objectOrClass!, ref, context)
|
||||
: inferLocalReceiverType(objectOrClass!, ref, context));
|
||||
// A pytest test's parameter is what its fixture returns: flaskbb's
|
||||
// `cli_runner.invoke(…)` is click's `CliRunner`, not the project's one `invoke`.
|
||||
if (!inferredType && ref.language === 'python' && dotMatch) inferredType = pythonFixtureReturnType(objectOrClass!, ref, context);
|
||||
if (!inferredType && MEMBER_TYPED_LANGUAGES.has(ref.language) && dotMatch) {
|
||||
inferredType = nmTimedT('mc-member', ref, () => inferMemberReceiverType(objectOrClass!, ref, context));
|
||||
// A field of a built-in type (`string _name`, `int count`) has no project method.
|
||||
|
||||
Reference in New Issue
Block a user