From 042d3e06425505c3feae8c206b9c083365f5ac12 Mon Sep 17 00:00:00 2001 From: Josh66 Date: Fri, 30 Oct 2026 11:31:59 -0500 Subject: [PATCH] fix(python): a base's type argument is no supertype, so Repo.save() is not User.save The Python branch of declaredSupertypes read every word of a class's base list, so class Repo(Base[User]) declared both Base and User. A call on the class or on a receiver named after it, Repo.save() or repo.save(), walks those supertypes for the method (classStrategyMethod to inheritedClassMethod), and when Base declares no save it reached User.save at 0.85 or 0.8. In class Mixed(Base[Optional[User]], Mixin) the type argument also came ahead of Mixin, so Mixed.save() reached User.save instead of Mixin.save. Bracketed type arguments, nested ones and quoted forward references too, are now dropped before the names are read. The base itself is still read and searched. Other languages are untouched. Validation: - The new test file: red on main (the type-argument and later-base cases fail, the case that a base's own method is still reached passes), green here. We added the Mixed and quoted forward-reference cases to the contributor's test. - A/B against main on 7 Python repos, 670,226 edges on main: requests, flask, litestar, mypy, polar's server, django unchanged, sqlalchemy 1 edge removed, 0 added, 0 retargeted. That edge is a.references(b) in relationships.py. Its local a named a test's class a(TypedColumns), and TypedColumns' type argument "Column[Any]" reached Column.references: the right method by chance, by the path this fixes. Its twin b.references(a) two lines down has no edge in either arm. - Controls express and gin: full dumps byte-identical. - Kernel parity on flask and requests: 43/43 in both arms (the kernel is not touched). Index cost on sqlalchemy, median of 3 interleaved: 29.5 s on main, 25.7 s here, so nothing measurable. Original PR #3000 by @JosefAschauer. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../python-supertype-type-arguments.test.ts | 136 ++++++++++++++++++ src/resolution/name-matcher.ts | 8 +- 3 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 __tests__/python-supertype-type-arguments.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 773a2459f..32df517fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 on a class or on a receiver named after it, like `Repo.save()` or `repo.save()` with `class Repo(Base[User])`, no longer links to a method of the base's type argument, like `User.save`. Before, every name in the base list counted as a supertype, so `User` was searched as if `Repo` inherited from it. The base itself, `Base`, is still searched. Re-index Python projects after upgrading. Thanks @JosefAschauer. - 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, Scala and Java, a standard-library method called on a value whose type CodeGraph can't see, like `options.setdefault(…)` on a dictionary, `m.getOrElse(…)` on an `Option` or `data.put(…)` on a map held in a `var`, no longer links to an unrelated project method that happens to share its name; the call still links when the variable is named after your class, like `registry.get(…)` for a `Registry`. Re-index Python, Scala and Java projects after upgrading. Thanks @danusha2345. (#1947) - 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) diff --git a/__tests__/python-supertype-type-arguments.test.ts b/__tests__/python-supertype-type-arguments.test.ts new file mode 100644 index 000000000..2cc72f802 --- /dev/null +++ b/__tests__/python-supertype-type-arguments.test.ts @@ -0,0 +1,136 @@ +/** + * A Python class's supertypes, as its head declares them, are the classes its + * bases name — not their type arguments. `class Repo(Base[User])` extends + * `Base`; reading every word of the base list also counted `User`, so a call on + * the class or on a receiver named after it, `Repo.save()` / `repo.save()`, + * reached `User.save` when `Base` declares no `save`. + */ +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-supertype-type-args-')); + fs.mkdirSync(path.join(root, 'app')); + fs.writeFileSync(path.join(root, 'app/__init__.py'), ''); + fs.writeFileSync(path.join(root, 'app/m.py'), `from typing import Generic, Optional, TypeVar + +T = TypeVar("T") +U = TypeVar("U") + + +class Base(Generic[T]): + def load(self): + pass + + +class Pair(Generic[T, U]): + def load(self): + pass + + +class User: + def save(self): + pass + + +class Other: + def save(self): + pass + + +class Repo(Base[User]): + pass + + +class Store(Pair[str, dict[str, User]]): + pass + + +class Keyed(Base[User], metaclass=type): + pass + + +class Forward(Base["Pair[str, User]"]): + pass + + +class Mixin: + def save(self): + pass + + +class Mixed(Base[Optional[User]], Mixin): + pass + + +def save_by_class(): + Repo.save() + + +def save_by_receiver(repo): + repo.save() + + +def save_nested(): + Store.save() + + +def save_keyed(): + Keyed.save() + + +def save_forward(): + Forward.save() + + +def save_mixed(): + Mixed.save() + + +def load_by_class(): + Repo.load() + + +def load_nested(): + Store.load() +`); + 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); +}; + +describe('a Python type argument is no supertype', () => { + it('does not reach a method of the type argument', () => { + expect(calls('save_by_class')).not.toContain('User::save'); + expect(calls('save_by_receiver')).not.toContain('User::save'); + expect(calls('save_nested')).not.toContain('User::save'); + expect(calls('save_keyed')).not.toContain('User::save'); + expect(calls('save_forward')).not.toContain('User::save'); + }); + + it('still reaches the method the base declares', () => { + expect(calls('load_by_class')).toEqual(['Base::load']); + expect(calls('load_nested')).toEqual(['Pair::load']); + }); + + it('reaches a later base past the type argument that came first', () => { + expect(calls('save_mixed')).toEqual(['Mixin::save']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e2a180ee1..1d88f3b54 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -21514,8 +21514,12 @@ function readDeclaredSupertypes(cls: Node, context: ResolutionContext): string[] // A base built by a class's factory — Django's `class Manager( // BaseManager.from_queryset(QuerySet))` — subclasses that class: the // qualifier is the supertype, not the method called on it. - case 'python': return names(/\bclass\s+\w+\s*\(([^)]*)\)/.exec(head)?.[1]?.replace(/\w+\s*=\s*[\w.]+/g, '') - .replace(/([A-Za-z_][\w.]*)\.\w+\s*\(/g, '$1 (')); + // A parameterized base, `Base[User]`, is `Base`: its type arguments are no supertypes. + case 'python': { + let bases = /\bclass\s+\w+\s*\(([^)]*)\)/.exec(head)?.[1]; + while (bases && /\[[^[\]]*\]/.test(bases)) bases = bases.replace(/\[[^[\]]*\]/g, ''); + return names(bases?.replace(/\w+\s*=\s*[\w.]+/g, '').replace(/([A-Za-z_][\w.]*)\.\w+\s*\(/g, '$1 (')); + } case 'ruby': return names(/\bclass\s+[\w:]+\s*<\s*([\w:]+)/.exec(head)?.[1]); case 'php': return names(/\bextends\s+([\w.\\]+)/.exec(head)?.[1]); case 'typescript': case 'tsx': case 'javascript': case 'jsx':