Skip to content

Fix: Python Packaging Not Working — Build Fails, Package Not Found After Install, or PyPI Upload Errors

FixDevs · (Updated: )

Part of:  Python Errors

Quick Answer

How to fix Python packaging issues — pyproject.toml setup, build backends (setuptools/hatchling/flit), wheel vs sdist, editable installs, package discovery, and twine upload to PyPI.

The Problem

Your package builds but can’t be imported after installing:

pip install dist/mypackage-0.1.0-py3-none-any.whl
python -c "import mypackage"
# ModuleNotFoundError: No module named 'mypackage'

Or pip install -e . fails:

pip install -e .
# ERROR: File "setup.py" or "setup.cfg" not found
# OR:
# ERROR: pyproject.toml does not contain a [build-system] table

Or the build fails with a backend error:

python -m build
# * Creating venv isolated environment...
# * Installing packages in isolated environment... (setuptools>=61)
# ERROR: Could not build wheels for mypackage, which is required to install...

Or twine upload fails with authentication or metadata errors:

twine upload dist/*
# HTTPError: 400 Bad Request — File already exists.
# OR:
# InvalidDistribution: Invalid distribution metadata

Why This Happens

Python’s packaging story has shifted three times in five years, and most tutorials still describe a world that no longer exists. The modern reality is that setup.py is no longer the source of truth — pyproject.toml is, per PEP 517 and PEP 518. The build is delegated to a “build backend” (setuptools, hatchling, flit, poetry-core, or pdm-backend), and pip runs that backend inside an isolated environment with only the packages listed in [build-system].requires. Most “build fails” messages are really “the isolated environment was missing something” or “the backend you declared does not match the backend the rest of the file expects.”

The second source of confusion is package discovery. setuptools changed its automatic discovery rules in version 61 — it now refuses to guess what a “flat” project means and forces you to declare [tool.setuptools.packages.find] or use the src layout. Older guides that say find_packages() “just works” are obsolete. Hatchling, by contrast, defaults to scanning src/<name> and silently produces an empty wheel if your package sits elsewhere. Inspecting the actual wheel with python -m zipfile -l is the only way to be sure your code shipped.

The third recurring failure is the editable install. PEP 660 redefined what pip install -e . means: instead of dropping a .pth file, the backend now produces a “PEP 660 wheel” that points back at the source tree. Backends that have not implemented PEP 660 (or older setuptools/hatchling versions) fall back to a “legacy editable” path that may or may not work depending on your project layout and pip version. Most “ModuleNotFoundError after pip install -e .” reports trace back here.

  • Missing or incorrect pyproject.toml — PEP 517/518 made pyproject.toml the standard. A project without a [build-system] table falls back to legacy setup.py behavior, which pip may refuse to use.
  • Package not found after install — the [tool.setuptools.packages.find] or equivalent discovery config is wrong, so the package directory isn’t included in the distribution.
  • Wrong package structure — flat layout (src/mypackage/) vs src layout (src/mypackage/) require different discovery settings. Using the wrong setting includes the wrong files.
  • Editable install requires PEP 660pip install -e . with a pyproject.toml backend requires the backend to support PEP 660. Older versions of setuptools don’t. Update setuptools.
  • Version conflicts in the wheel filename — the version in pyproject.toml must match what PyPI expects. Re-uploading the same version fails; you must bump the version.
  • Entry points silently dropped[project.scripts] only registers commands if the package itself was discovered. An empty wheel produces zero entry points, and the command is “not found” after install.

Diagnostic Timeline

The reflex is to open setup.py and start patching. Stop — in 2026 the bug is almost never in setup.py because you should not have one. Walk the layers.

Minute 0 — inspect the wheel. Before debugging anything else, build the wheel and list its contents: python -m build --wheel && python -m zipfile -l dist/*.whl. If your package directory is not in the listing, the install will never work no matter what you change about the environment. This single command resolves about 60% of “ModuleNotFoundError after install” reports.

Minute 3 — identify the build backend. Open pyproject.toml and read [build-system].build-backend. If it says setuptools.build_meta, discovery follows [tool.setuptools.packages.find]. If it says hatchling.build, discovery follows [tool.hatch.build.targets.wheel].packages. If it says poetry.core.masonry.api, you have a Poetry project and pip cannot edit it through pyproject.toml alone — you need poetry install. Mismatched backend and discovery config is the second-most-common bug.

Minute 6 — check layout vs config. Run ls src/ 2>/dev/null and compare with where = ["src"] (or its absence). If src/<name>/__init__.py exists but your config says where = ["."], setuptools will not look there. If src/ does not exist and config says where = ["src"], the wheel ships nothing.

Minute 10 — verify setuptools version for editable installs. pip install -e . failing with “no module named X” after a successful install almost always means setuptools is below 64. Run pip show setuptools and check the version. Upgrade with pip install --upgrade 'setuptools>=64' pip. Hatchling has supported PEP 660 since 1.0; if you switched backends mid-project, stale *.egg-info directories from an older setuptools install can still shadow the new editable install. Delete them.

Minute 14 — check the right interpreter. If everything looks correct but import mypackage still fails, run python -c "import sys; print(sys.executable)" and pip show mypackage. If the location in pip show does not start with the same prefix as sys.executable, you installed into one environment and are importing from another. This is the single most embarrassing root cause and accounts for roughly a quarter of all “I followed every step” failures.

Fix 1: Set Up pyproject.toml Correctly

Modern Python projects use pyproject.toml exclusively — no setup.py or setup.cfg needed:

# pyproject.toml — minimal working example (setuptools backend)
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.backends.legacy:build"

[project]
name = "mypackage"
version = "0.1.0"
description = "A short description of mypackage"
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.9"
authors = [
    { name = "Your Name", email = "[email protected]" }
]
keywords = ["keyword1", "keyword2"]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.9",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
]

# Runtime dependencies
dependencies = [
    "requests>=2.28.0",
    "pydantic>=2.0.0",
]

# Optional dependencies (extras)
[project.optional-dependencies]
dev = [
    "pytest>=7.0",
    "pytest-cov",
    "black",
    "mypy",
]
docs = [
    "sphinx",
    "sphinx-rtd-theme",
]

# URLs shown on PyPI
[project.urls]
Homepage = "https://github.com/yourname/mypackage"
Repository = "https://github.com/yourname/mypackage"
Documentation = "https://mypackage.readthedocs.io"
"Bug Tracker" = "https://github.com/yourname/mypackage/issues"

# Console scripts (CLI entrypoints)
[project.scripts]
mypackage-cli = "mypackage.cli:main"

Choose the right build backend:

# Option 1: setuptools (most flexible, most packages use this)
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.backends.legacy:build"

# Option 2: hatchling (modern, zero-config for simple packages)
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

# Option 3: flit (for pure-Python packages with minimal config)
[build-system]
requires = ["flit_core>=3.2"]
build-backend = "flit_core.buildapi"

# Option 4: poetry-core (if you use Poetry)
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

Fix 2: Fix Package Discovery

The most common reason a package installs but can’t be imported is incorrect discovery config.

Flat layout — your package is directly in the project root:

myproject/
├── mypackage/
│   ├── __init__.py
│   └── module.py
├── tests/
├── pyproject.toml
└── README.md
# pyproject.toml — flat layout (default for setuptools)
[tool.setuptools.packages.find]
where = ["."]          # Look in the root directory
exclude = ["tests*"]   # Exclude test directories

Src layout — recommended for larger projects:

myproject/
├── src/
│   └── mypackage/
│       ├── __init__.py
│       └── module.py
├── tests/
├── pyproject.toml
└── README.md
# pyproject.toml — src layout
[tool.setuptools.packages.find]
where = ["src"]  # Look inside src/

Verify what’s included in your package:

# Build and inspect the wheel contents
python -m build --wheel
python -m zipfile -l dist/mypackage-0.1.0-py3-none-any.whl

# Should show:
# mypackage/__init__.py
# mypackage/module.py
# mypackage-0.1.0.dist-info/METADATA
# etc.

Include non-Python files:

# pyproject.toml — include data files
[tool.setuptools.package-data]
mypackage = ["*.json", "data/*.csv", "templates/*.html"]

# Or use MANIFEST.in for sdist
# MANIFEST.in
# include mypackage/data/*.csv
# include mypackage/templates/*.html

Hatchling package discovery:

# pyproject.toml with hatchling
[tool.hatch.build.targets.wheel]
packages = ["src/mypackage"]

# Or for multiple packages
[tool.hatch.build.targets.wheel]
packages = ["src/mypackage", "src/mypackage_utils"]

Fix 3: Fix Editable Installs

pip install -e . (editable/development install) lets you modify source code without reinstalling:

# Ensure you have a recent pip and setuptools
pip install --upgrade pip setuptools

# Install in editable mode
pip install -e .

# Install with optional extras
pip install -e ".[dev,docs]"

# If still failing, try with build isolation disabled
pip install -e . --no-build-isolation

pyproject.toml must declare editable support:

# setuptools — editable installs work by default with setuptools>=64
[build-system]
requires = ["setuptools>=64"]  # Not 61 — 64+ for reliable editable installs
build-backend = "setuptools.backends.legacy:build"

# For setuptools editable mode with src layout
[tool.setuptools]
package-dir = {"" = "src"}  # Needed for src layout editable installs

Verify the editable install worked:

import mypackage
print(mypackage.__file__)
# Should point to your source directory, not site-packages:
# /path/to/myproject/src/mypackage/__init__.py

Legacy fallback if nothing else works:

# setup.py — minimal fallback for legacy editable installs
from setuptools import setup
setup()
# pyproject.toml still holds all metadata
# This file just exists as a hook for legacy tooling

Fix 4: Build and Inspect the Distribution

Use the build package to create distributions:

# Install build
pip install build

# Build both wheel and sdist
python -m build

# Build only wheel (faster)
python -m build --wheel

# Build only sdist
python -m build --sdist

# Output in dist/
ls dist/
# mypackage-0.1.0-py3-none-any.whl
# mypackage-0.1.0.tar.gz

Wheel naming convention:

{distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl

mypackage-0.1.0-py3-none-any.whl
# py3      = Python 3 (pure Python)
# none     = no ABI requirements
# any      = platform independent

mypackage-0.1.0-cp312-cp312-linux_x86_64.whl
# cp312    = CPython 3.12
# cp312    = CPython 3.12 ABI
# linux_x86_64 = Linux, 64-bit

Validate the distribution before uploading:

# Install twine for validation
pip install twine

# Check metadata validity
twine check dist/*

# Common errors from twine check:
# - Missing long description content type (add readme to pyproject.toml)
# - Invalid classifier strings (check https://pypi.org/classifiers/)
# - Invalid project URL format

Fix 5: Upload to PyPI with Twine

# Install twine
pip install twine

# Upload to TestPyPI first (always test here first)
twine upload --repository testpypi dist/*
# Then verify installation works:
pip install --index-url https://test.pypi.org/simple/ mypackage

# Upload to real PyPI
twine upload dist/*

Authentication setup:

# Option 1: API token (recommended — never use your password)
# Create a token at https://pypi.org/manage/account/token/
twine upload dist/* -u __token__ -p pypi-your-token-here

# Option 2: Store in .pypirc
# ~/.pypirc
[distutils]
index-servers =
    pypi
    testpypi

[pypi]
username = __token__
password = pypi-your-token-here

[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-your-test-token-here

Common upload errors:

# "File already exists" — you can't overwrite a release on PyPI
# Bump the version in pyproject.toml, rebuild, then upload
# version = "0.1.1"  # Not 0.1.0

# "Invalid distribution metadata" — run twine check first
twine check dist/*

# "403 Forbidden" — wrong token or no permission to upload
# Check that the token scope includes the specific project

# "400 Bad Request: The name X is too similar to an existing project"
# Choose a more unique name

Automate with GitHub Actions:

# .github/workflows/publish.yml
name: Publish to PyPI

on:
  release:
    types: [published]

jobs:
  build-and-publish:
    runs-on: ubuntu-latest
    environment:
      name: pypi
      url: https://pypi.org/p/mypackage
    permissions:
      id-token: write  # For trusted publishing (no token needed!)

    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"

      - name: Build distribution
        run: |
          pip install build
          python -m build

      - name: Publish to PyPI
        uses: pypa/gh-action-pypi-publish@release/v1
        # Uses OIDC trusted publishing — no API token required
        # Configure at: https://pypi.org/manage/project/mypackage/settings/publishing/

Fix 6: Version Management

Managing versions across files leads to drift. Use a single source of truth:

# Option 1: Static version in pyproject.toml (simplest)
[project]
version = "0.1.0"

# Option 2: Dynamic version from __version__ in your package
[project]
dynamic = ["version"]

[tool.setuptools.dynamic]
version = {attr = "mypackage.__version__"}
# mypackage/__init__.py
__version__ = "0.1.0"

Use hatch-vcs or setuptools-scm for Git tag-based versioning:

# pyproject.toml — hatch-vcs (version from Git tags)
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"

[project]
dynamic = ["version"]

[tool.hatch.version]
source = "vcs"  # Uses git tags like v0.1.0

# Tag your release, then build
# git tag v0.1.0
# git push origin v0.1.0
# python -m build  -> version is 0.1.0
# pyproject.toml — setuptools-scm
[build-system]
requires = ["setuptools>=61", "setuptools-scm"]
build-backend = "setuptools.backends.legacy:build"

[project]
dynamic = ["version"]

[tool.setuptools_scm]
# Generates version from git tags
# v1.0.0 -> 1.0.0
# v1.0.0-3-gdeadbeef -> 1.0.1.dev3+gdeadbeef (between tags)

Bump version consistently with bump-my-version:

pip install bump-my-version

# Configure in pyproject.toml
# [tool.bumpversion]
# current_version = "0.1.0"
# [[tool.bumpversion.files]]
# filename = "pyproject.toml"
# search = 'version = "{current_version}"'
# replace = 'version = "{new_version}"'

# Bump patch/minor/major
bump-my-version bump patch   # 0.1.0 -> 0.1.1
bump-my-version bump minor   # 0.1.0 -> 0.2.0
bump-my-version bump major   # 0.1.0 -> 1.0.0

Still Not Working?

ModuleNotFoundError after pip install -e . — the editable install succeeded but the wrong Python or pip is in use. Check which python and which pip — they should point to the same environment. If you’re using virtual environments (you should be), activate it first: source .venv/bin/activate (or .venv\Scripts\activate on Windows), then install.

Namespace packages not found — if your package uses implicit namespace packages (no __init__.py at the top level), setuptools may not find them automatically. Either add __init__.py or configure discovery explicitly:

[tool.setuptools.packages.find]
namespaces = true  # Enable namespace package discovery

C extension compilation fails during build — if your package includes C extensions (.pyd, .so), the build requires a C compiler. On Linux: sudo apt install python3-dev build-essential. On macOS: xcode-select --install. On Windows: install Visual Studio Build Tools. For distributing compiled packages, build platform-specific wheels on each target platform using cibuildwheel:

# .github/workflows/build-wheels.yml
- name: Build wheels
  uses: pypa/[email protected]
  env:
    CIBW_SKIP: "pp* *-musllinux*"  # Skip PyPy and musl Linux

Old dist/ artifacts causing confusion — always clean dist/ before building to avoid uploading stale files:

rm -rf dist/ build/ *.egg-info
python -m build
twine upload dist/*

pip install -e . succeeds but command-line entry point is missing — the [project.scripts] section maps a command name to package.module:function. If the package was not discovered (empty wheel), the script is never registered. After fixing discovery and rebuilding, run pip install -e . --force-reinstall to refresh entry points. On Windows, also confirm Scripts/ is in PATH — many users see “command not found” when the install actually succeeded.

pip warns about externally-managed-environment on modern Linux — PEP 668 marks the system Python as managed by the OS package manager. Direct pip install is blocked. Always work inside a virtualenv (python -m venv .venv && source .venv/bin/activate) or use pipx for tools. Passing --break-system-packages works but leaves your distro Python broken on the next apt upgrade.

Wheel builds on dev machine, fails on PyPI for users — you are shipping a “pure Python” wheel name (py3-none-any) but the package contains C extensions, so users on a different platform get import errors. Either ship platform-tagged wheels built with cibuildwheel on each OS, or ship the sdist only and let users compile on install (pip will warn them when no compiler is available).

For related Python issues, see Fix: ModuleNotFoundError No module named, Fix: pip could not build wheels, Fix: ModuleNotFoundError venv, and Fix: Poetry Dependency Conflict.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles