Packaging, project layout and pyproject.toml
The src layout, pyproject.toml and build backends, editable installs, publishing to PyPI, version pinning and lockfiles.
Project layout
Put importable code under src/. That one directory of indirection prevents Python from importing your working directory by accident, so tests exercise the installed package rather than a stray local file.
repo/
pyproject.toml
README.md
LICENSE
src/
shop_tools/
__init__.py
cart.py
cli.py
tests/
test_cart.py
test_cli.py
.gitignore
# install the project itself, in editable mode, into the active environment
pip install -e ".[dev]"pip install -e .links the source tree so edits take effect without reinstalling.- Keep tests outside
src/— they are not part of the distributed package. - Add
.venv/,__pycache__/,*.egg-info/anddist/to.gitignore. - One distribution package can expose several import packages, but a flat single-purpose layout stays easiest to navigate.
pyproject.toml
pyproject.toml is the single file that carries metadata, build configuration and tool settings. Older setup.py and setup.cfg still work but are no longer needed.
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "shop-tools"
version = "0.3.1"
description = "Helpers for the shop service"
readme = "README.md"
requires-python = ">=3.10"
license = { text = "MIT" }
dependencies = [
"requests>=2.31,<3",
"click>=8.1",
]
[project.optional-dependencies]
dev = ["pytest>=8", "pytest-cov", "mypy>=1.10", "ruff"]
[project.scripts]
shop-tools = "shop_tools.cli:main"
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.ruff]
line-length = 100
target-version = "py311"| Build backend | Config style | Good for |
|---|---|---|
| hatchling | Declarative, few knobs | Pure-Python packages, the common default |
| setuptools | Mature, very flexible | Existing projects, C extensions |
| flit-core | Minimal, no version duplication | Small single-module libraries |
| poetry-core | Lockfile-oriented | Teams already using Poetry |
dependencies, and keep exact pins for applications in a lockfile.# src/shop_tools/__init__.py
__version__ = "0.3.1"
# read the version from one place instead of two
# [project] dynamic = ["version"]
# [tool.hatch.version] path = "src/shop_tools/__init__.py"Building, publishing and pinning
pip install build twine
python -m build # writes dist/*.tar.gz and dist/*.whl
twine check dist/*
twine upload --repository testpypi dist/* # rehearse first
twine upload dist/* # then the real index
pip install dist/shop_tools-0.3.1-py3-none-any.whl # verify locally# applications: pin exactly, reproducibly
pip freeze > requirements.txt
pip install -r requirements.txt
# modern alternatives
pip install -r requirements.txt --require-hashes
uv lock && uv sync
pip-compile requirements.in -o requirements.txtVersion numbers carry meaning: patch for fixes, minor for additive changes, major for anything a consumer must react to. Tag the release in git and keep a short changelog, because the package index keeps every version forever and cannot be edited or deleted.
- Test the built wheel in a clean environment before uploading; a missing file in the sdist is found only after publish.
- Requirements files describe an environment;
dependenciesinpyproject.tomldescribe compatibility. Keep them separate. - Commit a lockfile for applications and never for libraries.
- Set up CI to build and check the package on every pull request so a broken release is caught before tagging.
FAQ
Do I need a src layout?
requirements.txt or pyproject.toml?
pyproject.toml declares what your code needs and what versions it tolerates; a lockfile records the exact versions that were tested so a deployment is reproducible.Related
Testing with pytest Command-line tools: argparse and logging
Last refreshed 2026-09-18.