From 298bb935d0a0985361ff0d191de3607f24014155 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Sat, 31 Oct 2026 14:44:05 -0500 Subject: [PATCH] fix(python): a script's import of a module beside it in a package folder links again PR #1921 made Python imports resolve from the package roots a project has. A file's own folder counts as a root only when it holds no __init__.py, because in a real package a bare `from utils import x` must not take a sibling over the true top-level module. That also lost this layout: rocksdb's tools/block_cache_analyzer/block_cache_pysim_test.py does `from block_cache_pysim import (...)` beside block_cache_pysim.py, in a folder that holds an __init__.py. A script run from there has that folder first on sys.path all the same. The fallback from PR #3141 now starts at the importer's own folder, still after every root has answered, so a top-level module a root provides keeps winning. A package above the importer stays passed over. Proving the names is not enough beside a file. A trace of every candidate found that most bare names with a module beside them are the stdlib's, and the proof passes on django/utils/text.py, whose `from html import escape` would have taken django/utils/html.py. So two checks now come with it. A name in sys.stdlib_module_names stays the stdlib's, above a script as well as beside it; CPython's `test` package isn't on that list, so redis's `from test import TestCase` is unaffected. And a module that imports its own name wraps the real one, which is what that name then means. That covers a wrapper's neighbours and the wrapper itself, so a file is never its own answer. A sync follows it. An edit re-opens a module's edges only if an import node spells that module from a folder that can reach it: a file in a package folder, for the path from that folder, or a file anywhere below a plain folder that isn't a root. Those spellings are looked up by name, one per folder above the module, so an edit nobody imports that way re-opens nothing, even for a hub like mypy/types.py, and costs nothing measurable. It also closes the gap PR #3141 left open, a dotted module above a script, and a module that is a package. The [Unreleased] bullet from PR #1921 gains a clause. No new bullet, because the regression never shipped. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- __tests__/python-module-resolution.test.ts | 355 +++++++++++++++++++++ src/index.ts | 4 +- src/resolution/import-resolver.ts | 98 ++++-- 4 files changed, 432 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ae667acd..dad005ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -420,7 +420,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - A call on a variable or attribute named after a class, like `client` or `nodelist`, now prefers the class it spells whole, `Client` or `NodeList`, over another that only shares a word with it, like `RedisCacheClient`. - A call through an attribute of an imported value, like flask's `app.jinja_env.filters.keys()`, no longer links to the value's own `jinja_env`: the method called is `keys`. Re-index to update an existing project. - A call through a class name, like `Logger.log()`, or through a variable named after one, like `logger.log()`, now links to that class's own method or the one it inherits, such as Django's `form.is_valid()` reaching `BaseForm`, instead of the same-named method of a class whose name merely contains it, such as `FileLogger`. These calls also no longer link to a class a test declares inside one of its functions, or, from your own code, to a class in a test suite. Re-index to update an existing project. Thanks @inth3shadows. -- Python imports now resolve the way Python finds modules: from your project's package roots — the repository root, `src/` layouts, each service with its own `pyproject.toml`, `setup.cfg` or `setup.py`, and the package directories those declare — so `from shop.cart import total` reaches `src/shop/cart.py`, `from pkg import name` reaches what the package's `__init__.py` defines or re-exports (respecting `__all__`), and `import json` or `import requests` no longer links to a same-named file nested in a test fixture, an example, a vendored copy or another package. A script can still import a module from a folder above it, like `from test import TestCase` in a `tests/` folder with the runner `test.py` one level up, when that module defines every name the script takes from it. After a module, package or `__init__.py` is added or removed, a package's `__init__.py` is edited, or a build config is edited, the next sync brings Python imports back in line with a fresh index; the file watcher doesn't react to a build-config edit by itself, so run `codegraph sync` after one. Re-index Python projects after upgrading. (#1704) +- Python imports now resolve the way Python finds modules: from your project's package roots — the repository root, `src/` layouts, each service with its own `pyproject.toml`, `setup.cfg` or `setup.py`, and the package directories those declare — so `from shop.cart import total` reaches `src/shop/cart.py`, `from pkg import name` reaches what the package's `__init__.py` defines or re-exports (respecting `__all__`), and `import json` or `import requests` no longer links to a same-named file nested in a test fixture, an example, a vendored copy or another package. A script can still import a module from a folder above it, like `from test import TestCase` in a `tests/` folder with the runner `test.py` one level up, or from beside it in a folder with an `__init__.py`, like `from pysim import run` next to `pysim.py`, when that module defines every name the script takes from it and isn't named after a standard library module or a package it wraps. After a module, package or `__init__.py` is added or removed, a package's `__init__.py` is edited, or a build config is edited, the next sync brings Python imports back in line with a fresh index; the file watcher doesn't react to a build-config edit by itself, so run `codegraph sync` after one. Re-index Python projects after upgrading. (#1704) - An Express route keeps its named handler when a middleware before it contains an arrow function. `rateLimit({ keyGenerator: (req) => req.ip })` or `(req, res, next) => next()` no longer hides the handler at the end of the route (re-index to update an existing project). - A TypeScript type re-exported through a barrel (`export type { Foo } from`, `export { type Foo } from`, `export type * from`) now links to its real declaration instead of a same-named type elsewhere in the project, and import lists with comments, string names, or a JSDoc `@import` are read correctly. Re-index to update an existing project. - In TypeScript, a file that re-exports a whole module's types, like `export type * from './types'` or `export type * as Types from './types'`, now parses cleanly. Before, the parser couldn't read that line, so a barrel made of re-exports was reported as producing no symbols and `codegraph status` listed it among the files it could not parse. Re-index a project that uses it after upgrading. Thanks @gavinwilliams for the report and @danusha2345 for the pull request. (#2474) diff --git a/__tests__/python-module-resolution.test.ts b/__tests__/python-module-resolution.test.ts index dae97f2ac..c771242c1 100644 --- a/__tests__/python-module-resolution.test.ts +++ b/__tests__/python-module-resolution.test.ts @@ -900,6 +900,193 @@ def go(): }); }); + // A script's folder is first on `sys.path` whether or not it holds an + // `__init__.py`: rocksdb's tools/block_cache_analyzer/block_cache_pysim_test.py + // and the block_cache_pysim.py beside it. A file of a real package reaches + // a bare name through the roots instead, so the names it takes must prove it. + describe('a module beside a script in a package folder', () => { + const pysim = `class HashTable: + def size(self): + return 0 + + +def run(n): + return n + + +kLimit = 3 +`; + + it('binds the names a script imports from the module beside it', async () => { + write({ + 'tools/an/__init__.py': '', + 'tools/an/pysim.py': pysim, + 'tools/an/pysim_test.py': `from pysim import HashTable, kLimit, run + + +def test_all(): + table = HashTable() + return run(kLimit) +`, + }); + cg = await CodeGraph.init(tempDir, { index: true }); + + expect(targets('test_all', 'tools/an/pysim_test.py', 'calls')).toEqual(['run@tools/an/pysim.py']); + expect(targets('pysim_test.py', 'tools/an/pysim_test.py', 'imports').sort()).toEqual([ + 'HashTable@tools/an/pysim.py', 'kLimit@tools/an/pysim.py', 'pysim.py@tools/an/pysim.py', 'run@tools/an/pysim.py', + ]); + }); + + it('leaves a bare name to the top-level module a root provides', async () => { + write({ + 'utils.py': 'def load(n):\n return "top"\n', + 'pkg/__init__.py': '', + 'pkg/utils.py': 'def load(n):\n return "beside"\n', + 'pkg/mod.py': `from utils import load + + +def go(): + return load(1) +`, + }); + cg = await CodeGraph.init(tempDir, { index: true }); + + expect(targets('go', 'pkg/mod.py', 'calls')).toEqual(['load@utils.py']); + }); + + it('binds nothing when the module beside it lacks a name the file imports', async () => { + write({ + 'pkg/__init__.py': '', + 'pkg/yaml.py': 'def dump(x):\n return x\n', + 'pkg/mod.py': `from yaml import dump, safe_load + + +def go(): + return safe_load(dump(1)) +`, + }); + cg = await CodeGraph.init(tempDir, { index: true }); + + expect(targets('go', 'pkg/mod.py', 'calls')).toEqual([]); + expect(targets('mod.py', 'pkg/mod.py', 'imports')).toEqual([]); + }); + + it('never takes a file for the package it is named after', async () => { + write({ + 'pkg/__init__.py': '', + // A wrapper of the same name: its import is the real package's. + 'pkg/client.py': `from client import Client + + +class Client(Client): + pass + + +def make(): + return Client() +`, + }); + cg = await CodeGraph.init(tempDir, { index: true }); + + expect(targets('client.py', 'pkg/client.py', 'imports')).toEqual([]); + }); + + it('leaves a name to the package a wrapper beside the file wraps', async () => { + write({ + 'pkg/__init__.py': '', + 'pkg/yaml.py': `import yaml + + +def load(text): + return yaml.safe_load(text) +`, + 'pkg/mod.py': `from yaml import load + + +def go(): + return load("a: 1") +`, + }); + cg = await CodeGraph.init(tempDir, { index: true }); + + expect(targets('go', 'pkg/mod.py', 'calls')).toEqual([]); + expect(targets('mod.py', 'pkg/mod.py', 'imports')).toEqual([]); + }); + + // As `from html import escape` reads in django/utils/text.py, beside + // django/utils/html.py and its own `escape`. + it.each([ + ['beside the file', 'pkg/__init__.py', 'pkg/html.py', 'pkg/text.py'], + ['in a folder above the file', 'tools/other.py', 'tools/html.py', 'tools/sub/text.py'], + ])('leaves a stdlib name to the stdlib, with a module of that name %s', async (_label, other, module, importer) => { + write({ + [other]: '', + [module]: 'def escape(text):\n return text\n', + [importer]: `from html import escape + + +def go(): + return escape("a") +`, + }); + cg = await CodeGraph.init(tempDir, { index: true }); + + expect(targets('go', importer, 'calls')).toEqual([]); + expect(targets(importer.slice(importer.lastIndexOf('/') + 1), importer, 'imports')).toEqual([]); + }); + + it('takes a dotted name below its own folder, the module proved the same way', async () => { + write({ + 'pkg/__init__.py': '', + 'pkg/sub/__init__.py': '', + 'pkg/sub/helper.py': 'def assist(n):\n return n\n', + 'pkg/run.py': `from sub.helper import assist + + +def go(): + return assist(1) +`, + }); + cg = await CodeGraph.init(tempDir, { index: true }); + + expect(targets('go', 'pkg/run.py', 'calls')).toEqual(['assist@pkg/sub/helper.py']); + }); + + it('binds nothing for a plain import, which takes no names to prove the module', async () => { + write({ + 'pkg/__init__.py': '', + 'pkg/helper.py': 'def assist(n):\n return n\n', + 'pkg/mod.py': `import helper + + +def go(): + return helper.assist(1) +`, + }); + cg = await CodeGraph.init(tempDir, { index: true }); + + expect(targets('go', 'pkg/mod.py', 'calls')).toEqual([]); + expect(targets('mod.py', 'pkg/mod.py', 'imports')).toEqual([]); + }); + + it('looks in no package above the file, only in its own folder', async () => { + write({ + 'pkg/__init__.py': '', + 'pkg/helper.py': 'def assist(n):\n return n\n', + 'pkg/sub/__init__.py': '', + 'pkg/sub/mod.py': `from helper import assist + + +def go(): + return assist(1) +`, + }); + cg = await CodeGraph.init(tempDir, { index: true }); + + expect(targets('go', 'pkg/sub/mod.py', 'calls')).toEqual([]); + }); + }); + describe('sync', () => { /** Every Python calls/imports edge, as a sorted list of readable triples. */ function pythonEdges(graph: CodeGraph): string[] { @@ -1157,6 +1344,174 @@ def go(): expect(fresh.join('\n')).not.toContain('assist'); expect(pythonEdges(cg)).toEqual(fresh); }); + + it.each([ + ['a module in a folder below it', 'from lib.helper import assist', 'tools/lib/helper.py', 'tools/lib/other.py'], + // A package directly below `tools` would make it a root. + ['a package below it', 'from x.lib import assist', 'tools/x/lib/__init__.py', 'tools/x/lib/other.py'], + ])('follows an edit of %s', async (_label, line, moduleFile, otherFile) => { + write({ + 'tools/sub/run.py': `${line}\n\n\ndef go():\n return assist(1)\n`, + [otherFile]: '', + [moduleFile]: 'def other():\n return 0\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(pythonEdges(cg).join('\n')).not.toContain('assist'); + + write({ [moduleFile]: 'def assist(n):\n return n\n' }); + await cg.sync(); + let fresh = await freshEdges(); + expect(fresh).toContain(`calls go@tools/sub/run.py -> function:assist@${moduleFile}`); + expect(pythonEdges(cg)).toEqual(fresh); + + write({ [moduleFile]: 'def other():\n return 0\n' }); + await cg.sync(); + fresh = await freshEdges(); + expect(fresh.join('\n')).not.toContain('assist'); + expect(pythonEdges(cg)).toEqual(fresh); + }); + + it('re-opens nothing when an edited module has no importer below it that spells it', async () => { + write({ + 'tools/helper.py': 'def assist(n):\n return n\n', + 'tools/sub/run.py': 'def go():\n return 1\n', + 'other/run.py': 'from helper import assist\n\n\ndef go():\n return assist(1)\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + const spy = spyReopen(cg); + + write({ 'tools/helper.py': 'def assist(n):\n return n + 1\n' }); + await cg.sync(); + + expect(spy.narrow).not.toHaveBeenCalled(); + }); + + it('re-opens nothing when a root answers the import, not a script\'s folder', async () => { + write({ + 'svc/pyproject.toml': '[project]\nname = "svc"\n', + 'svc/helper.py': 'def assist(n):\n return n\n', + 'svc/sub/run.py': 'from helper import assist\n\n\ndef go():\n return assist(1)\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(pythonEdges(cg)).toContain('calls go@svc/sub/run.py -> function:assist@svc/helper.py'); + const spy = spyReopen(cg); + + write({ 'svc/helper.py': 'def assist(n):\n return n + 1\n' }); + await cg.sync(); + + expect(spy.narrow).not.toHaveBeenCalled(); + }); + }); + + describe('a module beside a script in a package folder', () => { + const importer = `from helper import assist + + +def go(): + return assist(1) +`; + + it('follows an edit that gives the module the name, or takes it away', async () => { + write({ + 'pkg/__init__.py': '', + 'pkg/run.py': importer, + 'pkg/helper.py': 'def other():\n return 0\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(pythonEdges(cg).join('\n')).not.toContain('assist'); + + write({ 'pkg/helper.py': 'def assist(n):\n return n\n' }); + await cg.sync(); + let fresh = await freshEdges(); + expect(fresh).toContain('calls go@pkg/run.py -> function:assist@pkg/helper.py'); + expect(pythonEdges(cg)).toEqual(fresh); + + write({ 'pkg/helper.py': 'def other():\n return 0\n' }); + await cg.sync(); + fresh = await freshEdges(); + expect(fresh.join('\n')).not.toContain('assist'); + expect(pythonEdges(cg)).toEqual(fresh); + }); + + it('links it when the module is added, and unlinks it when removed', async () => { + write({ 'pkg/__init__.py': '', 'pkg/run.py': importer }); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(pythonEdges(cg).join('\n')).not.toContain('assist'); + + write({ 'pkg/helper.py': 'def assist(n):\n return n\n' }); + await cg.sync(); + let fresh = await freshEdges(); + expect(fresh).toContain('calls go@pkg/run.py -> function:assist@pkg/helper.py'); + expect(pythonEdges(cg)).toEqual(fresh); + + fs.rmSync(path.join(tempDir, 'pkg/helper.py')); + await cg.sync(); + fresh = await freshEdges(); + expect(fresh.join('\n')).not.toContain('assist'); + expect(pythonEdges(cg)).toEqual(fresh); + }); + + // The name below the folder, and what the folder's own `__init__.py` holds. + it.each([ + ['a module in a package below it', 'from sub.helper import assist', 'pkg/sub/helper.py', 'pkg/sub/__init__.py'], + ['a package below it', 'from sub import assist', 'pkg/sub/__init__.py', 'pkg/sub/mod.py'], + ])('follows an edit of %s', async (_label, line, moduleFile, otherFile) => { + write({ + 'pkg/__init__.py': '', + [otherFile]: '', + 'pkg/run.py': `${line}\n\n\ndef go():\n return assist(1)\n`, + [moduleFile]: 'def other():\n return 0\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + expect(pythonEdges(cg).join('\n')).not.toContain('assist'); + + write({ [moduleFile]: 'def assist(n):\n return n\n' }); + await cg.sync(); + let fresh = await freshEdges(); + expect(fresh).toContain(`calls go@pkg/run.py -> function:assist@${moduleFile}`); + expect(pythonEdges(cg)).toEqual(fresh); + + write({ [moduleFile]: 'def other():\n return 0\n' }); + await cg.sync(); + fresh = await freshEdges(); + expect(fresh.join('\n')).not.toContain('assist'); + expect(pythonEdges(cg)).toEqual(fresh); + }); + + it('re-opens nothing when an edited module has no such importer', async () => { + write({ + 'pkg/__init__.py': '', + 'pkg/helper.py': 'def assist(n):\n return n\n', + 'pkg/run.py': 'from .helper import assist\n\n\ndef go():\n return assist(1)\n', + // Spelled the same, but from a folder that doesn't hold the module. + 'other/run.py': 'from helper import assist\n\n\ndef go():\n return assist(1)\n', + // Beside it, but neither a Python import nor an import at all. + 'pkg/widget.ts': "import { assist } from 'helper';\nexport const go = assist;\n", + 'pkg/named.py': 'def helper():\n return 0\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + const spy = spyReopen(cg); + + write({ 'pkg/helper.py': 'def assist(n):\n return n\n\n\ndef more():\n return 2\n' }); + await cg.sync(); + + expect(spy.narrow).not.toHaveBeenCalled(); + }); + + it('re-opens nothing for a module named after the stdlib module its neighbours import', async () => { + write({ + 'pkg/__init__.py': '', + 'pkg/types.py': 'class Thing:\n pass\n', + 'pkg/run.py': 'from types import ModuleType\nfrom .types import Thing\n\n\ndef go():\n return Thing()\n', + }); + cg = await CodeGraph.init(tempDir, { index: true }); + const spy = spyReopen(cg); + + write({ 'pkg/types.py': 'class Thing:\n pass\n\n\nclass More:\n pass\n' }); + await cg.sync(); + + expect(spy.narrow).not.toHaveBeenCalled(); + }); }); it('re-opens nothing on a sync that changes nothing', async () => { diff --git a/src/index.ts b/src/index.ts index 5aed7ba8f..d14ab409b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1680,8 +1680,8 @@ export class CodeGraph { this.resolver.clearCaches(); for (const f of this.orchestrator.resurrectPythonPackageImporters(this.resolver.getPythonPackageImporters(editedInits))) reopened.add(f); } - // An edit can give a module above a script the names its importers - // take from it, or take them away. + // An edit can give a module that only a script's folder provides the + // names its importers take from it, or take them away. const scriptModules = this.resolver.getPythonScriptModuleScope( (result.changedFilePaths ?? []).filter((f) => pythonFilesBefore.has(f) && after.has(f)) ); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index 34a8587fa..0bb67f9e9 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -616,6 +616,37 @@ const PYTHON_NON_PROJECT_DIRS = new Set([ 'tests', 'test', 'testing', 'fixtures', 'testdata', 'examples', 'example', 'samples', 'vendor', 'third_party', ]); +/** + * `sys.stdlib_module_names`, less the private `_x` modules. A folder only a + * script has on its path never takes one of these: `from html import escape` in + * `django/utils/text.py` is the stdlib's, though `django/utils/html.py` beside it + * defines `escape`. CPython's own `test` package isn't one, as Python leaves it + * out too, so `from test import TestCase` stays open to a project's runner. + */ +const PYTHON_STDLIB_MODULES = new Set([ + '__future__', 'abc', 'annotationlib', 'antigravity', 'argparse', 'array', 'ast', 'asyncio', 'atexit', + 'base64', 'bdb', 'binascii', 'bisect', 'builtins', 'bz2', 'cProfile', 'calendar', 'cmath', 'cmd', 'code', + 'codecs', 'codeop', 'collections', 'colorsys', 'compileall', 'compression', 'concurrent', 'configparser', + 'contextlib', 'contextvars', 'copy', 'copyreg', 'csv', 'ctypes', 'curses', 'dataclasses', 'datetime', + 'dbm', 'decimal', 'difflib', 'dis', 'doctest', 'email', 'encodings', 'ensurepip', 'enum', 'errno', + 'faulthandler', 'fcntl', 'filecmp', 'fileinput', 'fnmatch', 'fractions', 'ftplib', 'functools', 'gc', + 'genericpath', 'getopt', 'getpass', 'gettext', 'glob', 'graphlib', 'grp', 'gzip', 'hashlib', 'heapq', + 'hmac', 'html', 'http', 'idlelib', 'imaplib', 'importlib', 'inspect', 'io', 'ipaddress', 'itertools', + 'json', 'keyword', 'linecache', 'locale', 'logging', 'lzma', 'mailbox', 'marshal', 'math', 'mimetypes', + 'mmap', 'modulefinder', 'msvcrt', 'multiprocessing', 'netrc', 'nt', 'ntpath', 'nturl2path', 'numbers', + 'opcode', 'operator', 'optparse', 'os', 'pathlib', 'pdb', 'pickle', 'pickletools', 'pkgutil', 'platform', + 'plistlib', 'poplib', 'posix', 'posixpath', 'pprint', 'profile', 'profiling', 'pstats', 'pty', 'pwd', + 'py_compile', 'pyclbr', 'pydoc', 'pydoc_data', 'pyexpat', 'queue', 'quopri', 'random', 're', 'readline', + 'reprlib', 'resource', 'rlcompleter', 'runpy', 'sched', 'secrets', 'select', 'selectors', 'shelve', + 'shlex', 'shutil', 'signal', 'site', 'smtplib', 'socket', 'socketserver', 'sqlite3', 'ssl', 'stat', + 'statistics', 'string', 'stringprep', 'struct', 'subprocess', 'symtable', 'sys', 'sysconfig', 'syslog', + 'tabnanny', 'tarfile', 'tempfile', 'termios', 'textwrap', 'this', 'threading', 'time', 'timeit', + 'tkinter', 'token', 'tokenize', 'tomllib', 'trace', 'traceback', 'tracemalloc', 'tty', 'turtle', + 'turtledemo', 'types', 'typing', 'unicodedata', 'unittest', 'urllib', 'uuid', 'venv', 'warnings', 'wave', + 'weakref', 'webbrowser', 'winreg', 'winsound', 'wsgiref', 'xml', 'xmlrpc', 'zipapp', 'zipfile', + 'zipimport', 'zlib', 'zoneinfo', +]); + /** A def at module level — what `from mod import name` can bind. */ function isPythonModuleLevelDef(n: Node): boolean { return PYTHON_MODULE_LEVEL_KINDS.has(n.kind) && !n.qualifiedName.includes('::'); @@ -903,20 +934,25 @@ function resolvePythonModule( if (found !== null && found !== hit) return null; found = hit; } - return found ?? pythonModuleAboveScript(specifier, relPath, fromFile, importerDir, index, context); + return found ?? pythonModuleOfScript(specifier, relPath, fromFile, importerDir, index, context); } /** - * The module in a folder above a script that no root provides: `from test - * import TestCase` in `modules/vs/tests/with_a.py`, with the runner - * `modules/vs/test.py` whose folder is first on `sys.path`. Nothing in the tree - * says that folder is a root, and a name like `test` or `utils` may just as - * well be the stdlib's, so what proves the module is what the importer takes - * from it: every name must be one the file defines at module level. A plain - * `import utils` or a star import takes no name, so it proves nothing and stays - * unresolved. Nearest folder first; a package is no root, so it is passed over. + * The module only a script's own `sys.path[0]` provides, which no root does: + * `from test import TestCase` in `modules/vs/tests/with_a.py`, with the runner + * `modules/vs/test.py` above it, or `from pysim import HashTable` in + * `tools/an/pysim_test.py`, beside `tools/an/pysim.py` in a folder that also + * holds an `__init__.py`. Nothing in the tree says such a folder is on the path, + * and a file of a real package means the top-level module instead, so the folder + * has to earn it. A stdlib name never does. A module that imports its own name + * wraps the real one, which that name then means, for its neighbours and for + * itself. Otherwise what proves the module is what the importer takes from it: + * every name must be one the file defines at module level. A plain `import + * utils` or a star import takes no name, so it proves nothing and stays + * unresolved. Nearest folder first, the importer's own included; a package above + * it is no root, so it is passed over. */ -function pythonModuleAboveScript( +function pythonModuleOfScript( specifier: string, relPath: string, fromFile: string, @@ -924,14 +960,15 @@ function pythonModuleAboveScript( index: PythonModuleIndex, context: ResolutionContext ): string | null { + if (PYTHON_STDLIB_MODULES.has(specifier.split('.')[0]!)) return null; const taken = context.getImportMappings(fromFile, 'python') .filter((imp) => imp.source === specifier && !imp.isNamespace) .map((imp) => imp.exportedName); if (taken.length === 0) return null; - for (let dir = parentDir(importerDir); dir; dir = parentDir(dir)) { - if (index.packageDirs.has(dir)) continue; + for (let dir = importerDir; dir; dir = parentDir(dir)) { + if (dir !== importerDir && index.packageDirs.has(dir)) continue; const file = pythonModuleUnder(dir, relPath, index); - if (!file) continue; + if (!file || context.getImportMappings(file, 'python').some((imp) => imp.source === specifier)) continue; const defined = new Set(context.getNodesInFile(file).filter(isPythonModuleLevelDef).map((n) => n.name)); if (taken.every((name) => defined.has(name))) return file; } @@ -1006,11 +1043,13 @@ export function pythonReopenScope( /** * What an edit of these Python files can move through - * {@link pythonModuleAboveScript}: such a module binds only while it defines - * the names its importers take, so an edit links or unlinks them though no file - * appeared or vanished. Only a module in a folder that is neither a package nor - * a root, with Python folders below it, can be one, so any other edit re-opens - * nothing. + * {@link pythonModuleOfScript}: such a module binds only while it defines the + * names its importers take, so an edit links or unlinks them though no file + * appeared or vanished. Import nodes are named by their specifier, so each folder + * above a module asks for the spelling that reaches it from there: a file in a + * package folder, or a file anywhere below a plain folder that isn't a root. An + * edit nobody imports that way re-opens nothing, however many other importers + * the module has: `from types import ModuleType` beside `mypy/types.py` never does. */ export function pythonScriptModuleScope( context: ResolutionContext, @@ -1021,12 +1060,23 @@ export function pythonScriptModuleScope( const moduleLeaves: string[] = []; for (const raw of editedFiles) { const file = raw.replace(/\\/g, '/'); - const dir = parentDir(file); - if (!PYTHON_MODULE_FILE.test(file) || !dir || index.packageDirs.has(dir) || - index.projectRoots.has(dir) || index.localRoots.has(dir)) continue; - if (![...index.dirs].some((d) => d.startsWith(`${dir}/`))) continue; - targetFiles.push(file); - moduleLeaves.push(file.slice(dir.length + 1, -'.py'.length)); + if (!PYTHON_MODULE_FILE.test(file)) continue; + const mod = file.replace(/(?:^|\/)__init__\.py$/, '').replace(/\.py$/, ''); + for (let dir = parentDir(mod); dir; dir = parentDir(dir)) { + const specifier = mod.slice(dir.length + 1).replace(/\//g, '.'); + if (PYTHON_STDLIB_MODULES.has(specifier.split('.')[0]!)) continue; + const importers = context.getNodesByName(specifier) + .filter((n) => n.kind === 'import' && PYTHON_MODULE_FILE.test(n.filePath)) + .map((n) => parentDir(n.filePath)); + const reached = index.packageDirs.has(dir) + ? importers.includes(dir) + : !index.projectRoots.has(dir) && !index.localRoots.has(dir) && + importers.some((d) => d.startsWith(`${dir}/`)); + if (!reached) continue; + targetFiles.push(file); + moduleLeaves.push(mod.slice(mod.lastIndexOf('/') + 1)); + break; + } } return { targetFiles, sourceDirs: [], moduleLeaves }; }