2026-07-26 Ruff 0.16 Defaults and the select vs extend-select Trap

Context: python-project-template (github.com/achhina/python-project-template). Ruff 0.16.0 landed 2026-07-23 with a huge default-rule expansion; wanted the template on it. Shipped as PR #5 / v1.5.0.

What I set out to do

Bump the template’s ruff floor to >=0.16.0 and compare what 0.16 enables by default against the curated select list already in template/pyproject.toml.jinja.

What I actually did

  • Measured instead of guessed. Rather than reading release notes and inferring, diffed the two rule sets empirically: rendered a config with only [tool.ruff] and one with the template’s select, ran ruff check --show-settings on each, and diffed the linter.rules.enabled lists. Ruff 0.16 enables 413 rules by default, up from 59.
  • Found the actual bug. select replaces the default set rather than adding to it. So on 0.16 the curated list was silently discarding 135 default rules — the version bump alone would have changed nothing. Switched to extend-select, which layers. Generated projects went 506 → 643 rules.
  • Checked whether the list could shrink. The obvious follow-up was “drop the prefixes the defaults now cover.” Computed marginal contribution per prefix: none of the 19 is fully subsumed. S still adds 55/58, PTH 33/35, PT 22/28; even the thin ones contribute (I adds only I002, C4 only C416/C420, ERA and TID and ARG add 100% of theirs). List stayed unchanged.
  • The new defaults surfaced a real template bug. A default generation failed ruff check with D419 (empty docstring), because project_description defaults to "" and __init__.py.jinja was """{{ project_description }}""". Chasing that revealed the same root cause broke mdformat too: the empty value left stray blank lines in README.md, AGENTS.md, and docs/index.md. A default generation shipped a project that failed its own pre-commit run.
  • Fixed both: or project_name fallback in the docstring, whitespace-controlled {%- if project_description %} blocks in the three markdown templates. Verified against both empty and non-empty descriptions.
  • Opened PR #5 bumping template_version to 1.5.0 with a matching CHANGELOG section, per the automated release flow.

What was striking

  • select vs extend-select is a silent-downgrade trap, not a style choice. Anyone with a curated select who upgrades to 0.16 gets fewer rules than a config with no lint section at all, and nothing warns them. The curated list looks like the strict option and is now the permissive one.
  • --show-settings is the right tool for this class of question. It prints the fully-resolved linter.rules.enabled list, so “what does this config actually turn on” becomes a diff instead of an argument. Worth remembering: it prints both rules.enabled and rules.should_fix, so naive grepping double-counts (I did, briefly, and got 826 instead of 413).
  • The lint upgrade found a generation bug that lint upgrades aren’t supposed to find. D419 was the thread; the mdformat failure was the same empty-string hole one layer over. The empty project_description default had been broken since forever and nothing caught it because nobody ran the generated project’s own hooks on a default generation.
  • Closed out a deferred item from 2026-06-29 Python Project Template Modernization and Release Automation. That entry’s “Top 3 next” #2 listed “ruff rule additions (DTZ/LOG/TC/…)” as deferred polish. Adopting the 0.16 defaults delivered exactly those — DTZ, LOG, TC, plus ASYNC, TRY, G, FURB, BLE001, T100 — without hand-curating a single one. Sometimes the right move on a deferred config chore is to wait for upstream to make it the default.
  • That same entry’s #3 was “watch the next real version-bump PR merge auto-release end-to-end.” PR #5 is that PR — first real exercise of the release pipeline since the git-identity fix.

Top 3 next

  1. Merge PR #5 and confirm the release workflow cuts v1.5.0 end-to-end with no manual workflow_dispatch (the deferred verification from June).
  2. Consider whether existing downstream projects need a heads-up beyond the CHANGELOG “Upgrading” note — copier update will surface ~137 new rules and can fail lint on previously-passing code.
  3. Remaining June polish bucket, still open: exclude_also coverage config, GPL-3.0GPL-3.0-or-later SPDX, dropping dead check-dependabot / check-readthedocs hooks.

2026-06-29 Python Project Template Modernization and Release Automation