diff --git a/__tests__/function-ref.test.ts b/__tests__/function-ref.test.ts index e4c594b36..26701c0f3 100644 --- a/__tests__/function-ref.test.ts +++ b/__tests__/function-ref.test.ts @@ -1397,6 +1397,32 @@ def init(): 'def cb(pool):\n pool.submit(conn.fetch)\n' }, []], annotation_over_factory: [{ 'm.py': 'from store import Store\nconn: Store = make_store()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n' }, ['cb -> Store::fetch']], + external_production_write: [{ 'settings.py': header, + 'reset.py': 'import settings\nfrom decoy import Decoy\n\ndef reset():\n settings.conn = Decoy()\n', + 'c.py': 'import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, []], + external_aliased_write: [{ 'pkg/__init__.py': '', 'pkg/settings.py': header, + 'reset.py': 'import pkg.settings as cfg\n\ndef reset():\n cfg.conn = make_conn()\n', + 'c.py': 'from pkg import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, []], + external_relative_write: [{ 'pkg/__init__.py': '', 'pkg/settings.py': header, + 'pkg/reset.py': 'from . import settings\nfrom decoy import Decoy\n\ndef reset():\n settings.conn = Decoy()\n', + 'c.py': 'from pkg import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, []], + external_setattr: [{ 'settings.py': header, + 'reset.py': 'import settings\nfrom decoy import Decoy\n\ndef reset():\n setattr(settings, "conn", Decoy())\n', + 'c.py': 'import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, []], + external_subclass_write: [{ 'settings.py': header, + 'sub.py': 'from store import Store\nclass SubStore(Store):\n def fetch(self, ids):\n return ids\n', + 'reset.py': 'import settings\nfrom sub import SubStore\n\ndef reset():\n settings.conn = SubStore()\n', + 'c.py': 'import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, ['cb -> Store::fetch']], + test_double_write: [{ 'settings.py': header, + 'tests/test_c.py': 'import settings\nfrom unittest.mock import MagicMock\n\ndef test_cb(pool):\n settings.conn = MagicMock()\n pool.submit(settings.conn.fetch)\n', + 'c.py': 'import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, ['cb -> Store::fetch']], + test_monkeypatch: [{ 'settings.py': header, + 'tests/test_c.py': 'import settings\n\ndef test_cb(monkeypatch, pool):\n monkeypatch.setattr(settings, "conn", object())\n pool.submit(settings.conn.fetch)\n' }, []], + globals_literal_write: [{ 'm.py': header + 'def reset():\n globals()["conn"] = Decoy()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n' }, []], + globals_dynamic_write: [{ 'm.py': header + 'for name, obj in [("conn", Decoy())]:\n globals()[name] = obj\n\ndef cb(pool):\n pool.submit(conn.fetch)\n' }, []], + unrelated_attribute_write: [{ 'settings.py': header, + 'other.py': 'from decoy import Decoy\n\nclass Holder:\n def __init__(self):\n self.conn = Decoy()\n', + 'c.py': 'import settings\n\ndef cb(pool):\n pool.submit(settings.conn.fetch)\n' }, ['cb -> Store::fetch']], dotted_import_collision: [{ 'alpha/__init__.py': '', 'beta/__init__.py': '', 'alpha/foo.py': 'class Client:\n def fetch(self):\n return 1\n', 'beta/foo.py': 'class Client:\n def fetch(self):\n return 2\n', 'm.py': 'import beta.foo\nimport alpha.foo\nconn = None\n\ndef init():\n global conn\n conn = alpha.foo.Client()\n\ndef cb(pool):\n pool.submit(conn.fetch)\n' }, []], diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 787812df4..6620516b4 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9,6 +9,7 @@ import { Language, Node } from '../types'; import { UnresolvedRef, ResolvedRef, ResolutionContext, isSupertypeTarget, CPP_DEFINE_SIGNATURE, isInheritanceRef, isImportableKind } from './types'; import { blankStringContents, stripCommentsForRegex } from './strip-comments'; import { JS_BUILT_INS, JS_BUILTIN_METHODS, TS_PRIMITIVE_TYPES } from './js-builtins'; +import { isTestFile } from '../search/query-utils'; /** * Ceiling on how many same-named definitions a FUZZY name-match strategy will * score. A name defined more times than this is "ubiquitous" — a method/symbol @@ -607,45 +608,136 @@ const PYTHON_GLOBAL_CLASSES = new WeakMap(); - let classes: Node[] | null = []; - for (const b of pythonGlobalBindings(global.name, file, context)) { + const external = pythonExternalWrites(global, context); + // Each type is resolved in the file that wrote it (its imports name the class). + const writes: Array<{ type: string; file: string }> = [...external.writes]; + let classes: Node[] | null = external.unknown || pythonDynamicGlobalWrite(global.name, file, context) ? null : []; + for (const b of classes ? pythonGlobalBindings(global.name, file, context) : []) { if (b.kind !== 'assign') { classes = null; break; } const constructor = b.value && b.value !== 'None' ? pythonConstructorCall(b.value) : null; if (b.type) { // `conn: Base = make()` trusts the annotation; `conn: A = B()` contradicts it. if (constructor && constructor.split('.').pop() !== b.type.split('.').pop()) { classes = null; break; } - types.add(b.type); + writes.push({ type: b.type, file }); continue; } if (b.value === 'None') continue; if (!constructor) { classes = null; break; } - types.add(constructor); + writes.push({ type: constructor, file }); } - for (const type of classes ? types : []) { - const cls = pythonRefClass(type, { ...ref, filePath: file }, context); + const seen = new Set(); + for (const write of classes ? writes : []) { + const cls = pythonRefClass(write.type, { ...ref, filePath: write.file }, context); if (!cls) { classes = null; break; } - classes!.push(cls); + if (!seen.has(cls.id)) { seen.add(cls.id); classes!.push(cls); } } memo.set(global.id, classes); return classes; } +/** `a/b/c.py` → `a.b.c`; a package's `__init__.py` → the package. */ +function pythonModulePath(filePath: string): string { + return filePath.replace(/\\/g, '/').replace(/\.pyi?$/, '').replace(/\/__init__$/, '').split('/').join('.'); +} + +/** Names in `filePath` that bind module `module` (dotted; matched as a suffix, so source roots don't matter). */ +function pythonModuleAliases(filePath: string, module: string, context: ResolutionContext): string[] { + const names = (dotted: string) => dotted === module || module.endsWith(`.${dotted}`); + const aliases = new Set(); + for (const m of context.getImportMappings(filePath, 'python')) { + let source = m.source; + const dots = source.match(/^\.+/)?.[0].length ?? 0; + if (dots) { + // `from . import x` in `pkg/mod.py` → `pkg.x`; one more dot per level up. + const here = pythonModulePath(filePath).split('.'); + const pkg = /\/__init__\.pyi?$/.test(filePath.replace(/\\/g, '/')) ? here : here.slice(0, -1); + source = [...pkg.slice(0, pkg.length - (dots - 1)), ...source.slice(dots).split('.').filter(Boolean)].join('.'); + } + if (m.isNamespace) { + if (!names(source)) continue; + aliases.add(m.localName); + aliases.add(source); // `import a.b` binds `a`, so the module is spelled `a.b` + } else if (names(source ? `${source}.${m.exportedName}` : m.exportedName)) { + aliases.add(m.localName); + } + } + return [...aliases]; +} + +/** + * Writes to module global `global` from OTHER files. A production write + * `. = Cls(...)` adds a type; any other production write + * (another value, a tuple target, `setattr`) makes the type unknown. Test + * files install doubles (`settings.conn = MagicMock()`, `monkeypatch.setattr`) + * that do not define the production type; they are recorded as `writers`, and + * a ref inside a writer resolves nothing through the global. + */ +function pythonExternalWrites(global: Node, context: ResolutionContext): { writes: Array<{ type: string; file: string }>; unknown: boolean; writers: Set } { + return pythonNameScan(context, `external\0${global.id}`, () => { + const out = { writes: [] as Array<{ type: string; file: string }>, unknown: false, writers: new Set() }; + const module = pythonModulePath(global.filePath); + const name = global.name; + const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + for (const file of context.getAllFiles()) { + if (file === global.filePath || !/\.pyi?$/.test(file) || !context.readFile(file)?.includes(name)) continue; + const test = isTestFile(file); + const aliases = pythonModuleAliases(file, module, context); + if (!test && !aliases.length) continue; + const receiver = test ? '[\\w.]+' : `(?:${aliases.map(escape).join('|')})`; + const target = new RegExp(`(? target.test(t.trim())) ?? false; + if (!assigned && !dynamic.test(statement)) continue; + if (test) { out.writers.add(file); continue; } + const single = assignment && assignment.targets.length === 1 && !assignment.augmented && exact.test(assignment.targets[0]!.trim()); + const value = single ? assignment!.value.trim() : ''; + if (value === 'None') continue; + const constructor = value ? pythonConstructorCall(value) : null; + if (!constructor) { out.unknown = true; return out; } + out.writes.push({ type: constructor, file }); + } + } + return out; + }); +} + +/** `globals()[...] = ...` naming the global (or with a computed key), or `globals().update(...)`, in its own module. */ +function pythonDynamicGlobalWrite(name: string, filePath: string, context: ResolutionContext): boolean { + return pythonMemberLines(filePath, context).some(line => { + if (/\bglobals\(\)\s*\.\s*update\s*\(/.test(line)) return true; + const key = line.match(/\bglobals\(\)\s*\[\s*([^\]]+?)\s*\]\s*=(?!=)/)?.[1]; + if (!key) return false; + const literal = key.match(/^(['"])(\w+)\1$/); + return !literal || literal[2] === name; + }); +} + /** * The method a module global's value can dispatch to: one class resolves to * its own method; several bind to the nearest declaration they all inherit, * as a base-typed receiver does. Otherwise, no edge. */ function pythonGlobalMembers(global: Node, member: string, ref: UnresolvedRef, context: ResolutionContext): Node[] { + // A test that installs its own double sees the double, not the production type. + if (pythonExternalWrites(global, context).writers.has(ref.filePath)) return []; const classes = pythonGlobalClasses(global, ref, context); if (!classes) return []; const targets = [...new Map(classes.flatMap(cls => pythonMembers(cls, member, ref, context)).map(n => [n.id, n])).values()];