Python Packaging Basics: Structuring a Project for pip install
- A pip-installable project needs, at minimum, a
pyproject.tomldeclaring a build backend and package metadata —setup.pyis no longer required for ordinary projects. - The src-layout (source code under
src/yourpackage/rather than directly in the project root) prevents accidentally importing the local uninstalled copy instead of the installed one during testing. pip install -e .installs the project in editable mode: changes to the source take effect immediately without reinstalling.- Building a distributable wheel and publishing it to PyPI are separate, later steps — a project is pip-installable locally long before it needs to be published anywhere.
A minimal installable project layout
A small pip-installable project needs surprisingly little structure:
myproject/
├── pyproject.toml
├── README.md
└── src/
└── mypackage/
├── __init__.py
└── core.py
Putting the actual package under src/ rather than directly in the project root (a "flat" layout) is a deliberate choice called the src-layout. It matters because, with a flat layout, running tests from the project root can accidentally import the local, uninstalled source directory instead of whatever version is actually installed in the environment — masking a packaging bug where the installed distribution doesn't actually contain a file that's present locally. The src-layout forces test runs to import the genuinely installed package, catching that class of bug early.
pyproject.toml: the single config file
Modern Python packaging centres on one file, pyproject.toml, replacing what used to be split across setup.py, setup.cfg, and MANIFEST.in:
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "mypackage"
version = "0.1.0"
description = "A short description of what this does"
requires-python = ">=3.10"
dependencies = [
"requests>=2.28",
]
The [build-system] table tells pip which tool actually builds the package (setuptools here, though hatchling and flit_core are common lighter-weight alternatives) — this is what lets pip install . work without any project-specific build script needing to run first.
The [project] metadata table
The [project] table is where the package's own metadata lives: its name (what pip install would use), version, description, minimum Python version, and dependency list. This table follows a standardised format (PEP 621) that any compliant build backend understands, which is precisely why it replaced tool-specific configuration scattered across multiple files — a dependency listed here is understood identically whether the build backend is setuptools, hatchling, or something else. requires-python is worth setting deliberately rather than leaving unset: it stops pip from even attempting an install on a Python version the package was never tested against, turning a confusing runtime failure into a clear install-time rejection.
Editable installs: pip install -e .
During development, reinstalling the package after every code change would be tedious. An editable install solves this by linking the installed package back to the source directory instead of copying it:
python -m venv .venv
source .venv/bin/activate
pip install -e .
After this, import mypackage anywhere in that virtual environment resolves directly to the source files under src/mypackage/, so edits take effect immediately without a reinstall step. This is distinct from just adding the src/ directory to sys.path manually, since an editable install still goes through the same dependency resolution and entry-point registration a normal install does — it only changes where the actual code is read from.
Building a wheel and where publishing fits
When the project is ready to be distributed rather than just installed locally, python -m build (from the separate build package) produces a wheel and a source distribution in a dist/ directory:
python -m build
# creates dist/mypackage-0.1.0-py3-none-any.whl
# and dist/mypackage-0.1.0.tar.gz
Publishing that wheel to PyPI (typically with the twine tool) is a separate, later concern from getting the package structured and pip-installable in the first place — plenty of internal or personal projects never get published anywhere and are simply installed from a local path or a private git URL instead. The Python Packaging Authority's tutorial covers the full path through to a public PyPI release once that becomes relevant.
One detail that trips people up on their first release: the package name registered in [project] name has to be unique across all of PyPI, not just unique to your own machine, and it's checked only when you actually try to upload — building the wheel locally succeeds regardless of whether the name is already taken. It's worth searching PyPI for the intended name before writing any code that assumes it, since renaming a package after dependents already exist is far more disruptive than picking a slightly more specific name up front. Non-Python files that need to ship alongside the code — a default config template, a bundled data file — also need to be listed explicitly, either via package-data settings in the build backend's own configuration or an included MANIFEST.in; forgetting this is a common reason an installed package works locally in the src-layout but breaks once actually installed from a built wheel, because a file that was silently readable from the source tree simply isn't present in the built artifact at all.