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':