Skip to content

Packaging and Distribution | Python

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 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"]
# 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
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
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
[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
  • 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.

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

Packaging turns your code into something others can install and use. pyproject.toml is the modern configuration file that replaces setup.py and setup.cfg. pip installs packages from PyPI, while uv is a faster alternative. Virtual environments isolate project dependencies so different projects can use different versions of the same package. The workflow is: write code, add a pyproject.toml, build a wheel, and upload to PyPI.