Skip to content

Adopt ruff 0.16 and its default rule set#73

Merged
tony merged 21 commits into
mainfrom
ruff-0.16
Jul 26, 2026
Merged

Adopt ruff 0.16 and its default rule set#73
tony merged 21 commits into
mainfrom
ruff-0.16

Conversation

@tony

@tony tony commented Jul 26, 2026

Copy link
Copy Markdown
Member

Summary

  • Migrate the workspace to ruff 0.16.0, pinning ruff>=0.16.0 in the dev dependency group so contributors and CI see the same diagnostics.
  • Adopt ruff's curated default rule set by switching [tool.ruff.lint] select to extend-select, taking the enabled-rule count from 351 to 565.
  • Fix the eight rules from that set whose findings were real, and scope a per-file ignore — with its reasoning in pyproject.toml — for the five that flag a deliberate idiom.
  • Format Python code blocks embedded in Markdown, which ruff format now does by default.

Changes

Dependencies

pyproject.toml, uv.lockruff floor raised to >=0.16.0; lockfile moves ruff 0.15.22 to 0.16.0.

Lint configuration

pyproject.tomlselect becomes extend-select, same entries, one per line with the linter it selects named in a trailing comment. select now stays deliberately unset, with a comment in the file saying why: naming it would replace ruff's default set rather than layer on top of it. There is no token for "the defaults" that an explicit select could name.

Rules fixed

  • RUF036 (none-not-at-end-of-union) — None moved to the end of the ScenarioInputValue and FrozenScenarioValue unions. Purely positional; the union members are unchanged.
  • ISC004 (implicit-string-concatenation-in-collection-literal) — multi-line implicit concatenations sitting bare inside a list or tuple literal are indistinguishable from a missing comma. The cluster blurbs, generated-page lead paragraphs, and one expected warning format string are now parenthesized.
  • PYI025 (unaliased-collections-abc-set-import) — from collections.abc import Set reads as the set builtin at every annotation site, and these are exactly the signatures where it matters: Sphinx hands merge_domaindata and prepare_writing an immutable set view. Imported as Set as AbstractSet in the three domains, the demo builder, and the OpenGraph description parser.
  • PYI047 (unused-private-type-alias) — _AsyncProcess and _AsyncioBus in sphinx_vite_builder._internal.vite were private, absent from __all__, and referenced nowhere; the comment above them claimed they were re-exports for consumers, which a leading underscore rules out. Deleted, along with the typing import they were the last user of.
  • PIE810 (multiple-starts-ends-with) — chained x.startswith(a) or x.startswith(b) collapsed into the tuple form.
  • TC005 (empty-type-checking-block) — two if t.TYPE_CHECKING: guards whose body was a bare pass, left behind after the last type-only import moved out.
  • PIE790 (unnecessary-placeholder) — a pass trailing two live imports inside a TYPE_CHECKING block.
  • FURB188 (slice-to-remove-prefix-or-suffix) — url[len("git+") :] and url[: -len(".git")] replaced with removeprefix / removesuffix, which state the affix once and carry the "only when present" guard themselves.
  • FURB167 (regex-flag-alias) — re.S spelled re.DOTALL in the palette block patterns.

Ignores scoped, with reasons

  • BLE001 (blind-except) — every site guards a call into code the extension does not own: introspecting a documented project's modules, stringifying whatever annotation autodoc supplies, resolving a builder URI, importing a user's argparse parser factory, reading intersphinx's untyped inventory. Each handler degrades to a fallback or reports a docutils error, so one malformed object cannot abort the docs build. Narrowing them would let exotic exceptions from foreign code escape and do exactly that. Ignored in the six modules that hold those boundaries.
  • S102 (exec-builtin) — three suites compile a literal source string into a throwaway module, because the shapes under test cannot be written as ordinary imports: deferred annotations that must not evaluate, positional-only fixture parameters, TYPE_CHECKING-only aliases. There is no untrusted input here.
  • FLY002 (static-join-to-f-string) — both joins assemble line-oriented text, one literal per output line: a Python program handed to a subprocess, and the expected RST block a generator emits line by line. An f-string with embedded newlines loses the shape that makes them reviewable.
  • PYI046 (unused-private-protocol) — _FixtureMarker is the return type of _detection._get_fixture_marker, but the rule only looks inside the file that defines a protocol, so one private to the package rather than to the module reads as dead. Renaming it public would export an internal normalisation detail to satisfy a check.
  • S110 (try-except-pass) — the intersphinx probe in _resolve_builtin_link is opportunistic; a failure means the inventory has no usable answer, the same outcome as a miss, and the next statement supplies it from the static map. Logging it would emit a warning in every docs build that no docs author can act on.

Formatting

README.md, docs/quickstart.md, docs/packages/sphinx-vite-builder/how-to.md, and the gp-sphinx, sphinx-autodoc-fastmcp, sphinx-ux-badges, and sphinx-vite-builder package READMEs — Python snippets reformatted. Blank line after a module docstring, magic trailing comma expansion, and comment alignment. No prose or shell blocks touched, and no .py file needed reformatting.

Docs

CHANGES — unreleased entry covering the toolchain bump, the default-set adoption, and the warning that running the formatter on an older ruff reverts the embedded Markdown snippets.

Design decisions

The 0.16.0 headline is the much larger default rule set, and an explicit select was silently opting the workspace out of all of it. extend-select layers this project's own linters on top of the defaults instead of in place of them, which is the only way to get both.

Expanding the explicit selection to whole prefixes instead was considered and rejected: it drags in all of D, PL, S, and friends rather than the curated subset, and produces one to two orders of magnitude more findings.

Every rule that fired got a decision — fixed, or ignored with the reason recorded in pyproject.toml. Ignores are scoped to the files that need them; none is repo-wide. Each fix and each ignore is its own commit, so a reviewer can take them one at a time and revert any single decision.

The Markdown formatting is the load-bearing part of the version bump itself: ruff format . --check runs in CI, so the embedded-snippet churn had to land or CI would go red on the next contributor's first push.

The floor lives in the dev group only. Nothing in the published packages depends on ruff, so runtime consumers are unaffected.

Test plan

  • uv run ruff check . — clean
  • uv run ruff format . --check — clean
  • uv run mypy . — clean
  • uv run pytest — full suite green

tony added 4 commits July 26, 2026 13:22
why: uv's global `exclude-newer = "3 days"` supply-chain cooldown hides
ruff 0.16.0 (released 2026-07-23) from the resolver, so the version
floor in the next commit cannot resolve.

what:
- Add `ruff = false` to `[tool.uv.exclude-newer-package]`

Temporary. Revert before merge - the cooldown clears on its own and the
`ruff>=0.16` floor is what actually holds the version.
why: ruff 0.16.0 stabilizes rules inside prefixes this workspace already
selects and starts formatting Python code blocks in Markdown. Pinning a
floor keeps contributors and CI on the same diagnostics instead of
splitting on whatever ruff each machine resolved.

what:
- Raise `ruff` to `>=0.16.0` in the `dev` dependency group
- Relock `uv.lock`: ruff 0.15.22 -> 0.16.0

https://astral.sh/blog/ruff-v0.16.0
why: ruff 0.16.0 stabilizes RUF036, which requires `None` to be the last
member of a type union. It reads as the fallback case and matches how
`Optional[X]` and typeshed order their unions.

what:
- Move `None` to the end of the `ScenarioInputValue` and
  `FrozenScenarioValue` type aliases

https://docs.astral.sh/ruff/rules/none-not-at-end-of-union/
why: ruff 0.16.0 formats Python and pycon code blocks inside Markdown by
default, so `ruff format . --check` now fails CI on READMEs and docs
pages that were previously out of scope. The churn is mechanical - the
formatter applying its existing Python rules to embedded snippets.

what:
- Reformat Python code blocks in the workspace and package READMEs and
  in the docs pages, matching `ruff format` output

https://astral.sh/blog/ruff-v0.16.0
@codecov-commenter

codecov-commenter commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 92.80%. Comparing base (5f287df) to head (600d7a0).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #73      +/-   ##
==========================================
- Coverage   92.80%   92.80%   -0.01%     
==========================================
  Files         275      275              
  Lines       22129    22123       -6     
==========================================
- Hits        20537    20531       -6     
  Misses       1592     1592              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

tony added 17 commits July 26, 2026 13:31
why: Contributors formatting with an older ruff will revert the Markdown
code blocks this bump reflowed, so the floor belongs in the changelog.

what:
- Add a `### Development` entry for the `ruff>=0.16.0` floor and the new
  Markdown code-block formatting
why: ruff 0.16.0 published 2026-07-23T19:10Z and has now cleared uv's
3-day supply-chain cooldown, so the resolver reaches it unaided. The
`ruff>=0.16.0` floor is what holds the version; leaving the exemption
would permanently opt ruff out of the cooldown guard.

what:
- Drop `ruff = false` from `[tool.uv.exclude-newer-package]`
- Relock: the setting is recorded in `uv.lock`, so removing it forces a
  re-resolve. ruff stays at 0.16.0 and no other package moves.

This reverts commit 20572d3.
why: ruff 0.16 ships a curated default rule set as its recommended
baseline. An explicit `select` replaces that set rather than extending
it, so this workspace was running 351 rules and silently opting out of
the rest. `extend-select` layers the project's own linters on top of the
default set instead of in place of it.

what:
- Replace `select` with `extend-select`, same entries, one per line
- Note in the file why `select` stays unset

Enabled rules go from 351 to 565.
https://docs.astral.sh/ruff/linter/#rule-selection
why: `url[len("git+") :]` and `url[: -len(".git")]` restate the affix
twice each and depend on the slice bounds staying in sync with the
literal. `str.removeprefix` / `str.removesuffix` say the same thing once
and carry the "only when present" guard themselves.

what:
- Replace the conditional slices with `removeprefix` / `removesuffix`

https://docs.astral.sh/ruff/rules/slice-to-remove-prefix-or-suffix/
why: `re.S` is a one-letter alias whose meaning has to be looked up.
`re.DOTALL` names what it does at the call site, and the palette
patterns rely on that flag to span CSS blocks across newlines.

what:
- Use `re.DOTALL` instead of `re.S` in the three block patterns

https://docs.astral.sh/ruff/rules/regex-flag-alias/
why: Two modules kept an `if t.TYPE_CHECKING:` guard whose body was a
bare `pass` — scaffolding left behind after the last type-only import
moved out. It reads as though something is imported under it.

what:
- Delete the empty guards in `_internal/process.py` and
  `tests/test_gp_furo_theme.py`
- Drop the now-unused `typing` import the guard was the last user of

https://docs.astral.sh/ruff/rules/empty-type-checking-block/
why: The `if t.TYPE_CHECKING:` block already imports two names, so the
trailing `pass` is dead — it reads like the block is empty when it is
not.

what:
- Remove the `pass` from the TYPE_CHECKING block

https://docs.astral.sh/ruff/rules/unnecessary-placeholder/
why: Chained `x.startswith(a) or x.startswith(b)` walks the string
twice and repeats the receiver. `str.startswith` accepts a tuple and
short-circuits internally, so the tuple form is both shorter and the
one CPython optimises.

what:
- Collapse the paired `startswith` calls in the em-dash separator check
  and the how-to prose policy test

https://docs.astral.sh/ruff/rules/multiple-starts-ends-with/
why: `_AsyncProcess` and `_AsyncioBus` are private, absent from
`__all__`, and referenced nowhere — the comment above them claims they
are re-exports for consumers, but a leading underscore makes them
unimportable in that sense. The module already uses `AsyncProcess` and
`AsyncioBus` directly.

what:
- Remove both aliases and the comment describing them
- Drop the `typing` import they were the last user of

https://docs.astral.sh/ruff/rules/unused-private-type-alias/
why: `from collections.abc import Set` shadows nothing but reads as the
`set` builtin at every annotation site, and these are exactly the
signatures where the distinction matters — Sphinx hands
`merge_domaindata` and `prepare_writing` an immutable set view, not a
`set`.

what:
- Import `Set as AbstractSet` and annotate with `AbstractSet` in the
  three domains, the demo builder, and the OpenGraph description parser

https://docs.astral.sh/ruff/rules/unaliased-collections-abc-set-import/
why: A multi-line implicit concatenation sitting bare inside a list or
tuple literal is indistinguishable from a missing comma — the reader
has to count elements to tell whether two sentences are one item or
two. Parentheses make the intent explicit at the site.

what:
- Wrap the concatenated cluster blurbs, generated-page lead paragraphs,
  and the expected warning format string in parentheses

https://docs.astral.sh/ruff/rules/implicit-string-concatenation-in-collection-literal/
why: Every BLE001 site in this workspace guards a call into code the
extension does not own — introspecting a documented project's modules,
stringifying whatever annotation autodoc supplies, resolving a builder
URI, importing a user's argparse parser factory, reading intersphinx's
untyped inventory. Each handler degrades to a fallback or reports a
docutils error, so a single malformed object cannot abort the docs
build. Narrowing the catches would let exotic exceptions from foreign
code escape and do exactly that.

what:
- Per-file-ignore BLE001 in the six modules holding those boundaries

https://docs.astral.sh/ruff/rules/blind-except/
why: Three suites build throwaway modules by compiling a literal source
string, because the shapes under test cannot be written as ordinary
imports: deferred annotations that must not evaluate, positional-only
fixture parameters, `TYPE_CHECKING`-only aliases. The source is a
literal in the test file — there is no untrusted input for S102 to
protect against.

what:
- Per-file-ignore S102 in the three suites that synthesise modules

https://docs.astral.sh/ruff/rules/exec-builtin/
why: `_resolve_builtin_link` asks intersphinx for a pytest fixture URL
before falling back to the static `PYTEST_BUILTIN_LINKS` map. A failure
inside the probe means the inventory has no usable answer — the same
outcome as a plain miss, and the next statement already handles it. The
`pass` is that fallthrough, not a swallowed error, and logging it would
emit a warning in every docs build that no docs author can act on.

what:
- Add S110 alongside BLE001 on `_store.py`

https://docs.astral.sh/ruff/rules/try-except-pass/
why: `_FixtureMarker` is the return type of
`_detection._get_fixture_marker`, but PYI046 only looks inside the file
that defines a protocol, so one that is private to the package rather
than to the module reads as dead. Renaming it public would export an
internal normalisation detail purely to satisfy the check.

what:
- Per-file-ignore PYI046 in `_models.py`

https://docs.astral.sh/ruff/rules/unused-private-protocol/
why: Both FLY002 sites join a sequence of string literals where each
literal is one output line — a Python program handed to a subprocess,
and the expected RST block that
`_build_autofixtures_directive_text` emits line by line. The
one-literal-per-line shape is what makes them readable and reviewable;
collapsing them into an f-string with embedded newlines (and, in the
subprocess case, nested quoting) loses that.

what:
- Per-file-ignore FLY002 in `package_tools.py` and
  `test_sphinx_pytest_fixtures.py`

https://docs.astral.sh/ruff/rules/static-join-to-f-string/
why: The changelog recorded the ruff floor but not the larger change it
enabled — the workspace now lints against ruff's default rule set, so
contributors see diagnostics from linters this project never named.

what:
- Extend the ruff 0.16 development entry with the default-set adoption
  and the scoped per-file ignores
@tony tony changed the title Require ruff>=0.16.0 Adopt ruff 0.16 and its default rule set Jul 26, 2026
@tony
tony merged commit 3387bb2 into main Jul 26, 2026
44 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants