Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
136 changes: 136 additions & 0 deletions __tests__/python-supertype-type-arguments.test.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
8 changes: 6 additions & 2 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down