Modernize Python projects to follow best current practices for packaging, linting, CI, and releases.
Use when the user asks to:
- "modernize this python project"
- "fix python project to follow best practices"
- "add pyproject.toml"
- "set up CI for python project"
- "add ruff to project"
- "set up pre-commit hooks"
- "add PyPI release workflow"
Modern Python projects should have:
| Component | Standard | Tool |
|---|---|---|
| Build system | pyproject.toml | Hatch (preferred) or Poetry |
| Versioning | Automatic from git tags | hatch-vcs or poetry-dynamic-versioning |
| CLI version flag | --version / -V |
argparse or click (import from _version.py) |
| Linting | Configured in pyproject.toml | ruff |
| Tests | Lots of them under ./tests | pytest and/or tox |
| Pre-commit | .pre-commit-config.yaml | ruff, lychee and other hooks |
| CI Tests | GitHub Actions or similar | Python >=3.10 <4.0 matrix |
| Release | GitHub Actions or similar | PyPI trusted publishing on tags |
| Changelog | CHANGELOG.md | Keep a Changelog format |
The matrix above and configuration examples below is not a universal everlasting truth:
- If there are good reasons for only supporting newer python versions, then inform the user and wait for feedback.
- The user should always approve any major migrations or upgrades of existing code base.
- The user believes this was best current practice by 2026-01-21, but he may be wrong, and the truth may be different in some few months. For projects that are forked and following other standards, do some research on what makes sense.
Hatch (recommended for new projects):
- PEP 621 compliant (
[project]table - the standard) - Lighter, no lock file
- Built-in environment management
- Use hatch-vcs for git tag versioning
Poetry (acceptable, don't migrate existing Poetry projects):
- Has lock file (poetry.lock) for reproducible builds
- Use poetry-dynamic-versioning for git tag versioning
- Only convert Poetry→Hatch if user explicitly requests it
When was this document updated last? If it's more than two weeks ago, do some internet searches and verify that this document still is up-to-date with the best current practices.
Check what exists:
ls -la pyproject.toml setup.py setup.cfg .pre-commit-config.yaml
ls -la .github/workflows/
cat pyproject.toml 2>/dev/null || echo "No pyproject.toml"Decision logic:
- Project uses Poetry → keep Poetry
- Project uses setup.py/setuptools → migrate to Hatch
- Project uses unknown/other build system → research first, ask user before migrating
[build-system]
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[project]
name = "project-name"
dynamic = ["version"]
description = "Project description"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "GPL-3.0-or-later"}
authors = [{ name = "Author Name", email = "email@example.com" }]
classifiers = [
"Development Status :: 4 - Beta",
"Programming Language :: Python",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = []
[project.optional-dependencies]
dev = ["pytest>=8.0", "ruff>=0.14", "pre-commit>=4.0"]
[project.scripts]
script-name = "package.module:main" # CLI should support --version
[project.urls]
Repository = "https://github.com/owner/repo"
Issues = "https://github.com/owner/repo/issues"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.hooks.vcs]
version-file = "src/package_name/_version.py"CRITICAL: poetry-dynamic-versioning version placement
poetry-dynamic-versioning patches [tool.poetry].version, NOT [project].version.
If using PEP 621 [project] table, you MUST:
- Put
dynamic = ["version"]in[project](NOT a staticversion = "0.0.0") - Put
version = "0.0.0"in[tool.poetry]
Getting this wrong causes version 0.0.0 to be published silently.
Classic Poetry layout (all metadata in [tool.poetry]):
[tool.poetry]
name = "project-name"
version = "0.0.0" # Managed by poetry-dynamic-versioning
description = "Project description"
authors = ["Author Name <email@example.com>"]
license = "GPL-3.0-or-later"
readme = "README.md"
repository = "https://github.com/owner/repo"
[tool.poetry.dependencies]
python = "^3.10"
# Add dependencies here
[tool.poetry.group.dev.dependencies]
pytest = "^8.0"
ruff = "^0.14"
pre-commit = "^4.0"
[tool.poetry.scripts]
script-name = "package.module:main"
[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"
style = "pep440"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
build-backend = "poetry_dynamic_versioning.backend"PEP 621 hybrid layout (metadata in [project], Poetry for build):
[project]
name = "project-name"
dynamic = ["version"] # Version comes from git tags via poetry-dynamic-versioning
description = "Project description"
authors = [{ name = "Author Name", email = "email@example.com" }]
license = { text = "GPL-3.0-or-later" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = []
[project.urls]
Repository = "https://github.com/owner/repo"
[project.scripts]
script-name = "package.module:main"
[tool.poetry]
version = "0.0.0" # Managed by poetry-dynamic-versioning
packages = [{include = "package_name", from = "src"}]
[tool.poetry-dynamic-versioning]
enable = true
vcs = "git"
style = "pep440"
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0", "poetry-dynamic-versioning>=1.0.0,<2.0.0"]
build-backend = "poetry_dynamic_versioning.backend"Add to pyproject.toml:
[tool.ruff]
line-length = 120
target-version = "py39"
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP", "B", "C4", "PT"]
ignore = ["E501"] # Line length handled separately
[tool.ruff.lint.isort]
known-first-party = ["package_name"]Variations should be considered:
- Strict adherence to the typing hint guidelines is good to have in public libraries with a well-defined API, but not so much needed for ad-hoc scripts.
- Line length - for mature projects one should consider to keep whatever limits exists. For new projects we skip being obsessed by the line length.
Add to pyproject.toml:
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
# Treat warnings as errors - tests should run clean
filterwarnings = ["error"]
# Exclude directories that aren't test code
norecursedirs = ["scripts", "docs", "build"]Key principle: The test suite should run without warnings. A warning often indicates a real problem (deprecated API, misconfiguration, potential bug). Making warnings into errors ensures they get fixed rather than ignored.
When a warning must be ignored, document the reason inline:
[tool.pytest.ini_options]
filterwarnings = [
"error",
# dateutil uses deprecated datetime.utcnow(), fixed in unreleased version
# See: https://github.com/dateutil/dateutil/issues/1314
"ignore:datetime.datetime.utcnow:DeprecationWarning:dateutil",
# Pillow 10.x deprecation warning for ANTIALIAS, will be fixed when we upgrade
"ignore:ANTIALIAS is deprecated:DeprecationWarning:PIL",
]Rules for ignoring warnings:
- Always document WHY the warning is being ignored (upstream bug, pending fix, explicit testing that legacy code still works, etc.)
- Include a link to the relevant issue if available
- Be as specific as possible in the filter (module, message pattern) to avoid hiding new warnings
- Periodically review ignored warnings to see if they can be removed
Note: Use norecursedirs (not collect_ignore) to exclude directories - collect_ignore is a conftest.py variable, not an ini option.
Pre-commit hooks run at two stages:
- On commit (pre-commit): Quick checks - ruff lint/format, trailing whitespace, etc.
- On push (pre-push): Full test suite - catches issues before they reach CI
Create .pre-commit-config.yaml:
repos:
# === PRE-COMMIT STAGE (quick checks on every commit) ===
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
# === PRE-PUSH STAGE (full test suite before pushing) ===
- repo: local
hooks:
- id: pytest
name: pytest
entry: pytest
language: system
pass_filenames: false
always_run: true
stages: [pre-push]
# === PRE-PUSH: Prevent direct pushes to main/master (v1.0+ projects) ===
# Check with: git tag | grep v1
# Users can bypass with: git push --no-verify
- repo: local
hooks:
- id: no-push-to-main
name: Prevent direct push to main branch
entry: bash -c 'branch=$(git rev-parse --abbrev-ref HEAD); if [ "$branch" = "main" ] || [ "$branch" = "master" ]; then echo "Direct pushes to $branch are not allowed. Use --no-verify to bypass."; exit 1; fi'
language: system
pass_filenames: false
stages: [pre-push]
# === PRE-PUSH: Link checker (with local cache for speed) ===
- repo: https://github.com/lycheeverse/lychee
rev: lychee-v0.22.0
hooks:
- id: lychee
args: ["--no-progress", "--timeout", "10", "--cache", "--max-cache-age", "1d", "."]
stages: [pre-push]
# === COMMIT-MSG: Enforce Conventional Commits ===
# See: https://www.conventionalcommits.org/en/v1.0.0/
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.4.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]Install hooks for all stages:
pre-commit install # Install pre-commit hooks
pre-commit install --hook-type pre-push # Install pre-push hooks
pre-commit install --hook-type commit-msg # Install commit-msg hooks (for conventional commits)
pre-commit run --all-files # Run all pre-commit hooks nowRuns on every push/PR plus daily schedule. Creates a GitHub issue when broken links are found.
Requires issues: write permission. Cache is important for performance.
name: Link check
on:
push:
pull_request:
workflow_dispatch:
schedule:
- cron: "03 22 * * *"
jobs:
linkcheck:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/checkout@v5
- name: Restore lychee cache
uses: actions/cache@v4
with:
path: .lycheecache
key: cache-lychee-${{ github.sha }}
restore-keys: cache-lychee-
- name: Check links with Lychee
id: lychee
uses: lycheeverse/lychee-action@v2
with:
fail: false
args: >-
--root-dir "$(pwd)"
--timeout 20
--max-retries 3
--cache
--max-cache-age 14d
.
- name: Create Issue From File
if: steps.lychee.outputs.exit_code != 0
uses: peter-evans/create-issue-from-file@v5
with:
title: Link Checker Report
content-filepath: ./lychee/out.md
labels: report, automated issueNote: "create github issues" is somewhat experimental but worth enabling.
name: Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Run ruff
run: ruff check .
- name: Run tests
run: pytestname: Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
- name: Install dependencies
run: poetry install
- name: Run ruff
run: poetry run ruff check .
- name: Run tests
run: poetry run pytestCreate .github/workflows/publish.yml:
name: Publish to PyPI
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/project-name
permissions:
id-token: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install build tools
run: pip install build hatch-vcs
- name: Build package
run: python -m build
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1Uses PEP 517 build (python -m build) which automatically installs build
dependencies from build-system.requires in an isolated environment. This is
more reliable than poetry build which requires manually installing the
poetry-dynamic-versioning plugin.
name: Publish to PyPI
on:
push:
tags:
- "v*"
jobs:
publish:
runs-on: ubuntu-latest
environment:
name: pypi
url: https://pypi.org/p/project-name
permissions:
id-token: write
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Install build tools
run: |
python -m pip install --upgrade pip
pip install build
- name: Verify version from git tags
run: |
git describe --tags --long
echo "Git tag detected above ^"
- name: Build package
run: python -m build --sdist --wheel
- name: Display built packages
run: |
ls -lh dist/
echo "Verifying version in wheel metadata:"
unzip -p dist/*.whl */METADATA | grep "^Version:"
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
with:
verbose: trueNote: Requires PyPI trusted publishing configured at pypi.org.
Create CHANGELOG.md using Keep a Changelog format:
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project should adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) - except, for pre-releases PEP440 takes precedence.
## [Unreleased]
## [0.1.0] - YYYY-MM-DD
### Added
- Initial releaseThe Makefile should provide a make install that Just Works on any system
(Arch, Ubuntu, macOS, etc.) without the user needing to know about pip quirks.
Install target detection order:
- root — system-wide
pip install(for server deployments, containers, puppet/ansible) - uv — fast, isolated tool install (no venv/PATH issues)
- pipx — isolated tool install (established, widely available)
- pip --user — fallback with
PIP_BREAK_SYSTEM_PACKAGES=1for PEP 668 systems
Other targets use python -m instead of bare commands (e.g. python -m pytest
instead of pytest) so they work even when ~/.local/bin is not on PATH
(common on Ubuntu).
The README should always istruct people to use make and never suggests things like pip install.
.PHONY: help install dev lint format test clean venv-install
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
install: ## Install the package (auto-detects root, uv, pipx, or pip)
@if [ "$$(id -u)" = "0" ]; then \
echo "Running as root, installing system-wide..."; \
pip install .; \
elif command -v uv >/dev/null 2>&1; then \
echo "Installing with uv..."; \
uv tool install .; \
elif command -v pipx >/dev/null 2>&1; then \
echo "Installing with pipx..."; \
pipx install .; \
else \
echo "Tip: Install uv or pipx for isolated installs (pacman -S uv, apt install pipx, brew install uv)"; \
echo "Falling back to pip install --user ..."; \
PIP_BREAK_SYSTEM_PACKAGES=1 pip install --user .; \
fi
dev: ## Install with dev dependencies (editable)
PIP_BREAK_SYSTEM_PACKAGES=1 pip install -e ".[dev]"
lint: ## Run ruff linter and formatter check
python -m ruff check src/ tests/
python -m ruff format --check src/ tests/
format: ## Auto-format code
python -m ruff check --fix src/ tests/
python -m ruff format src/ tests/
test: ## Run tests
python -m pytest
clean: ## Remove build artifacts
rm -rf dist/ build/ *.egg-info src/*.egg-info .pytest_cache .ruff_cache
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
VENV := .venv
WRAPPER_DIR := $(HOME)/bin
venv-install: ## Install into a venv and create ~/bin wrapper
python3 -m venv $(VENV)
$(VENV)/bin/pip install --upgrade pip
$(VENV)/bin/pip install .
mkdir -p $(WRAPPER_DIR)
@printf '#!/bin/sh\nexec %s/bin/SCRIPT_NAME "$$@"\n' "$$(pwd)/$(VENV)" > $(WRAPPER_DIR)/SCRIPT_NAME
chmod +x $(WRAPPER_DIR)/SCRIPT_NAME
@echo "Installed: $(WRAPPER_DIR)/SCRIPT_NAME -> $$(pwd)/$(VENV)/bin/SCRIPT_NAME".PHONY: help install dev lint format test clean
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | \
awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
install: ## Install the package (auto-detects root, uv, pipx, or pip)
@if [ "$$(id -u)" = "0" ]; then \
echo "Running as root, installing system-wide..."; \
pip install .; \
elif command -v uv >/dev/null 2>&1; then \
echo "Installing with uv..."; \
uv tool install .; \
elif command -v pipx >/dev/null 2>&1; then \
echo "Installing with pipx..."; \
pipx install .; \
else \
echo "Tip: Install uv or pipx for isolated installs (pacman -S uv, apt install pipx, brew install uv)"; \
echo "Falling back to pip install --user ..."; \
PIP_BREAK_SYSTEM_PACKAGES=1 pip install --user .; \
fi
dev: ## Install with dev dependencies
poetry install
lint: ## Run linter
poetry run ruff check .
format: ## Auto-format code
poetry run ruff check --fix .
poetry run ruff format .
test: ## Run tests
poetry run pytest
clean: ## Remove build artifacts
rm -rf dist/ build/ *.egg-info/ .pytest_cache/ .ruff_cache/
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || trueRemove if present:
- setup.py
- setup.cfg (if fully migrated)
- MANIFEST.in (usually not needed with modern build systems)
- requirements.txt (dependencies in pyproject.toml)
git add pyproject.toml .pre-commit-config.yaml .github/workflows/ CHANGELOG.md
git rm setup.py setup.cfg 2>/dev/null || true
git commit -m "Modernize project: Hatch/Poetry, ruff, CI, PyPI release
- Migrate to pyproject.toml with hatch-vcs/poetry-dynamic-versioning
- Configure ruff for linting
- Add pre-commit hooks
- Add GitHub Actions for tests (Python 3.10-3.14)
- Add GitHub Actions for PyPI trusted publishing
- Add CHANGELOG.md
Generated with Claude Code"After creating the workflow, configure trusted publishing on PyPI:
- Go to pypi.org/manage/project/PROJECT/settings/publishing/
- Add new pending publisher or trusted publisher
- Set:
- Owner: github-username
- Repository: repo-name
- Workflow name: publish.yml
- Environment: pypi
- Always use
fetch-depth: 0in checkout steps for dynamic versioning - Test the release workflow by creating a tag:
git tag v0.1.0 && git push --tags - Run
pre-commit run --all-filesto fix existing issues before committing - Hatch is preferred for new projects (PEP 621 compliant, lighter)
- Don't migrate existing Poetry projects to Hatch unless explicitly requested
- Both hatch-vcs and poetry-dynamic-versioning achieve automatic git tag versioning
All projects must have a CONTRIBUTING.md. Minimum required content:
# Contributing to <project-name>
Contributions are mostly welcome (but do inform about it if you've used AI or other tools). If the length of this text scares you, then I'd rather want you to skip reading and just produce a pull-request in GitHub. If you find it too difficult to write test code, etc, then you may skip it and hope the maintainer will fix it.
## What to include
Every submission should ideally include:
- **Test code** covering the new behaviour or bug fix
- **Documentation** updates where relevant
- **A changelog entry** in `CHANGELOG.md` under `[Unreleased]`
## Commit messages
Please follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) and write messages in the imperative mood:
- `fix: correct time-range search handling for recurring events`
- `feat: add new check`
- `docs: update README`
Rather than:
- `This commit fixes the time-range search`
- `Added new check`
Note: older commits in this repository predate this convention and do not follow it.For CLI projects:
-
Installation: README.md or INSTALL.md must use
make installas the official method, neverpip install. Example:## Installationmake install
(This auto-detects `uv`, `pipx`, or `pip` and does the right thing.) -
Options listing: In README.md or USAGE.md, do NOT list all CLI options. Instead, state that
--helpgives the up-to-date list, then explain the most important or non-obvious parameters in prose. Example:Run `caldav-server-tester --help` for the full list of options. The most important parameters are...
Modern CLI tools should support shell tab-completion for commands, options, and arguments.
The most popular choice for argparse-based CLIs. Supports bash, zsh, fish, and tcsh.
Install: pip install argcomplete
Implementation:
#!/usr/bin/env python
import argparse
import argcomplete
def main():
parser = argparse.ArgumentParser(description="My CLI tool")
parser.add_argument('--config', help="Config file").completer = argcomplete.completers.FilesCompleter()
parser.add_argument('--format', choices=['json', 'yaml', 'toml'])
argcomplete.autocomplete(parser) # Must be before parse_args()
args = parser.parse_args()User activation (one-time):
# Global activation (all argcomplete-enabled scripts)
activate-global-python-argcomplete --user
# Or per-script in ~/.bashrc:
eval "$(register-python-argcomplete my-script)"Custom completers:
from argcomplete.completers import FilesCompleter, DirectoriesCompleter, ChoicesCompleter
parser.add_argument('--input', help="Input file").completer = FilesCompleter(('*.json', '*.yaml'))
parser.add_argument('--output-dir').completer = DirectoriesCompleter()Click has native shell completion support.
Implementation:
import click
@click.command()
@click.option('--name', help="Your name")
@click.option('--format', type=click.Choice(['json', 'yaml', 'toml']))
def cli(name, format):
pass
if __name__ == '__main__':
cli()User activation:
# Bash (~/.bashrc)
eval "$(_MY_SCRIPT_COMPLETE=bash_source my-script)"
# Zsh (~/.zshrc)
eval "$(_MY_SCRIPT_COMPLETE=zsh_source my-script)"
# Fish (~/.config/fish/completions/my-script.fish)
_MY_SCRIPT_COMPLETE=fish_source my-script > ~/.config/fish/completions/my-script.fishGenerates completion scripts that can be shipped with your package. Works with argparse and docopt.
Install: pip install shtab
Implementation:
import argparse
import shtab
parser = argparse.ArgumentParser()
shtab.add_argument_to(parser, ["-s", "--print-completion"]) # Adds completion flag
parser.add_argument('--config', help="Config file")
# User runs: my-script --print-completion bash >> ~/.bash_completionGenerate at build time:
shtab --shell=bash my_package.cli:parser > completions/my-script.bash
shtab --shell=zsh my_package.cli:parser > completions/_my-script| Framework | Best Choice | Why |
|---|---|---|
| argparse | argcomplete | Mature, widely used, dynamic completions |
| click/typer | Built-in | Native support, no extra deps |
| Need static files | shtab | Ship completions with package |
Packaging completions:
For distributing completion scripts with your package, add to pyproject.toml:
[tool.setuptools.data-files]
"share/bash-completion/completions" = ["completions/my-script"]
"share/zsh/site-functions" = ["completions/_my-script"]All CLI tools MUST support --version (and -V as a shorthand).
Implementation with argparse:
from ._version import __version__
parser = argparse.ArgumentParser(description="My CLI tool")
parser.add_argument(
'--version', '-V',
action='version',
version=f'%(prog)s {__version__}'
)Implementation with click:
import click
from ._version import __version__
@click.group()
@click.version_option(version=__version__, prog_name="my-cli")
def cli():
passThe version should come from the auto-generated _version.py file (created by hatch-vcs or poetry-dynamic-versioning) to ensure consistency with the installed package version.
Symptom: CI builds and publishes version 0.0.0 despite correct git tags.
Root cause: version = "0.0.0" is in [project] instead of [tool.poetry].
poetry-dynamic-versioning only patches [tool.poetry].version.
Fix: Move the version field:
[project]
name = "my-project"
dynamic = ["version"] # NOT version = "0.0.0"
[tool.poetry]
version = "0.0.0" # poetry-dynamic-versioning patches THIS fieldVerification: Run python -m build locally and check the filename includes
the correct version (e.g., my_project-1.2.3.tar.gz, not my_project-0.0.0.tar.gz).
Symptom: pip install -e . works on modern pip but fails with
ModuleNotFoundError: No module named 'pkg.subpackage' on old pip (Ubuntu 22.04).
Root cause: Modern hatchling discovers packages without __init__.py files, but
old pip's editable install mechanism requires them.
Fix: Always include __init__.py files in all packages and subpackages. They
can be empty. This costs nothing and ensures compatibility across all pip versions.
Symptom: pip install . fails on Ubuntu 22.04 (pip 22.0) with errors about
packaging.licenses.
Root cause: license = "Apache-2.0" (PEP 639 SPDX string format) triggers
hatchling to call packaging.licenses.canonicalize_license_expression(). Old pip's
build isolation leaks the system's ancient packaging (v21) which lacks that module.
Fix: Use PEP 621 table format instead:
# Bad — breaks on old pip
license = "Apache-2.0"
# Good — works everywhere
license = {text = "Apache-2.0"}Adding packaging>=24.0 to build-system.requires does NOT fix this because old
pip doesn't properly resolve it in the build isolation overlay.
poetry build requires poetry-dynamic-versioning to be installed as a Poetry
plugin. python -m build uses PEP 517 and automatically installs everything
listed in build-system.requires in an isolated environment. Prefer
python -m build in CI for reliability.