Skip to content

Require ruff>=0.16.0#549

Merged
tony merged 18 commits into
masterfrom
ruff-0.16
Jul 26, 2026
Merged

Require ruff>=0.16.0#549
tony merged 18 commits into
masterfrom
ruff-0.16

Conversation

@tony

@tony tony commented Jul 26, 2026

Copy link
Copy Markdown
Member

Summary

  • Migrate the toolchain to ruff 0.16.0 by raising the floor in the dev and lint dependency groups and relocking.
  • Adopt ruff's curated default rule set by switching select to extend-select, taking the enabled-rule count from 351 to 565.
  • Fix the diagnostics that adoption surfaced, and scope a per-file ignore, with its reason, for each site where the flagged construct is deliberate.
  • Format the Python code blocks inside README.md and notes/, which ruff format now rewrites by default.

Changes

Dependencies

pyproject.toml raises ruff to >=0.16.0 in both the dev and lint groups, and uv.lock moves ruff 0.15.22 to 0.16.0. A floor rather than a pin, so contributors and CI agree on the diagnostics without freezing patch releases out.

Rule selection

ruff 0.16 ships a curated 413-rule default set as its recommended baseline. An explicit select replaces that set rather than extending it, so naming linters there silently opted the project out of everything ruff enables by default. RuleSelector has no DEFAULT token, so there is no way to name the default set from inside select; leaving select unset and listing the project's own linters under extend-select is the only way to get defaults-plus-extras.

pyproject.toml makes that switch with the same entries, one per line, each labelled with the linter it selects, and records in the file why select stays unset. Enabled rules go from 351 to 565.

Lint fixes

src/libvcs/_internal/query_list.py, src/libvcs/_internal/subprocess.py, and tests/test_shortcuts.py move None to the end of their type unions (RUF036). Annotation-only reordering with no runtime or type-checking effect — int | IO[Any] | None and None | int | IO[Any] denote the same type. The _FILE alias in subprocess.py now matches the identically named alias in _internal/run.py, which already ordered None last.

Every exception class calls super().__init__(...) as a statement instead of returning it (PLE0101). __init__ must return None; the old form read as if the base initializer produced a value worth propagating.

ProgressCallbackProtocol receives an aware UTC datetime rather than a naive local one (DTZ005). Naive timestamps taken on hosts in different zones compared and serialized as though they named the same instant, and a DST rollover made them ambiguous on a single host. Callbacks that only format the value for display are unaffected.

GitSubmoduleManager._ls reads submodule name, url, and branch from .gitmodules directly (S110, BLE001). Each lookup already passes check_returncode=False, which is exactly the switch that stops run() raising on a non-zero exit, so the try/except Exception/pass around it guarded a failure mode the call cannot produce while swallowing any real one.

_TXT is gone from libvcs._internal.subprocess (PYI047) — a private type alias with no annotation referencing it. The two subprocess.run calls in the test suite that assert on returncode themselves now say check=False (PLW1510).

libvcs.sync.svn loses its #!/usr/bin/env python line (EXE001). The module is imported, never executed — no __main__ guard, no executable bit — so the shebang advertised an entry point that does not exist. This one is only visible on CI: ruff skips the rule on WSL, where it was authored.

Scoped ignores

Four rules fire on constructs that are deliberate here. Each is scoped to the file it applies to, never repo-wide, with the reason recorded next to the entry in pyproject.toml.

S102 in docs/conf.py: the Sphinx config reads __title__, __version__, and friends by exec'ing src/libvcs/__about__.py into a dict, which keeps the docs build from importing libvcs so version metadata resolves even when the package is not installed. The exec'd file is this repository's own source.

PLW1509 in src/libvcs/_internal/run.py: run() reproduces subprocess.Popen's signature so callers can reach any of it, preexec_fn included, and defaults it to None. libvcs never forks with a preexec hook on its own; the thread-safety hazard belongs to a caller that opts in.

BLE001 in src/libvcs/_internal/query_list.py: QueryList filters arbitrary caller-supplied objects, so resolving a food__istartswith path walks __getattr__, properties, and __eq__ that libvcs did not write. An unresolvable lookup is a non-match, not an error — keygetter and parse_lookup log at debug and return None while the in/nin predicates return False. Narrowing the handlers would let a foreign property abort an entire filter.

BLE001 in src/libvcs/sync/svn.py: SvnSync.url_rev falls back to shelling out to svn info --xml and scraping the result when neither the .svn/entries nor the XML layout applies. What that can raise depends on the installed svn, the working copy layout, and the output format. The method's contract is to answer (None, 0) for a working copy it cannot read.

Formatting

README.md and notes/2025-11-26-command-support.md get their Python blocks normalized: double quotes, standard argument wrapping, single-space comment gaps, trailing whitespace removed. All of it is mechanical output of ruff format, which in 0.16.0 descends into Markdown fences by default. ruff format . --check in CI fails without it.

Design decisions

Expanding to whole prefixes instead of the curated default set was rejected: it is one to two orders of magnitude noisier, because it drags in all of D, PL, S, and friends rather than ruff's own recommended subset.

Each rule lands as its own commit — one per fix, one per ignore — so a disagreement over any single disposition can be reverted without unpicking the rest. Ignores carry their justification in pyproject.toml rather than as # noqa comments in the code.

The _FILE alias fix was applied by hand because ruff classifies it as an unsafe fix — the alias is a runtime assignment, not an annotation, so ruff cannot rule out code that inspects typing.get_args() ordering. Nothing in the tree does; the alias is referenced only from annotations and docstrings.

The notes/ reformat was accepted rather than excluded from the formatter. It flattens some hand-aligned trailing comments in a design note, but carving notes/ out of ruff format is a policy change that belongs in its own PR, not in a version bump.

Test plan

  • uv run ruff check . — clean
  • uv run ruff format . --check — clean
  • uv run mypy — clean
  • uv run py.test — green

The Git.help doctest calls git help --all, which intermittently hangs on the author's machine regardless of this branch: run() does not drain the child's pipes when no timeout is given, so the child blocks on write while the parent spins in its poll loop. It reproduces identically on an unmodified master checkout, and it is on a code path this branch does not touch. Pre-existing and out of scope here; CI is the authority on that test.

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 project 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` and `lint` dependency groups
- 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:
- Reorder `keygetter`'s return annotation to end in `| None`
- Reorder the `_FILE` subprocess alias to end in `| None`, matching the
  identical alias in `_internal/run.py`
- Reorder `test_create_project`'s `raises_exception` parameter

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 on docs that were never
run through the formatter. The churn is mechanical - quote style,
argument wrapping, comment spacing, trailing whitespace.

what:
- Reformat the Python blocks in `README.md`
- Reformat the Python blocks in `notes/2025-11-26-command-support.md`

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

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 32.00000% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 61.34%. Comparing base (12e4469) to head (491ed25).

Files with missing lines Patch % Lines
src/libvcs/cmd/git.py 44.44% 2 Missing and 3 partials ⚠️
src/libvcs/sync/git.py 0.00% 4 Missing ⚠️
src/libvcs/_internal/shortcuts.py 0.00% 3 Missing ⚠️
src/libvcs/_internal/query_list.py 50.00% 1 Missing ⚠️
src/libvcs/_internal/subprocess.py 50.00% 1 Missing ⚠️
src/libvcs/cmd/svn.py 0.00% 1 Missing ⚠️
src/libvcs/pytest_plugin.py 0.00% 1 Missing ⚠️
src/libvcs/sync/svn.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #549      +/-   ##
==========================================
+ Coverage   61.31%   61.34%   +0.03%     
==========================================
  Files          40       40              
  Lines        6566     6556      -10     
  Branches     1104     1104              
==========================================
- Hits         4026     4022       -4     
+ Misses       1946     1940       -6     
  Partials      594      594              

☔ 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 14 commits July 26, 2026 14:32
why: The ruff floor changes what contributors need installed and widens
what `ruff format` rewrites, so it belongs in the unreleased entry.

what:
- Add a `#### Ruff moves to 0.16 (#549)` deliverable under `### Development`
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 7790836.
why: ruff 0.16 ships a curated 413-rule default set as its recommended
baseline. An explicit `select` replaces that set rather than extending
it, so this project was running 351 rules and silently opting out of
the default set. `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: `__init__` must return None. `return super().__init__(...)` reads as
if the base initializer produced a value worth propagating; it does not,
and the form masks a genuine mistake if one is ever introduced there.

what:
- Call `super().__init__(...)` as a statement in every exception class

https://docs.astral.sh/ruff/rules/return-in-init/
why: `_TXT` was a private type alias with no annotation referencing it,
so it carried no meaning for readers or type checkers.

what:
- Remove the `_TXT` type alias from `libvcs._internal.subprocess`

https://docs.astral.sh/ruff/rules/unused-private-type-alias/
why: The `timestamp` handed to a progress callback came from a naive
`datetime.now()`, so it carried the host's local wall clock with no
offset attached. Two hosts in different zones produced timestamps that
compare and serialize as if they were the same instant, and a DST
rollover makes them ambiguous or non-monotonic on a single host.

what:
- Pass `tz=datetime.timezone.utc` to every `datetime.now()` feeding
  `ProgressCallbackProtocol`

https://docs.astral.sh/ruff/rules/call-datetime-now-without-tzinfo/
why: `subprocess.run` defaults to `check=False`, so a reader cannot tell
whether a call site meant to ignore the exit status or forgot to check
it. Both of these deliberately inspect `returncode` themselves to attach
stderr to the assertion message.

what:
- Pass `check=False` at the two call sites that assert on `returncode`

https://docs.astral.sh/ruff/rules/subprocess-run-without-check/
why: Each `.gitmodules` lookup passes `check_returncode=False`, which is
precisely the switch that stops `run()` raising on a non-zero exit — a
missing or unreadable `.gitmodules` already comes back as output text
that the following `if` filters out. The wrapping `try`/`except
Exception`/`pass` therefore guarded a failure mode the call cannot
produce, while silently swallowing any real one.

what:
- Call `git config` directly for submodule name, url, and branch

The same deletion clears the blind-except at these sites.
https://docs.astral.sh/ruff/rules/try-except-pass/
https://docs.astral.sh/ruff/rules/blind-except/
why: `docs/conf.py` reads `__title__`, `__version__`, and friends by
exec'ing `src/libvcs/__about__.py` into a dict. That is deliberate: it
keeps the docs build from importing libvcs, so version metadata resolves
even when the package itself is not installed. The exec'd file is this
repository's own source, not untrusted input.

what:
- Per-file-ignore S102 for `docs/conf.py`, with the reason in the config

https://docs.astral.sh/ruff/rules/exec-builtin/
why: `run()` reproduces `subprocess.Popen`'s signature so callers can
reach any of it, and `preexec_fn` is one of those parameters. It defaults
to None, so libvcs never forks with a preexec hook on its own; the
thread-safety hazard the rule warns about belongs to a caller that opts
in. Removing the parameter would break that caller with nothing to
replace it.

what:
- Per-file-ignore PLW1509 for `src/libvcs/_internal/run.py`, with the
  reason in the config

https://docs.astral.sh/ruff/rules/subprocess-popen-preexec-fn/
why: QueryList filters arbitrary caller-supplied objects. Resolving a
`food__istartswith` path means walking `__getattr__`, properties, and
`__eq__` that libvcs did not write and cannot enumerate the failure modes
of. An unresolvable lookup is a non-match, not an error, so `keygetter`
and `parse_lookup` log at debug and return None while the `in`/`nin`
predicates return False. Narrowing the handlers would let a foreign
property abort an entire filter.

what:
- Per-file-ignore BLE001 for `src/libvcs/_internal/query_list.py`, with
  the reason in the config

https://docs.astral.sh/ruff/rules/blind-except/
why: `SvnSync.url_rev` falls back to shelling out to `svn info --xml` and
scraping the result when neither the `.svn/entries` nor the XML layout
applies. What that can raise depends on the installed svn, the working
copy layout, and the output format — a `CommandError`, a failed match, a
non-integer revision. The method's contract is to answer `(None, 0)` for
a working copy it cannot read, so the fallback absorbs all of them.

what:
- Per-file-ignore BLE001 for `src/libvcs/sync/svn.py`, with the reason in
  the config

https://docs.astral.sh/ruff/rules/blind-except/
why: Adopting ruff's default set changes what `just ruff` enforces for
contributors, and the progress-callback timestamp change is visible to
anyone who compares or serializes that value.

what:
- Extend the ruff 0.16 deliverable with the `extend-select` switch
- Add a `### Fixes` deliverable for tz-aware callback timestamps
why: `libvcs.sync.svn` is imported, never executed — it has no
`__main__` guard and ships without the executable bit — so the
`#!/usr/bin/env python` line advertised an entry point that does not
exist. The rule is skipped on WSL, so it only surfaced on CI's Linux
runners.

what:
- Remove the shebang from `src/libvcs/sync/svn.py`

https://docs.astral.sh/ruff/rules/shebang-not-executable/
@tony
tony merged commit 46b4d85 into master Jul 26, 2026
7 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.

1 participant