Files
Patel Dhruv 49fd3f3faf Add new skill: Python PyPI Package Builder (#1302)
* Add python-pypi-package-builder skill for Python packaging

- Created `SKILL.md` defining decision-driven workflow for building, testing, versioning, and publishing Python packages.
- Added reference modules covering PyPA packaging, architecture patterns, CI/CD, testing, versioning strategy, and release governance.
- Implemented scaffold script to generate complete project structure with pyproject.toml, CI workflows, tests, and configuration.
- Included support for multiple build backends (setuptools_scm, hatchling, flit, poetry) with clear decision rules.
- Added secure release practices including tag-based versioning, branch protection, and OIDC Trusted Publishing.

* fix: correct spelling issues detected by codespell
2026-04-09 10:36:17 +10:00

13 KiB

pyproject.toml, Backends, Versioning, and Typed Package

Table of Contents

  1. Complete pyproject.toml — setuptools + setuptools_scm
  2. hatchling (modern, zero-config)
  3. flit (minimal, version from __version__)
  4. poetry (integrated dep manager)
  5. Versioning Strategy — PEP 440, semver, dep specifiers
  6. setuptools_scm — dynamic version from git tags
  7. setup.py shim for legacy editable installs
  8. PEP 561 typed package (py.typed)

1. Complete pyproject.toml

[build-system]
requires = ["setuptools>=68", "wheel", "setuptools_scm"]
build-backend = "setuptools.build_meta"

[project]
name = "your-package"
dynamic = ["version"]           # Version comes from git tags via setuptools_scm
description = "<your description> — <key feature 1>, <key feature 2>"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"                # PEP 639 SPDX expression (string, not {text = "MIT"})
license-files = ["LICENSE"]
authors = [
    {name = "Your Name", email = "you@example.com"},
]
maintainers = [
    {name = "Your Name", email = "you@example.com"},
]
keywords = [
    "python",
    # Add 10-15 specific keywords that describe your library — they affect PyPI discoverability
]
classifiers = [
    "Development Status :: 3 - Alpha",          # Change to 5 at stable release
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
    "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 :: Python Modules",
    "Typing :: Typed",          # Add this when shipping py.typed
]
dependencies = [
    # List your runtime dependencies here. Keep them minimal.
    # Example: "httpx>=0.24", "pydantic>=2.0"
    # Leave empty if your library has no required runtime deps.
]

[project.optional-dependencies]
redis = [
    "redis>=4.2",               # Optional heavy backend
]
dev = [
    "pytest>=7.0",
    "pytest-asyncio>=0.21",
    "httpx>=0.24",
    "pytest-cov>=4.0",
    "ruff>=0.4",
    "black>=24.0",
    "isort>=5.13",
    "mypy>=1.0",
    "pre-commit>=3.0",
    "build",
    "twine",
]

[project.urls]
Homepage      = "https://github.com/yourusername/your-package"
Documentation = "https://github.com/yourusername/your-package#readme"
Repository    = "https://github.com/yourusername/your-package"
"Bug Tracker" = "https://github.com/yourusername/your-package/issues"
Changelog     = "https://github.com/yourusername/your-package/blob/master/CHANGELOG.md"

# --- Setuptools configuration ---
[tool.setuptools.packages.find]
include = ["your_package*"]   # flat layout
# For src/ layout, use:
# where = ["src"]

[tool.setuptools.package-data]
your_package = ["py.typed"]  # Ship the py.typed marker in the wheel

# --- setuptools_scm: version from git tags ---
[tool.setuptools_scm]
version_scheme = "post-release"
local_scheme   = "no-local-version"  # Prevents +local suffix breaking PyPI uploads

# --- Ruff (linting) ---
[tool.ruff]
target-version = "py310"
line-length    = 100

[tool.ruff.lint]
select = ["E", "F", "W", "I", "N", "UP", "B", "SIM", "C4", "PTH", "RUF"]
ignore = ["E501"]   # Line length enforced by formatter

[tool.ruff.lint.per-file-ignores]
"tests/*"   = ["S101", "ANN"]    # Allow assert and missing annotations in tests
"scripts/*" = ["T201"]           # Allow print in scripts

[tool.ruff.format]
quote-style = "double"

# --- Black (formatting) ---
[tool.black]
line-length    = 100
target-version = ["py310", "py311", "py312", "py313"]

# --- isort (import sorting) ---
[tool.isort]
profile     = "black"
line_length = 100

# --- mypy (static type checking) ---
[tool.mypy]
python_version         = "3.10"
warn_return_any        = true
warn_unused_configs    = true
warn_unused_ignores    = true
disallow_untyped_defs  = true
disallow_any_generics  = true
ignore_missing_imports = true
strict                 = false     # Set true for maximum strictness

[[tool.mypy.overrides]]
module = "tests.*"
disallow_untyped_defs = false     # Relaxed in tests

# --- pytest ---
[tool.pytest.ini_options]
asyncio_mode  = "auto"
testpaths     = ["tests"]
pythonpath    = ["."]          # For flat layout; remove for src/
python_files  = "test_*.py"
python_classes = "Test*"
python_functions = "test_*"
addopts       = "-v --tb=short --cov=your_package --cov-report=term-missing"

# --- Coverage ---
[tool.coverage.run]
source = ["your_package"]
omit   = ["tests/*"]

[tool.coverage.report]
fail_under   = 80
show_missing = true
exclude_lines = [
    "pragma: no cover",
    "def __repr__",
    "raise NotImplementedError",
    "if TYPE_CHECKING:",
    "@abstractmethod",
]

2. hatchling (Modern, Zero-Config)

Best for new pure-Python projects that don't need C extensions. No setup.py needed. Use hatch-vcs for git-tag versioning, or omit it for manual version bumps.

[build-system]
requires = ["hatchling", "hatch-vcs"]     # hatch-vcs for git-tag versioning
build-backend = "hatchling.build"

[project]
name = "your-package"
dynamic = ["version"]            # Remove and add version = "1.0.0" for manual versioning
description = "One-line description"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
license-files = ["LICENSE"]
authors = [{name = "Your Name", email = "you@example.com"}]
keywords = ["python"]
classifiers = [
    "Development Status :: 3 - Alpha",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: MIT License",
    "Operating System :: OS Independent",
    "Programming Language :: Python :: 3",
    "Typing :: Typed",
]
dependencies = []

[project.optional-dependencies]
dev = ["pytest>=8.0", "pytest-cov>=5.0", "ruff>=0.6", "mypy>=1.10"]

[project.urls]
Homepage  = "https://github.com/yourusername/your-package"
Changelog = "https://github.com/yourusername/your-package/blob/master/CHANGELOG.md"

# --- Hatchling build config ---
[tool.hatch.build.targets.wheel]
packages = ["src/your_package"]    # src/ layout
# packages = ["your_package"]      # ← flat layout

[tool.hatch.version]
source = "vcs"                     # git-tag versioning via hatch-vcs

[tool.hatch.version.raw-options]
local_scheme = "no-local-version"

# ruff, mypy, pytest, coverage sections — same as setuptools template above

3. flit (Minimal, Version from __version__)

Best for very simple, single-module packages. Zero config. Version is read directly from your_package/__init__.py. Always requires a static string for __version__.

[build-system]
requires = ["flit_core>=3.9"]
build-backend = "flit_core.buildapi"

[project]
name = "your-package"
dynamic = ["version", "description"]  # Read from __init__.py __version__ and docstring
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [{name = "Your Name", email = "you@example.com"}]
classifiers = [
    "License :: OSI Approved :: MIT License",
    "Programming Language :: Python :: 3",
    "Typing :: Typed",
]
dependencies = []

[project.urls]
Homepage = "https://github.com/yourusername/your-package"

# flit reads __version__ from your_package/__init__.py automatically.
# Ensure __init__.py has: __version__ = "1.0.0"  (static string — flit does NOT support
# importlib.metadata for dynamic version discovery)

4. poetry (Integrated Dependency + Build Manager)

Best for teams that want a single tool to manage deps, build, and publish. Poetry v2+ supports the standard [project] table.

[build-system]
requires = ["poetry-core>=2.0"]
build-backend = "poetry.core.masonry.api"

[project]
name = "your-package"
version = "1.0.0"
description = "One-line description"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [{name = "Your Name", email = "you@example.com"}]
classifiers = [
    "Programming Language :: Python :: 3",
    "Typing :: Typed",
]
dependencies = []   # poetry v2+ uses standard [project] table

[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.6", "mypy>=1.10"]

# Optional: use [tool.poetry] only for poetry-specific features
[tool.poetry.group.dev.dependencies]
# Poetry-specific group syntax (alternative to [project.optional-dependencies])
pytest = ">=8.0"

5. Versioning Strategy

PEP 440 — The Standard

Canonical form:  N[.N]+[{a|b|rc}N][.postN][.devN]

Examples:
  1.0.0          Stable release
  1.0.0a1        Alpha (pre-release)
  1.0.0b2        Beta
  1.0.0rc1       Release candidate
  1.0.0.post1    Post-release (e.g., packaging fix only — no code change)
  1.0.0.dev1     Development snapshot (NOT for PyPI)

Semantic Versioning (SemVer) — use this for every library

MAJOR.MINOR.PATCH

MAJOR: Breaking API change (remove/rename public function/class/arg)
MINOR: New feature, fully backward-compatible
PATCH: Bug fix, no API change
Change What bumps Example
Remove / rename a public function MAJOR 1.2.3 → 2.0.0
Add new public function MINOR 1.2.3 → 1.3.0
Bug fix, no API change PATCH 1.2.3 → 1.2.4
New pre-release suffix 2.0.0a1, 2.0.0rc1

Version in code — read from package metadata

# your_package/__init__.py
from importlib.metadata import version, PackageNotFoundError

try:
    __version__ = version("your-package")
except PackageNotFoundError:
    __version__ = "0.0.0-dev"    # Fallback for uninstalled dev checkouts

Never hardcode __version__ = "1.0.0" when using setuptools_scm — it goes stale after the first git tag. Use importlib.metadata always.

Version specifier best practices for dependencies

# In [project] dependencies — for a LIBRARY:
"httpx>=0.24"            # Minimum version — PREFERRED for libraries
"httpx>=0.24,<1.0"       # Upper bound only when a known breaking change exists

# ONLY for applications (never for libraries):
"httpx==0.27.0"          # Pin exactly — breaks dep resolution in libraries

# NEVER do this in a library:
# "httpx~=0.24.0"        # Compatible release operator — too tight
# "httpx==0.27.*"        # Wildcard pin — fragile

6. Dynamic Versioning with setuptools_scm

setuptools_scm reads your git tags and sets the package version automatically — no more manually editing version strings before each release.

How it works

git tag v1.0.0        →  package version = 1.0.0
git tag v1.1.0        →  package version = 1.1.0
(commits after tag)   →  version = 1.1.0.post1+g<hash>  (stripped for PyPI)

local_scheme = "no-local-version" strips the +g<hash> suffix so PyPI uploads never fail with a "local version label not allowed" error.

Access version at runtime

# your_package/__init__.py
from importlib.metadata import version, PackageNotFoundError

try:
    __version__ = version("your-package")
except PackageNotFoundError:
    __version__ = "0.0.0-dev"  # Fallback for uninstalled dev checkouts

Never hardcode __version__ = "1.0.0" when using setuptools_scm — it will go stale after the first tag.

Full release flow (this is it — nothing else needed)

git tag v1.2.0
git push origin master --tags
# GitHub Actions publish.yml triggers automatically

7. setup.py Shim

Some older tools and IDEs still expect a setup.py. Keep it as a three-line shim — all real configuration stays in pyproject.toml.

# setup.py — thin shim only. All config lives in pyproject.toml.
from setuptools import setup

setup()

Never duplicate name, version, dependencies, or any other metadata from pyproject.toml into setup.py. If you copy anything there it will eventually drift and cause confusing conflicts.


8. Typed Package (PEP 561)

A properly declared typed package means mypy, pyright, and IDEs automatically pick up your type hints without any extra configuration from your users.

Step 1: Create the marker file

# The file must exist; its content doesn't matter — its presence is the signal.
touch your_package/py.typed

Step 2: Include it in the wheel

Already in the template above:

[tool.setuptools.package-data]
your_package = ["py.typed"]

Step 3: Add the PyPI classifier

classifiers = [
    ...
    "Typing :: Typed",
]

Step 4: Type-annotate all public functions

# Good — fully typed
def process(
    self,
    data: dict[str, object],
    *,
    timeout: int = 30,
) -> dict[str, object]:
    ...

# Bad — mypy will flag this, and IDEs give no completions to users
def process(self, data, timeout=30):
    ...

Step 5: Verify py.typed ships in the wheel

python -m build
unzip -l dist/your_package-*.whl | grep py.typed
# Must show: your_package/py.typed

If it's missing, check your [tool.setuptools.package-data] config.