Skip to content

Upgrade ruff to 0.16.5, target Python 3.13, and prune dead noqa directives - #38625

Draft
antiguru wants to merge 5 commits into
MaterializeInc:mainfrom
antiguru:ruff-upgrade
Draft

Upgrade ruff to 0.16.5, target Python 3.13, and prune dead noqa directives#38625
antiguru wants to merge 5 commits into
MaterializeInc:mainfrom
antiguru:ruff-upgrade

Conversation

@antiguru

@antiguru antiguru commented Sep 2, 2026

Copy link
Copy Markdown
Member

Stacked on #38624, which must land first. This branch is built on it, so until that merges the diff here also shows its commits. Review the two commits on this branch; the 3.13 target depends on the floor that #38624 raises.

The pinned ruff was 0.0.292, released in October 2023, and it rejects py313, so the lint target trailed the minimum version the repository enforces. This upgrade closes that gap and lets the UP rules see the language level we actually run on.

Interface changes in ruff

Linting now requires the check subcommand. A bare ruff <files> fails with error: unrecognized subcommand, which would have broken bin/fmt and check-python-files.sh, so both call sites are updated. Top-level linter settings also moved under a lint section, making select and isort into lint.select and lint.isort.

What retargeting surfaced

24 findings, all addressed. Most are mechanical: PEP 695 type parameters in place of TypeVar, dropping default type arguments such as Generator[int, None, None], and a few string and annotation modernizations. Ruff rewrites a generic signature but does not remove the now-unreferenced TypeVar declaration, so those are cleaned up by hand in util.py, buildkite.py, spawn.py, and benchmark_result_evaluator.py.

The two mzexplore enums become StrEnum, which is the only finding with observable semantics, so it was checked member by member rather than trusted:

  • ExplainStage is unchanged in every respect, because it already defined __str__ to return its value. That override is now redundant and removed.
  • ItemType changes: str() and formatting produce the value rather than ItemType.NAME. No caller observes this. Every use goes through sql(), which calls a str method on the value either way, and no code formats a member directly.

That was verified by snapshotting str, formatting, value, equality, isinstance(str), by-value lookup, and sql()/show_create() for all sixteen members before and after the conversion, and diffing. The only differences are the ItemType str/format entries described above.

Dead suppressions

The second commit selects RUF100 and removes what it finds. Of 38 noqa directives, 29 suppressed nothing. They were invisible because the rule that reports them was not selected, and they are not fallout from this upgrade: ruff 0.0.292 reports the same 29.

Most are star imports annotated F401 F403. A star import triggers F403 and never F401, so only the F403 half ever did anything, and those are narrowed to it. Three directives name rules from families this repository does not select, SLF001, E731 and BLE001, and one is a blanket directive on an import that is used; those four go. The eight that remain sit on genuine re-exports in mzexplore/__init__.py, where F401 does fire. Selecting the rule means a directive that stops applying fails the lint instead of accumulating.

Verification

bin/lint passes in full. That includes pyright with --warnings over the whole Python tree, which is what actually validates the PEP 695 rewrites, plus both updated ruff check invocations and check-python-version.sh.

Not addressed here

Beyond RUF100 nothing selects new rule families; select remains F, I, UP, E711. A three-year upgrade will have changed behavior within those families beyond what our tree currently trips, so a later divergence is possible even though the tree is clean today. The dbt adapter keeps its py38 target, which ruff 0.16.5 still accepts, since pyproject.toml exempts it from internal compatibility rules.

Release notes

No user-visible changes.

🤖 Posted by Claude Code

antiguru and others added 2 commits September 2, 2026 13:56
…eter

`bin/pyactivate` validates the interpreter running it and then hands
virtualenv creation to `uv venv` without naming an interpreter. `uv` resolves
one by its own preference order, which favors uv-managed installs over the
system Python, so the virtualenv can end up on a different and older Python
than the one the check just accepted. On a machine with system Python 3.14 and
a uv-managed 3.10 present, the virtualenv is built on 3.10. Passing
`sys.executable` removes that second, independent choice and makes the check
authoritative. It also aligns the two creation paths, since the `venv.create`
fallback right below already builds from the running interpreter.

That fallback passes `clear=True`, and `uv venv` is now given `--clear` to
match. Control reaches this branch only when the virtualenv is missing or its
Python will not execute, which is exactly when it should be replaced. Without
the flag `uv` refuses to touch an existing directory and aborts, so a
virtualenv left behind without its `dep_stamp` wedges every later invocation
instead of being rebuilt.

The floor moves from 3.10 to 3.13. Python 3.10 has been security-only for
years and reaches end of life in October 2026, and the tree had already
drifted past it: two mzcompose files import `LiteralString` from `typing`,
which needs 3.11. That went unnoticed because
`ci/test/lint-main/checks/check-python-version.sh` only byte-compiles the
tree, and compilation does not resolve imports, so a newer-than-floor import
passes the check and fails at run time. 3.13 is what the CI builder already
runs, since that is the system Python in Debian 13.

Raising `target-version` lets ruff replace `datetime.timezone.utc` with the
`datetime.UTC` alias and pull `LiteralString` back from `typing`, which is the
whole of the mechanical churn here. Ruff trails at `py312` because the pinned
version predates 3.13 and rejects `py313`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… bootstrap

The version gate in `main` inspects the interpreter that runs the script, while
the code the repository executes runs inside `misc/python/venv`. Those need not
agree, because the virtualenv outlives the interpreter it was built from, and
the reuse path only confirmed that a `dep_stamp` existed and that the
virtualenv's Python could execute at all. Raising the minimum therefore left
every existing virtualenv below it in place, and this branch is the first to
depend on that difference, since `datetime.UTC` does not exist before 3.11. The
liveness probe now also reports the version, so a virtualenv older than the
minimum takes the existing recreation path.

`uv venv --clear` refuses a target directory that is not a valid virtualenv and
suggests `--force`. That refusal is an error rather than the `FileNotFoundError`
the surrounding code catches, so it would abort with a traceback instead of
falling back to `venv.create`. The half-finished directory this leaves behind is
the case the comment above the check already describes, and `--force` is the
flag that actually matches `clear=True`.

The developer guide named 3.12 and derived it from the default Python in the
most recent Ubuntu LTS. That release is now 26.04 "Resolute Raccoon", which
ships 3.14, so the stated rule no longer produced the stated version and taken
literally would demand a version newer than the one CI runs. The guide now
gives the minimum directly and explains that it tracks the CI builder image,
noting that a current LTS satisfies it.

The minimum lives in `MIN_HEXVERSION` and `MIN_VERSION` so the gate, the probe,
and the operator-facing messages cannot drift apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@antiguru antiguru changed the title Upgrade ruff to 0.16.5 and target Python 3.13 Upgrade ruff to 0.16.5, target Python 3.13, and prune dead noqa directives Sep 2, 2026
antiguru and others added 3 commits September 2, 2026 15:30
`uv venv --force` was added in uv 0.11.17. Earlier versions exit with a usage
error, which reaches `subprocess.check_call` as a `CalledProcessError` and not
as the `FileNotFoundError` that selects the `venv.create` fallback, so
`bin/pyactivate` would abort. Everything in the tree runs through that script,
and the CI builder image installs uv 0.9.10, so the flag cannot be used here.

The version probe added alongside it makes the timing worse rather than
academic. Any developer holding a virtualenv below the new minimum takes the
recreation path on their first invocation, which is exactly the path that would
have failed, and deleting the virtualenv by hand leads to the same call.

Removing the directory before invoking `uv` behaves the same on every version
and needs no flag. It also settles what `--clear` and `--force` disagree about,
a path that exists but is not a virtualenv, which is the half-finished state the
comment above the check already describes. The `venv.create` fallback is
unaffected, since it creates a directory that does not exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pinned ruff was 0.0.292, released in October 2023, and it rejects `py313`,
so the lint target trailed the minimum version the repository enforces. This
upgrade closes that gap and lets the `UP` rules see the language level we
actually run on.

Two interface changes come with it. Linting now requires the `check`
subcommand, since a bare `ruff <files>` is no longer accepted, which affects
`bin/fmt` and the lint check script. Top-level linter settings moved under a
`lint` section, so `select` and `isort` become `lint.select` and
`lint.isort`.

Retargeting to 3.13 surfaces rewrites the old target could not suggest. The
mechanical ones are PEP 695 type parameters in place of `TypeVar`, dropping
default type arguments, and a few string and annotation modernizations. The
`TypeVar` bindings that the PEP 695 conversion left with no remaining
references are removed, since ruff rewrites the signature but does not clean
up the declaration.

The two `mzexplore` enums become `StrEnum`. For `ExplainStage` nothing changes
at all, because it already defined `__str__` to return its value, and that
override is now redundant. For `ItemType`, `str()` and formatting now produce
the value rather than `ItemType.NAME`. No caller observes that: every use goes
through `sql()`, which reads the value either way, and formatting the member
directly does not appear anywhere.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Of 38 `noqa` directives, 29 had no effect. They were invisible because the rule
that reports them was not selected, and they predate the ruff upgrade: version
0.0.292 reports the same 29.

Most are star imports annotated `F401 F403`. A star import triggers F403 and
never F401, so only the F403 half ever did anything, and those directives are
narrowed to it. Three name rules from families this repository does not select,
`SLF001`, `E731` and `BLE001`, are removed along with one blanket directive on
an import that is used. The eight that remain sit on genuine re-exports in
`mzexplore/__init__.py`, where F401 does fire.

Selecting RUF100 keeps the set honest, since a directive that stops applying now
fails the lint rather than accumulating.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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