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:
Colby Mchenry
2026-10-01 01:35:48 +00:00
committed by GitHub
parent 3e6252f38b
commit 92f141f04f
3 changed files with 157 additions and 1 deletions
+1
View File
@@ -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.
+81
View File
@@ -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');
});
});
+75 -1
View File
@@ -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.