Provide a canonical, declarative mechanism for import redirects (and their reverse resolution)
Nessuno ha ancora preso questa issue.
- Lingua principale
- Python
- Stelle
- 77.2k
- Fork
- 35.9k
- Metriche di merge delle PR
- Metriche PR in attesa
Descrizione
Feature or enhancement
Proposal
Python has no canonical way to declare that an importable name should resolve to a location other than where the standard path finders would look for it, nor any way for a tool to resolve such a redirection backwards (given a file, what name would it be imported under?).
Every tool that needs this capability — most visibly build backends implementing editable installs (PEP 660) — invents its own mechanism, and every one of those mechanisms is opaque to anything other than the interpreter's own import machinery at run time. The result is a long tail of broken behavior in type checkers, linters, test runners, and IDEs, and a growing reliance on reverse-engineered implementation details of individual build backends.
This issue distills the problem from astral-sh/ty#475 (see also python/mypy#13392, DetachHead/basedpyright#731), where the conclusion was that the ecosystem needs a standard for this and that no static analyzer can solve it on its own. I'd like to consider it more broadly than static analysis: the same mechanism, if supported by the import system itself, would remove the need for proxy modules and .pth tricks in the first place.
The need
Two capabilities, which should be two views of a single declaration:
- Forward: a name (module or package) is importable, but its content lives somewhere other than a path entry on
sys.path. - Reverse: given a file path, determine the fully-qualified name under which it is (or would be) imported.
The reverse direction is what is missing everywhere today, and it is what type checkers, linters (e.g. Ruff's N999, which flags a package under development as not matching its module name), test collectors (pytest deriving a module name from a collected file), coverage tools, and IDEs all need.
Beyond "look over there too"
The basic need is for a name to be discoverable in another location. But a mechanism limited to "add this directory to the search path" is insufficient for real layouts; redirection must also be able to point a name at a location that does not itself contain that name:
-
A single name exposed from a directory of many. A project may want
mypkgto resolve to/src/mypkgwithout exposing every other sibling in/srcas importable. Adding/srctosys.path(what a plain.pthline does, and what several backends do for editable installs) leaks siblings, shadows unrelated distributions, and makes the resulting import namespace depend on unrelated directory contents. -
A name whose target directory has a different — possibly non-importable — name. In the Coherent "essential" layout, the repo is the package: the checkout directory for
coherent.buildis namedcoherent.buildand contains__init__.pydirectly. The import name iscoherent.build(a namespace packagecoherentcontainingbuild), but there is nocoherent/directory anywhere, andcoherent.buildis not a legal identifier for a directory on the path. A checkout ofzope.interfacelaid out this way has the same shape. In other systems the directory simply doesn't match the name at all (my-project/providingmy_project, or a build directory providing a differently-named extension package). No path-entry-based mechanism can express this; only a name→location mapping can.
How this is solved today, and why each approach falls short
1. Path entries in .pth files
hatchling and others write a .pth file containing the project's source directory. Static tools can read these, so this is the case that mostly works today.
Limitations:
- Exposes every sibling of the target directory, not the declared name (case 1 above).
- Cannot express a name that differs from the directory name (case 2 above).
- The reverse mapping is only an inference: a tool must guess that a file under that directory corresponds to a dotted name derived from its relative path, which is exactly the assumption that breaks for namespace packages and non-matching layouts.
2. import lines in .pth files installing a meta path finder
setuptools' default editable mode writes __editable__.<dist>.pth containing an import line that installs __editable___<dist>_finder.py, whose MAPPING and NAMESPACES dicts hold the real name→path mapping.
Limitations:
- Requires executing Python to learn the mapping, so every static analyzer is blind to it — the direct cause of astral-sh/ty#475 and its siblings.
- The mapping exists in a precise, machine-readable form, but in a private module whose shape is an implementation detail. Tools are now weighing whether to parse it anyway (astral-sh/ty#475 (comment)), which is bad for them and for setuptools, which loses the ability to change it.
- With PEP 829 deprecating
importlines in.pthfiles, this technique is on its way out regardless, and backends relying on it will need somewhere to go.
3. Proxy modules
coherent.build installs a real module into site-packages at the imported name whose body sets __path__ to the checkout and execs the target's __init__.py (backend.py).
Limitations:
- The redirection is only discoverable by executing the proxy.
- The reverse mapping is unavailable: nothing in the checkout says what name it is exposed under. This breaks
ty, Ruff'sN999, and pytest's module-name derivation for Coherent projects. - Tracebacks,
__file__, and tooling that round-trips a file back to a module all see the seams.
4. Ad-hoc redirection outside packaging
The same need appears well outside editable installs: vendored/bundled dependencies aliased under another name, compatibility shims that map an old name to a new location, test layouts that want one directory importable under a specific name, and in-tree builds that expose generated artifacts under the source name. Each of these is solved today with a sys.path hack, a meta path finder, or a stub module — none of them declarative, none of them reversible.
The common thread
Every one of these is a one-way, tool-specific, execution-dependent encoding of what is fundamentally a small, static, declarative fact: the name X lives at path P. Each build backend rolls its own because there is nothing to adopt; each consuming tool rolls its own detection because there is nothing to read.
What a solution should provide
- R1. A declarative, static, machine-readable statement that name
Nresolves to pathP, discoverable without executing arbitrary code. - R2.
Pneed not be (and often is not) a directory whose basename equals the last component ofN; and declaringNmust not implicitly expose siblings ofP. - R3. Package redirections are inherited: declaring
mypkg -> /src/mypkgcoversmypkg.sub.modwithout enumerating every module. A file-by-file mapping is not acceptable ergonomically or for a working tree that changes. - R4. Resolvable in both directions: name → path for the import system, and path → name for static tooling.
- R5. Honored by CPython's own import system, so a redirect is the mechanism rather than metadata describing a separately-implemented hack. That eliminates proxy modules and finder-installing
.pthlines entirely, and means the runtime and the static view cannot disagree. - R6. Support for namespace packages, including a redirect that contributes a portion of a namespace (
zope.interface -> /repos/zope.interface) without materializingzope/. - R7. Introspectable at run time (something like
importlibexposing the active redirections), so that debuggers, test runners, and error messages can explain what happened.
Prototype: # import redirect comments in .pth files
As an early experiment, coherent.build 0.41 emits alongside the proxy a <name>-redirects.pth containing only comments:
# import redirect coherent.build -> /home/jaraco/code/coherent/coherent.build
# import redirect editable_1 -> /users/other/py/editable_1
# import redirect editable_mod -> /users/other/py/editable_mod.py
Comments are ignored by site, so the file is inert today, and a static tool that understands the convention gets exactly the name→path mapping it needs, from a location it already scans.
I offer this only as an existence proof that the data is small and that emitting it is easy — not as a proposed format. Smuggling a second syntax inside the comments of a file format that already has two meanings is dirty, and a .pth file is a poor host for structured data (as noted in the ty thread, .pth syntax is essentially "a path, or an import line," and the latter is being deprecated by PEP 829).
A dedicated, structured file seems clearly preferable — for example <name>.redirect in a site directory (parallel to how PEP 829 introduced .start alongside .pth), or an entry in the distribution's .dist-info, in JSON or TOML:
# mydist.redirect
[redirects]
"coherent.build" = "/home/jaraco/code/coherent/coherent.build"
"editable_1" = "/users/other/py/editable_1"
"editable_mod" = "/users/other/py/editable_mod.py"
Open questions on placement and format:
- Site directory file vs.
.dist-infometadata. A site-directory file is visible to a bare interpreter with no packaging metadata reader;.dist-infoties the redirect to the distribution that owns it and to uninstallation. Possibly both, with one canonical form. - Relative vs. absolute paths, and what relative is relative to.
- Precedence: against regular
sys.pathentries, against other redirects, and within a namespace portion. - Whether a redirect target may itself be redirected, and whether cycles need detection.
- Whether redirects may point at zip/other importer-backed locations or only the filesystem.
- Where the spec lives: the file format and packaging integration are a packaging standard (PEP), while honoring it in the import system is CPython's part. I expect this issue to feed a discussion on discuss.python.org rather than to be resolved here alone, but the import-system half is what makes the rest coherent, so I'd like to start the conversation here.
Value
- Editable installs become statically analyzable for every tool, without anyone parsing anyone else's private finder module.
- Build backends stop inventing (and maintaining) bespoke import machinery, and setuptools gets a path off of
.pthimportlines ahead of PEP 829's removal. - Layouts where the directory name doesn't match the import name become first-class instead of requiring a proxy module.
- Tools that must map a file back to a module name (type checkers, Ruff
N999, pytest collection, coverage, tracebacks, IDE "go to definition") get an answer instead of a heuristic.
Has this already been discussed elsewhere?
This is a minor feature, which does not need previous discussion elsewhere
Links to previous discussion of this feature:
- https://github.com/astral-sh/ty/issues/475
- https://github.com/python/mypy/issues/13392
- https://github.com/python/cpython/issues/92054 (implementing PEP 420 namespace packages via import hooks for the PEP 660 use case)
- https://peps.python.org/pep-0829/
Guida per i contributori
Apri la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Direzione di ricerca
Inizia leggendo il sistema di importazione e il comportamento di site descritti nell’issue, insieme a PEP 829 e alle discussioni collegate. Le questioni aperte riguardano il posizionamento dei file, la precedenza, i percorsi, i namespace, i cicli e l’introspezione a runtime, quindi si tratta di lavoro di progettazione e non di un’implementazione localizzata. Per considerarlo completato sarebbero necessari un meccanismo dichiarativo concordato e un’integrazione definita con CPython.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Valutazione
- Stack tecnologico
- python
- Ambito
- backend-api-design, devtools
- Tipo di issue
- Funzionalità
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Stato di attività
- Attiva
- Chiarezza
- Da chiarire
- Idoneità per principianti
- 25/100