Features and Decisions#

This template makes explicit choices for a working baseline for modern Python projects.

The original rationale is described in the blog post My opinionated scaffolding for modern Python projects. This chapter translates that rationale into a feature-by-feature reference.

Copier template, plus a wrapper#

The foundation is Copier. Copier treats the generated project as a maintained instance of the template: it records the template version and the answers used during generation, then uses that information to apply later template updates. This gives the project a clear lifecycle from the initial scaffold through subsequent improvements, while leaving conflicts visible for the project maintainer to review.

This repository also publishes a wrapper CLI as python-package-copier-template. The wrapper is intentionally small:

  • it detects copy vs update mode from the destination,

  • it keeps the happy path short,

  • it hides the extra copier-template-extensions setup most users do not want to remember.

When you want full control, you can always drop to raw Copier commands.

Python packaging defaults#

Generated projects assume:

  • Python 3.12 or newer; CI currently tests Python 3.12 through 3.15,

  • the exact Python version used to run Copier is recorded in .python-version,

  • a src/ layout,

  • metadata centralized in pyproject.toml,

  • uv_build as the build backend for pure-Python packages,

  • an optional CLI entrypoint implemented with argparse.

These defaults provide a modern baseline with a small packaging surface. They fit libraries and small applications that do not need compiled extensions.

Dependency management with uv#

The template uses uv for environment management, dependency resolution, and package publishing. That decision is mostly about coherence:

  • one tool for local environments and CI,

  • fast installs and syncs,

  • dependency groups in pyproject.toml,

  • native support for building and publishing workflows.

Generated projects split dependencies by purpose, typically across runtime, docs, tests, and QA. This follows the direction of PEP 735 dependency groups and keeps installs task-focused.

The dependency groups also use include-group to compose higher-level groups from narrower ones instead of repeating the same tools across sections. That gives generated projects a small inheritance-style structure:

        flowchart TD
    lint["lint"] --> qa["qa"]
    test["test"] --> dev["dev"]
    qa --> dev
    docs["docs"] -. optional local install .-> run_docs["uv run --group docs ..."]
    test -. focused install .-> run_test["uv run --group test pytest"]
    dev -. default dev environment .-> run_dev["uv run ..."]
    

In the generated pyproject.toml, that looks like this:

[dependency-groups]
test = [
    "pytest>=9.0.1",
    "pytest-freezer>=0.4.9",
    "pytest-mock>=3.15.0",
    "pytest-cov>=7.0.0",
]
lint = ["ruff"]
qa = [
    { include-group = "lint" },
    "ty>=0.0.27",
]
docs = [
    "myst-parser>=3.0.0",
    "sphinx>=8.2",
    "sphinx-book-theme>=1.1.0",
    "sphinxcontrib-mermaid>=1.0.0",
    "richterm[sphinx]>=0.1.0",
]
dev = [
    { include-group = "test" },
    { include-group = "qa" },
    "ipdb",
    "ipython",
]

This keeps each group focused:

  • test contains only what is needed to run tests,

  • qa layers type-checking on top of linting,

  • dev gives contributors the broadest working set without duplicating test and qa.

Simon Willison called out this exact pattern in Dependency groups and uv run, noting that he learned it from python-package-copier-template.

Note

I learn from Simon’s writing all the time, so it felt especially meaningful to give something back for once. Seeing this project teach him one small trick was a real point of pride and gratitude. 🙏

Dependency cooldowns#

The template enables uv dependency cooldowns by default with [tool.uv].exclude-newer. The goal is not perfect supply-chain security; it is a practical delay buffer so projects do not pull the newest releases the moment they appear.

Some QA tools can still opt into fresher versions when needed. That tradeoff keeps projects conservative by default while preserving room to adopt toolchain fixes intentionally.

Ruff for linting and formatting#

Ruff is the linting and formatting baseline. The main value here is consolidation: a single fast tool can cover what used to require multiple linters and formatters, which makes local feedback and CI simpler.

The configuration includes Ruff’s default rules and selects every rule in each listed family. Ruff’s default selection is limited to E4, E7, E9, and F; selecting E, W, and F therefore activates the complete families containing those defaults. The table lists the complete selection. The remaining families and rule are additional checks.

Selector

Documentation

Scope

E, W

pycodestyle

Includes default E4, E7, and E9, plus the complete families; catches style and syntax-adjacent issues.

F

Pyflakes

Includes the default family; finds undefined names and other likely errors.

I

isort

Sorts and groups imports.

C90

McCabe

Flags functions above complexity 10, Ruff’s default threshold.

A

flake8-builtins

Prevents shadowing Python built-ins.

ANN

flake8-annotations

Checks function annotations.

UP

pyupgrade

Encourages modern Python syntax.

RUF

Ruff-specific

Applies Ruff-specific correctness and style checks.

T10

flake8-debugger

Finds debugger calls.

ISC

flake8-implicit-str-concat

Detects implicit string concatenation.

SIM

flake8-simplify

Suggests simpler control flow.

ASYNC

flake8-async

Checks async code for common problems.

ERA

eradicate

Finds commented-out code.

TRY

tryceratops

Checks exception handling practices.

YTT

flake8-2020

Finds Python-version compatibility traps.

BLE

flake8-blind-except

Flags overly broad exception handling.

B

flake8-bugbear

Finds likely bugs and design problems.

EXE

flake8-executable

Checks executable files and shebangs.

FA

flake8-future-annotations

Checks safe use of future annotations.

C4

flake8-comprehensions

Simplifies unnecessary comprehensions.

DTZ

flake8-datetimez

Requires explicit timezone handling.

FBT

flake8-boolean-trap

Flags ambiguous boolean arguments.

INT

flake8-gettext

Checks gettext usage.

LOG, G

flake8-logging

Checks logging calls and format strings.

S

flake8-bandit

Finds common security issues.

SLF

flake8-self

Restricts access to private members across classes.

FLY

flynt

Simplifies string formatting.

N

pep8-naming

Checks naming conventions.

PIE

flake8-pie

Finds unnecessary or error-prone code.

PYI

flake8-pyi

Checks type stub files.

PT

flake8-pytest-style

Checks pytest conventions.

TC

flake8-type-checking

Organizes type-checking imports.

PTH

flake8-use-pathlib

Encourages pathlib APIs.

PERF

Perflint

Finds avoidable performance issues.

D

pydocstyle

Checks docstring conventions.

PGH

pygrep-hooks

Checks fragile code patterns.

PL

Pylint

Adds broader code-quality checks.

FURB

refurb

Suggests modern Python improvements.

RET

flake8-return

Checks return statements.

TID252

flake8-tidy-imports

Requires absolute imports.

A regression test protects the required selectors in both this package and the generated project template.

Specific per-file ignores remain narrow. Test modules skip ANN and D, and also allow the security patterns S101, S108, S603, and S607 that are common in test fixtures. docs/conf.py skips A and D100 because it is a Sphinx configuration module. The incompatible pydocstyle pairs D203/D211 and D212/D213 are resolved explicitly in favor of D211 and D212.

ty for type checking#

ty is the default type checker. This is a modern choice: the tool is fast, improving quickly, and a good fit for projects that want explicit types with little ceremony. The generated package includes a py.typed marker, which declares that its inline annotations are intended to be consumed by type checkers downstream. ty check runs as part of the QA group and as a Prek hook, checking the package source and reporting type errors before they reach CI.

pytest for tests#

pytest remains the default testing framework, together with pytest-cov and coverage.py. It is still the least surprising default for most Python teams, and it keeps the generated test suite straightforward to extend.

prek for orchestration and hooks#

prek is included as an optional layer for QA orchestration and git hook management. The template does not hard-require it to exist everywhere, but when it is available it gives generated projects a convenient way to install hooks and run the whole QA suite consistently.

The generated Makefile exposes stable shortcuts such as make qa and make test.

The configured checks are:

Group

Checks

File hygiene

trailing-whitespace, end-of-file-fixer, mixed-line-ending, check-added-large-files

Names and links

check-case-conflict, check-illegal-windows-names, check-symlinks, destroyed-symlinks, check-vcs-permalinks

File formats

check-json, check-yaml, check-toml, check-xml

Repository safety

check-merge-conflict, detect-private-key, no-commit-to-branch for main

Executables

check-executables-have-shebangs, check-shebang-scripts-are-executable

Python QA

ruff check --fix, ruff format, ty check

Documentation with Sphinx and MyST#

Generated projects include a docs/ directory from day one. That is a deliberate choice: documentation is much easier to maintain when the scaffolding already exists before the project becomes complicated.

The docs stack is:

  • Sphinx,

  • MyST for Markdown authoring,

  • sphinx-book-theme for the generated site theme,

  • GitHub Pages for hosting,

  • plus a couple of extensions in generated projects for diagrams and terminal captures.

This keeps docs in the same lifecycle as code:

  • authored in-repo,

  • built locally with make docs,

  • validated in CI,

  • published automatically.

GitHub automation#

The template automates several repository tasks through GitHub Actions and, when available, GitHub CLI:

  • CI on pushes and pull requests,

  • docs previews for documentation PRs,

  • releases to PyPI through Trusted Publishing,

  • scheduled template refreshes for generated projects,

  • optional initial repository creation and push.

The point is to reduce the amount of “project setup work” that usually gets postponed and then repeated by hand across repositories.

Demo repository#

The canonical generated-project example is mgaitan/yet-another-demo. Use experiment branches there when validating changes against a real repository created from this scaffold. That demo is useful for three different reasons:

  • it shows what the scaffold looks like after rendering,

  • it gives a realistic target for smoke-testing updates,

  • it helps keep the template honest by forcing changes to work in a generated project, not only in the template repository itself.

Trusted Publishing for releases#

Generated projects are configured to publish to PyPI through OIDC-based Trusted Publishing rather than long-lived tokens. That removes a class of secret-management problems from normal release automation.

It still requires a one-time manual registration in PyPI, because PyPI must know which repository and workflow are allowed to publish the project. After that, the release flow is intentionally boring:

make bump
make release

Release attestations#

Release workflows sign every wheel and source distribution without storing a long-lived signing key. GitHub Actions exchanges its OIDC identity for a short-lived Sigstore certificate, and the resulting signed statement binds the artifact digest to the workflow that produced it.

astral-sh/attest-action creates PEP 740 publish attestations, which uv publish uploads with the distributions to PyPI.

Verify a published wheel or source distribution by passing its PyPI file URL:

uvx pypi-attestations verify pypi \
  --repository https://github.com/mgaitan/python-package-copier-template \
  https://files.pythonhosted.org/path/to/distribution.whl

Repository ergonomics#

The template also generates the boring but useful project files early:

  • LICENSE,

  • CODE_OF_CONDUCT.md,

  • AGENTS.md,

  • starter docs,

  • Makefile targets,

  • GitHub workflows.

This is less about ceremony and more about reducing setup variance. When those pieces already exist, projects are easier to maintain consistently.

Agent-facing guidance#

The generated AGENTS.md is part of the scaffold on purpose. As code agents become a normal part of day-to-day maintenance, repositories benefit from having explicit local instructions for editing style, release habits, documentation expectations, and operational constraints.

This turns agent guidance into project infrastructure instead of ad-hoc chat context.

Updating generated projects#

The most distinctive feature of using Copier instead of a one-shot scaffold is updateability. Generated projects keep a .copier-answers.yml file with template metadata and answers from the original questionnaire.

That enables:

  • manual updates with uvx python-package-copier-template .,

  • direct updates with copier update,

  • automated refresh PRs through the generated workflow.

That update path is one of the main reasons to use this template at all. It allows the scaffold to behave more like shared project infrastructure than a static starting snapshot.