* 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
13 KiB
pyproject.toml, Backends, Versioning, and Typed Package
Table of Contents
- Complete pyproject.toml — setuptools + setuptools_scm
- hatchling (modern, zero-config)
- flit (minimal, version from
__version__) - poetry (integrated dep manager)
- Versioning Strategy — PEP 440, semver, dep specifiers
- setuptools_scm — dynamic version from git tags
- setup.py shim for legacy editable installs
- PEP 561 typed package (py.typed)
1. Complete pyproject.toml
setuptools + setuptools_scm (recommended for git-tag versioning)
[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.