Skip to content

Instantly share code, notes, and snippets.

@tobixen
Last active April 13, 2026 11:31
Show Gist options
  • Select an option

  • Save tobixen/e4df792142a6b0ca33295d8d3e040473 to your computer and use it in GitHub Desktop.

Select an option

Save tobixen/e4df792142a6b0ca33295d8d3e040473 to your computer and use it in GitHub Desktop.
My standards for python projects

Python Project Modernization

Modernize Python projects to follow best current practices for packaging, linting, CI, and releases.

Trigger

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"

Target Configuration

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.

Build System Choice

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

Workflow

0. Review the python project modernization guidelines

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.

1. Assess Current State

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

2. Create/Migrate pyproject.toml

Option A: Hatch (preferred for new projects)

[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"

Option B: Poetry (for existing Poetry projects)

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 static version = "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"

3. Configure Ruff

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.

4. Configure Pytest

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:

  1. Always document WHY the warning is being ignored (upstream bug, pending fix, explicit testing that legacy code still works, etc.)
  2. Include a link to the relevant issue if available
  3. Be as specific as possible in the filter (module, message pattern) to avoid hiding new warnings
  4. 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.

5. Set Up Pre-commit

Pre-commit hooks run at two stages:

  1. On commit (pre-commit): Quick checks - ruff lint/format, trailing whitespace, etc.
  2. 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 now

6. Create GitHub Actions Workflows

Link Checker (.github/workflows/linkcheck.yml)

Runs 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 issue

Note: "create github issues" is somewhat experimental but worth enabling.

Test CI (.github/workflows/tests.yml):

For Hatch projects:

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: pytest

For Poetry projects:

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 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 pytest

7. Create PyPI Release Workflow

Create .github/workflows/publish.yml:

For Hatch projects:

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/v1

For Poetry projects:

Uses 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: true

Note: Requires PyPI trusted publishing configured at pypi.org.

8. Create Changelog

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 release

9. Create Makefile

The 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:

  1. root — system-wide pip install (for server deployments, containers, puppet/ansible)
  2. uv — fast, isolated tool install (no venv/PATH issues)
  3. pipx — isolated tool install (established, widely available)
  4. pip --user — fallback with PIP_BREAK_SYSTEM_PACKAGES=1 for 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.

For Hatch projects:

.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"

For Poetry projects:

.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 || true

10. Clean Up Legacy Files

Remove if present:

  • setup.py
  • setup.cfg (if fully migrated)
  • MANIFEST.in (usually not needed with modern build systems)
  • requirements.txt (dependencies in pyproject.toml)

11. Commit Changes

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"

PyPI Trusted Publishing Setup

After creating the workflow, configure trusted publishing on PyPI:

  1. Go to pypi.org/manage/project/PROJECT/settings/publishing/
  2. Add new pending publisher or trusted publisher
  3. Set:
    • Owner: github-username
    • Repository: repo-name
    • Workflow name: publish.yml
    • Environment: pypi

Notes

  • Always use fetch-depth: 0 in 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-files to 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

12. CONTRIBUTING.md

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.

13. CLI README/Documentation Guidelines

For CLI projects:

  • Installation: README.md or INSTALL.md must use make install as the official method, never pip install. Example:

    ## Installation

    make 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 --help gives 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...

CLI Best Practices

Shell Tab Completion

Modern CLI tools should support shell tab-completion for commands, options, and arguments.

Option 1: argcomplete (for argparse)

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()

Option 2: click (built-in completion)

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.fish

Option 3: shtab (generates static completion scripts)

Generates 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_completion

Generate at build time:

shtab --shell=bash my_package.cli:parser > completions/my-script.bash
shtab --shell=zsh my_package.cli:parser > completions/_my-script

Recommendation

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"]

Version Flag

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():
    pass

The 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.

Common Pitfalls

Poetry dynamic versioning publishes 0.0.0

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 field

Verification: 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).

Missing init.py breaks editable installs on old pip

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.

Hatch license format breaks on old pip (Ubuntu 22.04)

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 vs python -m build

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment