Programming with PythonFunctions and Program Design › Day 61

Hands-on lab — Day 61: Writing Readable Code

Commands

Setup

cd labs/sections/programming-with-python/day-061-writing-readable-code
python3 --version

Run

python3 starter/messy.py 70 85 90 55 60
python3 examples/report.py 70 85 90 55 60
diff <(python3 starter/messy.py 70 85 90 55 60) <(python3 examples/report.py 70 85 90 55 60)

Test

bash tests/run_tests.sh

File tree

examples/report.py
expected-output/FIELDS.md
expected-output/sample-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
security.md
starter/messy.py
starter/refactor-worksheet.md
tests/run_tests.sh
troubleshooting.md

Lab README

Day 061 lab — Refactor for Readability

Lesson

  • Lesson title: Writing Readable Code
  • Day number: 61 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-061-writing-readable-code
  • Lab files: everything you need is in this directory — follow “How to run” below.
  • Browse the course locally: from the repository root, this lab also appears in the course website at /labs/day-061-writing-readable-code when the site is running.

Purpose

Day 61's lesson is about code that humans — and your future self — can read. This lab makes it concrete. You start from starter/messy.py: a small program that works (it prints a summary of the numbers you give it) but is painful to read — one giant function called d, single-letter variables, no docstrings, no type hints, a bare magic number, cramped formatting. Your job is to refactor it into clean, documented, type-hinted, PEP 8 code without changing what it does — proven by a test suite that passes before and after. The finished examples/report.py is the target to compare against. This is the exact discipline that keeps machine-learning experiments reviewable and reproducible, and it is what AI coding assistants read best.

Learning objectives

  • Refactor a working script in small, safe steps, running tests after each so behaviour never changes.
  • Replace poor names with meaningful, searchable ones and a magic number with a named constant.
  • Decompose one giant function into small, single-purpose functions.
  • Add docstrings that say why, and type hints that document and help tools.
  • Run an optional formatter (Black) and linter (Ruff/flake8), and understand that a missing optional tool must degrade gracefully, never hard-fail.

Prerequisites

  • The Day 61 lesson (read it first — it explains PEP 8, PEP 257, PEP 20, type hints, and the tools).
  • Days 57–60: functions, modules and imports, and the standard library.
  • Day 49: the shape of a real program — named functions, a main(), and the if __name__ == "__main__": guard.
  • A text editor and a terminal. No experience beyond this course is assumed.

Supported operating systems

  • macOS — fully supported (tested on macOS with Apple Silicon, Python 3.14.0).
  • Linux — fully supported (any distribution with Python 3.9+ and bash).
  • Windows — use WSL and follow the Linux path, or substitute python for python3 if that is how Python is exposed. The scripts are pure standard-library Python and behave identically everywhere.

Hardware requirements

Any computer that runs Python 3. The scripts do a handful of arithmetic operations on the numbers you pass; they need no special memory, disk, or GPU.

Required software

  • python3 (3.9 or newer; tested on 3.14.0).
  • bash for the test runner (preinstalled on macOS and Linux).
  • Standard library only — just sys. No packages to install. See requirements/README.md.

Free and open-source options

Everything here is free and open source: Python, bash, and the standard library. The optional style tools introduced in the lesson — Black (formatter) and Ruff or flake8 (linter) — are also free and open source, but they are not required: the test suite skips their checks cleanly when they are absent. No account, API key, network access, or purchase is needed.

Installation

None beyond Python itself. Move into this directory and you are ready:

cd labs/sections/programming-with-python/day-061-writing-readable-code
python3 --version   # confirm Python 3.9+ is available

Optionally, to try the style tools: python3 -m pip install black ruff.

File structure

day-061-writing-readable-code/
├── README.md                       ← you are here
├── metadata.yml                    ← machine-readable lab metadata
├── starter/
│   ├── messy.py                    ← YOUR working file — refactor it for readability
│   └── refactor-worksheet.md       ← the five numbered refactoring steps + notes
├── examples/
│   └── report.py                   ← the clean, readable target (same behaviour)
├── tests/
│   └── run_tests.sh                ← behaviour + readability checks (optional style tools skipped cleanly)
├── expected-output/
│   ├── sample-run.txt              ← real captured runs (messy == clean, byte for byte)
│   ├── test-run.txt                ← real captured test-suite run
│   └── FIELDS.md                   ← required behaviour on every platform
├── requirements/
│   └── README.md                   ← dependency statement (Python 3 only)
├── troubleshooting.md
└── security.md

How to run

From this directory:

## 1. See that the messy starter already works.
python3 starter/messy.py 70 85 90 55 60

## 2. See the clean target — and prove it behaves identically.
python3 examples/report.py 70 85 90 55 60
diff <(python3 starter/messy.py 70 85 90 55 60) <(python3 examples/report.py 70 85 90 55 60) && echo IDENTICAL

## 3. Establish the safety net BEFORE you touch anything.
bash tests/run_tests.sh

## 4. Refactor starter/messy.py, one step at a time (see the worksheet),
##    running the tests after each step.

## 5. Optional: if you installed them, auto-format and lint.
black starter/messy.py       # skips gracefully if not installed
ruff check starter/messy.py  # or: flake8 starter/messy.py

## 6. Confirm behaviour is unchanged and readability now passes too.
bash tests/run_tests.sh

What the commands do

  • python3 starter/messy.py 70 85 90 55 60 — runs the messy-but-working program: it parses the numbers, then prints count, mean, median, min, max, population standard deviation, and the percentage passing (≥ 60).
  • python3 examples/report.py 70 85 90 55 60 — runs the clean reference, which produces the same output from small, named, typed, documented functions.
  • diff <(...) <(...) && echo IDENTICAL — proves the two versions are byte-for-byte identical: refactoring changed readability, not behaviour.
  • bash tests/run_tests.sh — characterises the behaviour with a golden output, asserts both the starter and the reference match it (and the empty-input and even-count-median paths), checks the reference for readability markers (type hints, docstrings, ≥ 4 functions, no single-letter function names), and — once your starter is refactored — holds it to the same standard. If Black or Ruff/flake8 are installed it runs them as a bonus; if not, it skips those checks. Exits 0 only if every non-skipped check passes.
  • black / ruff — the optional formatter and linter from the lesson; both skip gracefully when not installed.

Expected output

See expected-output/sample-run.txt — a real captured session. For the input 70 85 90 55 60 both versions print:

count: 5
mean: 72.00
median: 70.00
min: 55.00
max: 90.00
stdev: 13.64
passing: 80.0%

The tool is deterministic, so your output will match. expected-output/FIELDS.md lists the required behaviour for every input on every platform.

Validation steps

  1. python3 starter/messy.py 70 85 90 55 60 prints the seven summary lines above and exits 0.
  2. diff <(python3 starter/messy.py 70 85 90 55 60) <(python3 examples/report.py 70 85 90 55 60) prints nothing (the two are identical), and the && echo IDENTICAL fires.
  3. bash tests/run_tests.sh reports 10 checks, 0 failure(s), 2 skipped. before you refactor.
  4. Refactor starter/messy.py following the worksheet, testing after each step; behaviour must stay identical the whole way.
  5. bash tests/run_tests.sh now reports 14 checks, 0 failure(s), 2 skipped. — the readability checks now apply to your cleaned-up starter too.

Tests

bash tests/run_tests.sh

Expected final line while the starter is still messy: 10 checks, 0 failure(s), 2 skipped. Once you complete the refactor, the suite additionally holds your starter to the readability standard, giving 14 checks, 0 failure(s), 2 skipped. The two skips are the optional Black and Ruff/flake8 checks, which are not installed by default; they never turn the suite red. The command exits 0 on success and non-zero on any real failure, so it can run in CI. A full captured run is in expected-output/test-run.txt.

Cleanup

The scripts write no files, so there is nothing to delete. To reset your refactor and start over, restore the starter from git:

git checkout -- starter/messy.py

Troubleshooting

See troubleshooting.md for the full list: python vs python3, tests going red mid-refactor, the golden output not matching (sample vs population standard deviation), old-Python list[float] syntax, why the style-tool checks say "skip", the module import path, and permissions.

Security notes

See security.md. Short version: the tool makes no network calls, needs no privileges, and writes no files. It parses numbers with float(), never eval(). And readable code is safer code — a reviewer can only catch what they can see, which is exactly why names, small functions, docstrings, and type hints matter.

Extension exercises

  1. Add a sample standard deviation function (divide by n - 1) alongside the population one, with a clear name and docstring explaining the difference — and a test that pins its value so it never drifts.
  2. Add a --help-style usage line printed to standard error (and exit 2) when a value cannot be parsed as a number, instead of letting float() raise — turning a crash into a readable error message.
  3. Install Black and Ruff and run them on both files; read Ruff's output, understand each rule it flags, and fix or justify each one in a comment.
  4. Write a short tests/test_report.py that imports mean, median, and population_stdev from the reference and asserts their values on a known list, printing all tests passed only if every assertion holds.
  • Previous day: Day 60 — A Tour of the Standard Library (labs/sections/programming-with-python/day-060-a-tour-of-the-standard-library/).
  • Next day: Day 62 — Recursion (labs/sections/programming-with-python/day-062-recursion/, to be written).
  • Week 9 theme: Functions and Program Design — writing code that is not just correct but clear, reusable, and ready for review.

Expected output

FIELDS.md

# Expected output — Day 061 lab

These are real captured runs from the authoring machine (macOS, Apple
Silicon, Python 3.14.0, bash 3.2, 2026-07-13). The score summariser is
deterministic: given the same numbers it prints the same report and the same
exit code on every platform Python 3 runs on. The whole point of the lab is
that the messy starter and the clean reference produce **identical** output —
refactoring changes readability, never behaviour.

## Files

- `sample-run.txt` — the messy starter and the clean reference driven on the
  same input (byte-for-byte identical, confirmed with `diff`), plus an
  even-count median, a floating-point set, a single value, the empty-input
  path, and a `python3 -c` import of one function.
- `test-run.txt` — a real run of `bash tests/run_tests.sh`. The first block is
  the suite with the starter still messy (10 checks, 2 skipped); the second
  block is the extra readability checks that run once the starter has been
  refactored (14 checks, 2 skipped).

## Required behaviour on every platform

For the input `70 85 90 55 60`, every correct version of the tool must print
exactly these lines and exit 0:

| Line | Value | Why |
| --- | --- | --- |
| `count: 5` | number of scores | `len(scores)` |
| `mean: 72.00` | arithmetic mean | `360 / 5`, two decimals |
| `median: 70.00` | middle of the sorted list | sorted `[55,60,70,85,90]`, index 2 |
| `min: 55.00` | smallest score | `min(scores)` |
| `max: 90.00` | largest score | `max(scores)` |
| `stdev: 13.64` | population standard deviation | `sqrt(930/5) = sqrt(186)` |
| `passing: 80.0%` | share at or above the pass mark 60 | 4 of 5, one decimal |

Other captured cases (reference tool):

| Command | Key output | Exit code |
| --- | --- | --- |
| `report.py 70 85 90 55` | `median: 77.50` (even count → mean of two middle) | 0 |
| `report.py 100` | `stdev: 0.00`, `passing: 100.0%` | 0 |
| `report.py` (no args) | `no data` (stderr path in spirit; printed plainly here) | 1 |

## Skipped-tool behaviour (important)

The optional Black and Ruff/flake8 checks are **skipped, not failed**, when
those tools are not installed — which is the case on a plain Python install,
including the authoring machine (see the two `skip:` lines in
`test-run.txt`). A missing optional tool must never turn the suite red. If you
install them (`python3 -m pip install black ruff`), the same command runs
them on the reference and reports two extra `ok:` lines instead of skips.

## Platform notes

- The only visible difference between platforms is the shell prompt (`$`)
  shown before each command; the program's own output is identical.
- Scores may be integers or decimals; they are parsed with `float()`, so
  `90` and `90.0` are equivalent and always print with two decimals.
- The tool writes no files and makes no network calls; there is nothing to
  clean up.

sample-run.txt

$ python3 starter/messy.py 70 85 90 55 60
count: 5
mean: 72.00
median: 70.00
min: 55.00
max: 90.00
stdev: 13.64
passing: 80.0%

$ python3 examples/report.py 70 85 90 55 60
count: 5
mean: 72.00
median: 70.00
min: 55.00
max: 90.00
stdev: 13.64
passing: 80.0%

$ diff <(python3 starter/messy.py 70 85 90 55 60) <(python3 examples/report.py 70 85 90 55 60) && echo IDENTICAL
IDENTICAL

$ python3 examples/report.py 70 85 90 55   ; echo "exit: $?"
count: 4
mean: 75.00
median: 77.50
min: 55.00
max: 90.00
stdev: 13.69
passing: 75.0%
exit: 0

$ python3 examples/report.py 88.5 92 79.5 61 45
count: 5
mean: 73.20
median: 79.50
min: 45.00
max: 92.00
stdev: 17.73
passing: 80.0%

$ python3 examples/report.py 100
count: 1
mean: 100.00
median: 100.00
min: 100.00
max: 100.00
stdev: 0.00
passing: 100.0%

$ python3 examples/report.py   ; echo "exit: $?"
no data
exit: 1

$ python3 -c "import sys; sys.path.insert(0,'examples'); from report import median; print(median([1,2,3,4]))"
2.5

test-run.txt

Testing the clean reference (examples/report.py) ...
  ok: reference: golden report matches, exit 0
  ok: reference: empty input prints 'no data', exit 1
  ok: reference: even-count median is 77.50
  ok: reference: uses type hints (->)
  ok: reference: has docstrings
  ok: reference: decomposed into >= 4 functions (7)
  ok: reference: no single-letter function names
Testing the starter (starter/messy.py) ...
  ok: starter: golden report matches, exit 0
  ok: starter: empty input prints 'no data', exit 1
  ok: starter: even-count median is 77.50
Note: starter/messy.py is not refactored yet — checking behaviour only.
      Refactor it (see starter/refactor-worksheet.md), then rerun to be
      held to the readability standard too.
Optional style tools (skipped cleanly if not installed) ...
  skip: black (tool not installed — optional)
  skip: ruff / flake8 (tool not installed — optional)

10 checks, 0 failure(s), 2 skipped.

--- After completing the refactor of starter/messy.py, the same command
--- holds the cleaned-up starter to the readability standard as well:

Testing the starter (starter/messy.py) ...
  ok: starter: golden report matches, exit 0
  ok: starter: empty input prints 'no data', exit 1
  ok: starter: even-count median is 77.50
Starter looks refactored — applying the readability standard to it too.
  ok: starter: uses type hints (->)
  ok: starter: has docstrings
  ok: starter: decomposed into >= 4 functions (7)
  ok: starter: no single-letter function names

14 checks, 0 failure(s), 2 skipped.

Source files

examples/report.py (2673 bytes)
#!/usr/bin/env python3
"""Summarise a list of numeric scores passed as command-line arguments.

This is the readable target of the Day 61 lab: the same behaviour as
``starter/messy.py``, but rewritten in small, named, type-hinted, documented
functions that follow PEP 8. It prints the count, mean, median, minimum,
maximum, population standard deviation, and the percentage of scores at or
above the pass mark.

All input comes from ``sys.argv``, so the tool can be scripted and tested
without a human at the keyboard:

    python3 report.py 70 85 90 55 60
"""
from __future__ import annotations

import sys

# A score at or above this mark counts as "passing". Naming the number gives
# it meaning and one place to change it, instead of a bare 60 in the code.
PASS_MARK = 60.0


def parse_scores(raw_values: list[str]) -> list[float]:
    """Convert the raw command-line strings into a list of floats."""
    return [float(value) for value in raw_values]


def mean(scores: list[float]) -> float:
    """Return the arithmetic mean of a non-empty list of scores."""
    return sum(scores) / len(scores)


def median(scores: list[float]) -> float:
    """Return the middle score, or the mean of the two middle scores."""
    ordered = sorted(scores)
    count = len(ordered)
    middle = count // 2
    if count % 2 == 1:
        return ordered[middle]
    return (ordered[middle - 1] + ordered[middle]) / 2


def population_stdev(scores: list[float], average: float) -> float:
    """Return the population standard deviation around a known average."""
    variance = sum((score - average) ** 2 for score in scores) / len(scores)
    return variance ** 0.5


def passing_rate(scores: list[float]) -> float:
    """Return the percentage of scores at or above PASS_MARK."""
    passing = sum(1 for score in scores if score >= PASS_MARK)
    return 100 * passing / len(scores)


def format_report(scores: list[float]) -> str:
    """Build the multi-line summary report for a non-empty list of scores."""
    average = mean(scores)
    lines = [
        f"count: {len(scores)}",
        f"mean: {average:.2f}",
        f"median: {median(scores):.2f}",
        f"min: {min(scores):.2f}",
        f"max: {max(scores):.2f}",
        f"stdev: {population_stdev(scores, average):.2f}",
        f"passing: {passing_rate(scores):.1f}%",
    ]
    return "\n".join(lines)


def main(argv: list[str]) -> int:
    """Parse scores from argv, print the report, and return an exit code."""
    scores = parse_scores(argv)
    if not scores:
        print("no data")
        return 1
    print(format_report(scores))
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
metadata.yml (825 bytes)
lesson_id: D061
day: 61
kind: python-program
languages: [python]
setup_commands:
  - cd labs/sections/programming-with-python/day-061-writing-readable-code
  - python3 --version
run_commands:
  - python3 starter/messy.py 70 85 90 55 60
  - python3 examples/report.py 70 85 90 55 60
  - 'diff <(python3 starter/messy.py 70 85 90 55 60) <(python3 examples/report.py 70 85 90 55 60)'
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - 'git checkout -- starter/messy.py  # optional: reset your refactor'
requires_network: false
requires_api_key: false
estimated_minutes: 30
last_executed: '2026-07-13'
executed_on: 'macOS (Apple Silicon), Python 3.14.0, bash tests/run_tests.sh -> 10 checks, 0 failure(s), 2 skipped, exit 0 (unrefactored starter); 14 checks, 0 failure(s), 2 skipped once the starter is refactored'
requirements/README.md (1426 bytes)
# Dependencies — Day 061 lab

**Python 3 only. No third-party packages required.**

- `python3` (3.9 or newer; tested on 3.14.0). The reference uses
  `list[float]` type-hint syntax, which reads cleanly on 3.9+. Preinstalled on
  most Linux distributions and installable on macOS; you set this up on Day 43.
- `bash` for the test runner (preinstalled on macOS and Linux).
- Only the Python standard library is used — `sys` and nothing else. There is
  deliberately no `requirements.txt`: refactoring for readability needs no
  libraries at all.

Check your Python is present and new enough:

```bash
python3 --version
```

## Optional style tools (not required)

The lesson introduces a **formatter** (Black) and a **linter** (Ruff, or the
older flake8). They make style automatic, but they are entirely optional here:
the test suite runs with or without them, and simply **skips** their checks
when they are absent. If you want to try them:

```bash
python3 -m pip install black ruff      # optional, one-time
black examples/report.py               # auto-format in place
ruff check examples/report.py          # report style/lint issues
```

If `pip` is restricted on your machine, skip this entirely — everything the
lab asserts works on a plain Python install with nothing downloaded. Windows
users: run the commands inside WSL, or use `python` in place of `python3` if
that is how Python is exposed on your system.
starter/messy.py (1058 bytes)
# Refactor THIS file for readability without changing what it does.
# The numbered steps are in starter/refactor-worksheet.md. After every step,
# run  bash tests/run_tests.sh  and confirm the output is still identical.
# It works today; your job is to make it readable, not to change its behaviour.
import sys
def d(a):
    l=[]
    for i in a:
        l.append(float(i))
    if len(l)==0:
        print("no data")
        return 1
    t=0
    for i in l: t=t+i
    m=t/len(l)
    s=sorted(l)
    if len(l)%2==1:
        md=s[len(l)//2]
    else:
        md=(s[len(l)//2-1]+s[len(l)//2])/2
    v=0
    for i in l:
        v=v+(i-m)**2
    v=v/len(l)
    sd=v**0.5
    p=0
    for i in l:
        if i>=60: p=p+1
    print("count: "+str(len(l)))
    print("mean: "+format(m,".2f"))
    print("median: "+format(md,".2f"))
    print("min: "+format(s[0],".2f"))
    print("max: "+format(s[-1],".2f"))
    print("stdev: "+format(sd,".2f"))
    print("passing: "+format(100*p/len(l),".1f")+"%")
    return 0
if __name__=="__main__":
    sys.exit(d(sys.argv[1:]))
starter/refactor-worksheet.md (2942 bytes)
# Refactor worksheet — turn `messy.py` into readable code

`starter/messy.py` works: it prints a summary of the numbers you pass it. It
is also hard to read — one giant function called `d`, single-letter variables,
no docstrings, no type hints, a bare magic number, and cramped formatting.
Your job is to make it **readable without changing what it does**. The test
suite is your safety net: `bash tests/run_tests.sh` passes now and must keep
passing after every step.

The golden rule of refactoring: **one small change, then run the tests.** If
they still pass, keep going; if they fail, undo that one change and try again.
Never rename and restructure and retype all at once.

## The five steps (do them in order, testing after each)

1. **Rename for meaning.** Give every name a job title. `d` → `main`;
   `a` → the raw argument strings; `l` → `scores`; `t` → a running `total`;
   `m` → `average`; `s` → `ordered`; `md` → `median`; `sd` → `stdev`;
   `p` → the `passing` count; `i` → `score` (or `value`). Run the tests.

2. **Kill the magic number.** The bare `60` is the pass mark. Add a module
   constant near the top — `PASS_MARK = 60.0` — and use it in the comparison.
   Now the number has a name and one place to change. Run the tests.

3. **Decompose into small functions.** Pull each distinct job out of the giant
   function into its own function: `parse_scores`, `mean`, `median`,
   `population_stdev`, `passing_rate`, and a `format_report` that assembles the
   lines. Leave `main` as a short conductor that parses input, handles the
   empty case, and prints the report. Run the tests after each extraction.

4. **Document and type.** Give the module a top docstring (what the file does)
   and each function a one-line docstring that says **why / what it returns**,
   not a play-by-play of the code. Add type hints to every function signature:
   `def mean(scores: list[float]) -> float:`. Run the tests.

5. **Format and (optionally) lint.** Put blank lines between functions, spaces
   around operators (`t=t+i` → `total += score`), and one statement per line.
   If you have them installed, run `black messy.py` to auto-format and
   `ruff check messy.py` (or `flake8`) to catch leftovers; if you do not, the
   suite skips those checks cleanly. Run the tests one last time.

When you are done, `starter/messy.py` should read like
`examples/report.py` — and `bash tests/run_tests.sh` should report 14 checks,
0 failures (plus the readability checks now applied to your starter too).

## Before/after notes (fill these in)

- **Hardest name to choose, and what you picked:**
- **A function you extracted, and the one job it now does:**
- **One comment you deleted because the code already said it, or one you kept
  because it explained *why*:**
- **Did the tests ever go red mid-refactor? What did you change to fix it?**
- **Final test line (`N checks, 0 failure(s), M skipped.`):**
tests/run_tests.sh (5740 bytes)
#!/usr/bin/env bash
# Tests for the Day 061 lab — "Refactor for Readability". Run from the lab
# directory:
#   bash tests/run_tests.sh
#
# The central claim of refactoring is: behaviour does not change, only
# readability does. So these tests characterise the behaviour of the score
# summariser with a fixed golden output, then assert that BOTH the messy
# starter and the clean reference produce exactly that output and the same
# exit codes. They pass before you refactor (messy starter) and after you
# refactor (your cleaned-up starter) — that is the whole point.
#
# The suite also checks the reference for the readability markers this lesson
# teaches (type hints, docstrings, small functions, a named constant), and
# once your starter is refactored it holds it to the same standard.
#
# Finally, if a formatter (Black) or linter (Ruff / flake8) happens to be
# installed, it runs them on the reference as a bonus; if they are not
# installed, those checks are SKIPPED, never failed — the lab must run on a
# plain Python install with nothing extra to download.
#
# No network, non-interactive. Exits 0 only if every non-skipped check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ref="${lab_dir}/examples/report.py"
starter="${lab_dir}/starter/messy.py"
failures=0
checks=0
skips=0

# The golden behaviour: for the input below, every correct version of the
# tool must print exactly these seven lines and exit 0.
golden_input=(70 85 90 55 60)
golden_output="count: 5
mean: 72.00
median: 70.00
min: 55.00
max: 90.00
stdev: 13.64
passing: 80.0%"

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

skip() {
  echo "  skip: $1 (tool not installed — optional)"
  skips=$((skips + 1))
}

# behaviour_checks <label-prefix> <script>
# Asserts the golden output + exit 0, the empty-input path, and an even-count
# median — the behaviour that refactoring must preserve.
behaviour_checks() {
  local name="$1" script="$2"
  local out code

  out="$(python3 "${script}" "${golden_input[@]}")"
  code=$?
  if [ "${code}" -eq 0 ] && [ "${out}" = "${golden_output}" ]; then
    check "${name}: golden report matches, exit 0" "yes"
  else
    check "${name}: golden report matches, exit 0" "no"
    echo "    (exit ${code}; output was:)"
    printf '%s\n' "${out}" | sed 's/^/      /'
  fi

  out="$(python3 "${script}" 2>&1)"
  code=$?
  if [ "${code}" -eq 1 ] && [ "${out}" = "no data" ]; then
    check "${name}: empty input prints 'no data', exit 1" "yes"
  else
    check "${name}: empty input prints 'no data', exit 1" "no"
    echo "    (exit ${code}; output: ${out})"
  fi

  # Even count: sorted [55,70,85,90] -> median (70+85)/2 = 77.50.
  out="$(python3 "${script}" 70 85 90 55)"
  if printf '%s\n' "${out}" | grep -qF "median: 77.50"; then
    check "${name}: even-count median is 77.50" "yes"
  else
    check "${name}: even-count median is 77.50" "no"
    echo "    (output: ${out})"
  fi
}

# readability_checks <label-prefix> <script>
# The markers this lesson teaches: type hints, docstrings, several small
# functions, and a named constant instead of a bare magic number.
readability_checks() {
  local name="$1" script="$2"
  grep -q -- '->' "${script}" \
    && check "${name}: uses type hints (->)" "yes" \
    || check "${name}: uses type hints (->)" "no"
  grep -q '"""' "${script}" \
    && check "${name}: has docstrings" "yes" \
    || check "${name}: has docstrings" "no"
  local func_count
  func_count="$(grep -c '^def ' "${script}")"
  if [ "${func_count}" -ge 4 ]; then
    check "${name}: decomposed into >= 4 functions (${func_count})" "yes"
  else
    check "${name}: decomposed into >= 4 functions (${func_count})" "no"
  fi
  if grep -qE '^def [a-z]\(' "${script}"; then
    check "${name}: no single-letter function names" "no"
  else
    check "${name}: no single-letter function names" "yes"
  fi
}

echo "Testing the clean reference (examples/report.py) ..."
behaviour_checks "reference" "${ref}"
readability_checks "reference" "${ref}"

echo "Testing the starter (starter/messy.py) ..."
# Behaviour must hold whether or not the learner has refactored yet.
behaviour_checks "starter" "${starter}"
if grep -qE '^def d\(' "${starter}"; then
  echo "Note: starter/messy.py is not refactored yet — checking behaviour only."
  echo "      Refactor it (see starter/refactor-worksheet.md), then rerun to be"
  echo "      held to the readability standard too."
else
  echo "Starter looks refactored — applying the readability standard to it too."
  readability_checks "starter" "${starter}"
fi

# --- Optional formatter / linter (bonus; never a hard failure) ---
echo "Optional style tools (skipped cleanly if not installed) ..."
if command -v black >/dev/null 2>&1; then
  if black --check --quiet "${ref}" 2>/dev/null; then
    check "black --check: reference is already formatted" "yes"
  else
    check "black --check: reference is already formatted" "no"
    echo "    (run 'black ${ref}' to see the suggested formatting)"
  fi
else
  skip "black"
fi

if command -v ruff >/dev/null 2>&1; then
  if ruff check "${ref}" >/dev/null 2>&1; then
    check "ruff check: reference is clean" "yes"
  else
    check "ruff check: reference is clean" "no"
  fi
elif command -v flake8 >/dev/null 2>&1; then
  if flake8 --max-line-length=100 "${ref}" >/dev/null 2>&1; then
    check "flake8: reference is clean" "yes"
  else
    check "flake8: reference is clean" "no"
  fi
else
  skip "ruff / flake8"
fi

echo
echo "${checks} checks, ${failures} failure(s), ${skips} skipped."
[ "${failures}" -eq 0 ]

Troubleshooting

Troubleshooting — Day 061 lab

python: command not found

Use python3 explicitly, as every command in this lab does. On macOS and most Linux systems, bare python may be missing or point to an old version. Check with python3 --version (you want 3.9 or newer for the list[float] hints).

The tests failed after I renamed something

That is the safety net doing its job — you changed behaviour, not just a name. Undo the last single change and run bash tests/run_tests.sh again to get back to green, then redo it more carefully. The most common causes are a typo that renamed one use of a variable but not another, or accidentally changing the order of operations while "tidying" an expression. Refactor in the smallest steps you can, testing after each.

The golden output does not match after my refactor

The tool must print exactly the seven lines in expected-output/FIELDS.md for the input 70 85 90 55 60. Watch for changes that alter numbers or formatting: switching population standard deviation (divide by n) to sample standard deviation (divide by n - 1) changes stdev; changing the pass mark changes passing; using round() instead of :.2f formatting can differ at the last digit. Readability refactoring keeps all of these identical — only the names and structure change.

SyntaxError mentioning list[float]

You are on a Python older than 3.9. Either upgrade, or write the hints as from typing import List and List[float]. The reference targets 3.9+, where the built-in list[...] form works directly.

The black / ruff checks say "skip"

That is expected and fine — those tools are optional and are not installed by default. The suite skips them cleanly and still passes. If you want them, run python3 -m pip install black ruff; if pip is restricted on your machine, ignore this entirely, as nothing the lab asserts depends on them.

ModuleNotFoundError: No module named 'report' in the import check

Python looks for modules on its search path (sys.path), which does not include the examples/ subfolder by default. The import one-liner adds it first:

python3 -c "import sys; sys.path.insert(0,'examples'); from report import median; print(median([1,2,3,4]))"

Run it from the lab directory (the folder that contains examples/).

bash: tests/run_tests.sh: Permission denied

Run it through bash explicitly, as the README shows: bash tests/run_tests.sh. You do not need to chmod +x anything.

The starter still prints NotImplementedError — no, it does not

Unlike some labs, this starter is a complete, working program from the start — the exercise is to refactor it, not to fill in blanks. If it ever raises an error, you introduced it during the refactor; restore the original with git checkout -- starter/messy.py and begin again in smaller steps.

Security notes

Security notes — Day 061 lab

  • What the tool does: reads numbers from the command line, computes summary statistics, and prints them. It makes no network connections, needs no privileges, writes no files, and touches nothing on disk. The test runner only reads the two scripts and runs them; it creates no temporary files.

  • Refactoring must not change behaviour — including security behaviour. The discipline this lab teaches (small safe steps, tests after each) is exactly how you avoid accidentally introducing a bug while "just cleaning up." A refactor that quietly changes what the code does is a classic source of regressions, some of them security-relevant. The test suite exists so that a behaviour change shows up immediately as a red test.

  • Parse input; do not execute it. Scores enter as strings and are converted with float(), which can only ever produce a number or raise a ValueError. Never reach for eval() to "read a number" — eval() executes its argument as Python, so a hostile value could run arbitrary code. float() is the safe parser, and it is what both versions of the tool use.

  • Readable code is safer code. A reviewer can only catch a security problem they can see. Meaningful names, small functions, docstrings, and type hints are not just tidiness — they are what make a bug or an unsafe call visible in review. Sloppy code hides mistakes from humans and from tools; the whole point of this lesson is to stop doing that.

  • Optional tools are optional. If you choose to install Black or Ruff, get them from the official Python Package Index with python3 -m pip install, and read what any tool does before running it. The lab never requires them and never fails when they are missing.

  • Reading before running: every file in this lab is short and commented. Read starter/messy.py, examples/report.py, and tests/run_tests.sh before running them. Running unread scripts is one of the most common ways developers get compromised; the course's rule is that every lab script is small enough to read and understand first.