You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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 mypkg to resolve to /src/mypkg without exposing every other sibling in /src as importable. Adding /src to sys.path (what a plain .pth line 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.build is named coherent.build and contains __init__.py directly. The import name is coherent.build (a namespace package coherent containing build), but there is no coherent/ directory anywhere, and coherent.build is not a legal identifier for a directory on the path. A checkout of zope.interface laid out this way has the same shape. In other systems the directory simply doesn't match the name at all (my-project/ providing my_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.
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 import lines in .pth files, 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's N999, 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 N resolves to path P, discoverable without executing arbitrary code.
R2.P need not be (and often is not) a directory whose basename equals the last component of N; and declaring N must not implicitly expose siblings of P.
R3. Package redirections are inherited: declaring mypkg -> /src/mypkg covers mypkg.sub.mod without 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 .pth lines 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 materializing zope/.
R7. Introspectable at run time (something like importlib exposing 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:
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:
Site directory file vs. .dist-info metadata. A site-directory file is visible to a bare interpreter with no packaging metadata reader; .dist-info ties 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.path entries, 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 .pthimport lines 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
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
.pthtricks in the first place.The need
Two capabilities, which should be two views of a single declaration:
sys.path.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
.pthfileshatchlingand others write a.pthfile containing the project's source directory. Static tools can read these, so this is the case that mostly works today.Limitations:
2.
importlines in.pthfiles installing a meta path findersetuptools' default editable mode writes__editable__.<dist>.pthcontaining animportline that installs__editable___<dist>_finder.py, whoseMAPPINGandNAMESPACESdicts hold the real name→path mapping.Limitations:
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.buildinstalls a real module into site-packages at the imported name whose body sets__path__to the checkout andexecs the target's__init__.py(backend.py).Limitations:
ty, Ruff'sN999, and pytest's module-name derivation for Coherent projects.__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.pathhack, 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
Xlives at pathP. 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
Nresolves to pathP, discoverable without executing arbitrary code.Pneed not be (and often is not) a directory whose basename equals the last component ofN; and declaringNmust not implicitly expose siblings ofP.mypkg -> /src/mypkgcoversmypkg.sub.modwithout enumerating every module. A file-by-file mapping is not acceptable ergonomically or for a working tree that changes..pthlines entirely, and means the runtime and the static view cannot disagree.zope.interface -> /repos/zope.interface) without materializingzope/.importlibexposing the active redirections), so that debuggers, test runners, and error messages can explain what happened.Prototype:
# import redirectcomments in.pthfilesAs an early experiment,
coherent.build0.41 emits alongside the proxy a<name>-redirects.pthcontaining only comments: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
.pthfile is a poor host for structured data (as noted in the ty thread,.pthsyntax is essentially "a path, or animportline," and the latter is being deprecated by PEP 829).A dedicated, structured file seems clearly preferable — for example
<name>.redirectin a site directory (parallel to how PEP 829 introduced.startalongside.pth), or an entry in the distribution's.dist-info, in JSON or TOML:Open questions on placement and format:
.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.sys.pathentries, against other redirects, and within a namespace portion.Value
.pthimportlines ahead of PEP 829's removal.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: