← Back to Blog

Adopting ruff into a codebase with 374 violations

·7 min read

The config was already perfect. [tool.ruff] sat in pyproject.toml with a sensible rule selection: E, F, B, I, TCH, SIM, UP, YTT. The only problem was that ruff had never been installed, never run, and never enforced. The first honest invocation reported 374 errors. This post is the playbook for turning that number into a green gate without a monster diff or a gutted rule set.

Step one: let the machine do the boring part

167 of the violations were safe autofixes: import sorting (53), PEP 585 and 604 annotation modernization (62), unused imports. One command cleared them. Two lessons from that pass are worth the price of admission:

  • Autofixers do not understand re-export intent. The fixer stripped from pydantic.dataclasses import dataclass as model out of core decorators because nothing in that module referenced it. It was the public re-export surface. Restored with an explicit __all__, which both silences F401 and documents the API.
  • The linter polices its operator. A blanket rename I applied for B007 hit two loops; one used the variable. F821 caught my mistake before CI did. That is the tool earning trust in its first hour.

Step two: hand-fix what teaches something

Thirty-two violations remained after autofix, and every category repaid attention:

  • B904, three raise-without-from sites inside Procedure validation. Adding from ve chains validation errors properly instead of silently swallowing context.
  • B023, loop-variable capture in the dev console’s command fallback lambda. Bound as a default argument (lambda _, c=cmd), the classic closure fix.
  • SIM115 open-without-context in tests became with-blocks; one intentional temp-file case got an inline noqa with a written reason: uvicorn reload needs the path to outlive the write.

Step three: encode accepted style as documented config

200 of the original findings were line length and single-line compound statements, concentrated in CLI code where if x: raise typer.Exit(1) is house style. Mass-rewriting 200 lines would produce a noisy diff that reviews worse than it reads. Instead:

ignore = ["E501", "E701", "E702", "SIM108", "SIM117"]

With comments explaining each choice. The distinction matters: an ignore list nobody can explain is rot, but a deliberate, commented acceptance of current style is exactly how enforcement should start. Tighten later if a formatter lands.

Step four: make it someone else’s problem forever

A ruff job in CI now runs the exact local command. Green means the next 374 cannot accumulate quietly. Combined with Dependabot keeping the tool itself fresh, the standard finally exists in the only place standards are real: the merge path.

Final tally: zero violations, 176 tests passing, and three small bugs caught on day one. The config barely changed. The behavior changed completely.