Programming with PythonTesting and Code Quality › Day 76

Hands-on lab — Day 76: Linting and Formatting with Ruff

Commands

Setup

cd labs/sections/programming-with-python/day-076-linting-and-formatting-with-ruff
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/ruff --version
.venv/bin/pytest --version

Run

cat starter/EXERCISES.md
cd starter && pytest -q
ruff check --isolated receipts.py
ruff check --isolated --select E,F,I,B,SIM,UP receipts.py
ruff rule B006
ruff format --isolated --diff receipts.py
ruff format --isolated receipts.py
ruff check --isolated --select E,F,I,B,SIM,UP --fix receipts.py
ruff check --isolated --select E,F,I,B,SIM,UP --diff --unsafe-fixes receipts.py
ruff check --isolated --select E,F,I,B,SIM,UP --fix --unsafe-fixes receipts.py
ruff check .
ruff format --check .

Test

bash tests/run_tests.sh

File tree

examples/clean/pyproject.toml
examples/clean/receipts.py
examples/clean/test_receipts.py
examples/messy/receipts.py
examples/messy/test_receipts.py
expected-output/alternatives-run.txt
expected-output/FIELDS.md
expected-output/format-diff.txt
expected-output/lint-run.txt
expected-output/test-run.txt
expected-output/timing.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/EXERCISES.md
starter/receipts.py
starter/test_receipts.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 076 lab — From messy to clean, mechanically

Lesson

Purpose

This lab hands you a module that works. receipts.py prices a small shop receipt with eight functions, and its ten tests all pass before you touch anything. It is also written badly on purpose, in eight distinct ways: unsorted imports, two imports nobody uses, a local variable nobody reads, a line ninety-three columns wide, two comparisons to None written with ==, percent-style string formatting made obsolete by newer syntax, an if/else block that wants to be a ternary — and one mutable default argument that is a genuine bug rather than a cosmetic complaint.

Your job is to run the tools over it and watch, with evidence, what each one can and cannot do. The formatter rewrites how the file reads and changes nothing about what it does — the same ten tests stay green. The linter finds ten problems across eight rule codes. Its safe autofix clears three of them and refuses to touch the bug. Its unsafe autofix does fix the bug, and then a test fails — because that test was written to record the broken behaviour. Deciding what to do about that failure is the one step no tool can take for you, and it is the moment the lab is built around.

You finish by retiring every command-line flag into a pyproject.toml you write yourself and can defend one entry at a time.

Learning objectives

  • Establish a ground truth with a passing test suite before letting any tool edit a file, and use that suite as evidence throughout.
  • Read a Ruff finding completely: file, line, column, rule code, message, and the [*] marker that means a safe fix is available.
  • Observe that Ruff's default selection (E4, E7, E9, F) finds five problems here and misses the only real bug, and widen it deliberately with --select E,F,I,B,SIM,UP to find ten across eight codes.
  • Demonstrate that ruff format changes appearance only — the suite stays green — and that it is idempotent, by running it twice.
  • Demonstrate that formatting fixes no lint findings, by re-linting a formatted file and seeing B006 and F401 still there.
  • Apply safe autofixes, then preview and apply unsafe ones, and articulate why the B006 fix is classified unsafe even though it is correct.
  • Make the human decision the unsafe fix forces: confirm the new behaviour, rewrite the test that pinned the old behaviour, and get back to green.
  • Suppress one rule on one line with # noqa: F401 and confirm it suppresses nothing else.
  • Write a pyproject.toml with select, a justified ignore, and a per-file ignore, and make ruff check . pass with no flags at all.
  • Measure Ruff's speed yourself with time rather than quoting anybody's benchmark, including this course's.

Prerequisites

  • The Day 76 lesson (read it first — it explains the four gates and the safe/unsafe distinction this lab proves).
  • Day 61: PEP 8, meaningful names, and the readability judgements this lab mechanises.
  • Days 71–74: pytest, running a suite from the terminal, and reading a test failure carefully rather than reacting to it.
  • Day 43: python3 -m venv and installing a pinned dependency with pip.
  • Comfort reading a unified diff (+ and - lines with @@ hunk headers).
  • A text editor and a terminal. Nothing beyond this course is assumed.

Supported operating systems

  • macOS — fully supported (tested on macOS 26.5.1, Apple Silicon, Python 3.14.0, Ruff 0.15.22, pytest 9.1.1, bash 3.2.57).
  • Linux — fully supported. Ruff ships prebuilt wheels for both platforms and the output is identical in every respect.
  • Windows — use WSL and follow the Linux path. Native Windows works for ruff and pytest themselves (the virtual environment puts them in .venv\Scripts\ rather than .venv/bin/, and Ruff prints \ path separators), but tests/run_tests.sh needs a bash, so run the harness from WSL or Git Bash. Rule codes, line numbers and totals are unchanged.

Hardware requirements

Any computer that runs Python 3. The module under test is 76 lines; the whole lab is a few hundred lines of Python and text. Ruff's whole-directory run here takes hundredths of a second. No special memory, disk, or GPU.

Required software

  • python3 (3.9 or newer; tested on 3.14.0).
  • ruff 0.15.22 and pytest 9.1.1, both pinned in requirements/requirements.txt. See requirements/README.md for what each is for.
  • bash for the test runner (preinstalled on macOS and Linux).

Free and open-source options

Everything in this lab is free and open source, with no account, API key or paid tier at any point. Ruff and pytest are both distributed under the MIT licence — Ruff by Astral, pytest by the pytest project. Python, bash and the standard-library modules the lab also exercises (ast, tomllib, py_compile, tabnanny) cost nothing either.

The lesson's Alternatives section discusses flake8, pylint, Black, isort and pre-commit — all free and open source too, and none of them installed on the machine that captured this lab's output. That is stated plainly in expected-output/alternatives-run.txt rather than papered over: no output from a tool that was not run appears anywhere here. If you want the comparison, install them and run them yourself; that file gives you the exact commands.

Installation

cd labs/sections/programming-with-python/day-076-linting-and-formatting-with-ruff
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/ruff --version     # ruff 0.15.22
.venv/bin/pytest --version   # pytest 9.1.1

Installing needs the network once. After that the lab runs entirely offline — neither tool contacts anything, and ruff rule <code> prints documentation compiled into the binary. .venv/ is already gitignored repo-wide; never commit it.

If you already have Ruff and pytest elsewhere, you can skip the virtual environment: the test suite resolves its tools from an explicit environment variable first, then .venv/bin/, then your PATH.

File structure

day-076-linting-and-formatting-with-ruff/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── starter/
│   ├── EXERCISES.md                ← the nine numbered exercises, in order
│   ├── receipts.py                 ← YOUR working file: works, written badly on purpose
│   └── test_receipts.py            ← ten tests pinning current behaviour (one pins the bug)
├── examples/
│   ├── messy/
│   │   ├── receipts.py             ← untouched reference copy of the starting point
│   │   └── test_receipts.py        ← the same ten tests
│   └── clean/
│       ├── receipts.py             ← the destination, after the tools and one human decision
│       ├── test_receipts.py        ← the last test, rewritten to assert correct behaviour
│       └── pyproject.toml          ← reference configuration, every line commented with its reason
├── tests/
│   └── run_tests.sh                ← 37 checks; exits 0 only if all pass
├── expected-output/
│   ├── lint-run.txt                ← real capture: pytest, then both ruff check selections
│   ├── format-diff.txt             ← real capture: ruff format --diff, every hunk
│   ├── alternatives-run.txt        ← real capture: each rule family run separately, plus an honesty note
│   ├── timing.txt                  ← real `time` measurement over 500 files and over one
│   ├── test-run.txt                ← real capture of the harness
│   └── FIELDS.md                   ← what must match everywhere, and what may differ
├── requirements/
│   ├── requirements.txt            ← ruff==0.15.22, pytest==9.1.1
│   └── README.md                   ← what each dependency is for, and the one-time install
├── troubleshooting.md
└── security.md

Running the tools also creates .ruff_cache/ and __pycache__/ directories next to the files they touched. Both are gitignored and safe to delete; see ## Cleanup.

How to run

From this directory. Substitute .venv/bin/ruff and .venv/bin/pytest if you made a lab-local virtual environment and have not activated it.

## 1. Read the exercises. They are the spine of the lab.
cat starter/EXERCISES.md

## 2. Establish the ground truth. Ten tests, all passing.
cd starter && pytest -q

## 3. See what Ruff's DEFAULT rule set finds. Note what it misses.
ruff check --isolated receipts.py

## 4. Ask for more rules. Ten findings, eight codes, including the real bug.
ruff check --isolated --select E,F,I,B,SIM,UP receipts.py

## 5. Look up a rule you do not recognise. No network needed.
ruff rule B006

## 6. Preview the formatter, apply it, then apply it again (idempotence).
ruff format --isolated --diff receipts.py
ruff format --isolated receipts.py
ruff format --isolated receipts.py

## 7. Prove it changed nothing that matters.
pytest -q

## 8. Apply the SAFE fixes. Watch B006 survive.
ruff check --isolated --select E,F,I,B,SIM,UP --fix receipts.py
pytest -q

## 9. Preview the UNSAFE fixes, then apply them. A test will fail.
ruff check --isolated --select E,F,I,B,SIM,UP --diff --unsafe-fixes receipts.py
ruff check --isolated --select E,F,I,B,SIM,UP --fix --unsafe-fixes receipts.py
pytest -q

## 10. Exercise 7: write starter/pyproject.toml, then run with no flags at all.
ruff check .
ruff format --check .

## 11. Check everything, from the lab directory.
cd .. && bash tests/run_tests.sh

What the commands do

  • cat starter/EXERCISES.md — the nine exercises with the exact command for each, plus the reset instructions. Work from inside starter/.
  • pytest -q — runs the ten behaviour tests beside receipts.py. This is the number you are protecting: every tool run below is followed by re-running this, and the whole argument of the lab is that it stays at 10 passed until you deliberately change behaviour.
  • ruff check --isolated receipts.py — the linter with its built-in default selection (E4, E7, E9, F). --isolated means "ignore any configuration file you might find above me", which keeps you seeing the rules you asked for rather than rules you inherited. Reports five findings and, importantly, does not report B006.
  • ruff check --isolated --select E,F,I,B,SIM,UP receipts.py — the widened selection: pycodestyle, pyflakes, isort, bugbear, simplify and pyupgrade. Ten findings across eight codes, one of which is the real defect.
  • ruff rule B006 — prints that rule's full documentation, including a "Known problems" section saying when the rule is wrong. Compiled into the binary; needs no network.
  • ruff format --isolated --diff receipts.py — shows what the formatter would do and changes nothing. Every hunk is appearance: quotes, spacing around = in a keyword default, a call re-wrapped because it exceeded 88 columns.
  • ruff format --isolated receipts.py (twice) — applies it, then proves idempotence: the second run prints 1 file left unchanged.
  • ruff check ... --fix — applies only the fixes Ruff can prove are safe. It removes the two unused imports and sorts the import block, and leaves B006.
  • ruff check ... --diff --unsafe-fixes then --fix --unsafe-fixes — previews and then applies the fixes that may change behaviour. One of them does, on purpose.
  • ruff check . and ruff format --check . — with your own starter/pyproject.toml in place, both run with no flags: the configuration is now carrying the rules instead of your memory. --check is the continuous-integration form — it edits nothing and exits non-zero.
  • bash tests/run_tests.sh — 37 checks proving every claim the lesson makes. Exits 0 only if all of them pass.

Expected output

Four real captures live in expected-output/. The heart of the lab is lint-run.txt:

$ pytest -q
..........                                                               [100%]
10 passed in 0.01s

$ ruff check --isolated receipts.py
receipts.py:13:8: F401 [*] `json` imported but unused
receipts.py:14:25: F401 [*] `collections.Counter` imported but unused
receipts.py:29:17: E711 Comparison to `None` should be `cond is None`
receipts.py:36:5: F841 Local variable `skipped` is assigned to but never used
receipts.py:39:20: E711 Comparison to `None` should be `cond is None`
Found 5 errors.
[*] 2 fixable with the `--fix` option (3 hidden fixes can be enabled with the `--unsafe-fixes` option).
exit: 1

$ ruff check --isolated --select E,F,I,B,SIM,UP receipts.py
receipts.py:13:1: I001 [*] Import block is un-sorted or un-formatted
receipts.py:13:8: F401 [*] `json` imported but unused
receipts.py:14:25: F401 [*] `collections.Counter` imported but unused
receipts.py:22:42: B006 Do not use mutable data structures for argument defaults
receipts.py:29:17: E711 Comparison to `None` should be `cond is None`
receipts.py:36:5: F841 Local variable `skipped` is assigned to but never used
receipts.py:39:20: E711 Comparison to `None` should be `cond is None`
receipts.py:65:5: SIM108 Use ternary operator `label = 'found' if hits else 'missing'` instead of `if`-`else`-block
receipts.py:69:12: UP031 Use format specifiers instead of percent format
receipts.py:75:89: E501 Line too long (93 > 88)
Found 10 errors.
[*] 3 fixable with the `--fix` option (5 hidden fixes can be enabled with the `--unsafe-fixes` option).
exit: 1

format-diff.txt is the whole formatter diff. Read it as the specification of what a formatter is allowed to do — and note the hunk where basket = [] becomes basket=[]: the formatter tidying the spacing around the bug and leaving the bug exactly where it was.

alternatives-run.txt runs each rule family separately (--select E,W, then F, then I, then B) plus the standard library's own tabnanny and py_compile, and ends with a plain statement of which tools were not installed and therefore produced no output anywhere in this lab.

timing.txt is a time measurement, not a benchmark: real 0m0.033s over 500 copies of the module (38,500 lines) and real 0m0.013s over one file, on the machine described above. Those numbers are hardware and will differ on yours. What is robust is the shape — five hundred files cost about twice one file, because process start-up dominates at this size.

FIELDS.md states field by field which parts of every capture are guaranteed on any platform with Ruff 0.15.22 (all rule codes, all line and column numbers, both Found N errors. totals, all 37 check labels) and which are allowed to differ (pytest's in 0.01s duration, every timing figure).

Validation steps

  1. pytest -q in starter/ reports 10 passed before you start.
  2. ruff check --isolated receipts.py reports Found 5 errors. and does not mention B006.
  3. ruff check --isolated --select E,F,I,B,SIM,UP receipts.py reports Found 10 errors. across exactly these eight codes: I001, F401, B006, E711, F841, SIM108, UP031, E501.
  4. Running ruff format --isolated receipts.py a second time prints 1 file left unchanged.
  5. pytest -q still reports 10 passed immediately after formatting, and again after the safe --fix.
  6. After the safe --fix, re-linting still reports B006. That is the tool being correct, not failing.
  7. After --fix --unsafe-fixes, exactly one test fails, and it is test_default_basket_is_shared_between_calls. You then rewrite it to assert first is not second and return to 10 passed.
  8. With your own starter/pyproject.toml, ruff check . prints All checks passed! with no --select anywhere, and ruff format --check . and pytest -q both succeed.
  9. Every entry in your ignore list carries a comment giving a reason you would defend out loud.
  10. From the lab directory, bash tests/run_tests.sh ends with 37 checks, 0 failure(s). and exits 0.

Tests

bash tests/run_tests.sh

Expected final line: 37 checks, 0 failure(s). The command exits 0 on success and non-zero on any failure, so it can run in CI. A full captured run is in expected-output/test-run.txt.

The suite is worth reading before you run it. It is organised into six sections, and each proves one claim the lesson makes rather than merely checking that files exist:

  • Ground truth — the messy module's pytest suite passes before any tool touches it, and the B006 bug is demonstrated at runtime by calling add_item twice and asserting the two results are the same object.
  • The linterruff check exits non-zero, reports each of I001, F401, B006, E711, F841, SIM108, UP031 and E501 (asserted by rule code, not by the wording of the message, so a reworded message in a future release does not silently break the check), totals exactly 10 under the widened selection, and totals 5 without B006 under the default one.
  • The formatter--check fails on the messy module and passes on a formatted copy; the behaviour suite is still green after formatting; a second format run reports 1 file left unchanged; and re-linting a formatted copy still finds B006 and F401, proving formatting fixes no lint findings.
  • Autofix — the safe fix removes F401 and refuses B006 and leaves the suite green; the unsafe fix does rewrite the mutable default, and then the suite fails. That last check passes when pytest fails, which is how it proves "unsafe" means something.
  • Suppression — a generated file with import json # noqa: F401 has no F401 finding and still has its E711 finding elsewhere.
  • Configuration and starterruff check . and ruff format --check . both exit 0 inside examples/clean/ using its own pyproject.toml; the cleaned module passes the same behaviour suite and no longer shares a basket; both modules agree on every price, description and receipt line; S101 fires on the test file with --isolated and is silent with the per-file ignore in force; the reference pyproject.toml parses and declares what it claims to; and your starter/receipts.py is still valid Python with all eight public functions and a passing suite.

Every temporary directory the harness makes with mktemp -d is removed as its check finishes.

Cleanup

The lab writes nothing outside its own directory except temporary directories under your system temp path, which the harness removes itself. What is left behind is tool cache:

find . -type d -name ".ruff_cache" -prune -exec rm -rf -- {} +
find . -type d -name ".pytest_cache" -prune -exec rm -rf -- {} +
find . -type d -name "__pycache__" -prune -exec rm -rf -- {} +

To reset your work, restore the starter from git: git checkout -- starter/. To remove the virtual environment entirely, rm -rf .venv.

Troubleshooting

See troubleshooting.md for the full list: ruff: command not found, the three reasons ruff check finds fewer findings than the exercise says (missing --select, missing --isolated, or you already ran --fix), why a different Ruff version legitimately finds more, why ruff format sometimes cannot fix an E501, what to do when the test in Exercise 6 fails (nothing — that failure is the lab working), ModuleNotFoundError: No module named 'receipts', why ruff check . behaves differently in examples/clean/ and examples/messy/, the two ways a pyproject.toml gets silently ignored, and the Windows path.

Security notes

See security.md. Short version: a linter is a security control and not as a metaphor. The S family flags patterns with a breach history — shell=True, unsafe YAML loading, pickle on untrusted input, hard-coded passwords — and the B family catches correctness traps that become security bugs. B006, this lab's own bug, is the clearest case: in add_item a mutable default means two shoppers share a trolley, and in def handler(request, cache={}) in a web application the identical defect leaks one user's data into the next user's response. The file also covers why S101 is relaxed for tests and only for tests, why a bare # noqa is an unauditable blind spot that hides security rules too, and the one real risk in pre-commit: it downloads and executes the hook repositories you name, so pin them to a revision and read what you are pinning.

Extension exercises

  1. Build a minimal linter from first principles. Using only the standard library and ast.parse, write a script that reports every module-level import whose name never appears again (your own F401) and every function argument whose default is a list, dict or set literal (your own B006), printed as path:line:col: X001 message, exiting 1 on findings. Run it on examples/messy/receipts.py and compare line numbers with Ruff's real output. Then find the first case yours gets wrong — an import used only inside an f-string, or only in an annotation — and appreciate how much of a mature rule is edge cases.
  2. Measure, do not quote. Follow Exercise 9 in starter/EXERCISES.md to time a run over one file and over 500 copies, then over the largest directory of Python you have. Write down files-per-second at all three sizes and describe in two sentences where process start-up stops dominating. Report your hardware; do not repeat this lab's figures.
  3. Add a rule family and pay for it. Add N (naming) and D (docstrings) to examples/clean/pyproject.toml and re-run ruff check .. Count the new findings. Decide, for each, whether you would fix it, suppress it with a reason, or drop the family — and write the reason as a comment in the configuration. This is the judgement half of the lesson, in one file.
  4. Break the suppression on purpose. In examples/clean/receipts.py, add an unused import with a bare # noqa, then with # noqa: F401, then with # noqa: E501. Predict what ruff check . reports each time before you run it. Then enable RUF100 and watch it flag the suppression that is no longer suppressing anything.
  5. Write the CI job. Without installing anything, write the three-command shell script a continuous-integration system would run over this lab — ruff format --check ., ruff check --no-fix ., pytest -q — and explain in one sentence each why --check and --no-fix are the right forms there and the wrong forms at your desk.
  • Previous day: Day 75 — Type Checking with mypy (labs/sections/programming-with-python/day-075-type-checking-with-mypy/). Yesterday's gate proves the type claims; today's proves nothing about types and catches an entirely different class of defect.
  • Next day: Day 77 — the last day of Week 11, which assembles the gates into one workflow (labs/sections/programming-with-python/).
  • Predecessor lesson: Day 61 — Writing Readable Code (labs/sections/programming-with-python/day-061-writing-readable-code/). Every judgement you made by hand there is a rule code here.
  • Week 11 project: the Tested Utility Library (labs/sections/programming-with-python/projects/week-11/), where the configuration you write in Exercise 7 becomes the project's real gate.

Expected output

FIELDS.md

# What every capture in this directory means, and what must match

Four of the five files here are literal captures from a real run on the
authoring machine (macOS 26.5.1, Apple Silicon, Python 3.14.0, Ruff
0.15.22, pytest 9.1.1, bash 3.2.57). This file says which parts of them are
guaranteed on every platform and which are allowed to differ.

## `lint-run.txt` — what the linter finds

Captured by running, from `examples/messy/`:

```bash
pytest -q
ruff check --isolated receipts.py
ruff check --isolated --select E,F,I,B,SIM,UP receipts.py
```

**Guaranteed identical everywhere, on any operating system, with Ruff
0.15.22:** every rule code, every line number, every column number, and
both `Found N errors.` totals. Ruff's analysis is deterministic; it does
not read the clock, the locale, or the filesystem's mood.

| Field | Value | Must match? |
| --- | --- | --- |
| Default selection total | `Found 5 errors.` | Yes |
| Default selection codes | `F401` ×2, `E711` ×2, `F841` | Yes |
| Extended selection total | `Found 10 errors.` | Yes |
| Extended selection codes | `I001`, `F401` ×2, `B006`, `E711` ×2, `F841`, `SIM108`, `UP031`, `E501` | Yes |
| Line and column numbers | `13:8`, `22:42`, `75:89`, … | Yes |
| `E501` measurement | `Line too long (93 > 88)` | Yes |
| `pytest -q` result | `10 passed` | Yes |
| `pytest -q` duration | `in 0.01s` | **No** — timing varies |
| Exit codes | `1` from both `ruff check` runs | Yes |

A **different Ruff version may legitimately differ.** Rules get added,
messages get reworded, and fixes get promoted from unsafe to safe between
releases. That is why `requirements/requirements.txt` pins `0.15.22`. If
your totals differ, check `ruff --version` before assuming anything is
broken.

## `format-diff.txt` — what the formatter would change

Captured with `ruff format --isolated --diff receipts.py`.

**Guaranteed identical everywhere with Ruff 0.15.22:** every hunk. Read the
diff as the specification of what a formatter is allowed to do. Everything
in it is one of: a blank line inserted after the module docstring, a quote
character changed from `'` to `"`, whitespace removed around `=` in a
keyword default, or a call re-wrapped because the single-line form exceeded
88 columns.

**Nothing in that diff changes behaviour.** In particular, notice that
`basket = []` becomes `basket=[]` — the formatter tidies the spacing around
the bug and leaves the bug exactly where it was. That contrast is the point
of the file.

The trailing `1 file would be reformatted` and the exit code `1` are both
guaranteed. `--diff` exits non-zero when it has something to say, which is
what makes it usable in continuous integration.

## `alternatives-run.txt` — the rule families, run one at a time

Captured with four `ruff check --isolated --select <family>` runs plus
`python3 -m tabnanny` and `python3 -m py_compile`.

Guaranteed on every platform: the codes and totals in each of the four Ruff
sections, the silence from `tabnanny` (it prints nothing when indentation
is unambiguous), and the word `parses` from `py_compile`.

The end of the file states plainly which tools were **not** installed on
the capture machine and therefore produced no output anywhere in this lab:
flake8, pylint, Black, isort and pre-commit. Nothing in this lab claims to
show you their output. If you install them, you will see something
different from Ruff, and pylint in particular will find things Ruff does
not.

## `timing.txt` — a measurement, not a benchmark

Captured with `time` around `ruff check` on a temporary directory holding
500 copies of the messy module (38,500 lines of Python), then on a single
file.

| Field | Captured value | Must match? |
| --- | --- | --- |
| File count | 500 | Yes, if you follow the same recipe |
| Total lines | 38,500 | Yes |
| `real` for 500 files | 0.033s, then 0.025s and 0.023s | **No** |
| `real` for one file | 0.013s | **No** |

**These numbers are hardware.** They will differ on your machine, possibly
by a lot, and the first run of any command is slower than the ones after it
because of filesystem caching. Do not quote them as a benchmark; reproduce
them as a measurement. What is robust, and what you should check, is the
*shape*: checking 500 files takes roughly twice as long as checking one,
not five hundred times as long, because process start-up dominates at this
size.

This lab makes no comparative speed claim against any tool it did not run.

## `test-run.txt` — the harness

Captured with `bash tests/run_tests.sh`.

**Guaranteed identical everywhere:** all 37 check labels, in order, all
reporting `ok:`, and the final line `37 checks, 0 failure(s).` with exit
code 0.

The two header lines echo `ruff --version` and `pytest --version` and will
show whatever you installed. If they do not read `ruff 0.15.22` and
`pytest 9.1.1`, some counts inside the run may differ; see the note under
`lint-run.txt`.

One label is worth reading twice:

```text
ok: the unsafe fix changes behaviour, so the test that recorded the bug now fails
```

That check passes when pytest **fails**. It is asserting that an unsafe fix
really is unsafe — that Ruff was right to make it opt-in.

## Platform differences

- **macOS and Linux** — identical in every respect. Ruff ships prebuilt
  wheels for both.
- **Windows** — run the lab under WSL and follow the Linux path. Native
  Windows works too, with `\` path separators in Ruff's output and
  `.venv\Scripts\` instead of `.venv/bin/`; the rule codes, line numbers
  and totals are unchanged. `bash tests/run_tests.sh` needs a bash, so use
  WSL or Git Bash for the harness.
- **Locale** — Ruff's messages are English-only in 0.15.22, so no locale
  setting changes them.

alternatives-run.txt

# One binary, one file, four different rule families run separately.
# These are Ruff's reimplementations of the original tools, NOT the
# original tools themselves — see the note at the end of this file.

$ ruff check --isolated --select E,W receipts.py    # the pycodestyle rules
receipts.py:29:17: E711 Comparison to `None` should be `cond is None`
receipts.py:39:20: E711 Comparison to `None` should be `cond is None`
receipts.py:75:89: E501 Line too long (93 > 88)
Found 3 errors.
No fixes available (2 hidden fixes can be enabled with the `--unsafe-fixes` option).
exit: 1

$ ruff check --isolated --select F receipts.py      # the pyflakes rules
receipts.py:13:8: F401 [*] `json` imported but unused
receipts.py:14:25: F401 [*] `collections.Counter` imported but unused
receipts.py:36:5: F841 Local variable `skipped` is assigned to but never used
Found 3 errors.
[*] 2 fixable with the `--fix` option (1 hidden fix can be enabled with the `--unsafe-fixes` option).
exit: 1

$ ruff check --isolated --select I receipts.py      # the isort rules
receipts.py:13:1: I001 [*] Import block is un-sorted or un-formatted
Found 1 error.
[*] 1 fixable with the `--fix` option.
exit: 1

$ ruff check --isolated --select B receipts.py      # the flake8-bugbear rules
receipts.py:22:42: B006 Do not use mutable data structures for argument defaults
Found 1 error.
No fixes available (1 hidden fix can be enabled with the `--unsafe-fixes` option).
exit: 1

$ python3 -m tabnanny receipts.py                   # the standard library's own tiny checker
exit: 0 (silence means: no ambiguous indentation)

$ python3 -m py_compile receipts.py && echo "parses"
parses

--------------------------------------------------------------------------
A note on honesty
--------------------------------------------------------------------------
flake8, pylint, Black, isort and pre-commit are NOT installed on the
machine that captured this file, so no output from them is shown anywhere
in this lab. Every line above came from a tool that is installed: Ruff, and
the `tabnanny` and `py_compile` modules that ship with Python itself.

What the four Ruff commands above demonstrate is real and worth seeing:
each of those rule families was originally a separate program with its own
install, its own configuration file and its own pass over your source. Ruff
runs all of them from one binary in a single pass, which is why the
combined `--select E,F,I,B,SIM,UP` run finds ten problems where each family
alone finds one to three.

If you want to compare against the originals, install them yourself and
run them on the same file:

    pip install flake8 pylint black isort
    flake8 --max-line-length 88 receipts.py
    pylint receipts.py
    black --diff receipts.py
    isort --diff receipts.py

Expect pylint in particular to say things Ruff does not — it builds a
deeper model of your program and will complain about missing docstrings,
too many arguments, and names it can prove are never used across modules.
Do not take this course's word for that either. Run it.

format-diff.txt

$ ruff format --isolated --diff receipts.py
--- receipts.py
+++ receipts.py
@@ -10,17 +10,18 @@
 Nothing here is exaggerated. Every one of these appears in real code
 written by people in a hurry.
 """
+
 import json
 from collections import Counter
 import re
 from decimal import Decimal
 
-TAX_RATE = Decimal('0.20')
-LINE_PATTERN = re.compile(r'^(.+?)\s+([0-9]+)$')
+TAX_RATE = Decimal("0.20")
+LINE_PATTERN = re.compile(r"^(.+?)\s+([0-9]+)$")
 
 
-def add_item(name, price_cents, basket = []):
-    basket.append({'name': name, 'price_cents': price_cents})
+def add_item(name, price_cents, basket=[]):
+    basket.append({"name": name, "price_cents": price_cents})
     return basket
 
 
@@ -28,7 +29,7 @@
     match = LINE_PATTERN.match(line.strip())
     if match == None:
         return None
-    return {'name': match.group(1), 'price_cents': int(match.group(2))}
+    return {"name": match.group(1), "price_cents": int(match.group(2))}
 
 
 def load_basket(lines):
@@ -38,14 +39,14 @@
         item = parse_line(line)
         if item == None:
             continue
-        add_item(item['name'], item['price_cents'], basket)
+        add_item(item["name"], item["price_cents"], basket)
     return basket
 
 
 def subtotal_cents(basket):
     total = 0
     for item in basket:
-        total += item['price_cents']
+        total += item["price_cents"]
     return total
 
 
@@ -60,18 +61,20 @@
 def describe(basket, name):
     hits = 0
     for item in basket:
-        if item['name'] == name:
+        if item["name"] == name:
             hits += 1
     if hits:
-        label = 'found'
+        label = "found"
     else:
-        label = 'missing'
-    return '%s: %s x%d' % (name, label, hits)
+        label = "missing"
+    return "%s: %s x%d" % (name, label, hits)
 
 
-def format_receipt(basket, shop = 'Corner Shop'):
+def format_receipt(basket, shop="Corner Shop"):
     rows = [shop]
     for item in basket:
-        rows.append('{:<20}{:>8}'.format(item['name'], '%.2f' % (item['price_cents'] / 100)))
-    rows.append('{:<20}{:>8}'.format('TOTAL', '%.2f' % (total_cents(basket) / 100)))
-    return '\n'.join(rows)
+        rows.append(
+            "{:<20}{:>8}".format(item["name"], "%.2f" % (item["price_cents"] / 100))
+        )
+    rows.append("{:<20}{:>8}".format("TOTAL", "%.2f" % (total_cents(basket) / 100)))
+    return "\n".join(rows)

1 file would be reformatted
exit: 1

lint-run.txt

$ pytest -q
..........                                                               [100%]
10 passed in 0.01s

$ ruff check --isolated receipts.py
receipts.py:13:8: F401 [*] `json` imported but unused
receipts.py:14:25: F401 [*] `collections.Counter` imported but unused
receipts.py:29:17: E711 Comparison to `None` should be `cond is None`
receipts.py:36:5: F841 Local variable `skipped` is assigned to but never used
receipts.py:39:20: E711 Comparison to `None` should be `cond is None`
Found 5 errors.
[*] 2 fixable with the `--fix` option (3 hidden fixes can be enabled with the `--unsafe-fixes` option).
exit: 1

$ ruff check --isolated --select E,F,I,B,SIM,UP receipts.py
receipts.py:13:1: I001 [*] Import block is un-sorted or un-formatted
receipts.py:13:8: F401 [*] `json` imported but unused
receipts.py:14:25: F401 [*] `collections.Counter` imported but unused
receipts.py:22:42: B006 Do not use mutable data structures for argument defaults
receipts.py:29:17: E711 Comparison to `None` should be `cond is None`
receipts.py:36:5: F841 Local variable `skipped` is assigned to but never used
receipts.py:39:20: E711 Comparison to `None` should be `cond is None`
receipts.py:65:5: SIM108 Use ternary operator `label = 'found' if hits else 'missing'` instead of `if`-`else`-block
receipts.py:69:12: UP031 Use format specifiers instead of percent format
receipts.py:75:89: E501 Line too long (93 > 88)
Found 10 errors.
[*] 3 fixable with the `--fix` option (5 hidden fixes can be enabled with the `--unsafe-fixes` option).
exit: 1

test-run.txt

Day 076 — Linting and Formatting with Ruff
  ruff:   ruff 0.15.22
  pytest: pytest 9.1.1

Ground truth: the messy module works
  ok: the messy module's pytest suite passes before any tool touches it
  ok: the messy suite reports a single all-passing line
  ok: the messy add_item really does share one basket between calls (the B006 bug)

The linter: what it finds, by rule code
  ok: ruff check exits non-zero on the messy module
  ok: ruff check reports I001 on the messy module
  ok: ruff check reports F401 on the messy module
  ok: ruff check reports B006 on the messy module
  ok: ruff check reports E711 on the messy module
  ok: ruff check reports F841 on the messy module
  ok: ruff check reports SIM108 on the messy module
  ok: ruff check reports UP031 on the messy module
  ok: ruff check reports E501 on the messy module
  ok: ruff check reports exactly 10 findings under --select E,F,I,B,SIM,UP
  ok: ruff's default rule set is smaller and misses B006 until you select it

The formatter: appearance only, and idempotent
  ok: ruff format --check FAILS on the messy module (the CI form)
  ok: the behaviour suite is STILL green after ruff format rewrote the module
  ok: formatting is idempotent — the second run changes nothing
  ok: ruff format --check now PASSES on the formatted copy
  ok: formatting fixes no lint findings — B006 and F401 both survive it

Autofix: safe, unsafe, and the line between them
  ok: the SAFE autofix removes the unused imports (F401)
  ok: the SAFE autofix refuses to touch B006 — behaviour is a human decision
  ok: the behaviour suite is still green after the safe autofix
  ok: the UNSAFE autofix does rewrite the mutable default
  ok: the unsafe fix changes behaviour, so the test that recorded the bug now fails

Suppression: noqa with a code, not a bare noqa
  ok: # noqa: F401 suppresses exactly that rule on that line
  ok: # noqa: F401 suppresses nothing else — E711 elsewhere still fires

The cleaned module and its configuration
  ok: ruff check exits 0 on examples/clean using its own pyproject.toml
  ok: ruff format --check exits 0 on examples/clean
  ok: the cleaned module passes the same behaviour suite
  ok: the cleaned add_item gives every caller a fresh basket — the bug is gone
  ok: messy and clean agree on every price, description and receipt line
  ok: S101 fires on the test file when the configuration is ignored
  ok: the per-file ignore silences S101 for test_*.py and nowhere else
  ok: the reference pyproject.toml parses and declares select, ignore and a per-file ignore

Your starter files
  ok: starter/receipts.py is valid Python
  ok: starter/ still passes its own behaviour suite (whatever stage you are at)
  ok: starter/receipts.py still defines all eight public functions

37 checks, 0 failure(s).

timing.txt

$ # 500 copies of the messy module = 38500 lines of Python
$ time ruff check --isolated --select E,F,I,B,SIM,UP <500-file directory>

real	0m0.033s
user	0m0.050s
sys	0m0.162s

$ # the same command run twice more, to show it is not a fluke

real	0m0.025s
user	0m0.044s
sys	0m0.051s

real	0m0.023s
user	0m0.045s
sys	0m0.050s

$ time ruff check --isolated --select E,F,I,B,SIM,UP <one file>

real	0m0.013s
user	0m0.004s
sys	0m0.005s

Source files

examples/clean/pyproject.toml (2654 bytes)
# Reference Ruff configuration for this lab.
#
# `pyproject.toml` is the standard place a Python project keeps its
# settings. Ruff looks for the closest one above the file it is checking,
# which is why running `ruff check .` from THIS directory picks these rules
# up automatically and running it from `examples/messy/` does not.
#
# Read this file top to bottom. Every line is a decision, and the comment
# next to it is the reason for the decision — which is the part a config
# file usually forgets to record.

[tool.ruff]
# 88 columns is Black's default and therefore Ruff's. The number matters
# far less than everyone using the same one.
line-length = 88

# The oldest Python this code must run on. `UP` rules use this: they will
# not rewrite something into syntax your target cannot parse.
target-version = "py39"

# Directories the tools should never look at. A virtual environment is full
# of other people's code and none of its style is your business.
extend-exclude = [".venv", "build", "dist"]

[tool.ruff.lint]
# The rule families this project has decided to enforce, smallest useful
# set first. Adding a family is a deliberate act with a reason:
#   E, W  pycodestyle  — PEP 8 layout and whitespace complaints
#   F     pyflakes     — real errors: unused imports, undefined names
#   I     isort        — import order
#   B     bugbear      — suspicious patterns that are usually bugs
#   SIM   simplify     — code that says something the long way round
#   UP    pyupgrade    — syntax made obsolete by the target version
#   N     pep8-naming  — snake_case, CapWords, UPPER_CASE
#   S     bandit       — common security mistakes
select = ["E", "W", "F", "I", "B", "SIM", "UP", "N", "S"]

ignore = [
  # The formatter owns line length. Once `ruff format` has run, E501 can
  # only fire on something the formatter is not allowed to split — a long
  # URL in a comment, a long string literal — and breaking those by hand
  # makes them worse, not better. Ruff's own documentation recommends
  # disabling E501 when the formatter is in use.
  "E501",
]

[tool.ruff.lint.per-file-ignores]
# `assert` is the entire point of a test, so the security rule that
# distrusts it (S101, "assert can be stripped by python -O") is noise
# here and only here. This is the classic per-file ignore: relax a rule
# in the one place where it is wrong, rather than switching it off
# everywhere.
"test_*.py" = ["S101"]

[tool.ruff.format]
# The formatter has almost nothing to configure, on purpose. These two
# lines are the defaults, written out so nobody has to go and look them up.
quote-style = "double"
indent-style = "space"
examples/clean/receipts.py (2411 bytes)
"""Price a small shop receipt.

This is the same module as `examples/messy/receipts.py`, after the tools
and one human decision have been through it. Compare the two files side by
side with `diff -u ../messy/receipts.py receipts.py`.

Almost every difference was made mechanically: `ruff check --fix` removed
the two unused imports and the unread local, `ruff check --fix
--unsafe-fixes` rewrote the two `== None` comparisons, collapsed the
if/else into a ternary and replaced the mutable default, and `ruff format`
re-quoted, re-spaced and re-wrapped everything. Only the percent-format
strings at the end were rewritten by hand, because the nested formatting
was beyond what the fix could see.

The behaviour is byte-for-byte identical to the messy version, with one
deliberate exception: `add_item` no longer shares a default basket between
calls. That was a real bug, `B006` found it, and fixing it is a decision a
person has to make and a test has to record.
"""

import re
from decimal import Decimal

TAX_RATE = Decimal("0.20")
LINE_PATTERN = re.compile(r"^(.+?)\s+([0-9]+)$")


def add_item(name, price_cents, basket=None):
    if basket is None:
        basket = []
    basket.append({"name": name, "price_cents": price_cents})
    return basket


def parse_line(line):
    match = LINE_PATTERN.match(line.strip())
    if match is None:
        return None
    return {"name": match.group(1), "price_cents": int(match.group(2))}


def load_basket(lines):
    basket = []
    for line in lines:
        item = parse_line(line)
        if item is None:
            continue
        add_item(item["name"], item["price_cents"], basket)
    return basket


def subtotal_cents(basket):
    total = 0
    for item in basket:
        total += item["price_cents"]
    return total


def tax_cents(basket):
    return int(Decimal(subtotal_cents(basket)) * TAX_RATE)


def total_cents(basket):
    return subtotal_cents(basket) + tax_cents(basket)


def describe(basket, name):
    hits = 0
    for item in basket:
        if item["name"] == name:
            hits += 1
    label = "found" if hits else "missing"
    return f"{name}: {label} x{hits}"


def format_receipt(basket, shop="Corner Shop"):
    rows = [shop]
    for item in basket:
        rows.append(f"{item['name']:<20}{item['price_cents'] / 100:>8.2f}")
    rows.append(f"{'TOTAL':<20}{total_cents(basket) / 100:>8.2f}")
    return "\n".join(rows)
examples/clean/test_receipts.py (2579 bytes)
"""Behaviour tests for the cleaned receipts module.

Every test below is character-for-character the same as the one in
`examples/messy/test_receipts.py`, except the last one. That is the whole
argument of this lab: the formatter and the linter rewrote the module
substantially and not one assertion about its behaviour had to change.

The last test changed because a person changed the behaviour on purpose,
after `B006` pointed at the bug.
"""

import receipts


def test_parse_line_reads_a_name_and_a_price():
    assert receipts.parse_line("apple 120") == {"name": "apple", "price_cents": 120}


def test_parse_line_keeps_multi_word_names():
    assert receipts.parse_line("brown bread 240") == {
        "name": "brown bread",
        "price_cents": 240,
    }


def test_parse_line_returns_none_for_junk():
    assert receipts.parse_line("nonsense") is None
    assert receipts.parse_line("") is None


def test_load_basket_skips_unparseable_lines():
    basket = receipts.load_basket(["apple 120", "nonsense", "pear 95"])
    assert [item["name"] for item in basket] == ["apple", "pear"]


def test_subtotal_adds_every_price():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    assert receipts.subtotal_cents(basket) == 215


def test_tax_is_twenty_percent_of_the_subtotal():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    assert receipts.tax_cents(basket) == 43


def test_total_is_subtotal_plus_tax():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    assert receipts.total_cents(basket) == 258


def test_describe_counts_matching_items():
    basket = receipts.load_basket(["apple 120", "apple 130", "pear 95"])
    assert receipts.describe(basket, "apple") == "apple: found x2"
    assert receipts.describe(basket, "plum") == "plum: missing x0"


def test_format_receipt_has_a_header_and_a_total_row():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    rows = receipts.format_receipt(basket).splitlines()
    assert rows[0] == "Corner Shop"
    assert rows[-1] == "TOTAL                   2.58"
    assert len(rows) == 4


def test_default_basket_is_fresh_on_every_call():
    """The fixed version of the B006 test.

    Two calls that omit `basket` must get two independent baskets. In the
    messy version they got the same one, and `first is second` was true.
    """
    first = receipts.add_item("apple", 120)
    second = receipts.add_item("pear", 95)
    assert first is not second
    assert first == [{"name": "apple", "price_cents": 120}]
    assert second == [{"name": "pear", "price_cents": 95}]
examples/messy/receipts.py (2080 bytes)
"""Price a small shop receipt.

This module WORKS. Every function does what its name says, and the test
suite next door passes. It is also written badly on purpose: unsorted
imports, an import nobody uses, a local variable nobody reads, a line far
too long, two comparisons to None written the wrong way, string formatting
from an older era of Python, and one mutable default argument that is a
genuine bug rather than a cosmetic complaint.

Nothing here is exaggerated. Every one of these appears in real code
written by people in a hurry.
"""
import json
from collections import Counter
import re
from decimal import Decimal

TAX_RATE = Decimal('0.20')
LINE_PATTERN = re.compile(r'^(.+?)\s+([0-9]+)$')


def add_item(name, price_cents, basket = []):
    basket.append({'name': name, 'price_cents': price_cents})
    return basket


def parse_line(line):
    match = LINE_PATTERN.match(line.strip())
    if match == None:
        return None
    return {'name': match.group(1), 'price_cents': int(match.group(2))}


def load_basket(lines):
    basket = []
    skipped = 0
    for line in lines:
        item = parse_line(line)
        if item == None:
            continue
        add_item(item['name'], item['price_cents'], basket)
    return basket


def subtotal_cents(basket):
    total = 0
    for item in basket:
        total += item['price_cents']
    return total


def tax_cents(basket):
    return int(Decimal(subtotal_cents(basket)) * TAX_RATE)


def total_cents(basket):
    return subtotal_cents(basket) + tax_cents(basket)


def describe(basket, name):
    hits = 0
    for item in basket:
        if item['name'] == name:
            hits += 1
    if hits:
        label = 'found'
    else:
        label = 'missing'
    return '%s: %s x%d' % (name, label, hits)


def format_receipt(basket, shop = 'Corner Shop'):
    rows = [shop]
    for item in basket:
        rows.append('{:<20}{:>8}'.format(item['name'], '%.2f' % (item['price_cents'] / 100)))
    rows.append('{:<20}{:>8}'.format('TOTAL', '%.2f' % (total_cents(basket) / 100)))
    return '\n'.join(rows)
examples/messy/test_receipts.py (2657 bytes)
"""Behaviour tests for the messy receipts module.

These tests exist to PIN CURRENT BEHAVIOUR. Their whole job is to be green
before you touch the file and green after the formatter and the linter have
rewritten it, so that you can see with your own eyes that neither tool
changed what the code does.

One test below deliberately asserts a bug. It is labelled.
"""

import receipts


def test_parse_line_reads_a_name_and_a_price():
    assert receipts.parse_line("apple 120") == {"name": "apple", "price_cents": 120}


def test_parse_line_keeps_multi_word_names():
    assert receipts.parse_line("brown bread 240") == {
        "name": "brown bread",
        "price_cents": 240,
    }


def test_parse_line_returns_none_for_junk():
    assert receipts.parse_line("nonsense") is None
    assert receipts.parse_line("") is None


def test_load_basket_skips_unparseable_lines():
    basket = receipts.load_basket(["apple 120", "nonsense", "pear 95"])
    assert [item["name"] for item in basket] == ["apple", "pear"]


def test_subtotal_adds_every_price():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    assert receipts.subtotal_cents(basket) == 215


def test_tax_is_twenty_percent_of_the_subtotal():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    assert receipts.tax_cents(basket) == 43


def test_total_is_subtotal_plus_tax():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    assert receipts.total_cents(basket) == 258


def test_describe_counts_matching_items():
    basket = receipts.load_basket(["apple 120", "apple 130", "pear 95"])
    assert receipts.describe(basket, "apple") == "apple: found x2"
    assert receipts.describe(basket, "plum") == "plum: missing x0"


def test_format_receipt_has_a_header_and_a_total_row():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    rows = receipts.format_receipt(basket).splitlines()
    assert rows[0] == "Corner Shop"
    assert rows[-1] == "TOTAL                   2.58"
    assert len(rows) == 4


def test_default_basket_is_shared_between_calls():
    """THIS TEST DOCUMENTS A BUG, NOT A FEATURE.

    `add_item`'s default `basket = []` is one list, created once when the
    `def` line ran. Every call that omits `basket` appends to that same
    list, so two unrelated shoppers end up sharing a trolley. The linter
    rule B006 flags exactly this. Exercise 6 fixes it, and this test is
    then rewritten to assert the correct behaviour instead.
    """
    first = receipts.add_item("apple", 120)
    second = receipts.add_item("pear", 95)
    assert first is second
    assert [item["name"] for item in second][-2:] == ["apple", "pear"]
metadata.yml (1522 bytes)
lesson_id: D076
day: 76
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-076-linting-and-formatting-with-ruff
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/ruff --version
  - .venv/bin/pytest --version
run_commands:
  - cat starter/EXERCISES.md
  - cd starter && pytest -q
  - ruff check --isolated receipts.py
  - ruff check --isolated --select E,F,I,B,SIM,UP receipts.py
  - ruff rule B006
  - ruff format --isolated --diff receipts.py
  - ruff format --isolated receipts.py
  - ruff check --isolated --select E,F,I,B,SIM,UP --fix receipts.py
  - ruff check --isolated --select E,F,I,B,SIM,UP --diff --unsafe-fixes receipts.py
  - ruff check --isolated --select E,F,I,B,SIM,UP --fix --unsafe-fixes receipts.py
  - ruff check .
  - ruff format --check .
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - 'find . -type d -name ".ruff_cache" -prune -exec rm -rf -- {} +'
  - 'find . -type d -name ".pytest_cache" -prune -exec rm -rf -- {} +'
  - 'find . -type d -name "__pycache__" -prune -exec rm -rf -- {} +'
  - 'git checkout -- starter/  # optional: reset your work'
  - 'rm -rf .venv  # optional: remove the lab virtual environment'
requires_network: true
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-19'
executed_on: 'macOS 26.5.1 (Apple Silicon), Python 3.14.0, Ruff 0.15.22, pytest 9.1.1, bash 3.2.57 — bash tests/run_tests.sh -> 37 checks, 0 failure(s), exit 0'
requirements/README.md (2440 bytes)
# Requirements for the Day 076 lab

Two dependencies, both free and open source, both pinned to an exact
version so that your output matches the captures in `expected-output/`.

| Package | Pinned version | Why this lab needs it |
| --- | --- | --- |
| `ruff` | `0.15.22` | The subject of the lesson: a linter and a formatter in one binary. Provides `ruff check`, `ruff format` and `ruff rule`. |
| `pytest` | `9.1.1` | The evidence. The whole argument of this lab is that the tools change how code reads without changing what it does, and a green test suite before and after is how you prove that. Introduced on Day 71. |

Both projects are distributed under the MIT licence. Ruff is developed by
Astral; its documentation is at `https://docs.astral.sh/ruff/`. pytest is
developed by the pytest project; its documentation is at
`https://docs.pytest.org/en/stable/`. Neither asks for an account, a
licence key, or a paid tier to do anything in this lab.

## One-time install

From the lab directory:

```bash
cd labs/sections/programming-with-python/day-076-linting-and-formatting-with-ruff
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/ruff --version
.venv/bin/pytest --version
```

You should see `ruff 0.15.22` and `pytest 9.1.1`.

`python3 -m venv` is the tool from Day 43. `.venv/` is ignored by this
repository's `.gitignore` and must never be committed.

## Network

Installing needs the network, once. After that the lab runs entirely
offline: neither Ruff nor pytest contacts anything, and `ruff rule <code>`
prints rule documentation that is compiled into the binary rather than
fetched.

## If you would rather not create a virtual environment

The test suite resolves its tools in this order: an explicit environment
variable, then `.venv/bin/` inside this lab, then whatever is on your
`PATH`. So if you already have Ruff and pytest installed somewhere, either
of these works:

```bash
bash tests/run_tests.sh                                   # uses PATH
RUFF=/path/to/ruff PYTEST=/path/to/pytest bash tests/run_tests.sh
```

If neither is found the suite stops with an install message rather than
quietly reporting success on checks it never ran.

## Caches

Ruff writes a `.ruff_cache/` directory next to the files it checks, and
pytest can write `__pycache__/` and `.pytest_cache/`. All three are ignored
by this repository. The `## Cleanup` section of the lab README removes
them.
requirements/requirements.txt (28 bytes)
ruff==0.15.22
pytest==9.1.1
starter/EXERCISES.md (7725 bytes)
# Exercises — from messy to clean, mechanically

Work through these in order, **from inside this `starter/` directory**:

```bash
cd labs/sections/programming-with-python/day-076-linting-and-formatting-with-ruff/starter
```

Everywhere below, `ruff` and `pytest` mean the ones you installed for this
lab. If you made a lab-local virtual environment, write `../.venv/bin/ruff`
and `../.venv/bin/pytest` instead, or activate the environment first.

The rule this whole exercise is built to prove: **the tools change how the
code reads, not what it does.** The test suite is your evidence. Run it
before, run it after, and watch it stay green.

---

## Exercise 1 — Establish the ground truth

Before changing anything, prove the module works.

```bash
pytest -q
```

You should see `10 passed`. Those ten tests pin every behaviour that
matters: parsing, skipping junk lines, the subtotal, the 20% tax, the
total, the description string, the receipt layout, and — the interesting
one — the fact that `add_item` currently shares a basket between calls.

Read `test_receipts.py`. The last test is labelled as documenting a bug
rather than a feature. Keep that in mind; you will come back to it.

**Write down the exact output line.** It is the number you are protecting.

## Exercise 2 — Look at what the default rule set finds

```bash
ruff check --isolated receipts.py
```

`--isolated` means "ignore any configuration file you might find" — use it
until Exercise 7, so that you are always seeing the rules you asked for and
not rules you inherited from somewhere up the directory tree.

Ruff's default selection is deliberately small: `E4`, `E7`, `E9` and `F`.
Count the findings. Note which of the problems you can see in the file it
did **not** mention.

## Exercise 3 — Ask for more rules

```bash
ruff check --isolated --select E,F,I,B,SIM,UP receipts.py
```

Now you should see **ten** findings across **eight** distinct rule codes.
Write each code down with one sentence in your own words about what it
means. If a code is unfamiliar, ask the tool:

```bash
ruff rule B006
ruff rule F841
```

`ruff rule <code>` prints the rule's full documentation, including why it
exists and what the fix does. It is the fastest way to learn the rule set,
and it needs no network.

Then look at the last line of the output. It tells you how many findings
are fixable safely, and how many more become fixable if you opt in to
unsafe fixes. Those two numbers are the subject of Exercise 5.

## Exercise 4 — Preview the formatter without running it

```bash
ruff format --isolated --diff receipts.py
```

`--diff` prints what the formatter *would* do and changes nothing. Read the
diff carefully. Every single line of it is about appearance: quotes,
spacing around `=` in a default argument, where a long call gets wrapped.
Not one line changes a value, a condition, or a name.

Now run it for real, and then run it again:

```bash
ruff format --isolated receipts.py
ruff format --isolated receipts.py
```

The second run must report `1 file left unchanged`. That property is called
**idempotence**: formatting formatted code is a no-op. It is what makes a
formatter safe to put in a commit hook.

**Re-run the tests: `pytest -q`. Still `10 passed`.** The file looks
different and behaves identically.

## Exercise 5 — Apply the fixes the linter is confident about

```bash
ruff check --isolated --select E,F,I,B,SIM,UP --fix receipts.py
```

Ruff applies only its **safe** fixes: ones it can prove do not change what
the program does and do not throw away a comment. Watch which findings
disappear and which survive.

`pytest -q` — still `10 passed`.

Now look at what is left and read the summary line. The remaining fixes are
marked **unsafe**, which does not mean "wrong" — it means "this fix may
change behaviour, so a human has to say yes." Preview them before applying:

```bash
ruff check --isolated --select E,F,I,B,SIM,UP --diff --unsafe-fixes receipts.py
```

Read that diff and decide, one hunk at a time, whether you agree. Then
apply it:

```bash
ruff check --isolated --select E,F,I,B,SIM,UP --fix --unsafe-fixes receipts.py
ruff format --isolated receipts.py
```

## Exercise 6 — The one the tools could not decide for you

Run the tests again.

```bash
pytest -q
```

This time one test **fails**, and it is the test labelled as documenting a
bug. Read the failure carefully before you do anything else.

What happened: `B006` found a real defect — `basket = []` in the signature
of `add_item` creates one list when the `def` line runs, and every call
that omits `basket` shares it. Two shoppers, one trolley. The unsafe fix
rewrote it to `basket=None` plus an `if basket is None:` guard, which is
the correct shape. Correct code, failing test — because the test was
written to record the broken behaviour.

Your job now is a judgement no tool can make for you:

1. Confirm the new behaviour is the behaviour you want. (It is. Read
   `add_item` and convince yourself.)
2. Rewrite the last test so it asserts the **correct** behaviour: two calls
   that omit `basket` return two independent lists. `assert first is not
   second`, and each list holds exactly one item.
3. Update the test's docstring so it no longer claims to document a bug.
4. `pytest -q` → `10 passed`.

If the unsafe fix did not run for you, make the same change by hand:

```python
def add_item(name, price_cents, basket=None):
    if basket is None:
        basket = []
    ...
```

This is the moment the lab is built around. Nine of the ten findings were
about how the code reads. One was a bug that would have shipped, and the
linter is the only thing in the room that noticed.

## Exercise 7 — Write the configuration

Nobody wants to type `--select E,F,I,B,SIM,UP` forever. Create a file
called `pyproject.toml` **in this `starter/` directory** with at least:

- a `[tool.ruff]` table setting `line-length` and `target-version`;
- a `[tool.ruff.lint]` table with a `select` list naming the rule families
  your project enforces, and an `ignore` list with a **comment explaining
  why** for each entry;
- a `[tool.ruff.lint.per-file-ignores]` table relaxing at least one rule
  for `test_*.py`.

Then check that it works with no flags at all:

```bash
ruff check .
ruff format --check .
pytest -q
```

All three must succeed. `ruff check .` printing `All checks passed!` with
no `--select` in sight means the configuration, not your memory, is now
carrying the rules.

Compare yours with `../examples/clean/pyproject.toml`. It does not have to
match — it has to be *defensible*. Every `ignore` entry you cannot justify
in one sentence is a rule you should either obey or remove from `select`.

## Exercise 8 — Confirm you arrived

```bash
diff -u ../examples/clean/receipts.py receipts.py
```

Differences in the module docstring are expected and fine — the reference
file explains itself. Differences in the *code* are worth reading: either
you found a better answer than the reference, or you missed a step.

Finally, from the lab directory:

```bash
cd .. && bash tests/run_tests.sh
```

## Exercise 9 (optional) — Measure the speed claim yourself

Do not take anybody's benchmark on trust, including this course's. Measure:

```bash
time ruff check --isolated --select E,F,I,B,SIM,UP ../examples/messy/receipts.py
```

Record the `real` figure. It is one small file, so most of what you are
measuring is process start-up — which is exactly the number that decides
whether a tool is tolerable in an editor on every keystroke. Try it again
against a large directory of Python you have lying around and watch how
little the number moves.

## Reset

To start over:

```bash
git checkout -- starter/
```
starter/receipts.py (2080 bytes)
"""Price a small shop receipt.

This module WORKS. Every function does what its name says, and the test
suite next door passes. It is also written badly on purpose: unsorted
imports, an import nobody uses, a local variable nobody reads, a line far
too long, two comparisons to None written the wrong way, string formatting
from an older era of Python, and one mutable default argument that is a
genuine bug rather than a cosmetic complaint.

Nothing here is exaggerated. Every one of these appears in real code
written by people in a hurry.
"""
import json
from collections import Counter
import re
from decimal import Decimal

TAX_RATE = Decimal('0.20')
LINE_PATTERN = re.compile(r'^(.+?)\s+([0-9]+)$')


def add_item(name, price_cents, basket = []):
    basket.append({'name': name, 'price_cents': price_cents})
    return basket


def parse_line(line):
    match = LINE_PATTERN.match(line.strip())
    if match == None:
        return None
    return {'name': match.group(1), 'price_cents': int(match.group(2))}


def load_basket(lines):
    basket = []
    skipped = 0
    for line in lines:
        item = parse_line(line)
        if item == None:
            continue
        add_item(item['name'], item['price_cents'], basket)
    return basket


def subtotal_cents(basket):
    total = 0
    for item in basket:
        total += item['price_cents']
    return total


def tax_cents(basket):
    return int(Decimal(subtotal_cents(basket)) * TAX_RATE)


def total_cents(basket):
    return subtotal_cents(basket) + tax_cents(basket)


def describe(basket, name):
    hits = 0
    for item in basket:
        if item['name'] == name:
            hits += 1
    if hits:
        label = 'found'
    else:
        label = 'missing'
    return '%s: %s x%d' % (name, label, hits)


def format_receipt(basket, shop = 'Corner Shop'):
    rows = [shop]
    for item in basket:
        rows.append('{:<20}{:>8}'.format(item['name'], '%.2f' % (item['price_cents'] / 100)))
    rows.append('{:<20}{:>8}'.format('TOTAL', '%.2f' % (total_cents(basket) / 100)))
    return '\n'.join(rows)
starter/test_receipts.py (2657 bytes)
"""Behaviour tests for the messy receipts module.

These tests exist to PIN CURRENT BEHAVIOUR. Their whole job is to be green
before you touch the file and green after the formatter and the linter have
rewritten it, so that you can see with your own eyes that neither tool
changed what the code does.

One test below deliberately asserts a bug. It is labelled.
"""

import receipts


def test_parse_line_reads_a_name_and_a_price():
    assert receipts.parse_line("apple 120") == {"name": "apple", "price_cents": 120}


def test_parse_line_keeps_multi_word_names():
    assert receipts.parse_line("brown bread 240") == {
        "name": "brown bread",
        "price_cents": 240,
    }


def test_parse_line_returns_none_for_junk():
    assert receipts.parse_line("nonsense") is None
    assert receipts.parse_line("") is None


def test_load_basket_skips_unparseable_lines():
    basket = receipts.load_basket(["apple 120", "nonsense", "pear 95"])
    assert [item["name"] for item in basket] == ["apple", "pear"]


def test_subtotal_adds_every_price():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    assert receipts.subtotal_cents(basket) == 215


def test_tax_is_twenty_percent_of_the_subtotal():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    assert receipts.tax_cents(basket) == 43


def test_total_is_subtotal_plus_tax():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    assert receipts.total_cents(basket) == 258


def test_describe_counts_matching_items():
    basket = receipts.load_basket(["apple 120", "apple 130", "pear 95"])
    assert receipts.describe(basket, "apple") == "apple: found x2"
    assert receipts.describe(basket, "plum") == "plum: missing x0"


def test_format_receipt_has_a_header_and_a_total_row():
    basket = receipts.load_basket(["apple 120", "pear 95"])
    rows = receipts.format_receipt(basket).splitlines()
    assert rows[0] == "Corner Shop"
    assert rows[-1] == "TOTAL                   2.58"
    assert len(rows) == 4


def test_default_basket_is_shared_between_calls():
    """THIS TEST DOCUMENTS A BUG, NOT A FEATURE.

    `add_item`'s default `basket = []` is one list, created once when the
    `def` line ran. Every call that omits `basket` appends to that same
    list, so two unrelated shoppers end up sharing a trolley. The linter
    rule B006 flags exactly this. Exercise 6 fixes it, and this test is
    then rewritten to assert the correct behaviour instead.
    """
    first = receipts.add_item("apple", 120)
    second = receipts.add_item("pear", 95)
    assert first is second
    assert [item["name"] for item in second][-2:] == ["apple", "pear"]
tests/run_tests.sh (16302 bytes)
#!/usr/bin/env bash
# Tests for the Day 076 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# What this suite proves, in order:
#
#   * the messy module WORKS — its pytest suite is green before anything
#     touches it, so we have a ground truth to protect;
#   * `ruff format` changes how it reads and NOT what it does — the same
#     suite is still green on a formatted copy, and formatting a second
#     time changes nothing (idempotence);
#   * `ruff check` finds the eight rule codes this lab is built around,
#     asserted by CODE rather than by the wording of the message;
#   * the safe autofix refuses to touch the real bug (B006) and the unsafe
#     autofix does fix it — the distinction the lesson turns on;
#   * the cleaned module is clean under the project's own configuration,
#     passes the same behaviour tests, and no longer shares a basket;
#   * `--check` mode fails on the messy file and passes on the clean one,
#     which is the shape both tools take in continuous integration;
#   * a `# noqa` comment with a specific code suppresses exactly that code.
#
# No network at test time, non-interactive, deterministic. Exits 0 only if
# every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
failures=0
checks=0

# The rule families this lab teaches. Kept in one variable so the harness
# and the exercises can never drift apart.
SELECT="E,F,I,B,SIM,UP"

check() {
  local label="$1" ok="$2"
  checks=$((checks + 1))
  if [ "${ok}" = "yes" ]; then
    echo "  ok: ${label}"
  else
    echo "  FAIL: ${label}"
    failures=$((failures + 1))
  fi
}

# Resolve a tool: an explicit override, then this lab's .venv, then whatever
# is on PATH. Fails loudly with instructions rather than silently skipping.
resolve_tool() {
  local tool="$1" override="$2"
  if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
  if [ -x "${lab_dir}/.venv/bin/${tool}" ]; then echo "${lab_dir}/.venv/bin/${tool}"; return 0; fi
  if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
  return 1
}

install_hint() {
  echo "  Install it with:" >&2
  echo "    python3 -m venv .venv" >&2
  echo "    .venv/bin/pip install -r requirements/requirements.txt" >&2
}

pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
  echo "FAIL: pytest not found." >&2
  install_hint
  echo "  Or point this suite at an existing pytest: PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
  exit 1
}

ruff_bin="$(resolve_tool ruff "${RUFF:-}")" || {
  echo "FAIL: ruff not found." >&2
  install_hint
  echo "  Or point this suite at an existing ruff: RUFF=/path/to/ruff bash tests/run_tests.sh" >&2
  exit 1
}

# run_pytest <dir> — runs the suite in <dir>, quiet, leaving no cache behind.
run_pytest() {
  (cd "$1" && "${pytest_bin}" -q -p no:cacheprovider >/dev/null 2>&1)
}

# scratch_copy <src_dir> — copies a directory's .py files to a fresh temp
# directory and echoes its path. The caller removes it.
scratch_copy() {
  local src="$1" dest
  dest="$(mktemp -d "${TMPDIR:-/tmp}/ruff-lab.XXXXXX")"
  cp "${src}"/*.py "${dest}/"
  echo "${dest}"
}

echo "Day 076 — Linting and Formatting with Ruff"
echo "  ruff:   $("${ruff_bin}" --version)"
echo "  pytest: $("${pytest_bin}" --version 2>&1 | head -1)"
echo

# --------------------------------------------------------------------------
echo "Ground truth: the messy module works"
# --------------------------------------------------------------------------

if run_pytest "${lab_dir}/examples/messy"; then
  check "the messy module's pytest suite passes before any tool touches it" "yes"
else
  check "the messy module's pytest suite passes before any tool touches it" "no"
fi

messy_count="$(cd "${lab_dir}/examples/messy" && "${pytest_bin}" -q -p no:cacheprovider 2>/dev/null | grep -cE '^[.]+ +\[100%\]$')"
if [ "${messy_count}" = "1" ]; then
  check "the messy suite reports a single all-passing line" "yes"
else
  check "the messy suite reports a single all-passing line" "no"
fi

# The bug the linter is going to find, demonstrated at runtime.
if (cd "${lab_dir}/examples/messy" && python3 -c "
import receipts
first = receipts.add_item('apple', 120)
second = receipts.add_item('pear', 95)
raise SystemExit(0 if first is second else 1)
" >/dev/null 2>&1); then
  check "the messy add_item really does share one basket between calls (the B006 bug)" "yes"
else
  check "the messy add_item really does share one basket between calls (the B006 bug)" "no"
fi

# --------------------------------------------------------------------------
echo
echo "The linter: what it finds, by rule code"
# --------------------------------------------------------------------------

lint_report="$(cd "${lab_dir}/examples/messy" && "${ruff_bin}" check --isolated --select "${SELECT}" --output-format concise receipts.py 2>&1)"
lint_status=$?

if [ "${lint_status}" -ne 0 ]; then
  check "ruff check exits non-zero on the messy module" "yes"
else
  check "ruff check exits non-zero on the messy module" "no"
fi

for code in I001 F401 B006 E711 F841 SIM108 UP031 E501; do
  if printf '%s\n' "${lint_report}" | grep -q " ${code} "; then
    check "ruff check reports ${code} on the messy module" "yes"
  else
    check "ruff check reports ${code} on the messy module" "no"
  fi
done

if printf '%s\n' "${lint_report}" | grep -q "^Found 10 errors\.$"; then
  check "ruff check reports exactly 10 findings under --select ${SELECT}" "yes"
else
  check "ruff check reports exactly 10 findings under --select ${SELECT}" "no"
fi

# The default selection is deliberately smaller than the one above.
default_report="$(cd "${lab_dir}/examples/messy" && "${ruff_bin}" check --isolated --output-format concise receipts.py 2>&1)"
if printf '%s\n' "${default_report}" | grep -q "^Found 5 errors\.$" \
  && ! printf '%s\n' "${default_report}" | grep -q " B006 "; then
  check "ruff's default rule set is smaller and misses B006 until you select it" "yes"
else
  check "ruff's default rule set is smaller and misses B006 until you select it" "no"
fi

# --------------------------------------------------------------------------
echo
echo "The formatter: appearance only, and idempotent"
# --------------------------------------------------------------------------

if (cd "${lab_dir}/examples/messy" && "${ruff_bin}" format --isolated --check receipts.py >/dev/null 2>&1); then
  check "ruff format --check FAILS on the messy module (the CI form)" "no"
else
  check "ruff format --check FAILS on the messy module (the CI form)" "yes"
fi

scratch="$(scratch_copy "${lab_dir}/examples/messy")"
"${ruff_bin}" format --isolated "${scratch}/receipts.py" >/dev/null 2>&1
if run_pytest "${scratch}"; then
  check "the behaviour suite is STILL green after ruff format rewrote the module" "yes"
else
  check "the behaviour suite is STILL green after ruff format rewrote the module" "no"
fi

second_pass="$("${ruff_bin}" format --isolated "${scratch}/receipts.py" 2>&1)"
if printf '%s\n' "${second_pass}" | grep -q "1 file left unchanged"; then
  check "formatting is idempotent — the second run changes nothing" "yes"
else
  check "formatting is idempotent — the second run changes nothing" "no"
fi

if "${ruff_bin}" format --isolated --check "${scratch}/receipts.py" >/dev/null 2>&1; then
  check "ruff format --check now PASSES on the formatted copy" "yes"
else
  check "ruff format --check now PASSES on the formatted copy" "no"
fi
rm -rf "${scratch}"

# A formatter is not a linter: formatting alone leaves every real finding.
scratch="$(scratch_copy "${lab_dir}/examples/messy")"
"${ruff_bin}" format --isolated "${scratch}/receipts.py" >/dev/null 2>&1
after_format="$("${ruff_bin}" check --isolated --select "${SELECT}" --output-format concise "${scratch}/receipts.py" 2>&1)"
if printf '%s\n' "${after_format}" | grep -q " B006 " \
  && printf '%s\n' "${after_format}" | grep -q " F401 "; then
  check "formatting fixes no lint findings — B006 and F401 both survive it" "yes"
else
  check "formatting fixes no lint findings — B006 and F401 both survive it" "no"
fi
rm -rf "${scratch}"

# --------------------------------------------------------------------------
echo
echo "Autofix: safe, unsafe, and the line between them"
# --------------------------------------------------------------------------

scratch="$(scratch_copy "${lab_dir}/examples/messy")"
"${ruff_bin}" check --isolated --select "${SELECT}" --fix "${scratch}/receipts.py" >/dev/null 2>&1
safe_left="$("${ruff_bin}" check --isolated --select "${SELECT}" --output-format concise "${scratch}/receipts.py" 2>&1)"

if ! printf '%s\n' "${safe_left}" | grep -q " F401 "; then
  check "the SAFE autofix removes the unused imports (F401)" "yes"
else
  check "the SAFE autofix removes the unused imports (F401)" "no"
fi

if printf '%s\n' "${safe_left}" | grep -q " B006 "; then
  check "the SAFE autofix refuses to touch B006 — behaviour is a human decision" "yes"
else
  check "the SAFE autofix refuses to touch B006 — behaviour is a human decision" "no"
fi

if run_pytest "${scratch}"; then
  check "the behaviour suite is still green after the safe autofix" "yes"
else
  check "the behaviour suite is still green after the safe autofix" "no"
fi

"${ruff_bin}" check --isolated --select "${SELECT}" --fix --unsafe-fixes "${scratch}/receipts.py" >/dev/null 2>&1
unsafe_left="$("${ruff_bin}" check --isolated --select "${SELECT}" --output-format concise "${scratch}/receipts.py" 2>&1)"
if ! printf '%s\n' "${unsafe_left}" | grep -q " B006 "; then
  check "the UNSAFE autofix does rewrite the mutable default" "yes"
else
  check "the UNSAFE autofix does rewrite the mutable default" "no"
fi

# ...and that rewrite genuinely CHANGES BEHAVIOUR, which is why it is
# opt-in: the test that recorded the bug now fails.
if run_pytest "${scratch}"; then
  check "the unsafe fix changes behaviour, so the test that recorded the bug now fails" "no"
else
  check "the unsafe fix changes behaviour, so the test that recorded the bug now fails" "yes"
fi
rm -rf "${scratch}"

# --------------------------------------------------------------------------
echo
echo "Suppression: noqa with a code, not a bare noqa"
# --------------------------------------------------------------------------

scratch="$(mktemp -d "${TMPDIR:-/tmp}/ruff-lab.XXXXXX")"
{
  printf 'import json  # noqa: F401\n'
  printf 'import re\n\n\n'
  printf 'def first_digit(value):\n'
  printf '    if value == None:\n'
  printf '        return None\n'
  printf '    return re.search("[0-9]", value)\n'
} >"${scratch}/suppressed.py"
noqa_report="$("${ruff_bin}" check --isolated --select F,E --output-format concise "${scratch}/suppressed.py" 2>&1)"
if ! printf '%s\n' "${noqa_report}" | grep -q " F401 "; then
  check "# noqa: F401 suppresses exactly that rule on that line" "yes"
else
  check "# noqa: F401 suppresses exactly that rule on that line" "no"
fi
if printf '%s\n' "${noqa_report}" | grep -q " E711 "; then
  check "# noqa: F401 suppresses nothing else — E711 elsewhere still fires" "yes"
else
  check "# noqa: F401 suppresses nothing else — E711 elsewhere still fires" "no"
fi
rm -rf "${scratch}"

# --------------------------------------------------------------------------
echo
echo "The cleaned module and its configuration"
# --------------------------------------------------------------------------

if (cd "${lab_dir}/examples/clean" && "${ruff_bin}" check . >/dev/null 2>&1); then
  check "ruff check exits 0 on examples/clean using its own pyproject.toml" "yes"
else
  check "ruff check exits 0 on examples/clean using its own pyproject.toml" "no"
fi

if (cd "${lab_dir}/examples/clean" && "${ruff_bin}" format --check . >/dev/null 2>&1); then
  check "ruff format --check exits 0 on examples/clean" "yes"
else
  check "ruff format --check exits 0 on examples/clean" "no"
fi

if run_pytest "${lab_dir}/examples/clean"; then
  check "the cleaned module passes the same behaviour suite" "yes"
else
  check "the cleaned module passes the same behaviour suite" "no"
fi

if (cd "${lab_dir}/examples/clean" && python3 -c "
import receipts
first = receipts.add_item('apple', 120)
second = receipts.add_item('pear', 95)
assert first is not second
assert first == [{'name': 'apple', 'price_cents': 120}]
assert second == [{'name': 'pear', 'price_cents': 95}]
" >/dev/null 2>&1); then
  check "the cleaned add_item gives every caller a fresh basket — the bug is gone" "yes"
else
  check "the cleaned add_item gives every caller a fresh basket — the bug is gone" "no"
fi

# The two modules must still agree on everything that is not the bug.
if (cd "${lab_dir}" && python3 -c "
import importlib.util, sys

def load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    module = importlib.util.module_from_spec(spec)
    sys.modules[name] = module
    spec.loader.exec_module(module)
    return module

messy = load('messy_receipts', 'examples/messy/receipts.py')
clean = load('clean_receipts', 'examples/clean/receipts.py')
lines = ['apple 120', 'nonsense', 'brown bread 240', 'pear 95']
for mod in (messy, clean):
    basket = mod.load_basket(lines)
    assert mod.subtotal_cents(basket) == 455, mod
    assert mod.tax_cents(basket) == 91, mod
    assert mod.total_cents(basket) == 546, mod
    assert mod.describe(basket, 'apple') == 'apple: found x1', mod
    assert mod.format_receipt(basket) == messy.format_receipt(basket), mod
" >/dev/null 2>&1); then
  check "messy and clean agree on every price, description and receipt line" "yes"
else
  check "messy and clean agree on every price, description and receipt line" "no"
fi

# The per-file ignore is doing real work: S101 fires on the tests without
# the configuration and is silent with it.
s101_isolated="$(cd "${lab_dir}/examples/clean" && "${ruff_bin}" check --isolated --select S --output-format concise test_receipts.py 2>&1)"
if printf '%s\n' "${s101_isolated}" | grep -q " S101 "; then
  check "S101 fires on the test file when the configuration is ignored" "yes"
else
  check "S101 fires on the test file when the configuration is ignored" "no"
fi

s101_configured="$(cd "${lab_dir}/examples/clean" && "${ruff_bin}" check --output-format concise test_receipts.py 2>&1)"
if ! printf '%s\n' "${s101_configured}" | grep -q " S101 "; then
  check "the per-file ignore silences S101 for test_*.py and nowhere else" "yes"
else
  check "the per-file ignore silences S101 for test_*.py and nowhere else" "no"
fi

if python3 -c "
import tomllib, pathlib
config = tomllib.loads(pathlib.Path('${lab_dir}/examples/clean/pyproject.toml').read_text())
lint = config['tool']['ruff']['lint']
assert config['tool']['ruff']['line-length'] == 88
assert {'E', 'F', 'I', 'B', 'SIM', 'UP'} <= set(lint['select'])
assert lint['ignore'] == ['E501']
assert lint['per-file-ignores']['test_*.py'] == ['S101']
" >/dev/null 2>&1; then
  check "the reference pyproject.toml parses and declares select, ignore and a per-file ignore" "yes"
else
  check "the reference pyproject.toml parses and declares select, ignore and a per-file ignore" "no"
fi

# --------------------------------------------------------------------------
echo
echo "Your starter files"
# --------------------------------------------------------------------------

if python3 -c "import ast, pathlib; ast.parse(pathlib.Path('${lab_dir}/starter/receipts.py').read_text())" >/dev/null 2>&1; then
  check "starter/receipts.py is valid Python" "yes"
else
  check "starter/receipts.py is valid Python" "no"
fi

if run_pytest "${lab_dir}/starter"; then
  check "starter/ still passes its own behaviour suite (whatever stage you are at)" "yes"
else
  check "starter/ still passes its own behaviour suite — if this fails, finish exercise 6" "no"
fi

if (cd "${lab_dir}/starter" && python3 -c "
import receipts
for name in ('add_item', 'parse_line', 'load_basket', 'subtotal_cents',
             'tax_cents', 'total_cents', 'describe', 'format_receipt'):
    assert hasattr(receipts, name), name
" >/dev/null 2>&1); then
  check "starter/receipts.py still defines all eight public functions" "yes"
else
  check "starter/receipts.py still defines all eight public functions" "no"
fi

echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ] || exit 1
exit 0

Troubleshooting

Troubleshooting — Day 076

ruff: command not found / pytest: command not found

You have not installed the dependencies, or you have installed them into a virtual environment you are not currently using. From the lab directory:

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/ruff --version     # ruff 0.15.22

Then either call the tools through .venv/bin/, or activate the environment (source .venv/bin/activate), or point the test suite at tools you already have:

RUFF=/path/to/ruff PYTEST=/path/to/pytest bash tests/run_tests.sh

The suite deliberately stops with this message instead of skipping the checks it cannot run. A test suite that reports success because it never executed is worse than no test suite.

ruff check finds nothing, or finds far less than the exercise says

Three usual causes.

You left out --select. Ruff's default selection is E4, E7, E9 and F — a deliberately small set chosen so that adding Ruff to an existing project is not an instant wall of complaints. B006, I001, SIM108 and UP031 are all outside it. Exercise 3 uses --select E,F,I,B,SIM,UP for exactly this reason.

You left out --isolated. Without it, Ruff walks up the directory tree looking for the nearest pyproject.toml or ruff.toml and uses whatever it finds. If you created starter/pyproject.toml in Exercise 7 with a narrow select, that file is now in charge. --isolated means "use the built-in defaults and ignore every configuration file", which is what you want while you are still learning which rule does what.

You already fixed the file. ruff check --fix edits in place. If you ran it and then re-ran the exercise expecting the original findings, they are gone because you removed them. git checkout -- starter/ restores the original.

ruff check finds more than the exercise says

You are probably on a different Ruff version. Check with ruff --version. Rules are added between releases, and fixes are sometimes promoted from unsafe to safe. requirements/requirements.txt pins 0.15.22, which is the version every capture in expected-output/ came from.

ruff format did not fix my E501 line

It usually does, by wrapping the call. When it does not, the long thing is something the formatter is not permitted to break: a single long string literal, a long URL in a comment, or a long identifier. That is precisely why the reference pyproject.toml puts E501 in ignore with a comment explaining the reasoning — once a formatter owns line length, the only E501 findings left are ones a human would make worse by "fixing".

ruff format reformatted my file and now the tests fail

That should not happen, and if it does, it is worth investigating rather than shrugging at. Check first that you did not also run ruff check --fix --unsafe-fixes in the same breath — the unsafe fixes are the ones allowed to change behaviour, and one of them (the B006 fix) changes it on purpose. Run the two steps separately and re-run the suite after each to see which one moved the needle.

The test in Exercise 6 fails and I do not know why

That failure is the lab working. test_default_basket_is_shared_between_calls was written to assert the broken behaviour: assert first is second. Once the mutable default is fixed, two calls get two independent baskets, so first is second is false and the test fails.

The fix is not to revert the code. It is to rewrite the test so it asserts what you now want to be true:

first = receipts.add_item("apple", 120)
second = receipts.add_item("pear", 95)
assert first is not second

A test that fails because you deliberately changed behaviour is a test doing its job. Compare with examples/clean/test_receipts.py.

ModuleNotFoundError: No module named 'receipts'

You ran pytest from the wrong directory. test_receipts.py does a plain import receipts, which only works when the module sits beside it and pytest has put that directory on the import path. cd into starter/, examples/messy/ or examples/clean/ before running pytest -q.

ruff check . behaves differently in examples/clean/ and examples/messy/

It should. examples/clean/pyproject.toml exists and examples/messy/ has no configuration above it, so the two directories are governed differently on purpose. Discovering that configuration is per-directory- tree rather than per-invocation is one of the things the lab is teaching.

bash: tests/run_tests.sh: No such file or directory

Run it from the lab directory, not from tests/ and not from the repository root:

cd labs/sections/programming-with-python/day-076-linting-and-formatting-with-ruff
bash tests/run_tests.sh

The harness leaves directories behind

It should not — every temporary directory it creates with mktemp -d is removed as its check finishes. What is left behind is Ruff's own .ruff_cache/ and pytest's __pycache__/, next to whichever files you checked. Both are ignored by this repository and safe to delete; the ## Cleanup section of the README has the command.

My pyproject.toml is ignored

Ruff reads [tool.ruff] from pyproject.toml. Two common slips: putting the settings under a bare [ruff] table (wrong — that form belongs in a standalone ruff.toml), and putting select directly under [tool.ruff] instead of under [tool.ruff.lint]. Confirm what Ruff actually resolved:

ruff check --show-settings . | head -40

ruff rule B006 prints nothing useful

Check the spelling and the case — rule codes are upper-case letters followed by digits, with no space. ruff rule needs no network; the documentation is compiled into the binary.

Windows

Use WSL and follow the Linux instructions. Native Windows works for ruff and pytest themselves — the virtual environment puts them in .venv\Scripts\ rather than .venv/bin/ — but tests/run_tests.sh needs a bash, so run the harness from WSL or Git Bash.

Security notes

Security notes — Day 076

What this lab does to your machine

It reads and writes Python files inside its own directory, creates temporary directories under your system temp path and deletes them again, and runs two programs you installed yourself. It opens no network connection, needs no API key, asks for no credentials, and never runs with elevated privileges. sudo appears nowhere in this lab and should not be used with it.

A linter is a security control, and this is not a metaphor

Two of the rule families the reference configuration selects exist to catch things that get people breached.

S, the bandit-derived family, flags patterns with a security history: subprocess calls with shell=True, yaml.load without a safe loader, pickle on untrusted input, hard-coded passwords, assert used for a runtime check in production code (because python -O strips asserts), and temporary files created with predictable names. None of these is necessarily wrong. All of them are worth a second look, which is exactly what a linter finding is.

B, the bugbear family, catches correctness traps that become security bugs under the right conditions. B006 — the rule this lab is built around — is the clearest case. A mutable default argument is shared state that outlives the call. In add_item it means two shoppers share a trolley. In a web request handler with def handler(request, cache={}), the same defect means data from one user's request is visible in the next user's response. That is a cross-request data leak, and it looks exactly as innocent as the line in this lab's messy module.

Run the demonstration yourself, from examples/messy/:

python3 -c "
import receipts
print(receipts.add_item('apple', 120))
print(receipts.add_item('pear', 95))
"

The second call prints both items. Nothing carried them across except a default argument evaluated once, when the def line ran.

Why S101 is ignored in tests, and only in tests

The reference pyproject.toml relaxes S101 for test_*.py. The rule is right in general — assert is compiled away under python -O, so an assert guarding a real invariant in shipped code can vanish silently. In a test file, assert is the mechanism, tests are never run under -O, and the rule is pure noise.

Note the shape of that decision. The rule was not deleted from select and it was not added to the global ignore. It was relaxed in the one file pattern where it is wrong, with a comment saying why. Every rule you switch off globally is a rule that stops protecting the code where it still applied.

Suppression comments are a security surface

# noqa is how you tell the linter to be quiet about one line. There is a right way and a wrong way, and the difference matters.

import config  # noqa: F401   — re-exported for backwards compatibility

That names one rule, on one line, with a reason. A reviewer can check it in five seconds.

import config  # noqa

That silences every rule on that line, forever, including rules that did not exist when it was written and including security rules. A bare # noqa sprinkled through a codebase is a set of blind spots nobody can audit. Ruff can find them for you — the rule PGH004 flags blanket # noqa directives, and RUF100 flags suppressions that no longer suppress anything, which is how you stop them accumulating.

The same logic applies to # type: ignore from yesterday's lesson. Name the code, give the reason, or fix the problem.

Running someone else's configuration

pyproject.toml is data, not code — Ruff parses it, it does not execute it. A malicious pyproject.toml cannot run commands on your machine through Ruff. It can, however, quietly switch off the rules that would have protected you, which is a good reason to read the ignore list of any project you join and ask what each entry is hiding.

pre-commit, discussed in the lesson, is a different matter: it downloads and runs hook repositories you name in .pre-commit-config.yaml. Pin those to a specific revision, and read what you are pinning.

Data

Nothing in this lab handles personal data. The receipts are three fictional grocery items with prices in cents. Nothing is written outside the lab directory and your system temp path, and nothing persists between runs except the tool caches, which contain only hashes and analysis results for files you already have.