2026, Jan 11 13:00

Python src layout imports: why referencing 'src' causes ModuleNotFoundError and how to fix it

Learn why imports like 'from src.pycard' fail after install in a Python src layout and how to fix them with proper package_dir and editable install (setup.py).

Packaging a Python project often feels straightforward until the first real install reveals an import error. A common trap appears when a project uses a src layout but keeps referencing src in the package’s imports. Locally everything looks fine, tests pass, IDEs resolve symbols, and the wheel builds. Then the installed package raises ModuleNotFoundError. Let’s break down why that happens and how to fix it cleanly.

Minimal setup that reproduces the issue

Consider a project using a src layout and setuptools. The build configuration maps your code under src, which is correct. The issue arises in the package’s own imports.

Build configuration:

import time as tm
from setuptools import setup as pkg_setup, find_packages as find_pkgs

pkg_setup(
    name="pycard",
    version=f"0.1.{int(tm.time())}",
    package_dir={"": "src"},
    packages=find_pkgs(where="src"),
    author="*****",
    python_requires=">=3.12",
)

Problematic package initializer under src/pycard/__init__.py:

from src.pycard.Cards import Card, Rank, Suit, RankSuitContext, AcesHighContext, RankSuitCard, CardContext
from src.pycard.Decks import Stack, StackContext, FiftyTwoCardDeck

Consumer code in a separate project tries to import types and use them:

from pycard.Cards import RankSuitCard as RSC, Rank as Rnk, Suit as St

if __name__ == "__main__":
    print(RSC(Rnk.ACE, St.SPADES))

After installing the wheel, the import blows up with ModuleNotFoundError: No module named 'src.pycard'.

What actually goes wrong

The crux is the package_dir mapping: package_dir={"": "src"}. This tells setuptools that importable packages live under src during build and install. It does not make src itself an importable package at runtime. Once the wheel is installed, Python sees the package pycard directly inside site-packages. There is no top-level src to import from. So any import that mentions src.pycard.* fails as soon as you step outside your local project environment.

Why did tests and the IDE seem fine? Your tooling likely adjusted sys.path in a way that made src discoverable in the local project, masking the mistake. That convenience evaporates in a clean environment where only the built package is installed.

The fix

Stop referencing src in your package’s own imports. Import from the installed package namespace. With the current layout, that namespace is pycard. Update the initializer accordingly.

Corrected src/pycard/__init__.py:

from pycard.Cards import Card, Rank, Suit, RankSuitContext, AcesHighContext, RankSuitCard, CardContext
from pycard.Decks import Stack, StackContext, FiftyTwoCardDeck

With that change, the installed environment can resolve pycard.* properly, and the consumer code runs as expected.

Working locally without leaking src into imports

If your test runs only passed when you wrote imports that included src, switch to an editable install during development. That keeps the import paths correct and reflects code changes without rebuilding.

pip install --editable path/to/cards

If you prefer not to install at all during local runs, point Python at your source directory explicitly, then run your tests:

cd path/to/cards
export PYTHONPATH=./src
pytest tests

Why this matters

The top-level package provides a stable namespace. In this layout, pycard is the root that owns all submodules. Keeping imports under that namespace avoids conflicts with other installed packages and ensures the wheel behaves the same way everywhere. Using src as an import path bypasses the namespace and only “works by accident” in environments where the test runner or IDE adds src to sys.path. In a clean install, that path is gone, and imports fail.

It’s technically possible to make src/pycard the package root from which you import directly, but you shouldn’t. A dedicated root package, pycard in this case, is the conventional and safe boundary that prevents collisions with similarly named modules in other distributions. Two separate packages can both have a module named Decks without stepping on each other as long as they live under different root namespaces.

Practical takeaway

Keep package_dir mapping and import paths aligned. If package_dir={"": "src"} and your package is named pycard, always import via pycard.* inside your package and in downstream code. For local development, prefer an editable install or set PYTHONPATH to src so you don’t smuggle src into import statements. This small discipline avoids runtime surprises and keeps your distribution predictable across environments.