Python Packaging — Build & Distribute Your Own Library

March 2026 · 18 min read · Python, Packaging, PyPI, DevOps

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:

💡 Rule of thumb: If you're starting fresh, use pyproject.toml with setuptools or Hatchling. Skip setup.py — it's legacy.

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)
💡 What's a wheel? A .whl file is a pre-built package — no compilation step during install. It's a zip file with a specific naming convention: {name}-{version}-{python}-{abi}-{platform}.whl. Pure Python packages use py3-none-any (works everywhere).

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",
]
🎯 Library vs Application: Libraries should use loose version bounds (>=2.0,<3.0) — your users have their own dependency trees. Applications can pin exact versions with a lockfile (pip freeze, poetry.lock, uv.lock).

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/
🔒 Trusted publishing: Since 2023, PyPI supports OIDC-based publishing from GitHub Actions. No API tokens to manage — just link your GitHub repo to your PyPI project. It's the recommended approach.

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

ToolBest ForKey Feature
setuptoolsMost projectsUniversal, well-documented
HatchlingModern librariesFast, PEP 621 native
PoetryApps with lockfilesDependency resolution + lockfile
PDMPEP 582 fansPEP standards-focused
FlitSimple packagesMinimal config
uvSpeed-focused workflowsRust-powered, extremely fast
MaturinRust+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

Checklist: Ready to Publish?

🚀 Want 50+ production-ready Python scripts and tools, all properly packaged?

Get the AI Agent Toolkit →

Related Articles

Need help packaging your Python project or setting up CI/CD? I build and publish Python libraries and automation tools. Reach out on Telegram →