Skip to content

Packaging and Distribution

pyproject.toml is the modern standard for Python project configuration, defined by PEP 518 (build System) and PEP 621 (project metadata).

[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"
[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.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 = 100
target-version = "py310"
[tool.mypy]
python_version = "3.10"
strict = true
[tool.coverage.run]
source = ["src/mylib"]
branch = true
## setup.py Legacy vs pyproject.toml
## setup.py — legacy approach, still supported but discouraged
from 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",
],
},
)
## 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"]
## setuptools
# setup.cfg — used alongside pyproject.toml for setuptools-specific config
[metadata]
name = mylib
version = attr: mylib.__version__
[options]
packages = find:
python_requires = >=3.10
install_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.0
from setuptools import find_packages
# Find all packages in src/
find_packages(where="src")
# Exclude test packages
find_packages(where="src", exclude=["tests*", "tests.*", "docs*"])
# Include specific packages
find_packages(where="src", include=["mylib*"])
[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
requests>=2.28.0,<3.0.0
pydantic>=2.0
click>=8.0

pip-tools provides deterministic dependency management by separating requirements into source (requirements.in) and locked (requirements.txt) files:

Terminal window
# Install
pip install pip-tools
# Create requirements.in (human-written)
# requirements.in
requests>=2.28.0
pydantic>=2.0
# Compile to requirements.txt (locked, with hashes)
pip-compile --generate-hashes requirements.in
# Upgrade specific packages
pip-compile --generate-hashes --upgrade-package pydantic requirements.in
# Exact version
package==1.2.3
# Compatible release (>=1.2.3, <2.0.0)
package>=1.2.3,<2.0.0
# Minimum version
package>=1.2.3
# Any version
package
# Git dependency
package @ git+https://github.com/org/package.git@v1.2.3
# Local path
package @ file:///path/to/package
# Extras
package[extra1,extra2]>=1.0
# requirements.txt with hashes (generated by pip-compile --generate-hashes)
requests==2.31.0 \
--hash=sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f \
--hash=sha256:942c5a758f98d790eaed1a29cb6eefc7cb0f27a2e5e71b6ed7d0e640c265d3a5
## Virtual Environments
Terminal window
# Create
python -m venv .venv
# Activate (bash/zsh)
source .venv/bin/activate
# Activate (fish)
source .venv/bin/activate.fish
# Activate (PowerShell)
.venv\Scripts\Activate.ps1
# Deactivate
deactivate
Terminal window
pip install virtualenv
# Faster than venv, supports more options
virtualenv .venv --python=3.12 --system-site-packages --clear
Terminal window
# Create with specific Python version
conda create -n myenv python=3.12
# Activate
conda activate myenv
# Export environment
conda env export > environment.yml
# Create from file
conda env create -f environment.yml
## Dependency Management
Terminal window
# Install
pip install package
# Install from requirements.txt
pip install -r requirements.txt
# Install in editable mode (development)
pip install -e .
# Install with extras
pip install ".[dev,server]"
# Freeze current environment
pip freeze > requirements.txt
# 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"
Terminal window
# Install
poetry install
# Install with dev dependencies
poetry install --with dev
# Add dependency
poetry add requests
poetry add pytest --group dev
# Lock
poetry lock
# Build
poetry build
Featurepippoetrypipenvconda
Lock fileManual (pip-tools)poetry.lockPipfile.lockenvironment.yml
ResolutionBasicFull (resolvelib)Full (pip)Full
Non-Python depsNoNoNoYes
Build systemsetuptools/hatch/flitBuilt-inpipBuilt-in
SpeedFastMediumSlowSlow
EcosystemLargestGrowingDecliningScientific
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
pyproject.toml
[tool.setuptools.packages.find]
where = ["src"]
mylib/
├── pyproject.toml
├── README.md
├── LICENSE
├── mylib/
│ ├── __init__.py
│ ├── core.py
│ └── cli.py
└── tests/
├── __init__.py
└── test_core.py
## Entry Points
[project.scripts]
myapp = "mylib.cli:main"
myapp-admin = "mylib.admin:main"

This generates a wrapper script that calls the specified function:

mylib/cli.py
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()
[project.entry-points."mylib.plugins"]
json = "mylib.plugins.json:register"
yaml = "mylib.plugins.yaml:register"
# Discovering plugins at runtime
from 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 plugins
Terminal window
pip install twine build
Terminal window
python -m build
# Creates dist/mylib-1.0.0.tar.gz and dist/mylib-1.0.0-py3-none-any.whl
Terminal window
twine upload --repository testpypi dist/*
Terminal window
twine upload dist/*

When using setuptools, files not tracked by VCS or included in package_data need MANIFEST.in:

MANIFEST.in
include README.md
include LICENSE
include pyproject.toml
recursive-include src/mylib/data *.json
global-exclude *.pyc
global-exclude *.pyo
prune tests
## Versioning
  • MAJOR: Breaking changes
  • MINOR: New features, backward compatible
  • PATCH: Bug fixes, backward compatible
src/mylib/__init__.py
__version__ = "1.2.3"
[project]
dynamic = ["version"]
[tool.setuptools.dynamic]
version = {attr = "mylib.__version__"}
from importlib.metadata import version, PackageNotFoundError
try:
v = version("mylib")
print(f"mylib version: {v}")
except PackageNotFoundError:
print("mylib is not installed")
[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.

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.

Terminal window
# Without __init__.py, the directory is not a package
# Python 3.3+ supports implicit namespace packages, but explicit is better
touch src/mylib/__init__.py
touch src/mylib/submodule/__init__.py

2. Editable Install Not Reflecting Changes

Section titled “2. Editable Install Not Reflecting Changes”
Terminal window
# If changes aren"t reflected after pip install -e .:
pip install -e ".[dev]" --force-reinstall --no-deps
a.py
from b import func_b
# b.py
from a import func_a # ImportError or AttributeError at runtime
# Fix: restructure to remove circular imports
# a.py
def func_a():
from b import func_b # Local import
return func_b()
# BAD — over-constraining
requests==2.31.0
urllib3==1.26.18
certifi==2023.7.22
charset-normalizer==3.2.0
idna==3.4
# GOOD — pin direct dependencies, let resolver handle transitive ones
requests>=2.28.0
# Use pip-compile for lock files with transitive pins
Terminal window
# BAD — pip install without lock file
pip install -r requirements.in
# GOOD — use locked requirements
pip install -r requirements.txt # Generated by pip-compile
# Or with poetry:
poetry install # Uses poetry.lock

6. 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 THIS

7. Missing py.typed Marker for Type Checking

Section titled “7. Missing py.typed Marker for Type Checking”
Terminal window
# 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.

Terminal window
# Always test both wheel and sdist
python -m build
pip install dist/mylib-1.0.0.tar.gz
python -c "import mylib; print(mylib.__version__)"
# The sdist must include all necessary files
# Common missing files: package_data, non-Python files, py.typed
[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.

Featuresetuptoolshatchflit
MaturityMost matureModernMinimal
Legacy compatFullPartialNone
PluginsManyGrowingNone
SpeedMediumFastFast
ComplexityHighMediumLow
VCS versioningVia pluginBuilt-inVia plugin