Python Packaging — Build & Distribute Your Own Library
You've written a useful Python module. Now you want others to install it with pip install your-package. This guide takes you from a loose collection of scripts to a properly packaged, versioned, and published Python library — following 2026 best practices.
Modern Python Packaging (The Short Version)
The packaging ecosystem has simplified dramatically. Here's what you need to know in 2026:
- pyproject.toml — the single config file (replaces setup.py, setup.cfg, MANIFEST.in)
- Build backends — setuptools (default), Hatchling, Flit, PDM, or Poetry
- python -m build — creates sdist + wheel
- twine upload — publishes to PyPI
Project Structure
The src layout (recommended)
my-awesome-lib/
├── src/
│ └── awesome/
│ ├── __init__.py
│ ├── core.py
│ ├── utils.py
│ └── cli.py
├── tests/
│ ├── __init__.py
│ ├── test_core.py
│ └── test_utils.py
├── pyproject.toml
├── README.md
├── LICENSE
└── CHANGELOG.md
Why src/ layout? It forces you to install the package before testing — catching import issues that flat layouts hide. The Python Packaging Authority (PyPA) recommends it.
The flat layout (simpler, also fine)
my-awesome-lib/
├── awesome/
│ ├── __init__.py
│ ├── core.py
│ └── utils.py
├── tests/
├── pyproject.toml
└── README.md
pyproject.toml — The One Config File
With setuptools (most common)
[build-system]
requires = ["setuptools>=75.0", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[project]
name = "awesome-lib"
version = "0.1.0"
description = "A genuinely awesome Python library"
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
authors = [
{name = "Your Name", email = "you@example.com"},
]
keywords = ["automation", "utilities", "awesome"]
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",
"Programming Language :: Python :: 3.13",
"Topic :: Software Development :: Libraries",
]
# Runtime dependencies
dependencies = [
"httpx>=0.27",
"pydantic>=2.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
"ruff>=0.8",
"mypy>=1.13",
]
docs = [
"mkdocs>=1.6",
"mkdocs-material>=9.5",
]
[project.urls]
Homepage = "https://github.com/you/awesome-lib"
Documentation = "https://awesome-lib.readthedocs.io"
Repository = "https://github.com/you/awesome-lib"
Changelog = "https://github.com/you/awesome-lib/blob/main/CHANGELOG.md"
# CLI entry point (optional)
[project.scripts]
awesome = "awesome.cli:main"
# Package discovery
[tool.setuptools.packages.find]
where = ["src"]
# Tool configs (all in one file!)
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-v --tb=short"
[tool.ruff]
target-version = "py310"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B", "SIM"]
[tool.mypy]
python_version = "3.10"
strict = true
With Hatchling (modern alternative)
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "awesome-lib"
dynamic = ["version"]
# ... same [project] section as above ...
[tool.hatch.version]
path = "src/awesome/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/awesome"]
Writing Your Package
__init__.py — public API
# src/awesome/__init__.py
"""Awesome Library — genuinely useful utilities."""
__version__ = "0.1.0"
# Public API — what users get with `from awesome import ...`
from .core import transform, validate, process
from .utils import retry, timer, chunk
__all__ = [
"transform",
"validate",
"process",
"retry",
"timer",
"chunk",
"__version__",
]
Core module
# src/awesome/core.py
"""Core functionality."""
from typing import Any, Callable
from pydantic import BaseModel, ValidationError
class TransformResult(BaseModel):
"""Result of a transformation."""
input_count: int
output_count: int
errors: list[str] = []
def transform(
data: list[dict],
fn: Callable[[dict], dict],
skip_errors: bool = False,
) -> tuple[list[dict], TransformResult]:
"""Apply a transformation function to each record.
Args:
data: Input records.
fn: Transformation function (dict -> dict).
skip_errors: If True, skip failed records instead of raising.
Returns:
Tuple of (transformed records, result summary).
Example:
>>> records = [{"name": "alice"}, {"name": "bob"}]
>>> output, result = transform(records, lambda r: {**r, "name": r["name"].upper()})
>>> output[0]["name"]
'ALICE'
"""
output = []
errors = []
for i, record in enumerate(data):
try:
output.append(fn(record))
except Exception as e:
if skip_errors:
errors.append(f"Record {i}: {e}")
else:
raise
result = TransformResult(
input_count=len(data),
output_count=len(output),
errors=errors,
)
return output, result
CLI entry point
# src/awesome/cli.py
"""Command-line interface."""
import argparse
import json
import sys
from . import __version__
from .core import transform
def main():
parser = argparse.ArgumentParser(
prog="awesome",
description="Awesome data transformation CLI",
)
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
parser.add_argument("input", help="Input JSON file (- for stdin)")
parser.add_argument("-o", "--output", default="-", help="Output file (default: stdout)")
args = parser.parse_args()
# Read input
if args.input == "-":
data = json.load(sys.stdin)
else:
with open(args.input) as f:
data = json.load(f)
# Process
output, result = transform(data, lambda r: r) # identity transform as example
print(f"Processed {result.input_count} records → {result.output_count} output",
file=sys.stderr)
# Write output
if args.output == "-":
json.dump(output, sys.stdout, indent=2)
else:
with open(args.output, "w") as f:
json.dump(output, f, indent=2)
if __name__ == "__main__":
main()
After installing, users can run awesome input.json -o output.json directly. For more on building CLI tools, see our CLI tool guide.
Building Your Package
# Install build tool
pip install build
# Build sdist + wheel
python -m build
# Output:
# dist/
# awesome_lib-0.1.0.tar.gz (sdist — source)
# awesome_lib-0.1.0-py3-none-any.whl (wheel — binary)
Test locally before publishing
# Install in development mode (editable)
pip install -e ".[dev]"
# Run tests
pytest
# Check the built package
pip install dist/awesome_lib-0.1.0-py3-none-any.whl --force-reinstall
# Verify it works
python -c "import awesome; print(awesome.__version__)"
awesome --version
Publishing to PyPI
First-time setup
# 1. Create account at https://pypi.org/account/register/
# 2. Enable 2FA (required since 2024)
# 3. Create API token at https://pypi.org/manage/account/token/
# Install twine
pip install twine
# Test on TestPyPI first!
twine upload --repository testpypi dist/*
# Enter: __token__ as username, your TestPyPI token as password
# Verify on TestPyPI
pip install --index-url https://test.pypi.org/simple/ awesome-lib
# If everything works — publish for real
twine upload dist/*
Using a .pypirc file
# ~/.pypirc — save your tokens (chmod 600!)
[distutils]
index-servers =
pypi
testpypi
[pypi]
username = __token__
password = pypi-AgEI...your_token...
[testpypi]
repository = https://test.pypi.org/legacy/
username = __token__
password = pypi-AgEI...your_test_token...
Versioning
Semantic versioning
# MAJOR.MINOR.PATCH
# 0.1.0 → initial development
# 0.2.0 → new feature (minor)
# 0.2.1 → bug fix (patch)
# 1.0.0 → first stable release
# 1.1.0 → new feature, backwards compatible
# 2.0.0 → breaking change
Automatic versioning with setuptools-scm
# pyproject.toml
[build-system]
requires = ["setuptools>=75.0", "setuptools-scm>=8"]
build-backend = "setuptools.build_meta"
[project]
dynamic = ["version"] # version comes from git tags
[tool.setuptools_scm]
# Automatically derives version from git tags
# Tag v0.1.0 → version is 0.1.0
# 3 commits after v0.1.0 → version is 0.1.1.dev3+g1234abc
# Tag a release
git tag v0.1.0
git push origin v0.1.0
# Build — version is auto-detected from tag
python -m build
Dependency Management
Pin wisely
[project]
dependencies = [
# Libraries: use MINIMUM version + upper bound for major
"httpx>=0.27,<1.0",
"pydantic>=2.0,<3.0",
# Don't over-pin (bad for library consumers)
# ❌ "requests==2.31.0" — forces exact version
# ✅ "requests>=2.28" — allows flexibility
]
[project.optional-dependencies]
# Dev tools can be pinned tighter
dev = [
"pytest>=8.0,<9.0",
"ruff>=0.8",
]
CI/CD: Automated Testing & Publishing
GitHub Actions workflow
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Lint
run: ruff check src/ tests/
- name: Type check
run: mypy src/
- name: Test
run: pytest --cov=awesome --cov-report=xml
publish:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: pypi
permissions:
id-token: write # Trusted publishing (no token needed!)
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Build
run: |
pip install build
python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# Uses trusted publishing — no API token needed!
# Configure at: https://pypi.org/manage/project/.../settings/publishing/
Release workflow
# 1. Update CHANGELOG.md
# 2. Bump version (or let setuptools-scm handle it)
# 3. Commit and tag
git add .
git commit -m "Release v0.2.0"
git tag v0.2.0
git push origin main --tags
# 4. CI runs tests → builds → publishes to PyPI automatically
Alternative Build Tools
| Tool | Best For | Key Feature |
|---|---|---|
| setuptools | Most projects | Universal, well-documented |
| Hatchling | Modern libraries | Fast, PEP 621 native |
| Poetry | Apps with lockfiles | Dependency resolution + lockfile |
| PDM | PEP 582 fans | PEP standards-focused |
| Flit | Simple packages | Minimal config |
| uv | Speed-focused workflows | Rust-powered, extremely fast |
| Maturin | Rust+Python (PyO3) | Compile Rust extensions |
Quick Poetry example
# Initialize
poetry new my-lib
cd my-lib
# Add dependencies
poetry add httpx pydantic
poetry add --group dev pytest ruff
# Build and publish
poetry build
poetry publish
Common Gotchas
- Name conflicts — check pip install your-name before choosing. PyPI names are global and case-insensitive.
- Missing files — if non-Python files (YAML, JSON, templates) aren't included, add them explicitly:
[tool.setuptools.package-data] awesome = ["*.yaml", "templates/*.html"] - Import confusion — the package name (what you import) and the project name (what you pip install) can differ. Keep them close: pip install awesome-lib → import awesome.
- Forgotten __init__.py — every directory in your package needs one (even if empty). Without it, Python won't recognize the directory as a package.
- Version mismatch — use setuptools-scm or hatch version to derive from git tags. One source of truth.
Checklist: Ready to Publish?
- ✅ pyproject.toml with all metadata (name, version, description, license, URLs)
- ✅ README.md with install instructions and usage examples
- ✅ LICENSE file (MIT, Apache 2.0, etc.)
- ✅ CHANGELOG.md documenting changes per version
- ✅ Tests pass on all target Python versions
- ✅ python -m build succeeds, wheel contains expected files
- ✅ Successfully installed from wheel in a clean virtualenv
- ✅ twine check dist/* passes (validates metadata)
- ✅ Tested on TestPyPI first
- ✅ CI/CD pipeline configured for automated publishing on tags
🚀 Want 50+ production-ready Python scripts and tools, all properly packaged?
Related Articles
- Build a Professional CLI Tool in Python — create the CLI that your package exposes
- Python Testing Guide — pytest, Mocking & CI — test before you publish
- Dockerize Python Apps — containerize your packaged application
- Python Environment & Config Management — manage configs across environments
- Python Design Patterns — architect your library cleanly
Need help packaging your Python project or setting up CI/CD? I build and publish Python libraries and automation tools. Reach out on Telegram →