Provide a canonical, declarative mechanism for import redirects (and their reverse resolution) · Issue #157524 · python/cpython · GitHub
Skip to content

Provide a canonical, declarative mechanism for import redirects (and their reverse resolution) #157524

Description

@jaraco

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:

  1. Forward: a name (module or package) is importable, but its content lives somewhere other than a path entry on sys.path.
  2. 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:

  1. 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.

  2. 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.

Limitations:

  • Requires executing Python to learn the mapping, so every static analyzer is blind to it — the direct cause of Is there some way we can support setuptools-style editable installs? 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 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:

# 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-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 .pth import 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

Links to previous discussion of this feature:

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions