Packaging and Distribution
pyproject.toml
Section titled “pyproject.toml”pyproject.toml is the modern standard for Python project configuration, defined by PEP 518 (build System) and PEP 621 (project metadata).
Minimal pyproject.toml
Section titled “Minimal pyproject.toml”[build-system]requires = ["setuptools>=68.0", "wheel"]build-backend = "setuptools.build_meta"
[project]name = "myapp"version = "1.0.0"description = "A sample application"readme = "README.md"requires-python = ">=3.10"license = {text = "MIT"}
authors = [ {name = "Jane Doe", email = "jane@example.com"},]
dependencies = [ "requests>=2.28.0", "pydantic>=2.0",]
[project.optional-dependencies]dev = [ "pytest>=7.0", "ruff>=0.1.0", "mypy>=1.0",]server = [ "fastapi>=0.100", "uvicorn>=0.23",]
[project.scripts]myapp = "myapp.cli:main"PEP 621 Metadata Fields
Section titled “PEP 621 Metadata Fields”[project]name = "mylib"version = "2.1.0"description = "Short description"readme = "README.md"requires-python = ">=3.10"license = {text = "MIT"}license-files = ["LICENSE"]keywords = ["http", "api", "client"]classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12",]
authors = [ {name = "Jane Doe", email = "jane@example.com"},]maintainers = [ {name = "John Smith", email = "john@example.com"},]
dynamic = ["version"] # Version computed dynamically
[project.urls]Homepage = "https://github.com/org/mylib"Documentation = "https://mylib.readthedocs.io"Repository = "https://github.com/org/mylib"Changelog = "https://github.com/org/mylib/blob/main/CHANGELOG.md"Tool Sections
Section titled “Tool Sections”[tool.setuptools]package-dir = {"" = "src"}packages = ["mylib", "mylib.submodule"]
[tool.setuptools.package-data]mylib = ["py.typed", "data/*.json"]
[tool.pytest.ini_options]testpaths = ["tests"]addopts = "-v --tb=short"
[tool.ruff]line-length = 100target-version = "py310"
[tool.mypy]python_version = "3.10"strict = true
[tool.coverage.run]source = ["src/mylib"]branch = trueLegacy setup.py
Section titled “Legacy setup.py”## setup.py — legacy approach, still supported but discouragedfrom setuptools import setup, find_packages
setup( name="mylib", version="1.0.0", packages=find_packages(), install_requires=[ "requests>=2.28.0", ], entry_points={ "console_scripts": [ "myapp=myapp.cli:main", ], },)Migration Path
Section titled “Migration Path”## pyproject.toml — modern replacement[build-system]requires = ["setuptools>=68.0", "wheel"]build-backend = "setuptools.build_meta"
[project]name = "mylib"version = "1.0.0"dependencies = [ "requests>=2.28.0",]
[project.scripts]myapp = "myapp.cli:main"
[tool.setuptools.packages.find]where = ["src"]setup.cfg (Complementary)
Section titled “setup.cfg (Complementary)”# setup.cfg — used alongside pyproject.toml for setuptools-specific config[metadata]name = mylibversion = attr: mylib.__version__
[options]packages = find:python_requires = >=3.10install_requires = requests>=2.28.0
[options.packages.find]where = src
[options.entry_points]console_scripts = myapp = mylib.cli:main
[options.extras_require]dev = pytest>=7.0 ruff>=0.1.0find_packages
Section titled “find_packages”from setuptools import find_packages
# Find all packages in src/find_packages(where="src")
# Exclude test packagesfind_packages(where="src", exclude=["tests*", "tests.*", "docs*"])
# Include specific packagesfind_packages(where="src", include=["mylib*"])Entry Points
Section titled “Entry Points”[project.entry-points."console_scripts"]myapp = "myapp.cli:main"myapp-admin = "myapp.admin:main"
[project.entry-points."gui_scripts"]myapp-gui = "myapp.gui:main"
[project.entry-points."mylib.plugins"]json_serializer = "mylib.serializers.json:Serializer"yaml_serializer = "mylib.serializers.yaml:Serializer"Entry points are discoverable at runtime via importlib.metadata:
from importlib.metadata import entry_points
eps = entry_points(group="mylib.plugins")for ep in eps: serializer_class = ep.load() print(f"Found plugin: {ep.name} -> {serializer_class}")requirements.txt
Section titled “requirements.txt”requests>=2.28.0,<3.0.0pydantic>=2.0click>=8.0pip-tools
Section titled “pip-tools”pip-tools provides deterministic dependency management by separating requirements into source (requirements.in) and locked (requirements.txt) files:
# Installpip install pip-tools
# Create requirements.in (human-written)# requirements.inrequests>=2.28.0pydantic>=2.0
# Compile to requirements.txt (locked, with hashes)pip-compile --generate-hashes requirements.in
# Upgrade specific packagespip-compile --generate-hashes --upgrade-package pydantic requirements.inDependency Specification
Section titled “Dependency Specification”# Exact versionpackage==1.2.3
# Compatible release (>=1.2.3, <2.0.0)package>=1.2.3,<2.0.0
# Minimum versionpackage>=1.2.3
# Any versionpackage
# Git dependencypackage @ git+https://github.com/org/package.git@v1.2.3
# Local pathpackage @ file:///path/to/package
# Extraspackage[extra1,extra2]>=1.0Hashes for Security
Section titled “Hashes for Security”# requirements.txt with hashes (generated by pip-compile --generate-hashes)requests==2.31.0 \ --hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \ --hash=sha256:942c5a758f98d790eaed1a29cb6eefc7cb0f27a2e5e71b6ed7d0e640c265d3a5# Createpython -m venv .venv
# Activate (bash/zsh)source .venv/bin/activate
# Activate (fish)source .venv/bin/activate.fish
# Activate (PowerShell).venv\Scripts\Activate.ps1
# Deactivatedeactivatevirtualenv
Section titled “virtualenv”pip install virtualenv
# Faster than venv, supports more optionsvirtualenv .venv --python=3.12 --system-site-packages --clear# Create with specific Python versionconda create -n myenv python=3.12
# Activateconda activate myenv
# Export environmentconda env export > environment.yml
# Create from fileconda env create -f environment.yml# Installpip install package
# Install from requirements.txtpip install -r requirements.txt
# Install in editable mode (development)pip install -e .
# Install with extraspip install ".[dev,server]"
# Freeze current environmentpip freeze > requirements.txtpoetry
Section titled “poetry”# pyproject.toml (poetry)[tool.poetry]name = "mylib"version = "1.0.0"description = "A sample library"
[tool.poetry.dependencies]python = "^3.10"requests = "^2.28.0"pydantic = "^2.0"
[tool.poetry.group.dev.dependencies]pytest = "^7.0"ruff = "^0.1.0"
[tool.poetry.scripts]myapp = "mylib.cli:main"# Installpoetry install
# Install with dev dependenciespoetry install --with dev
# Add dependencypoetry add requestspoetry add pytest --group dev
# Lockpoetry lock
# Buildpoetry buildComparison
Section titled “Comparison”| Feature | pip | poetry | pipenv | conda |
|---|---|---|---|---|
| Lock file | Manual (pip-tools) | poetry.lock | Pipfile.lock | environment.yml |
| Resolution | Basic | Full (resolvelib) | Full (pip) | Full |
| Non-Python deps | No | No | No | Yes |
| Build system | setuptools/hatch/flit | Built-in | pip | Built-in |
| Speed | Fast | Medium | Slow | Slow |
| Ecosystem | Largest | Growing | Declining | Scientific |
Package Structure
Section titled “Package Structure”src Layout (Recommended)
Section titled “src Layout (Recommended)”mylib/├── pyproject.toml├── README.md├── LICENSE├── src/│ └── mylib/│ ├── __init__.py│ ├── core.py│ ├── cli.py│ └── submodule/│ ├── __init__.py│ └── utils.py└── tests/ ├── __init__.py ├── test_core.py └── conftest.py[tool.setuptools.packages.find]where = ["src"]Flat Layout
Section titled “Flat Layout”mylib/├── pyproject.toml├── README.md├── LICENSE├── mylib/│ ├── __init__.py│ ├── core.py│ └── cli.py└── tests/ ├── __init__.py └── test_core.pyconsole_scripts
Section titled “console_scripts”[project.scripts]myapp = "mylib.cli:main"myapp-admin = "mylib.admin:main"This generates a wrapper script that calls the specified function:
import click
@click.command()@click.option("--config", default="config.yaml", help="Config file path")def main(config): """My application CLI.""" from mylib.core import load_config, run cfg = load_config(config) run(cfg)
if __name__ == "__main__": main()Plugin Entry Points
Section titled “Plugin Entry Points”[project.entry-points."mylib.plugins"]json = "mylib.plugins.json:register"yaml = "mylib.plugins.yaml:register"# Discovering plugins at runtimefrom importlib.metadata import entry_points
def load_plugins(): plugins = {} for ep in entry_points(group="mylib.plugins"): plugin_func = ep.load() plugins[ep.name] = plugin_func() return pluginsPublishing to PyPI
Section titled “Publishing to PyPI”pip install twine buildpython -m build# Creates dist/mylib-1.0.0.tar.gz and dist/mylib-1.0.0-py3-none-any.whlTest with TestPyPI
Section titled “Test with TestPyPI”twine upload --repository testpypi dist/*Publish to PyPI
Section titled “Publish to PyPI”twine upload dist/*MANIFEST.in
Section titled “MANIFEST.in”When using setuptools, files not tracked by VCS or included in package_data need MANIFEST.in:
include README.mdinclude LICENSEinclude pyproject.tomlrecursive-include src/mylib/data *.jsonglobal-exclude *.pycglobal-exclude *.pyoprune testsSemantic Versioning
Section titled “Semantic Versioning”- MAJOR: Breaking changes
- MINOR: New features, backward compatible
- PATCH: Bug fixes, backward compatible
__version__ Pattern
Section titled “__version__ Pattern”__version__ = "1.2.3"[project]dynamic = ["version"]
[tool.setuptools.dynamic]version = {attr = "mylib.__version__"}importlib.metadata
Section titled “importlib.metadata”from importlib.metadata import version, PackageNotFoundError
try: v = version("mylib") print(f"mylib version: {v}")except PackageNotFoundError: print("mylib is not installed")Automated Versioning with hatch-vcs
Section titled “Automated Versioning with hatch-vcs”[build-system]requires = ["hatchling", "hatch-vcs"]build-backend = "hatchling.build"
[tool.hatch.version]source = "vcs"
[tool.hatch.build.hooks.vcs]version-file = "src/mylib/_version.py"This generates a _version.py from git tags, so you never have to update version numbers manually.
Intuition
Section titled “Intuition”Sharing your code: Packaging is like wrapping a gift — it makes your code easy to install and use by others. Distribution is getting that gift to people who need it.
Why it matters: Proper packaging makes your code reusable and maintainable. It’s the difference between a script and a professional project.
The key insight: Virtual environments isolate dependencies — different projects can use different versions of the same library without conflicts.
Common Pitfalls
Section titled “Common Pitfalls”1. Forgetting __init__.py
Section titled “1. Forgetting __init__.py”# Without __init__.py, the directory is not a package# Python 3.3+ supports implicit namespace packages, but explicit is bettertouch src/mylib/__init__.pytouch src/mylib/submodule/__init__.py2. Editable Install Not Reflecting Changes
Section titled “2. Editable Install Not Reflecting Changes”# If changes aren"t reflected after pip install -e .:pip install -e ".[dev]" --force-reinstall --no-deps3. Circular Dependencies
Section titled “3. Circular Dependencies”from b import func_b
# b.pyfrom a import func_a # ImportError or AttributeError at runtime
# Fix: restructure to remove circular imports# a.pydef func_a(): from b import func_b # Local import return func_b()4. Pinning Every Dependency
Section titled “4. Pinning Every Dependency”# BAD — over-constrainingrequests==2.31.0urllib3==1.26.18certifi==2023.7.22charset-normalizer==3.2.0idna==3.4
# GOOD — pin direct dependencies, let resolver handle transitive onesrequests>=2.28.0# Use pip-compile for lock files with transitive pins5. Not Using a Lock File in Production
Section titled “5. Not Using a Lock File in Production”# BAD — pip install without lock filepip install -r requirements.in
# GOOD — use locked requirementspip install -r requirements.txt # Generated by pip-compile
# Or with poetry:poetry install # Uses poetry.lock6. Publishing with Credentials in pyproject.toml
Section titled “6. Publishing with Credentials in pyproject.toml”# NEVER put credentials in pyproject.toml# It is committed to version control
# Use environment variables or .env files (not committed)# [tool.mytool]# api_key = "sk-..." # NEVER DO THIS7. Missing py.typed Marker for Type Checking
Section titled “7. Missing py.typed Marker for Type Checking”# For libraries that ship type hints, include a py.typed marker:touch src/mylib/py.typed
# In pyproject.toml:[tool.setuptools.package-data]mylib = ["py.typed"]Without py.typedDownstream users cannot get type hints from your library even if you include .pyi stub files or inline annotations.
8. Not Testing the sdist
Section titled “8. Not Testing the sdist”# Always test both wheel and sdistpython -m buildpip install dist/mylib-1.0.0.tar.gzpython -c "import mylib; print(mylib.__version__)"
# The sdist must include all necessary files# Common missing files: package_data, non-Python files, py.typedAlternative Build Backends
Section titled “Alternative Build Backends”[build-system]requires = ["hatchling"]build-backend = "hatchling.build"
[project]name = "mylib"version = "1.0.0"
[tool.hatch.build.targets.wheel]packages = ["src/mylib"]Hatch is a modern build backend and project manager. It supports environment management, versioning, And publishing in a single tool.
[build-system]requires = ["flit_core >=3.2,<4"]build-backend = "flit_core.buildapi"
[project]name = "mylib"version = "1.0.0"dependencies = ["requests>=2.28.0"]
[tool.flit.module]name = "mylib"Flit is minimal — it does not support setup.py or complex build logic. Best for simple pure-Python Packages.
Comparison
Section titled “Comparison”| Feature | setuptools | hatch | flit |
|---|---|---|---|
| Maturity | Most mature | Modern | Minimal |
| Legacy compat | Full | Partial | None |
| Plugins | Many | Growing | None |
| Speed | Medium | Fast | Fast |
| Complexity | High | Medium | Low |
| VCS versioning | Via plugin | Built-in | Via plugin |