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/ and dist/ 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 backendConfig styleGood for
hatchlingDeclarative, few knobsPure-Python packages, the common default
setuptoolsMature, very flexibleExisting projects, C extensions
flit-coreMinimal, no version duplicationSmall single-module libraries
poetry-coreLockfile-orientedTeams already using Poetry
💡
Avoid hard-pinning dependencies inside a library: exact pins in a package that others install cause resolution conflicts. Constrain with ranges in 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.txt

Version 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; dependencies in pyproject.toml describe 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?
Not strictly, but it removes a whole class of import bugs where tests pass locally by importing a stray directory and fail after installation. For anything beyond a single script, the extra level is worth it.
requirements.txt or pyproject.toml?
Both, for different jobs. 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.

Testing with pytest Command-line tools: argparse and logging

Last refreshed 2026-09-18.