Skip to content

Require ruff>=0.16.0 and adopt its default rule set - #722

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

Require ruff>=0.16.0 and adopt its default rule set#722
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 linter floor to ruff>=0.16.0 in the dev and lint dependency groups so contributors and CI see the same diagnostics
  • Adopt ruff 0.16'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 diagnostics that surface, and scope a per-file ignore with a stated reason wherever the flagged construct is deliberate

Changes

Dependencies

pyproject.toml: ruff floor raised to >=0.16.0 in the dev and lint groups. uv.lock: relocked, ruff 0.15.22 -> 0.16.0.

Rule selection

An explicit [tool.ruff.lint] select replaces ruff's default rule set rather than extending it, so this repo was opting out of the 413 rules 0.16 enables by default. Ruff has no DEFAULT selector token that could name that set explicitly, so leaving select unset and listing this project's own linters under extend-select is the only way to get defaults plus extras. Every entry sits on its own line with a trailing comment naming the linter it selects.

Expanding to whole prefixes instead was rejected: that drags in all of D, PL, S and friends rather than ruff's curated subset, and is dramatically noisier for no gain in signal.

Fixed

RUF036 (none-not-at-end-of-union) — stabilized out of preview in 0.16.0; keygetter's return annotation now closes its union with None.

PYI034 (non-self-return-type) — ControlMode.__enter__ was annotated with the concrete class, so a subclass used as a context manager inferred as ControlMode and lost its own attributes inside the with block. It returns Self now, imported from typing_extensions under TYPE_CHECKING the way the rest of the package handles the 3.10 floor.

PLE0101 (return-in-init) — the exception constructors in exc.py, _internal/query_list.py, and the vendored version.py wrote return super().__init__(...), which relies on the base returning None by accident and hides which branch is early-exit control flow. The call is a statement now, with a bare return where the construct was doing control flow.

PLW1508 (invalid-envvar-default) — the retry constants passed numbers as os.getenv defaults. os.getenv hands its default back unchanged, so the documented str | None result only held because int() and float() accept numbers too.

PLR1704 (redefined-argument-from-local) — three tests in test_tmuxobject.py took the session fixture and then rebound that name as a loop variable, leaving the fixture object unreachable. The loop variable is session_ now.

PYI055 (unnecessary-type-union) — type[Server] | type[Session] | type[Window] | type[Pane] collapsed to type[Server | Session | Window | Pane].

BLE001 (blind-except) — test_dataclasses.py caught every exception around server.sessions[0], where the fallback exists for a server with no sessions. Narrowed to IndexError, so a genuinely broken Server.sessions fails the test instead of being papered over.

Scoped ignores

Each lands as its own per-file-ignores entry with the reason recorded in the config.

S102 (exec-builtin) in docs/conf.py — Sphinx reads version metadata by exec'ing the package's __about__.py, which keeps the docs build off an import of the package it documents. The input is a file in this repository.

PLC0414 (useless-import-alias) in src/libtmux/_internal/query_list.pyObjectDoesNotExist as ObjectDoesNotExist is PEP 484's explicit re-export form. mypy runs with strict = true, which turns on no_implicit_reexport; removing the aliases and re-running mypy turns every from libtmux._internal.query_list import ObjectDoesNotExist into an attr-defined error.

BLE001 in src/libtmux/_internal/query_list.py — the lookup operators are predicates run against whatever the caller put in the QueryList, including mappings with a custom __contains__. A comparison that raises is a non-match, not an error to propagate out of QueryList.filter.

BLE001 in src/libtmux/options.py — the option parsers read whatever tmux prints, across every tmux version this library supports. Each handler logs the failure and keeps the raw value, so an option this version of libtmux cannot parse does not take the rest of the object's options with it. Narrowing to the exception types today's tmux happens to produce would turn a future tmux's output into a hard failure.

BLE001 in src/libtmux/server.pyServer.is_alive answers a yes/no question about a server that may not be there at all. A missing socket, a dead daemon, a missing tmux binary and a crashed subprocess are the same answer. This is the documented lenient half of the pair; Server.raise_if_dead is the primitive for callers who need the reason.

Design decisions

Floor, not pin: >=0.16.0 rather than ==. The lockfile already reproduces the exact version for this repo; the floor exists to keep a contributor on an older ruff from producing a passing local run that fails CI.

Ignores are per-file, never global: every suppression names the file it applies to and carries a comment stating why the construct there is deliberate, so a new blind except in an unrelated module still gets flagged.

Vendored version.py is fixed, not exempted: the repo already lints and autofixes src/libtmux/_vendor/, and the flagged constructor is a local modification rather than upstream packaging code.

Test plan

  • uv run ruff check . — clean
  • uv run ruff format . --check — clean, including Markdown code blocks
  • uv run mypy src tests — clean
  • uv run pytest — full suite green, including doctests over src/, docs/, and README.md

tony added 3 commits July 26, 2026 13:16
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`

https://docs.astral.sh/ruff/rules/none-not-at-end-of-union/
@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 57.14286% with 9 lines in your changes missing coverage. Please review.
✅ Project coverage is 52.37%. Comparing base (db2f386) to head (1526155).

Files with missing lines Patch % Lines
src/libtmux/exc.py 53.33% 7 Missing ⚠️
src/libtmux/_internal/control_mode.py 0.00% 1 Missing ⚠️
src/libtmux/_internal/query_list.py 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #722      +/-   ##
==========================================
- Coverage   52.44%   52.37%   -0.07%     
==========================================
  Files          26       26              
  Lines        3726     3729       +3     
  Branches      747      747              
==========================================
- Hits         1954     1953       -1     
- Misses       1468     1472       +4     
  Partials      304      304              

☔ 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 15 commits July 26, 2026 13:22
why: Document the dev-tooling floor bump alongside the other Development
entries for the unreleased version.

what:
- Record `ruff>=0.16.0` minimum under `### Development`
- Note RUF036 stabilization and 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 44d044b0eb02fbd53fbd05e5a5bfe5b48b04a4de.
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 152 rules and silently opting out of the
other 413. `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 152 to 565.
https://docs.astral.sh/ruff/linter/#rule-selection
why: ruff's default rule set flags `type[A] | type[B]` unions, which
spell out one construct four times where `type[A | B]` says the same
thing.

what:
- Combine the `SubclassFixture.subclass` annotation into `type[...]`
- Combine the matching parametrized test argument annotation

https://docs.astral.sh/ruff/rules/unnecessary-type-union/
why: Annotating `__enter__` with the concrete class makes a subclass
used as a context manager infer as `ControlMode` instead of the
subclass, so subclass-only attributes fail type checking inside the
`with` block.

what:
- Annotate `ControlMode.__enter__` as returning `Self`
- Import `Self` from `typing_extensions` under `TYPE_CHECKING`, as the
  rest of the package does for the 3.10 floor

https://docs.astral.sh/ruff/rules/non-self-return-type/
why: `os.getenv` returns its default unchanged, so a non-str default is
returned verbatim when the variable is unset. The retry constants only
survived because `int()` and `float()` accept numbers as well as
strings; any consumer treating the result as the documented `str | None`
would break.

what:
- Quote the `RETRY_TIMEOUT_SECONDS` fallback
- Quote the `RETRY_INTERVAL_SECONDS` fallback

https://docs.astral.sh/ruff/rules/invalid-envvar-default/
why: Three tests take the `session` fixture and then rebind that name as
a loop variable. The fixture object becomes unreachable after the first
iteration, so a later edit that meant to use the fixture would silently
get whichever session the loop last visited.

what:
- Bind the loop variable as `session_` in `test_find_where`,
  `test_find_where_multiple_infos`, and `test_where`

https://docs.astral.sh/ruff/rules/redefined-argument-from-local/
why: `__init__` must return `None`, so `return super().__init__(...)`
relies on the return value being `None` by accident. Python raises
`TypeError` the moment a base class returns anything else, and the
construct hides which branch is early-exit control flow.

what:
- Call `super().__init__(...)` as a statement in the exception
  constructors
- Keep the early-exit branches explicit with a bare `return`

https://docs.astral.sh/ruff/rules/return-in-init/
why: The fallback exists for a server with no sessions yet, which is an
`IndexError`. Catching every exception meant a genuinely broken
`Server.sessions` would be papered over by creating a session, and the
test would pass while reporting nothing.

what:
- Catch `IndexError` when indexing into `server.sessions`

https://docs.astral.sh/ruff/rules/blind-except/
why: `docs/conf.py` reads the package's version metadata by exec'ing
`__about__.py` so the docs build does not import the package it is
documenting. The input is a file in this repository, not user data, so
the flake8-bandit warning has nothing to warn about here.

what:
- Per-file-ignore S102 for `docs/conf.py`

https://docs.astral.sh/ruff/rules/exec-builtin/
why: `from libtmux.exc import ObjectDoesNotExist as ObjectDoesNotExist`
is PEP 484's explicit re-export form. mypy runs with `strict = true`,
which turns on `no_implicit_reexport`, so dropping the alias makes
`from libtmux._internal.query_list import ObjectDoesNotExist` an
`attr-defined` error for every caller.

what:
- Per-file-ignore PLC0414 for `src/libtmux/_internal/query_list.py`

https://docs.astral.sh/ruff/rules/useless-import-alias/
why: `lookup_in` and `lookup_nin` are predicates run against whatever
objects the caller put in the `QueryList`, including mappings with
custom `__contains__`. Their contract is to answer "does this match",
so a comparison that raises is a non-match, not an error worth
propagating out of `QueryList.filter`.

what:
- Per-file-ignore BLE001 for `src/libtmux/_internal/query_list.py`

https://docs.astral.sh/ruff/rules/blind-except/
why: The option parsers read whatever tmux prints, across every tmux
version this library supports. Each handler logs the failure and keeps
the raw value, so an option this version of libtmux cannot parse does
not take the rest of the object's options down with it. Narrowing to
the exception types today's tmux happens to produce would turn a future
tmux's output into a hard failure.

what:
- Per-file-ignore BLE001 for `src/libtmux/options.py`

https://docs.astral.sh/ruff/rules/blind-except/
why: `Server.is_alive` answers a yes/no question about a server that may
not be there at all. A missing socket, a dead daemon, a missing tmux
binary and a crashed subprocess are all the same answer, and this is the
documented lenient half of the pair. `Server.raise_if_dead` is the
primitive for callers who need the reason.

what:
- Per-file-ignore BLE001 for `src/libtmux/server.py`

https://docs.astral.sh/ruff/rules/blind-except/
why: The ruff floor entry covered the version bump but not the rule
selection change that rides with it.

what:
- Note that lint runs on ruff's default set plus this project's linters
- Name the scoped per-file ignores and why they exist
@tony tony changed the title Require ruff>=0.16.0 Require ruff>=0.16.0 and adopt its default rule set Jul 26, 2026
@tony
tony merged commit 9e80112 into master Jul 26, 2026
15 checks passed
@tony
tony deleted the ruff-0.16 branch July 26, 2026 23:30
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