A comprehensive reference for packaging, versioning, and publishing Python libraries.
pyproject.toml is the single source of truth for modern Python projects (PEP 621).
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"The [build-system] table tells pip and build which backend to use. Alternatives include flit-core, hatchling, and pdm-backend.
[project]
name = "my-package" # PyPI package name (use hyphens)
version = "1.0.0" # Follow semver
requires-python = ">=3.9" # Minimum Python version
dependencies = ["httpx"] # Runtime dependenciesKey fields:
name: Must be unique on PyPI. Use lowercase with hyphens.version: Semantic versioning (MAJOR.MINOR.PATCH).classifiers: Trove classifiers for PyPI search/filtering.optional-dependencies: Groups like [dev], [docs].Tools like ruff, mypy, and pytest read config from [tool.*] tables, keeping everything in one file.
Keep the version in one place. Options:
1. __init__.py: __version__ = "1.0.0" + version = attr: package.__version__
2. pyproject.toml: Canonical, read at build time
3. setuptools-scm: Derive version from git tags automatically
Some projects use YYYY.MM.DD format (e.g., pip, Ubuntu). Useful for projects without a stable API contract.
.tar.gz)MANIFEST.in.whl file (a zip with metadata)Rule of thumb: Always publish both sdist and wheel. Use python -m build which creates both by default.
src/ Layoutmy-project/
├── src/
│ └── my_package/
│ ├── __init__.py
│ └── core.py
├── tests/
│ └── test_core.py
└── pyproject.toml
Why src/ layout?
Add an empty py.typed marker file to your package root:
src/my_package/py.typed
Then include it in your package data:
[tool.setuptools.package-data]
my_package = ["py.typed"]This tells type checkers (mypy, pyright) that your package ships inline type annotations.
For large organizations splitting a namespace across multiple packages:
# Package A installs: myorg/auth/...
# Package B installs: myorg/billing/...
Use implicit namespace packages (PEP 420) — omit __init__.py in the shared namespace directory. Each sub-package is an independent distribution.
python -m build
python -m twine check dist/*
python -m twine upload dist/*Always test on TestPyPI before publishing to production:
python -m twine upload --repository testpypi dist/*
pip install --index-url https://test.pypi.org/simple/ my-package- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}Use Trusted Publishers (OIDC) for keyless authentication — no API tokens needed.
include LICENSE README.md
recursive-include src *.py py.typed
recursive-include tests *.py
global-exclude *.pyc __pycache__
Common mistakes:
py.typed in the sdist.git/, node_modules/, or CI configspip install dist/*.tar.gzrepos:
- repo: https://github.com/astral-sh/ruff-pre-commit
hooks:
- id: ruff
- id: ruff-formatstrategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"]
os: [ubuntu-latest, macos-latest, windows-latest]pyproject.toml (or __init__.py)make check)py.typed marker includedtwine check)pip install dist/*.tar.gzpip install dist/*.whl