Package Structure
A modern Python package uses the src layout - it prevents accidentally importing from the source directory during testing:
my-package/
src/
mypackage/
__init__.py
core.py
utils.py
submodule/
__init__.py
feature.py
tests/
conftest.py
test_core.py
docs/
pyproject.toml
README.md
LICENSE
.gitignore
The __init__.py can be empty, or it can re-export the public API:
from .core import Client, Response
from .utils import retry, timeout
__version__ = '1.0.0'
__all__ = ['Client', 'Response', 'retry', 'timeout']
Add a py.typed marker file (empty) to signal that your package supports type checking (PEP 561):
# Create empty py.typed marker
touch src/mypackage/py.typed
pyproject.toml
The single configuration file for build, metadata, and tools:
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.backends.legacy:build"
[project]
name = "my-package"
version = "1.0.0"
description = "A brief description of what this package does"
readme = "README.md"
license = {file = "LICENSE"}
authors = [
{name = "Alice Smith", email = "alice@example.com"},
]
keywords = ["python", "example", "tutorial"]
classifiers = [
"Development Status :: 5 - Production/Stable",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
requires-python = ">=3.10"
dependencies = [
"requests>=2.28",
"click>=8.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"mypy>=1.10",
"black>=24.0",
]
[project.urls]
Homepage = "https://github.com/alice/my-package"
Repository = "https://github.com/alice/my-package"
"Bug Tracker" = "https://github.com/alice/my-package/issues"
Changelog = "https://github.com/alice/my-package/CHANGELOG.md"
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
mypackage = ["py.typed", "*.json"]
Install optional dependencies with:
# Install with dev extras
pip install "my-package[dev]"
# Install local package in editable mode
pip install -e ".[dev]"
Building a Distribution
Use the build package to create wheel and sdist distributions:
pip install build
# Build both wheel and sdist
python -m build
# Build only wheel
python -m build --wheel
# Build only sdist
python -m build --sdist
After building, the dist/ directory contains:
dist/
my_package-1.0.0-py3-none-any.whl # wheel (binary distribution)
my_package-1.0.0.tar.gz # sdist (source distribution)
# Wheel filename format: {name}-{version}-{python}-{abi}-{platform}.whl
# py3-none-any = pure Python 3, no ABI, any platform
# cp312-cp312-linux_x86_64 = CPython 3.12 extension module
Check the built distributions with twine check before uploading:
pip install twine
twine check dist/*
Publishing to PyPI
First test on TestPyPI, then publish to the real PyPI:
# Upload to TestPyPI first
twine upload --repository testpypi dist/*
# Test install from TestPyPI
pip install --index-url https://test.pypi.org/simple/ my-package
# Upload to real PyPI
twine upload dist/*
Set up PyPI credentials with an API token (more secure than password):
[pypi]
username = __token__
password = pypi-AgEIcHlwaS5vcmcCJDEyMzQ1... # your API token
[testpypi]
username = __token__
password = pypi-AgENdGVzdC5weXBpLm9yZwIk... # your TestPyPI token
Or use environment variables (preferred for CI/CD):
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-AgEI...
twine upload dist/*
GitHub Actions workflow for automatic publishing on tag push:
name: Publish to PyPI
on:
push:
tags: ['v*']
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: {python-version: '3.12'}
- run: pip install build twine
- run: python -m build
- run: twine upload dist/*
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
Entry Points (CLI scripts)
Entry points create console scripts that users can run as commands after installing your package:
[project.scripts]
my-tool = "mypackage.cli:main"
my-tool-debug = "mypackage.cli:main_debug"
import click
@click.command()
@click.argument('name')
@click.option('--count', default=1, help='Number of greetings')
def main(name, count):
for _ in range(count):
click.echo(f'Hello, {name}!')
if __name__ == '__main__':
main()
pip install -e .
my-tool Alice --count 3
# Hello, Alice!
# Hello, Alice!
# Hello, Alice!
Versioning
Python packages should follow Semantic Versioning (SemVer): MAJOR.MINOR.PATCH:
| Increment | When | Example |
|---|---|---|
| MAJOR | Breaking changes to public API | 1.0.0 -> 2.0.0 |
| MINOR | New backward-compatible features | 1.0.0 -> 1.1.0 |
| PATCH | Backward-compatible bug fixes | 1.0.0 -> 1.0.1 |
Single-source versioning - define version once, use everywhere:
__version__ = '1.0.0'
[project]
# Option 1: version in pyproject.toml
version = "1.0.0"
# Option 2: read from __init__.py dynamically (requires setuptools>=61)
dynamic = ["version"]
[tool.setuptools.dynamic]
version = {attr = "mypackage.__version__"}
Access the installed version at runtime:
from importlib.metadata import version, PackageNotFoundError
try:
__version__ = version('my-package')
except PackageNotFoundError:
__version__ = 'unknown' # package not installed
For pure-Python packages without C extensions, Flit provides a minimal build backend with almost no configuration - great for libraries. Hatch is a modern, feature-rich alternative to setuptools with built-in environment management, test orchestration, and versioning. Choose setuptools if you need C extensions or maximum compatibility.