2025, Sep 27 15:00

How to Keep proj.foo.* Imports Working in Monorepo and Standalone Repos: Pyright, VS Code, and Editable Installs

Fix unresolved proj.foo imports in Pyright/VS Code when splitting repos. Use an editable install (PEP 660) and a proj/foo symlink (PEP 420). Step-by-step guide

When a subpackage lives both inside a larger private monorepo and in its own standalone repository, absolute imports like proj.foo.bar must keep working in two worlds. The Python runtime can often be convinced with editable installs, but static analyzers such as Pyright in VS Code will still complain unless the on-disk shape also looks like proj/foo/...

Problem setup

Assume the developer-facing repo has code under src/proj/foo, but the intern can clone only a standalone repo that contains just the foo subtree. Inside this standalone tree, modules still import using the full namespace proj.foo.bar, and you cannot move files, add dummy stubs, or change those imports. The goal is to make both the interpreter and Pyright resolve proj.foo.* as if the standalone folder were part of the original parent package.

Minimal example

In the standalone clone, imagine this structure:

~/foo-repo/
  └── bar/
      ├── __init__.py
      ├── types.py
      └── jim.py

Here is the package code that relies on the proj.foo.* namespace. Names are rewritten but the behavior is identical.

~/foo-repo/bar/__init__.py

from proj.foo.bar.types import Payload
__all__ = ["Payload"]

~/foo-repo/bar/types.py

class Payload:
    pass

~/foo-repo/bar/jim.py

import proj.foo.bar as foopkg
print(foopkg.Payload)

If you try to run python bar/jim.py in a fresh venv, the runtime and Pyright will not find proj.foo.* because the standalone repo does not expose a proj/foo directory structure.

Why it fails

Setuptools can map package names to arbitrary source directories via package-dir and editable installs. That trick can make the interpreter happy, because an import hook provided by the editable install redirects proj.foo to your source. However, Pyright does not execute import hooks or consult packaging metadata. It resolves imports directly from the filesystem layout (or from stubs) and expects to see real directories that match the package’s dotted path on sys.path. Without a physical proj/foo hierarchy somewhere that Pyright can crawl, proj.foo.* won’t resolve in the IDE even if the code runs at runtime.

Two minimal paths that work

The first step is ensuring the interpreter can run your package in-place. The second step is giving Pyright a directory view that looks like proj/foo/…

The editable-install route below handles the interpreter cleanly. To satisfy Pyright, you then materialize the expected namespace shape using a symlink that you keep out of version control.

Option 1: Editable install with a clear mapping (PEP 660)

Place this in ~/foo-repo/pyproject.toml. It advertises the package under the proj.foo namespace and maps it to your existing layout while staying editable.

[project]
name = "proj.foo"
version = "0.0.0"
requires-python = ">=3.11"
[build-system]
requires = ["setuptools>=70", "wheel", "editables>=0.5"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
packages = ["proj.foo", "proj.foo.bar"]
package-dir = { "proj.foo" = ".", "proj.foo.bar" = "bar" }
include-package-data = true

Then create and activate the environment and run the module with -m, which avoids script-path quirks and uses package import semantics.

uv sync
source .venv/bin/activate
python -m proj.foo.bar.jim

At this point the runtime works because the editable install provides the necessary import hook. Pyright will still not resolve proj.foo.* based only on this metadata, because it does not run those hooks.

Option 2: Give Pyright the directory shape it expects (PEP 420 + symlink)

Expose a real proj/foo directory that points at your code, without moving files or adding stubs. Create an implicit namespace package layout by adding directories and a symlink. Keep this layout untracked so you do not disturb the canonical repository structure.

cd ~/foo-repo
mkdir -p proj/foo
ln -s ../../bar proj/foo/bar

Add proj/ to your .gitignore so these auxiliary paths never leave the standalone working copy. Now Pyright sees proj/foo/bar on disk and can resolve proj.foo.bar and its submodules, while the interpreter continues to load the same source.

Why you can’t do it with metadata alone

Pyright, and similarly Pylance, resolve imports from actual directories and files on interpreter search paths. They do not run build backends, import hooks, or read packaging metadata like package-dir to discover sources. For editable installs, path-based .pth entries that reveal real directories are what the analyzer indexes. Namespace packages also still require that each segment of the dotted name appears as a directory that is reachable on sys.path. If there is no physical proj/foo hierarchy, a static resolver has nothing to walk, so proj.foo.* remains unknown to the IDE even if runtime imports succeed.

Why this matters

In split-repo or subtree workflows, development speed depends on keeping one import path that works both in isolation and in the monorepo. Relying solely on packaging tricks can mask problems locally while leaving the IDE blind to your modules. Establishing a real directory view for the namespace yields correct runtime behavior and reliable static analysis without touching your source imports or moving files.

Wrap-up

Use an editable install to keep the interpreter mapping clean and prefer python -m to execute package modules. Then, if you also want Pyright and VS Code to fully resolve proj.foo.*, add a proj/foo directory and symlink bar into it, and ignore that scaffolding in version control. If none of the auxiliary filesystem steps are allowed, accept that Pyright will not resolve those imports, because static analysis is intentionally constrained to what the filesystem exposes.

The article is based on a question from StackOverflow by Phrogz and an answer by Mag_Amine.