diff --git a/packages/adapters/python/src/usage/imports.ts b/packages/adapters/python/src/usage/imports.ts index ecfe9f15..3eeaa222 100644 --- a/packages/adapters/python/src/usage/imports.ts +++ b/packages/adapters/python/src/usage/imports.ts @@ -199,6 +199,53 @@ function isTypeCheckingHeader( const attribute = /^([\p{XID_Start}_][\p{XID_Continue}]*)\s*\.\s*TYPE_CHECKING$/u.exec(condition); return attribute !== null && names.modules.has(attribute[1]!.normalize("NFKC")); } +/** Top-level arguments of the call whose "(" is at `open`, or undefined if it never closes. */ +function callArguments(text: string, open: number): string[] | undefined { + const args: string[] = []; + let depth = 0; + let start = open + 1; + for (let i = open; i < text.length; i++) { + const c = text[i]!; + if ("([{".includes(c)) depth++; + else if (")]}".includes(c)) { + depth--; + if (depth === 0) { + args.push(text.slice(start, i).trim()); + return args.filter((arg, k) => arg !== "" || k < args.length - 1); + } + } else if (c === "," && depth === 1) { + args.push(text.slice(start, i).trim()); + start = i + 1; + } + } + return undefined; +} + +/** + * The relative-import level a `__import__` call passes: a number when it is a + * plain integer literal, undefined when absent or not known statically. + */ +function importLevel(args: string[]): number | undefined { + let value: string | undefined; + let positional = 0; + let starred = false; + for (const arg of args) { + const keyword = /^([\p{XID_Start}_][\p{XID_Continue}]*)\s*=(?!=)\s*(.*)$/su.exec(arg); + if (keyword) { + if (keyword[1] === "level") value = keyword[2]!; + } else if (arg.startsWith("*")) { + starred = true; + } else { + if (positional === 4 && !starred) value = arg; + positional++; + } + } + if (value === undefined) return undefined; + let literal = value.trim(); + while (/^\(.*\)$/s.test(literal)) literal = literal.slice(1, -1).trim(); + if (!/^(?:0[xX][\da-fA-F_]+|0[oO][0-7_]+|0[bB][01_]+|\d[\d_]*)$/.test(literal)) return undefined; + return Number(literal.replace(/_/g, "")); +} const DYNAMIC = /(? { const literal = strings[Number(m[1])] ?? ""; if (literal === "" || literal.startsWith(".") || /\s/u.test(literal) || !DOTTED.test(literal)) continue; + // __import__(name, globals, locals, fromlist, level): a positive level is + // package-relative, never an external dependency. An unknown level keeps + // the credit, as before. + if (/__import__$/.test(m[0].slice(0, m[0].indexOf("(")).trim())) { + const args = callArguments(text, m.index + m[0].indexOf("(")); + const level = args && importLevel(args); + if (level !== undefined && level > 0) continue; + } imports.push({ module: literal, form: "dynamic", names: [], ...base }); } } diff --git a/packages/adapters/python/src/usage/usage.test.ts b/packages/adapters/python/src/usage/usage.test.ts index c5929bea..215e1ca4 100644 --- a/packages/adapters/python/src/usage/usage.test.ts +++ b/packages/adapters/python/src/usage/usage.test.ts @@ -821,3 +821,40 @@ it("invalidates TYPE_CHECKING aliases rebound through NFKC-equivalent identifier ); } }); + +it("does not credit a relative __import__ level as an external dependency", () => { + const source = [ + '__import__("rel_kw", level=1)', + '__import__("rel_kw2", level = 2)', + '__import__("rel_pos", globals(), locals(), [], 1)', + '__import__("rel_hex", globals(), locals(), [], 0x1)', + '__import__("rel_paren", globals(), locals(), [], (1))', + 'builtins.__import__("rel_b", level=1)', + 'importlib.__import__("rel_i", None, None, None, 3)', + '__import__(name="rel_named", level=1)', + '__import__("abs_zero", level=0)', + '__import__("abs_zero_pos", globals(), locals(), [], 0)', + '__import__("abs_plain")', + '__import__("abs_four", globals(), locals(), [])', + '__import__("abs_unknown", level=lvl)', + '__import__("abs_unknown_pos", globals(), locals(), [], n + 1)', + '__import__("abs_star", *args)', + '__import__("abs_kw", fromlist=["x"], level=0)', + '__import__("abs_nested", f(level=1))', + 'importlib.import_module(".rel_mod", package="app")', + 'importlib.import_module(".rel_mod2", "app")', + 'importlib.import_module("abs_mod", package="app")', + ].join("\n"); + assert.deepEqual(modules(source), [ + "abs_zero", + "abs_zero_pos", + "abs_plain", + "abs_four", + "abs_unknown", + "abs_unknown_pos", + "abs_star", + "abs_kw", + "abs_nested", + "abs_mod", + ]); +});