2025, Oct 25 09:00

How to fix ModuleNotFoundError after a clean wheel install: correct your src layout and setuptools package discovery

Installed from wheel but import fails? Learn how package layout and setuptools package discovery cause ModuleNotFoundError, and fix it with a proper src layout.

When a freshly built Python wheel installs cleanly but importing it ends with ModuleNotFoundError, the problem often isn’t your runtime at all. It’s the package layout and how setuptools discovers what to put into the distribution. Here’s a practical walkthrough of a real case: the package was installed and visible to pip, sys.path looked right, yet Python couldn’t import it.

The symptom: installed, but not importable

The package was built with the standard tooling:

python3 -m build
python3 -m pip install dist/jbpy-0.0.2.9-py3-none-any.whl

pip confirmed the installation, and the files landed under the expected user site-packages:

Location: /home/jbang/.local/lib/python3.12/site-packages

Python paths also looked fine; for example, sys.path included:

/home/jbang/.local/lib/python3.12/site-packages

However, attempting to import the package failed:

ModuleNotFoundError: No module named 'jbpy'

The import test used the same interpreter as the shell’s python3. The issue wasn’t a path or venv mismatch.

A minimal failing example

Here’s a distilled import test that reproduces the error. The behavior is the same; the name binding is just more explicit:

#!/usr/bin/python3
# -*- coding: UTF-8 -*-
import jbpy.logging as log_pkg
if __name__ == "__main__":
    print("Modules imported")

The package was configured via pyproject.toml like this:

[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"
[project]
name = "jbpy"
version = "0.0.2.9"
authors = [{name = "Jens Bang", email = "xxxx@xxxx.xx"}]
description = "A package with utility functions to make my Python life easier."
readme = "README.md"
requires-python = ">=3.6"
license = "BSD-3-Clause"
classifiers = [
    "Programming Language :: Python :: 3",
]
[tool.setuptools.packages.find]
where = ["src"]
include = ["argparse*", "logging*", "json*"]
exclude = ["__*"]
namespaces = false

The source tree placed Python files directly under src, without a jbpy package directory:

src/
  argparse.py
  general.py
  __init__.py
  json.py
  logging.py
  jbpy.egg-info/

What actually went wrong

setuptools wasn’t including any importable code in the distribution. Looking inside the source distribution shows only metadata and project files; the Python modules are missing entirely:

$ tar tf dist/jbpy-0.0.2.9.tar.gz
jbpy-0.0.2.9/
jbpy-0.0.2.9/PKG-INFO
jbpy-0.0.2.9/README.md
jbpy-0.0.2.9/pyproject.toml
jbpy-0.0.2.9/setup.cfg
jbpy-0.0.2.9/src/
jbpy-0.0.2.9/src/jbpy.egg-info/
jbpy-0.0.2.9/src/jbpy.egg-info/PKG-INFO
jbpy-0.0.2.9/src/jbpy.egg-info/SOURCES.txt
jbpy-0.0.2.9/src/jbpy.egg-info/dependency_links.txt
jbpy-0.0.2.9/src/jbpy.egg-info/top_level.txt

The reason is in the package discovery settings. The where = ["src"] directive tells setuptools to look for packages under src. But there is no jbpy package directory in src; there are only module files. The include patterns target names like argparse, logging, json, which would describe modules (and also collide with stdlib names), not a top-level package such as jbpy. As a result, nothing matched as a package to include.

The fix

Create a real package directory under src and let setuptools discover it. Place all related modules under src/jbpy and adjust discovery to include packages broadly. After restructuring, the layout should look like this:

src/
  jbpy/
    __init__.py
    argparse.py
    general.py
    json.py
    logging.py

Then use a package discovery configuration that actually picks up that package:

[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"
[project]
name = "jbpy"
version = "0.0.2.9"
authors = [{name = "Jens Bang", email = "xxxx@xxxx.xx"}]
description = "A package with utility functions to make my Python life easier."
readme = "README.md"
requires-python = ">=3.6"
license = "BSD-3-Clause"
classifiers = [
    "Programming Language :: Python :: 3",
]
[tool.setuptools.packages.find]
where = ["src"]
include = ["*"]
exclude = []
namespaces = false

Rebuild and reinstall:

python3 -m build
python3 -m pip install dist/jbpy-0.0.2.9-py3-none-any.whl

Now the source distribution contains the actual package and modules:

$ tar tf dist/jbpy-0.0.2.9.tar.gz
jbpy-0.0.2.9/
...
jbpy-0.0.2.9/src/jbpy/
jbpy-0.0.2.9/src/jbpy/__init__.py
jbpy-0.0.2.9/src/jbpy/argparse.py
jbpy-0.0.2.9/src/jbpy/general.py
jbpy-0.0.2.9/src/jbpy/json.py
jbpy-0.0.2.9/src/jbpy/logging.py
...

And the import works as expected:

import jbpy.logging as log_pkg

Why this matters

pip can report a package as installed even when the wheel or sdist effectively contains only metadata. That leads to a confusing mismatch: the environment looks correct, sys.path points to the right site-packages, and pip show confirms the install, yet import fails. Verifying the distribution’s contents surfaces the real issue quickly. In this case, package discovery didn’t match any actual packages, because the code lived as modules directly under src and the include/exclude patterns filtered out what was needed.

Wrap-up

Use a proper src layout with a top-level package directory under src, and make sure setuptools’ package discovery points to it. Favor broad include patterns until you have a reason to narrow them. If you hit ModuleNotFoundError after a successful install, inspect the built sdist or wheel to confirm your Python files are there. Once the package directory exists under src and discovery is configured to include it, the wheel will carry your code and imports will line up with what pip reports.

The article is based on a question from StackOverflow by Jens Bang and an answer by J Earls.