Lesson 3: Editor and Quality Tooling
Three tools, each catching a different class of problem, all running automatically so that catching them costs you nothing.
ruff for linting and formatting
Ruff is a linter and formatter for Python, written in Rust. A linter finds suspicious or non-conforming code without running it. A formatter rewrites layout to a consistent style. Ruff does both, replacing the several separate tools that used to be needed for the job. [VOLATILE: ruff 0.16.x is current at time of writing.]
Its speed changes the workflow rather than just saving time. A check that finishes in under a second can run on every file save. One that takes thirty seconds runs only in CI, where finding the problem is far more expensive.
uv add --dev ruff
uv run ruff check . # lint
uv run ruff check --fix . # lint and auto-fix what is safely fixable
uv run ruff format . # formatConfiguration.
[tool.ruff]
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "SIM"]
ignore = ["E501"]
[tool.ruff.lint.per-file-ignores]
"tests/*" = ["S101"]Rules are grouped by prefix, each corresponding to a tool that ruff absorbed:
EandWfor style errors and warningsFfor logical errors such as unused imports and undefined namesIfor import sortingUPfor modernisation, rewriting old syntax to current equivalentsBfor likely bugs such as mutable default argumentsSIMfor simplificationsSfor security checks
Start with ["E", "F", "I", "UP", "B"]. It catches real problems without generating noise you will learn to ignore, and a linter you ignore is worse than no linter at all, because it trains you to dismiss warnings.
E501, meaning line too long, is commonly disabled when the formatter already handles line length. Otherwise the two argue about long strings and URLs that the formatter cannot split.
What it catches that actually matters.
def add_item(item: str, basket: list[str] = []) -> list[str]:
basket.append(item)
return basketRuff flags this as B006. The default list is created once, when the function is defined, and shared by every call that omits the argument. Call it twice and the second call sees the first call's item. This bug is subtle, survives casual code review, and is caught instantly by a linter.
The fix:
def add_item(item: str, basket: list[str] | None = None) -> list[str]:
if basket is None:
basket = []
basket.append(item)
return basketPredict the output. What does this print?
def collect(value: int, seen: list[int] = []) -> list[int]:
seen.append(value)
return seen
print(collect(1))
print(collect(2))Answer
[1][1, 2]The default list persists between calls because it is created once at function definition time, not on each call. The second call appends to the same list object. Ruff's B006 rule catches this before you ever run the code.
mypy and format-on-save
Python does not check types when it runs. mypy is a static type checker: it reads your annotations and finds type errors without executing anything.
[VOLATILE: mypy 2.x is current, and version 2.0 changed several defaults and requires Python 3.10 or newer. There is also a newer Rust-based checker called ty from the same team as ruff and uv, still in beta at time of writing. Verify the recommended default before publishing.]
uv add --dev mypyuv run mypy srcWhy it is worth the annotations.
def parse_servings(raw: str) -> int: return int(raw.strip())
def scale_recipe(servings: int, factor: float) -> float: return servings * factor
result = scale_recipe(parse_servings("4"), "2")That last line passes "2" where a float is expected. Python raises nothing at import time. It fails only when the line executes, possibly in production, possibly on a rarely taken branch. mypy reports it in under a second:
error: Argument 2 to "scale_recipe" has incompatible type "str"; expected "float"This class of bug, a value of the wrong type crossing a function boundary, is among the most common in Python, and it is exactly what a type checker eliminates.
Configuration.
[tool.mypy]
python_version = "3.12"
strict = truestrict = true enables a group of stricter checks together, most importantly requiring that functions be annotated. On a new project, start strict. Adding types later to an untyped codebase is far more work than writing them as you go.
For an existing untyped codebase, adopt gradually:
[tool.mypy]
python_version = "3.12"
warn_return_any = true
warn_unused_configs = true
[[tool.mypy.overrides]]
module = "recipe_extractor.parser"
strict = trueStrict on new modules, lenient elsewhere, tightened over time.
Third-party libraries without types. You will hit this:
error: Skipping analyzing "some_library": module is installed, but missing library stubsThe library ships no type information. In order of preference: install its stub package if one exists, such as uv add --dev types-requests. Failing that, silence it for that import only:
[[tool.mypy.overrides]]
module = ["some_library.*"]
ignore_missing_imports = trueSilence per module. Never globally, because a global setting disables the checker for your own code too.
What type checking does not do. It does not validate data at runtime. Annotating a function as returning dict[str, int] does not stop it returning something else if the data came from an API response or a JSON file. Static checks cover what is knowable at write time. Runtime validation, such as Pydantic, covers what actually arrives while the program runs. You need both, and confusing the two is a common misunderstanding.
Format-on-save. Configure your editor to run ruff's formatter on every save and to show mypy errors inline. In VS Code, install the Ruff and Mypy extensions, set Ruff as the formatter, and enable formatOnSave. Other editors have equivalents.
The point is not neatness. It shortens the feedback loop from minutes, meaning write then run then read the error, to seconds, meaning see the error as you type. Across a working day that difference is large. It also removes formatting from code review entirely, because nobody is reviewing a decision a machine already made.
Pre-commit hooks
Tooling that only runs when you remember it does not run. Pre-commit is a framework that installs git hooks so your checks run automatically before each commit, against the files being committed.
uv add --dev pre-commitCreate .pre-commit-config.yaml:
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: check-added-large-files
- id: detect-private-key
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.6
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v2.1.0
hooks:
- id: mypy
additional_dependencies: [pydantic, pydantic-settings][VOLATILE: all rev values above are specific released versions and will age quickly. Verify current tags before publishing. Note also that the ruff hook id is ruff-check, renamed from ruff in a recent release. Older tutorials still show the old name and will fail.]
Install the hooks into your repository:
uv run pre-commit installEvery git commit now runs the checks, and failures block the commit.
Two details trip people up. Ruff's lint hook must come before its format hook when using --fix, because fixes can produce code that then needs reformatting. And mypy's hook runs in its own isolated environment, so any package whose types it needs must be listed in additional_dependencies. Otherwise you get import errors that do not occur when running mypy directly.
Two hooks deserve special attention. detect-private-key blocks a committed key before it enters history, where cleanup is painful. check-added-large-files stops the 400 MB model checkpoint. Both connect directly to the secrets rule from Lesson 2.
Running manually.
uv run pre-commit run --all-files # check the whole repositoryuv run pre-commit autoupdate # bump hook versionsRun --all-files immediately after adding pre-commit to an existing project. The first run will fix a great deal, and it should be its own separate commit so that it does not pollute a real change.
The emergency escape.
git commit --no-verify -m "wip: debugging, will clean up"This skips the hooks. Use it rarely and deliberately. If you find yourself using it often, your hooks are too slow or too strict, and the correct fix is to change the hooks rather than routinely bypass them.
Hooks are a fast local gate, not a guarantee, because anyone can skip them. CI must run the same checks, because that is the gate nobody can bypass.
[IMAGE PROMPT M1-4
Purpose: Show where each quality gate runs and how the cost of catching a problem rises the later it is caught.
Visual type: Horizontal stage diagram with an accompanying cost indicator.
Prompt: A clean educational diagram showing four sequential gates along a horizontal timeline reading left to right. Gate 1 is labelled "Editor" with the sub-label "format on save, inline errors" and the timing "seconds". Gate 2 is labelled "Pre-commit hook" with the sub-label "ruff, mypy on staged files" and the timing "before commit". Gate 3 is labelled "CI pipeline" with the sub-label "full checks, cannot be skipped" and the timing "minutes". Gate 4 is labelled "Production" with the sub-label "your users find it" and the timing "hours to days". Beneath the timeline runs a widening wedge shape labelled "cost of fixing", narrow under Editor and wide under Production, with tick marks aligned to each gate. Each gate is drawn as a labelled checkpoint symbol on the line.
Required elements: Four labelled gates in order, a sub-label and timing under each, a widening wedge beneath indicating rising cost, alignment between the wedge and the gates.
Style: Clean educational illustration, professional, uncluttered, high contrast, flat vector, minimal.
Layout: A single horizontal timeline reading left to right, with the wedge directly beneath and aligned to it.
Text labels: "Editor", "Pre-commit hook", "CI pipeline", "Production", "seconds", "before commit", "minutes", "hours to days", "cost of fixing".
Aspect ratio: 16:9
Accessibility: Convey rising cost through the width of the wedge and explicit timing labels rather than colour alone.
Avoid: Decorative icons, meaning-carrying gradients, tiny text, logos, watermarks, clutter.
Alt text: Timeline of four quality gates, editor, pre-commit hook, CI pipeline, and production, with a widening wedge beneath showing that the cost of fixing a problem increases at each later stage.
END IMAGE PROMPT]