Programming with Python › Testing and Code Quality › Day 77
Hands-on lab — Day 77: Quality Gates for a Python Project
- ← Back to the Day 77 lesson
- Open the hands-on files on GitHub — clone or download them from the public labs repository
- Local path in your clone:
labs/sections/programming-with-python/day-077-quality-gates-for-a-python-project/
Commands
Setup
cd labs/sections/programming-with-python/day-077-quality-gates-for-a-python-project
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/coverage --version Run
cd examples && bash check.sh
cd examples && bash check.sh --fail-fast
cd examples && coverage run -m pytest && coverage report
cd examples && pytest --cov=pricekit --cov-branch --cov-report=term-missing
cd examples/coverage-demo && grep -cE '^[[:space:]]*assert ' test_promo_no_assertions.py
cd examples/coverage-demo && coverage run --branch --source=promo -m pytest test_promo_no_assertions.py -q && coverage report --show-missing --fail-under=0
cd examples/coverage-demo && pytest test_promo_with_assertions.py -q
cat examples/ci-reference/quality-gate.yml
cd starter && bash check.sh Test
bash tests/run_tests.sh File tree
examples/check.sh examples/ci-reference/pre-commit-config.yaml examples/ci-reference/quality-gate.yml examples/coverage-demo/promo.py examples/coverage-demo/test_executes_everything.py examples/coverage-demo/test_promo_no_assertions.py examples/coverage-demo/test_promo_with_assertions.py examples/pricekit/__init__.py examples/pricekit/money.py examples/pricekit/receipt.py examples/pyproject.toml examples/tests/test_money.py examples/tests/test_receipt.py expected-output/coverage-reports.txt expected-output/FIELDS.md expected-output/gate-failures.txt expected-output/gate-pass.txt expected-output/starter-progress.txt expected-output/test-run.txt metadata.yml README.md requirements/README.md requirements/requirements.txt security.md starter/check.sh starter/pricekit/__init__.py starter/pricekit/money.py starter/pricekit/receipt.py starter/pyproject.toml starter/tests/test_money.py tests/fixtures/untested_addition.py tests/run_tests.sh troubleshooting.md
Lab README
Day 077 lab — One Command, Every Gate
Lesson
- Lesson title: Quality Gates for a Python Project
- Day number: 77 of 365
- Lesson article: https://ai-roadmap-365.github.io/day-077-quality-gates-for-a-python-project
- 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-077-quality-gates-for-a-python-projectwhen the site is running.
Purpose
Week 11 handed you five separate tools. Day 71 gave you pytest, Day 72 fixtures and parametrization, Day 73 the discipline of writing the failing test first, Day 74 mocking at the boundaries, Day 75 mypy, Day 76 Ruff. Each of them is a command you can type. None of them is a gate.
A quality gate is one command that runs all of them and returns one exit
code meaning "this change is safe to merge". In this lab you build that command
— check.sh — stage by stage on a small pricing library, configure all five
stages in a single pyproject.toml, and then do the thing that most projects
never do: prove the gate can actually fail.
That proof is the heart of the lab. It is trivially easy to write a check.sh
that prints five green lines and exits 0 no matter what the code does, and such
a script is worse than having nothing, because everyone downstream believes it.
So the test suite takes a temporary copy of the finished project, introduces
one defect, and asserts the gate goes red — five times, once per stage: a
formatting violation, an unused import, a wrong type annotation, a broken
implementation, and a drop in coverage.
You also prove, mechanically, the most abused claim in the industry. A test
file in examples/coverage-demo/ contains zero assert statements, calls a
function that is genuinely wrong, and produces a coverage report reading
100%. Coverage measures which lines executed. It has never measured whether
anything was checked.
Finally, the lab ships two files it does not run: a complete continuous
integration workflow and a pre-commit configuration. Both are shipped as
documented references, because running them needs a hosting service or a write
into your .git directory, and a lab that claimed output it never produced
would be teaching the opposite of what a gate is for.
Learning objectives
- Assemble the week's separate tools into one command that runs identically on a laptop and on a build server and returns a single meaningful exit code.
- Order the stages by cost — format, lint, types, tests, coverage — and explain why the cheapest feedback must arrive first.
- Choose between fail-fast and report-everything, and say which context wants which.
- Configure pytest, mypy, Ruff and coverage.py entirely in one
pyproject.toml, and explain what one configuration file buys over five dotfiles. - Run
coverage.pyandpytest-cov, read a real branch-coverage report, and use theMissingcolumn as a worklist. - Demonstrate that 100% coverage proves execution and not verification, and use a coverage floor as a ratchet rather than a target.
- Prove a gate has teeth by breaking one thing at a time and watching exactly one stage go red.
- Read a complete continuous-integration workflow line by line: clean checkout, pinned interpreter, pinned dependencies, the one gate command, the build matrix, and the artifacts it keeps.
- Explain what each stage cannot prove, and why branch protection rather than a workflow file is what actually blocks a bad merge.
Prerequisites
- The Day 77 lesson (read it first).
- Day 76: Ruff —
ruff format,ruff check, and rule codes such asF401. - Day 75: mypy and the difference between an annotation and a checked claim.
- Days 71–74: pytest, fixtures and parametrization, test-first development, and mocking at boundaries.
- Day 69: type hints and dataclasses; Day 70: value objects and invariants —
the
pricekitpackage under test is built from both. - Day 43:
python3 -m venvand installing into a virtual environment. - A terminal, a text editor, and one network connection for the install.
Supported operating systems
- macOS — fully supported (captures taken on macOS 26.5.1, Apple Silicon, Python 3.14.0, bash 3.2.57).
- Linux — fully supported (any distribution with Python 3.10+ and bash).
- Windows — use WSL and follow the Linux path.
check.shandtests/run_tests.share bash scripts, and a virtual environment on native Windows puts its executables in.venv\Scripts\rather than.venv/bin/.
Hardware requirements
Any computer that runs Python 3.10 or newer. The library is under 150 lines, the suite is 42 tests, and a full gate run finishes in well under a second on the authoring machine. No GPU, no special memory, no disk of consequence. Network access is needed once, for the install.
Required software
python3(3.10 or newer; captures taken on 3.14.0).bashforcheck.shand the test runner (preinstalled on macOS and Linux).- Five pinned packages —
ruff,mypy,pytest,coverage,pytest-cov— listed with their exact versions and their reasons inrequirements/README.md.
Free and open-source options
Every tool in this lab is free and open source, and the whole gate runs on your
own machine at no cost. ruff, mypy, pytest and pytest-cov are MIT
licensed and coverage is Apache-2.0, each per its own project documentation
or package metadata.
The lesson's Alternatives section covers the wider field honestly: a plain
shell script or Makefile costs nothing and needs no dependencies at all and
is genuinely enough for many projects; pre-commit, tox and nox are free
and open source; hosted CI is free for public repositories and metered for
private ones; and hosted quality dashboards such as SonarQube/SonarCloud and
Codecov have free or open-source tiers alongside paid plans. None of the paid
options is needed here or anywhere in this course.
Installation
cd labs/sections/programming-with-python/day-077-quality-gates-for-a-python-project
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
.venv/bin/coverage --version
The install needs the network once. Everything after it runs offline. .venv/
is ignored by version control and is never committed — it is a build product of
requirements.txt, which is the file that matters.
If you would rather not create a virtual environment here, point the scripts at tools you already have:
RUFF=/path/to/ruff MYPY=/path/to/mypy COVERAGE=/path/to/coverage \
PYTEST=/path/to/pytest bash tests/run_tests.sh
File structure
day-077-quality-gates-for-a-python-project/
├── README.md ← you are here
├── metadata.yml
├── examples/ ← the finished gate
│ ├── check.sh ← THE artefact: one command, five stages
│ ├── pyproject.toml ← one config file for all four tools
│ ├── pricekit/
│ │ ├── __init__.py
│ │ ├── money.py ← whole-cent Money value object
│ │ └── receipt.py ← pure receipt arithmetic
│ ├── tests/
│ │ ├── test_money.py ← 22 tests
│ │ └── test_receipt.py ← 20 tests
│ ├── coverage-demo/
│ │ ├── promo.py ← four lines, one real bug
│ │ ├── test_promo_no_assertions.py ← 100% coverage, zero assertions
│ │ ├── test_promo_with_assertions.py ← the same tests, one assertion each
│ │ └── test_executes_everything.py ← the realistic version, on pricekit
│ └── ci-reference/
│ ├── quality-gate.yml ← full CI workflow — REFERENCE, not run here
│ └── pre-commit-config.yaml ← pre-commit hooks — REFERENCE, not run here
├── starter/ ← YOUR work
│ ├── check.sh ← a gate with no stages (exercises 1–5)
│ ├── pyproject.toml ← skeleton config (exercises 1b–5b)
│ ├── pricekit/ ← the same library, complete
│ └── tests/
│ └── test_money.py ← deliberately incomplete: no receipt tests
├── tests/
│ ├── run_tests.sh ← 39 checks; proves the gate has teeth
│ └── fixtures/
│ └── untested_addition.py ← the code injected to drop coverage
├── expected-output/
│ ├── gate-pass.txt
│ ├── gate-failures.txt ← five defects, five red gates
│ ├── coverage-reports.txt
│ ├── starter-progress.txt
│ ├── test-run.txt
│ └── FIELDS.md
├── requirements/
│ ├── requirements.txt ← five exact pins
│ └── README.md
├── troubleshooting.md
└── security.md
How to run
From this directory, after the install:
## 1. Run the finished gate. One command, five stages, one exit code.
cd examples
bash check.sh
echo "exit code: $?"
## 2. Watch the gate go red. Break one thing, run it again, put it back.
## (This edits examples/ — the last command undoes it.)
python3 - <<'PY'
from pathlib import Path
p = Path('pricekit/money.py')
p.write_text(p.read_text().replace(
'from dataclasses import dataclass',
'from dataclasses import dataclass\nimport os',
))
PY
bash check.sh
git checkout -- pricekit/money.py
## 3. Compare the two modes on the same broken code.
bash check.sh --fail-fast
## 4. Read a coverage report properly.
coverage run -m pytest
coverage report
pytest --cov=pricekit --cov-branch --cov-report=term-missing
## 5. The demonstration that matters: 100% coverage, zero assertions,
## one real bug.
cd coverage-demo
grep -cE '^[[:space:]]*assert ' test_promo_no_assertions.py
coverage run --branch --source=promo -m pytest test_promo_no_assertions.py -q
coverage report --show-missing --fail-under=0
pytest test_promo_with_assertions.py -q
cd ..
## 6. Read the two reference files. Neither is executed by this lab.
cat ci-reference/quality-gate.yml
cat ci-reference/pre-commit-config.yaml
cd ..
## 7. Your task: build the gate yourself, one stage at a time.
cd starter
bash check.sh # a gate with no stages says yes to everything
## ... complete exercises 1-5 in check.sh and 1b-5b in pyproject.toml ...
cd ..
## 8. Check your work.
bash tests/run_tests.sh
Every command above uses the tools from .venv/bin/ automatically if you
created one; otherwise prefix them, e.g. .venv/bin/coverage report.
What the commands do
bash check.sh— runs all five stages in cost order (format, lint, types, tests, coverage), printsPASS:orFAIL:for each, and exits 0 only if every stage passed. The tests stage runs pytest undercoverage run, so the coverage stage costs nothing extra. Read the script: the tool resolution and the stage runner together are about forty lines, and that is the entire machinery.- The
python3 - <<'PY'block thenbash check.sh— inserts a single unusedimport osintopricekit/money.pyand shows the lint stage catching it withF401(andI001, because the import block is now out of order), while the other four stages stay green.git checkout --puts the file back. Note where the import goes: appended to the end of a file it would trip the formatter as well, and the point of this demonstration is one defect, one red stage. bash check.sh --fail-fast— the same gate, stopping at the first red stage. On broken code the difference is visible immediately: the later stage headers never print. Use fail-fast when you are iterating locally; use the default report-everything mode in CI, where one run should tell you everything that is wrong rather than making you fix and re-queue five times.coverage run -m pytestthencoverage report— the two-command form.runrecords which lines and branches executed;reportprints the table and exits non-zero when the total is belowfail_under. Both read their settings from[tool.coverage.*]inpyproject.toml.pytest --cov=pricekit --cov-branch --cov-report=term-missing— the same measurement through the pytest-cov plugin, in one command instead of two. It also prints the floor verdict as a sentence:Required test coverage of 95.0% reached. Total coverage: 100.00%.- The
coverage-demoblock — the sharpest fifteen seconds in the lab. Thegrepproves the test file has no assertions. The coverage report says 100%. Thenpytest test_promo_with_assertions.pyadds one assertion per test and the suite fails withassert 100 == 900, becausepromo_pricecharges members 10% of the price instead of taking 10% off it. Coverage never moved. cat ci-reference/quality-gate.yml— a complete, commented workflow: when it triggers, the clean throwaway machine it runs on, the pinned Python matrix, the pinned dependency install, the singlebash check.shline, and the artifacts it keeps. It is not executed by this lab and no run of it is claimed anywhere here; running it requires a hosting service, and this lab runs offline on your machine.cat ci-reference/pre-commit-config.yaml— a hook configuration holding only fast checks, with the test suite deliberately absent and the reason written out. Also a reference, not executed here.bash tests/run_tests.sh— 39 checks. Eight on the clean reference gate, five defect checks proving each stage can fail, two on fail-fast versus report-everything, three on the coverage demonstration, seven on the shipped reference files and the single-config-file claim, and the rest on the starter.
Expected output
The clean gate, in full — a real captured run (see
expected-output/gate-pass.txt):
$ bash check.sh
=== format ===
5 files already formatted
PASS: format
=== lint ===
All checks passed!
PASS: lint
=== types ===
Success: no issues found in 3 source files
PASS: types
=== tests ===
.......................................... [100%]
42 passed in 0.02s
PASS: tests
=== coverage ===
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 0 12 0 100%
pricekit/receipt.py 35 0 14 0 100%
------------------------------------------------------------------
TOTAL 66 0 26 0 100%
PASS: coverage
=== gate PASSED ===
all 5 stages green — this change is safe to merge
exit: 0
And the demonstration the lesson is really about
(expected-output/coverage-reports.txt):
$ grep -cE '^[[:space:]]*assert ' test_promo_no_assertions.py
0
$ coverage run --branch --source=promo -m pytest test_promo_no_assertions.py -q
.. [100%]
$ coverage report --show-missing --fail-under=0
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------
promo.py 4 0 2 0 100%
------------------------------------------------------
TOTAL 4 0 2 0 100%
$ pytest test_promo_with_assertions.py -q
F. [100%]
> assert promo_price(1000, True) == 900
E assert 100 == 900
expected-output/gate-failures.txt holds
the five defect runs in full — the evidence that each stage is wired in.
expected-output/FIELDS.md states the required
behaviour of the gate, the coverage demonstration and the starter on every
platform.
Validation steps
bash check.shinexamples/prints fivePASS:lines andgate PASSED, andecho $?shows0.coverage reportinexamples/showsTOTAL 66 0 26 0 100%.- Inserting
import osinto the import block ofpricekit/money.pymakes the gate fail at the lint stage only, withF401andI001, and exit 1. Undo it withgit checkout -- pricekit/money.py. bash check.sh --fail-faston that same broken file never prints=== types ===; the default mode does.grep -cE '^[[:space:]]*assert ' examples/coverage-demo/test_promo_no_assertions.pyprints0, and the coverage report forpromo.pystill reads100%.pytest examples/coverage-demo/test_promo_with_assertions.py -qfails withassert 100 == 900— the bug 100% coverage did not notice.- In
starter/,bash check.shas shipped printsgate PASSED (with no stages). After exercise 5 it fails on coverage at 55% until you write tests forpricekit/receipt.py. examples/contains no.flake8,setup.cfg,mypy.ini,pytest.ini,.isort.cfgor.coveragerc— every tool readspyproject.toml. The test suite checks this too.bash tests/run_tests.shends with39 checks, 0 failure(s).and exits 0.
Tests
bash tests/run_tests.sh
Expected final line: 39 checks, 0 failure(s). The command exits 0 on success
and non-zero on any failure.
Read the suite before you run it. Two blocks explain the whole lab. The first
is expect_gate_fails, which copies the reference project into a temporary
directory, applies one defect, and asserts both that the gate exited non-zero
and that the named stage is the one that complained — asserting only the
exit code would pass even if the wrong stage failed for the wrong reason. The
second is the coverage block, which asserts mechanically that a file with zero
assert statements produces promo.py 4 0 2 0 100%, and that adding one
assertion turns the same tests red.
A full captured run is in
expected-output/test-run.txt.
Cleanup
rm -f examples/.coverage examples/coverage-demo/.coverage starter/.coverage
rm -rf examples/.mypy_cache examples/.ruff_cache examples/.pytest_cache
rm -rf starter/.mypy_cache starter/.ruff_cache starter/.pytest_cache
To reset your work: git checkout -- starter/. To remove the tools as well:
rm -rf .venv. The test runner makes its own temporary directories with
mktemp -d and removes each one as that check finishes, so a completed run
leaves nothing behind.
Troubleshooting
See troubleshooting.md. The ones you are most likely to
meet: FAIL: ruff not found, which means the install has not run and the gate
is refusing to skip a stage silently; ModuleNotFoundError: No module named 'pricekit', which means pythonpath = ["."] is missing from
[tool.pytest.ini_options]; a format stage that flags files you never touched,
which is a missing line-length setting; a coverage stage stuck at 55% in the
starter, which is the floor doing its job; and the two entries worth reading
even if nothing is broken — what to do about a slow gate, and what to do about
a flaky test.
Security notes
See security.md. Short version: pinned tool versions are a security control, because they stop the software that judges your code from changing without review. A gate is not a security scanner — the table in that file states exactly what each of the five stages can and cannot prove. The shipped CI workflow is a reference and is not executed here; if you adopt it, keep secrets in the hosting service's secret store, never in the workflow file or a fixture, and remember that a workflow triggered by a fork is running a stranger's configuration. The lab itself needs no credentials and handles no personal data.
Extension exercises
- Add a sixth stage. Add a
buildstage that runspython -m build(or, with no packaging tooling,python -c "import pricekit"from a directory that is not the project root). Put it last, and say in one sentence what it proves that the other five do not. - Measure the cost of every stage. Wrap
run_stageso it prints the elapsed time per stage, then run the gate ten times and look at the spread. Now argue the ordering from your own numbers rather than from this lab's claim, and decide whether you would still put types before tests. - Ratchet the floor. Raise
fail_underto 100, run the gate, and see it pass. Then delete one test and watch it fail. Write down, in the file, the rule your team would follow for changing that number — and note that a rule which allows lowering it silently is the same as having no floor. - Break the gate on purpose, five ways. Reproduce by hand each of the five defects the test suite injects, and confirm that exactly one stage goes red each time. If two stages go red for one defect, you have found an overlap worth understanding.
- Make a stage lie. Change the tests stage to
"${coverage_bin}" run -m pytest || true. The gate now passes on broken code. Runbash tests/run_tests.shand watch the defect check catch it. This is the single most valuable minute in the lab: it shows that the suite is testing the gate, not the library. - Write the workflow for a different service. Using
ci-reference/quality-gate.ymlas the model, write the equivalent for another CI provider. The shape will be the same — trigger, clean machine, install pinned dependencies, runbash check.sh— and discovering that the shape is the same is the point of the exercise.
Navigation
- Previous day: Day 76 — Ruff
(
labs/sections/programming-with-python/day-076-linting-and-formatting-with-ruff/). - Next day: Day 78 — the first day of Week 12, Python for Automation and
the Web (
labs/sections/programming-with-python/). - Week 11 project: the Tested Utility Library
(
labs/sections/programming-with-python/projects/week-11/). It builds directly on this lab: the library grows, and the gate you wrote today is what keeps it honest while it does.
Expected output
FIELDS.md
# Expected output — Day 077 lab
Every file in this directory is a real capture from the authoring machine
(macOS 26.5.1, Apple Silicon, Python 3.14.0, bash 3.2.57, 2026-07-19) with
ruff 0.15.22, mypy 2.3.0, pytest 9.1.1, coverage 7.15.2 and pytest-cov 7.1.0.
Absolute paths appear as `<repo>`; on your machine they are your real
repository path.
Nothing in this lab reads the clock, the network, or a random number, so the
numbers below are the same on any machine running the pinned tool versions.
## Files
- `gate-pass.txt` — `bash check.sh` on the clean reference project, in both
modes: the default report-everything run and the `--fail-fast` run. Both are
green, so both look identical here; the difference only shows when something
breaks, and `gate-failures.txt` shows that.
- `gate-failures.txt` — the important one. Five fresh copies of the reference
project, one defect each, one red stage each. This is the evidence that the
gate is wired in rather than decorative.
- `coverage-reports.txt` — how to read a coverage report, in five parts:
the two-command form (`coverage run` then `coverage report`), the
`pytest --cov` one-command form, the 100%-coverage-of-a-buggy-module
demonstration, the same tests with one assertion added, and the realistic
assertion-free run against `pricekit` itself.
- `starter-progress.txt` — the starter gate before exercise 1 (no stages, so
it says yes to everything) and the coverage figure the starter begins with.
- `test-run.txt` — a full run of `bash tests/run_tests.sh`: 39 checks, 0
failures, exit 0.
## Required behaviour of the reference gate
| Command, run in `examples/` | Result |
| --- | --- |
| `bash check.sh` | five `PASS:` lines, `gate PASSED`, exit 0 |
| `bash check.sh --fail-fast` | identical on clean code, exit 0 |
| `coverage report` | `TOTAL 66 0 26 0 100%` |
| the gate with a formatting defect | `FAIL: format` only, exit 1 |
| the gate with an unused import | `FAIL: lint` only, reporting `F401` and `I001`, exit 1 |
| the gate with `__str__` annotated `-> int` | `FAIL: types` only, reporting `[override]` and `[return-value]`, exit 1 |
| the gate with `+ 1` added to `Money.__add__` | `FAIL: tests` only, 4 failed / 38 passed, exit 1 |
| the gate with an untested function appended to `receipt.py` | `FAIL: coverage` only, `total of 88 is less than fail-under=95`, exit 1 |
| `bash check.sh --fail-fast` with a formatting defect | stops after the format stage; the later stage headers never print |
Each defect trips exactly one stage. That is not a coincidence — it is what a
well-separated gate looks like, and it is why a red build tells you where to
look rather than merely that something is wrong.
## Required behaviour of the coverage demonstration
| Command, run in `examples/coverage-demo/` | Result |
| --- | --- |
| `grep -cE '^[[:space:]]*assert ' test_promo_no_assertions.py` | `0` |
| `coverage run --branch --source=promo -m pytest test_promo_no_assertions.py -q` then `coverage report --show-missing --fail-under=0` | `promo.py 4 0 2 0 100%` |
| `pytest test_promo_with_assertions.py -q` | one failure, `assert 100 == 900`, exit 1 |
The module under measurement is genuinely wrong: `promo_price(1000, True)`
returns 100 where 900 is intended. Coverage reports 100% anyway, because
coverage measures which lines ran, never whether anything about the result was
checked.
## Required behaviour of the starter
| Command, run in `starter/` | Result |
| --- | --- |
| `bash check.sh` as shipped | `gate PASSED (with no stages)`, exit 0 |
| `ruff format --check .` after exercise 1b | flags `tests/test_money.py` only |
| `coverage report` against `fail_under = 95` | `TOTAL 66 27 26 0 55%`, exit 2 |
The starter begins at 55% because it ships tests for `pricekit/money.py` and
none for `pricekit/receipt.py`. Exercise 5 is not a formality: you have to
write real tests to clear the floor.
## Platform notes
- **Linux** is identical apart from the `platform darwin` string that
pytest-cov prints in its header, which reads `platform linux` there.
- **Windows**: run everything inside WSL. `check.sh` and `run_tests.sh` are
bash scripts, and the tool paths inside a `.venv` differ on native Windows
(`.venv\Scripts\` rather than `.venv/bin/`).
- **Different tool versions** will change some text. Ruff's message layout,
mypy's wording, and pytest's failure formatting all evolve between releases.
The exit codes and the stage that fails will not change; that is exactly why
`tests/run_tests.sh` asserts on `FAIL: <stage>` and on exit codes rather
than on the tools' prose.
- The test runner makes its own temporary directories with `mktemp -d` and
removes each one as that check finishes, so a completed run leaves nothing
behind.
coverage-reports.txt
# Reading a coverage report — and reading what it does not say.
# Captured on macOS 26.5.1 (Apple Silicon), Python 3.14.0, coverage 7.15.2,
# pytest 9.1.1, pytest-cov 7.1.0, 2026-07-19.
########################################################################
# 1. The two-command form the gate uses: measure, then judge.
########################################################################
$ cd examples
$ coverage run -m pytest
.......................................... [100%]
42 passed in 0.02s
$ coverage report
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 0 12 0 100%
pricekit/receipt.py 35 0 14 0 100%
------------------------------------------------------------------
TOTAL 66 0 26 0 100%
exit: 0 (100% is at or above fail_under = 95 in pyproject.toml)
########################################################################
# 2. The one-command form, via the pytest-cov plugin. Same numbers, and
# it prints the fail_under verdict as a sentence.
########################################################################
$ pytest --cov=pricekit --cov-branch --cov-report=term-missing
.......................................... [100%]
================================ tests coverage ================================
_______________ coverage: platform darwin, python 3.14.0-final-0 _______________
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 0 12 0 100%
pricekit/receipt.py 35 0 14 0 100%
------------------------------------------------------------------
TOTAL 66 0 26 0 100%
Required test coverage of 95.0% reached. Total coverage: 100.00%
42 passed in 0.03s
########################################################################
# 3. The point of the whole lesson: 100% coverage of a module that is
# WRONG, measured by a test file with zero assert statements.
#
# coverage-demo/promo.py is meant to give members 10% off. It charges
# them 10% of the price instead. Both branches are executed.
########################################################################
$ cd coverage-demo
$ grep -cE '^[[:space:]]*assert ' test_promo_no_assertions.py
0
$ coverage run --branch --source=promo -m pytest test_promo_no_assertions.py -q
.. [100%]
$ coverage report --show-missing --fail-under=0
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------
promo.py 4 0 2 0 100%
------------------------------------------------------
TOTAL 4 0 2 0 100%
# 100%. Nothing missing. Zero assertions. The bug is still there.
########################################################################
# 4. The same two tests with ONE assertion each. Coverage is unchanged
# at 100% — only the exit code moves.
########################################################################
$ pytest test_promo_with_assertions.py -q
F. [100%]
=================================== FAILURES ===================================
________________ test_a_member_pays_ninety_percent_of_the_price ________________
def test_a_member_pays_ninety_percent_of_the_price() -> None:
> assert promo_price(1000, True) == 900
E assert 100 == 900
E + where 100 = promo_price(1000, True)
test_promo_with_assertions.py:15: AssertionError
=========================== short test summary info ============================
FAILED test_promo_with_assertions.py::test_a_member_pays_ninety_percent_of_the_price
exit: 1
########################################################################
# 5. The realistic version of the same lesson, on the real package: a
# test file that calls every pricekit.receipt function and checks
# nothing. Coverage of receipt.py stays high; correctness is unproven.
########################################################################
$ cd ..
$ coverage run --branch --source=pricekit -m pytest coverage-demo/test_executes_everything.py -q
..... [100%]
$ coverage report --show-missing --fail-under=0
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 6 12 6 70% 34, 36, 38, 42, 48, 50
pricekit/receipt.py 35 4 14 4 84% 37, 39, 47, 49
------------------------------------------------------------------
TOTAL 66 10 26 10 78%
# 84% of receipt.py, 78% overall — comfortably enough to look responsible
# in a status meeting, and not one line of it verified anything.
gate-failures.txt
# Proving the gate has teeth — five defects, five red gates.
#
# Captured on macOS 26.5.1 (Apple Silicon), Python 3.14.0, bash 3.2.57,
# 2026-07-19, with ruff 0.15.22, mypy 2.3.0, pytest 9.1.1, coverage 7.15.2.
#
# Each run below starts from a fresh temporary copy of examples/, applies ONE
# defect, and runs `bash check.sh` in the default report-everything mode. Read
# the last two lines of each block: exactly one stage goes red every time, and
# the gate's exit code is 1. tests/run_tests.sh reproduces all five
# automatically.
############ DEFECT: format ############
=== format ===
Would reformat: pricekit/receipt.py
1 file would be reformatted, 4 files already formatted
FAIL: format
=== lint ===
All checks passed!
PASS: lint
=== types ===
Success: no issues found in 3 source files
PASS: types
=== tests ===
.......................................... [100%]
42 passed in 0.02s
PASS: tests
=== coverage ===
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 0 12 0 100%
pricekit/receipt.py 35 0 14 0 100%
------------------------------------------------------------------
TOTAL 66 0 26 0 100%
PASS: coverage
=== gate FAILED ===
failing stages: format
exit: 1
############ DEFECT: lint ############
=== format ===
5 files already formatted
PASS: format
=== lint ===
I001 [*] Import block is un-sorted or un-formatted
--> pricekit/money.py:8:1
|
6 | """
7 |
8 | / from __future__ import annotations
9 | |
10 | | from dataclasses import dataclass
11 | | import os
| |_________^
|
help: Organize imports
F401 [*] `os` imported but unused
--> pricekit/money.py:11:8
|
10 | from dataclasses import dataclass
11 | import os
| ^^
|
help: Remove unused import: `os`
Found 2 errors.
[*] 2 fixable with the `--fix` option.
FAIL: lint
=== types ===
Success: no issues found in 3 source files
PASS: types
=== tests ===
.......................................... [100%]
42 passed in 0.03s
PASS: tests
=== coverage ===
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 29 0 12 0 100%
pricekit/receipt.py 35 0 14 0 100%
------------------------------------------------------------------
TOTAL 67 0 26 0 100%
PASS: coverage
=== gate FAILED ===
failing stages: lint
exit: 1
############ DEFECT: types ############
=== format ===
5 files already formatted
PASS: format
=== lint ===
All checks passed!
PASS: lint
=== types ===
pricekit/money.py:53: error: Return type "int" of "__str__" incompatible with return type "str" in supertype "builtins.object" [override]
pricekit/money.py:54: error: Incompatible return value type (got "str", expected "int") [return-value]
Found 2 errors in 1 file (checked 3 source files)
FAIL: types
=== tests ===
.......................................... [100%]
42 passed in 0.03s
PASS: tests
=== coverage ===
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 0 12 0 100%
pricekit/receipt.py 35 0 14 0 100%
------------------------------------------------------------------
TOTAL 66 0 26 0 100%
PASS: coverage
=== gate FAILED ===
failing stages: types
exit: 1
############ DEFECT: tests ############
=== format ===
5 files already formatted
PASS: format
=== lint ===
All checks passed!
PASS: lint
=== types ===
Success: no issues found in 3 source files
PASS: types
=== tests ===
F.......................F...............FF [100%]
=================================== FAILURES ===================================
______________________ test_money_adds_within_a_currency _______________________
def test_money_adds_within_a_currency() -> None:
> assert Money(2900) + Money(4900) == Money(7800)
E AssertionError: assert Money(cents=7...urrency='EUR') == Money(cents=7...urrency='EUR')
E
E Omitting 1 identical items, use -vv to show
E Differing attributes:
E ['cents']
E
E Drill down into differing attribute cents:
E cents: 7801 != 7800
tests/test_money.py:9: AssertionError
________________________ test_subtotal_adds_every_line _________________________
basket = [('coffee', Money(cents=450, currency='EUR'), 2), ('bread', Money(cents=320, currency='EUR'), 1), ('olive oil', Money(cents=1195, currency='EUR'), 3)]
def test_subtotal_adds_every_line(basket: list[Line]) -> None:
# 900 + 320 + 3585 = 4805
> assert subtotal(basket) == Money(4805)
E AssertionError: assert Money(cents=4...urrency='EUR') == Money(cents=4...urrency='EUR')
E
E Omitting 1 identical items, use -vv to show
E Differing attributes:
E ['cents']
E
E Drill down into differing attribute cents:
E cents: 4807 != 4805
tests/test_receipt.py:37: AssertionError
______________ test_format_receipt_without_tax_omits_the_tax_line ______________
basket = [('coffee', Money(cents=450, currency='EUR'), 2), ('bread', Money(cents=320, currency='EUR'), 1), ('olive oil', Money(cents=1195, currency='EUR'), 3)]
def test_format_receipt_without_tax_omits_the_tax_line(basket: list[Line]) -> None:
text = format_receipt(basket)
assert "tax" not in text
> assert text.splitlines()[-1] == "total 48.05 EUR"
E AssertionError: assert 'total 48.07 EUR' == 'total 48.05 EUR'
E
E - total 48.05 EUR
E ? ^
E + total 48.07 EUR
E ? ^
tests/test_receipt.py:86: AssertionError
________________ test_format_receipt_with_tax_shows_every_line _________________
basket = [('coffee', Money(cents=450, currency='EUR'), 2), ('bread', Money(cents=320, currency='EUR'), 1), ('olive oil', Money(cents=1195, currency='EUR'), 3)]
def test_format_receipt_with_tax_shows_every_line(basket: list[Line]) -> None:
text = format_receipt(basket, tax_percent=21)
lines = text.splitlines()
assert len(lines) == 6
assert lines[0] == "coffee 2 x 4.50 EUR = 9.00 EUR"
> assert lines[3] == "subtotal 48.05 EUR"
E AssertionError: assert 'subtotal 48.07 EUR' == 'subtotal 48.05 EUR'
E
E - subtotal 48.05 EUR
E ? ^
E + subtotal 48.07 EUR
E ? ^
tests/test_receipt.py:94: AssertionError
=========================== short test summary info ============================
FAILED tests/test_money.py::test_money_adds_within_a_currency - AssertionErro...
FAILED tests/test_receipt.py::test_subtotal_adds_every_line - AssertionError:...
FAILED tests/test_receipt.py::test_format_receipt_without_tax_omits_the_tax_line
FAILED tests/test_receipt.py::test_format_receipt_with_tax_shows_every_line
4 failed, 38 passed in 0.03s
FAIL: tests
=== coverage ===
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 0 12 0 100%
pricekit/receipt.py 35 0 14 0 100%
------------------------------------------------------------------
TOTAL 66 0 26 0 100%
PASS: coverage
=== gate FAILED ===
failing stages: tests
exit: 1
############ DEFECT: coverage ############
=== format ===
5 files already formatted
PASS: format
=== lint ===
All checks passed!
PASS: lint
=== types ===
Success: no issues found in 3 source files
PASS: types
=== tests ===
.......................................... [100%]
42 passed in 0.03s
PASS: tests
=== coverage ===
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 0 12 0 100%
pricekit/receipt.py 45 9 18 0 79% 71-79
------------------------------------------------------------------
TOTAL 76 9 30 0 88%
Coverage failure: total of 88 is less than fail-under=95
FAIL: coverage
=== gate FAILED ===
failing stages: coverage
exit: 1
gate-pass.txt
# Captured on macOS 26.5.1 (Apple Silicon), Python 3.14.0, bash 3.2.57, 2026-07-19.
# ruff 0.15.22 · mypy 2.3.0 · pytest 9.1.1 · coverage 7.15.2
$ cd examples
$ bash check.sh
=== format ===
9 files already formatted
PASS: format
=== lint ===
All checks passed!
PASS: lint
=== types ===
Success: no issues found in 3 source files
PASS: types
=== tests ===
.......................................... [100%]
42 passed in 0.02s
PASS: tests
=== coverage ===
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 0 12 0 100%
pricekit/receipt.py 35 0 14 0 100%
------------------------------------------------------------------
TOTAL 66 0 26 0 100%
PASS: coverage
=== gate PASSED ===
all 5 stages green — this change is safe to merge
exit: 0
$ bash check.sh --fail-fast # same clean project, fail-fast mode
=== format ===
9 files already formatted
PASS: format
=== lint ===
All checks passed!
PASS: lint
=== types ===
Success: no issues found in 3 source files
PASS: types
=== tests ===
.......................................... [100%]
42 passed in 0.02s
PASS: tests
=== coverage ===
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 0 12 0 100%
pricekit/receipt.py 35 0 14 0 100%
------------------------------------------------------------------
TOTAL 66 0 26 0 100%
PASS: coverage
=== gate PASSED ===
all 5 stages green — this change is safe to merge
exit: 0
starter-progress.txt
# The starter, as shipped and part-way through — real captures from
# macOS 26.5.1 (Apple Silicon), Python 3.14.0, bash 3.2.57, 2026-07-19.
########################################################################
# 1. The starter gate before exercise 1. It has no stages, so it says yes
# to everything. This is what a gate nobody finished looks like, and it
# is the reason "we have CI" is not the same claim as "we have a gate".
########################################################################
$ cd starter
$ bash check.sh
=== gate PASSED (with no stages) ===
A gate with nothing in it always says yes. Start with exercise 1.
exit: 0
########################################################################
# 2. Why exercise 5 (the coverage floor) bites. The starter ships tests
# for pricekit/money.py and none for pricekit/receipt.py. Measured
# against the same fail_under = 95 the finished gate uses:
########################################################################
$ coverage run -m pytest
...................... [100%]
22 passed in 0.02s
$ coverage report
Name Stmts Miss Branch BrPart Cover Missing
------------------------------------------------------------------
pricekit/__init__.py 3 0 0 0 100%
pricekit/money.py 28 0 12 0 100%
pricekit/receipt.py 35 27 14 0 16% 17, 26-31, 36-41, 46-51, 56-66
------------------------------------------------------------------
TOTAL 66 27 26 0 55%
Coverage failure: total of 55 is less than fail-under=95
exit: 2
# 55%. The Missing column is your worklist: every one of those line
# ranges is a rule in pricekit/receipt.py that nothing exercises. Write
# tests until the floor is cleared — and read what you wrote afterwards,
# because coverage will happily count a test that asserts nothing.
test-run.txt
# Real run of the Day 077 test suite on the authoring machine.
# macOS 26.5.1 (Apple Silicon), Python 3.14.0, bash 3.2.57, 2026-07-19.
# ruff 0.15.22 · mypy 2.3.0 · pytest 9.1.1 · coverage 7.15.2 · pytest-cov 7.1.0
#
# $ bash tests/run_tests.sh
# (exit code 0)
Testing the reference gate ...
ok: check.sh exits 0 on the clean reference project
ok: the clean run reports PASS for the format stage
ok: the clean run reports PASS for the lint stage
ok: the clean run reports PASS for the types stage
ok: the clean run reports PASS for the tests stage
ok: the clean run reports PASS for the coverage stage
ok: the clean run prints the gate PASSED verdict
ok: the clean run reports 100% coverage of pricekit
Proving each stage can actually fail ...
ok: a formatting violation makes the gate fail at the format stage
ok: an unused import makes the gate fail at the lint stage
ok: a wrong return annotation makes the gate fail at the types stage
ok: a broken implementation makes the gate fail at the tests stage
ok: untested new code makes the gate fail at the coverage stage
ok: --fail-fast stops before the later stages run
ok: the default (report-all) mode does reach the tests stage
Proving what coverage cannot see ...
ok: the assertion-free test file contains zero assert statements
ok: a test file with no assertions still reports 100% coverage
ok: adding one assertion catches the bug coverage could not see
Checking the shipped reference files ...
ok: the continuous-integration workflow reference is present
ok: the workflow reference contains checkout, setup, matrix and the gate
ok: the workflow reference says plainly that it is not executed here
ok: the pre-commit reference holds fast hooks and no test-suite hook
ok: one pyproject.toml configures pytest, mypy, ruff and coverage
ok: the reference project ships no .flake8
ok: the reference project ships no setup.cfg
ok: the reference project ships no .isort.cfg
ok: the reference project ships no mypy.ini
ok: the reference project ships no pytest.ini
ok: the reference project ships no .coveragerc
Checking the starter ...
ok: starter/check.sh is valid bash
ok: starter/pyproject.toml is valid TOML
ok: starter/check.sh states exercise 1
ok: starter/check.sh states exercise 2
ok: starter/check.sh states exercise 3
ok: starter/check.sh states exercise 4
ok: starter/check.sh states exercise 5
ok: starter/check.sh ships with no stages wired in yet
ok: the starter's suite is deliberately incomplete (no receipt tests)
ok: the starter's coverage starts below the 95% floor
39 checks, 0 failure(s).
Source files
examples/check.sh (4272 bytes)
#!/usr/bin/env bash
# check.sh — the whole quality gate, as one command.
#
# bash check.sh run every stage, report all failures
# bash check.sh --fail-fast stop at the first failing stage
#
# Exit code 0 means "this change is safe to merge". Any other exit code means
# it is not, and the output above says which stage disagreed.
#
# The stages run cheapest-first, so the feedback you get soonest is the
# feedback that costs least to produce:
#
# 1. format — is the code laid out the agreed way? (milliseconds)
# 2. lint — any dead imports, bugs, unsorted imports? (milliseconds)
# 3. types — do the annotations actually hold? (~a second)
# 4. tests — does it still do what it claims? (~a second here)
# 5. coverage — did any code sneak in unexecuted? (instant, reuses 4)
#
# This script is deliberately plain bash. It needs no plugin system, no YAML,
# and no network, and it runs identically on your laptop and on a build server.
set -u
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${project_dir}" || exit 1
fail_fast="no"
if [ "${1:-}" = "--fail-fast" ]; then
fail_fast="yes"
fi
# --- Tool resolution --------------------------------------------------------
# Resolve each tool: an explicit override first, then this project's .venv,
# then whatever is on PATH. Fails loudly with instructions rather than
# skipping a stage silently — a gate that quietly does nothing is worse than
# no gate at all, because people trust it.
resolve_tool() {
local tool="$1" override="$2"
if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
if [ -x "${project_dir}/.venv/bin/${tool}" ]; then echo "${project_dir}/.venv/bin/${tool}"; return 0; fi
if [ -x "${project_dir}/../.venv/bin/${tool}" ]; then echo "${project_dir}/../.venv/bin/${tool}"; return 0; fi
if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
return 1
}
require_tool() {
local tool="$1" override="$2" path
if ! path="$(resolve_tool "${tool}" "${override}")"; then
echo "FAIL: ${tool} not found." >&2
echo " Install the gate's tools with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
echo " Or point this script at existing ones, e.g. RUFF=/path/to/ruff bash check.sh" >&2
exit 1
fi
echo "${path}"
}
ruff_bin="$(require_tool ruff "${RUFF:-}")" || exit 1
mypy_bin="$(require_tool mypy "${MYPY:-}")" || exit 1
coverage_bin="$(require_tool coverage "${COVERAGE:-}")" || exit 1
# --- Stage runner -----------------------------------------------------------
failed_stages=""
stage_count=0
run_stage() {
local name="$1"
shift
stage_count=$((stage_count + 1))
printf '=== %s ===\n' "${name}"
if "$@"; then
printf 'PASS: %s\n\n' "${name}"
else
printf 'FAIL: %s\n\n' "${name}"
failed_stages="${failed_stages} ${name}"
if [ "${fail_fast}" = "yes" ]; then
printf 'stopping at first failure (--fail-fast)\n'
printf 'gate FAILED: %s\n' "${name}"
exit 1
fi
fi
}
# 1. Formatting. --check reports rather than rewrites, which is what a gate
# wants: it must never modify the thing it is judging.
run_stage "format" "${ruff_bin}" format --check .
# 2. Linting. Same tool, different job: rules about correctness and style
# that a formatter cannot express.
run_stage "lint" "${ruff_bin}" check .
# 3. Types. Reads the annotations Day 69 taught you to write and proves the
# claims they make, which Python itself never checks at runtime.
run_stage "types" "${mypy_bin}"
# 4. Tests, run under coverage measurement so stage 5 costs nothing extra.
run_stage "tests" "${coverage_bin}" run -m pytest
# 5. Coverage floor. `report` exits non-zero when total coverage is below
# fail_under in pyproject.toml.
run_stage "coverage" "${coverage_bin}" report
# --- Verdict ----------------------------------------------------------------
if [ -n "${failed_stages}" ]; then
printf '=== gate FAILED ===\n'
printf 'failing stages:%s\n' "${failed_stages}"
exit 1
fi
printf '=== gate PASSED ===\n'
printf 'all %d stages green — this change is safe to merge\n' "${stage_count}"
exit 0
examples/ci-reference/pre-commit-config.yaml (3009 bytes)
# pre-commit-config.yaml — a REFERENCE pre-commit configuration.
#
# Not executed by this lab: pre-commit is a separate free, open-source tool
# that this lab does not install, and installing its hook writes into a
# repository's .git directory. It is shown so you know the shape.
#
# To use it for real: copy this file to `.pre-commit-config.yaml` at the root
# of your repository, `pip install pre-commit`, then run `pre-commit install`
# once. From then on the hooks run automatically on `git commit`, over the
# staged files only.
#
# WHAT BELONGS IN A PRE-COMMIT HOOK: fast things. Formatting, linting, a
# whitespace tidy — anything that finishes in well under a second on the files
# you actually touched.
#
# WHAT DOES NOT BELONG: the full test suite, the type checker on a large
# codebase, anything that talks to the network. Not because they are
# unimportant — they are the most important stages — but because a hook that
# makes `git commit` take twenty seconds gets bypassed with `git commit
# --no-verify` within a week, and then you have no hook AND no habit. Slow
# stages belong in `check.sh` and in CI, where waiting is expected.
#
# NOTE ON `repo: local`: hooks below run the tools already installed in your
# project's environment, using the exact pinned versions from
# requirements.txt. The more common style is to name a remote hook repository
# and a tag, which lets pre-commit manage its own isolated environments; that
# is convenient, but it means the version your hook runs and the version your
# gate runs are configured in two different files and can drift. Local hooks
# keep one source of truth. Choose deliberately.
repos:
- repo: local
hooks:
# Fast: reformats only the staged files, in place.
- id: ruff-format
name: format with ruff
entry: .venv/bin/ruff format
language: system
types: [python]
# Fast: lints only the staged files, fixing what it safely can.
- id: ruff-check
name: lint with ruff
entry: .venv/bin/ruff check --fix
language: system
types: [python]
# Borderline. Type checking a small package is fast; on a large one it
# is not. `pass_filenames: false` makes it check the whole package
# rather than just the staged files, because a type error is usually
# created in one file and revealed in another.
#
# If this ever takes more than a second or two, delete it from here and
# let check.sh and CI carry it.
- id: mypy
name: type-check with mypy
entry: .venv/bin/mypy
language: system
pass_filenames: false
types: [python]
# DELIBERATELY ABSENT: a `pytest` hook and a `coverage` hook.
#
# They are in check.sh and in the CI workflow instead. The hook's job is to
# catch the cheap, boring mistakes before they reach a reviewer; the gate's
# job is to be right. Asking the hook to do the gate's job is how teams end up
# with `--no-verify` in their muscle memory.
examples/ci-reference/quality-gate.yml (5099 bytes)
# quality-gate.yml — a complete continuous-integration workflow, shown as a
# REFERENCE. It is not executed by this lab and no run of it is claimed
# anywhere in this repository: running it requires a hosting service, and this
# lab runs entirely offline on your own machine.
#
# To use it for real, copy this file to `.github/workflows/quality-gate.yml`
# at the root of a repository hosted on a service that runs GitHub Actions
# workflows, and adjust the paths to match your project layout.
#
# Read it top to bottom. Every line is explained in the lesson, and the shape
# is the same on every CI service: describe WHEN to run, describe WHAT
# machine, then run the same one command you run on your laptop.
name: quality gate
# WHEN. Two triggers, and both matter.
# push — every push to the main line, so main is always known-good.
# pull_request — every proposed change, BEFORE it lands, which is the run
# that branch protection consumes.
on:
push:
branches: [main]
pull_request:
branches: [main]
# Cancel an in-progress run when a newer commit arrives on the same branch.
# Nobody needs the verdict on a commit that has already been replaced, and
# queue time is the main reason gates feel slow.
concurrency:
group: quality-gate-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
# WHAT MACHINE. A clean, throwaway virtual machine. Nothing is on it
# except what this file installs — which is exactly why a green run here
# disproves "works on my machine".
runs-on: ubuntu-latest
# THE MATRIX. The whole job is run once per listed Python version, in
# parallel. If the project promises to support three versions, the gate
# must actually prove it on three versions; a promise nothing checks is
# a promise that quietly stops being true.
strategy:
fail-fast: false # report every version's result, not just the first failure
matrix:
python-version: ["3.10", "3.11", "3.12"]
steps:
# 1. CLEAN CHECKOUT. Fetch the repository at the exact commit being
# tested. Note what is NOT here: your editor settings, your stray
# uncommitted file, your globally installed package. If the gate
# passes here, it passes from the code alone.
- name: Check out the repository
uses: actions/checkout@v4
# 2. THE INTERPRETER. Install the Python version for this matrix leg.
# Pinning it here means the gate does not silently change behaviour
# the week the runner image updates its default Python.
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
cache: pip # reuse the downloaded wheels between runs
# 3. DEPENDENCIES, PINNED. requirements.txt pins exact versions, so the
# gate cannot change its mind because a linter released a new rule
# overnight. Upgrading a tool becomes a deliberate, reviewable commit.
- name: Install the gate's tools
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements/requirements.txt
# 4. THE GATE. One line. The SAME line you run on your laptop.
# That identity is the point of the whole exercise: if CI ran a
# different set of commands, a green build would tell you nothing
# about what you ran locally, and a red one would be unreproducible.
- name: Run the quality gate
run: bash check.sh
# 5. ARTIFACTS. Keep the machine-readable coverage data so a human can
# download and inspect it after a failure. `if: always()` runs this
# step even when step 4 failed — which is precisely when you want it.
- name: Upload the coverage report
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.python-version }}
path: |
.coverage
htmlcov/
if-no-files-found: ignore
retention-days: 7
# WHAT MAKES THIS A GATE RATHER THAN A NOTIFICATION
#
# By itself this workflow only reports. It becomes a gate when the repository
# is configured with BRANCH PROTECTION on `main`: require this check to pass
# before a pull request can be merged, and forbid pushing straight to main.
# That setting lives in the repository's settings, not in this file, and it is
# the difference between a red mark everybody scrolls past and a merge button
# that will not light up.
#
# COST, HONESTLY
#
# Running workflows on public repositories is free. For private repositories
# the hosting service includes an allowance and bills beyond it; the size of
# that allowance and its price change over time, so check the provider's
# current pricing page rather than trusting a number written in a lesson. The
# practical consequence for you is a design rule, not a number: keep the gate
# fast, cancel superseded runs (see `concurrency` above), and do not put a
# thirty-minute job on every keystroke-sized commit.
examples/coverage-demo/promo.py (837 bytes)
"""A four-line module with a real bug in it, used to show what coverage cannot see.
`promo_price` is supposed to give members ten percent off. It charges them ten
percent of the price — a ninety percent discount. Every line of this file is
reachable, and the tests in `test_promo_no_assertions.py` reach all of them, so
coverage.py reports 100% for a module that would bankrupt the shop.
This file is not part of the pricekit package and is not shipped by the gate.
It exists to be measured.
"""
def promo_price(cents: int, is_member: bool) -> int:
"""Return the price a customer pays, in cents. Members are meant to get 10% off."""
if is_member:
# The intended line is `cents * 90 // 100`. This one charges 10% of the
# price instead of taking 10% off it.
return cents * 10 // 100
return cents
examples/coverage-demo/test_executes_everything.py (1304 bytes)
"""The most important test file in this lab, because it is worthless.
Every function below is CALLED. Not one of them is CHECKED. There is not a
single `assert` in this file, so it cannot fail for any reason other than an
unhandled exception — and yet coverage.py will report a very high number for
`pricekit.receipt`, because coverage measures which lines RAN, never whether
anything about the result was true.
Run it and read the report:
coverage run --branch --source=pricekit -m pytest coverage-demo -q
coverage report --show-missing --fail-under=0
That is the whole argument against coverage as a target, in one file.
"""
from pricekit.money import Money
from pricekit.receipt import apply_discount, format_receipt, line_total, subtotal, with_tax
BASKET = [
("coffee", Money(450), 2),
("bread", Money(320), 1),
("olive oil", Money(1195), 3),
]
def test_line_total_runs() -> None:
line_total(Money(450), 2)
def test_subtotal_runs() -> None:
subtotal(BASKET)
subtotal([])
def test_apply_discount_runs() -> None:
apply_discount(Money(1000), 10)
def test_with_tax_runs() -> None:
with_tax(Money(1000), 21)
with_tax(Money(1000), 0)
def test_format_receipt_runs() -> None:
format_receipt(BASKET)
format_receipt(BASKET, tax_percent=21)
examples/coverage-demo/test_promo_no_assertions.py (486 bytes)
"""Two tests. Both branches executed. Zero assertions. 100% coverage.
Run:
coverage run --branch --source=coverage-demo/promo -m pytest \
coverage-demo/test_promo_no_assertions.py -q
coverage report --show-missing --fail-under=0
The report says 100%. The module it is reporting on is wrong.
"""
from promo import promo_price
def test_member_price_runs() -> None:
promo_price(1000, True)
def test_non_member_price_runs() -> None:
promo_price(1000, False)
examples/coverage-demo/test_promo_with_assertions.py (610 bytes)
"""The same two tests, plus the one thing that was missing: an assertion.
Coverage does not move — it was already 100%. What moves is the exit code,
because the first assertion is false. That gap between "100% covered" and
"actually correct" is the whole reason a coverage number is an alarm and not
a goal.
pytest coverage-demo/test_promo_with_assertions.py -q
"""
from promo import promo_price
def test_a_member_pays_ninety_percent_of_the_price() -> None:
assert promo_price(1000, True) == 900
def test_a_non_member_pays_the_full_price() -> None:
assert promo_price(1000, False) == 1000
examples/pricekit/__init__.py (665 bytes)
"""pricekit — a very small pricing library, used here as a subject for a quality gate.
The package is deliberately tiny. The interesting artefact in this lab is not
the library; it is `check.sh`, the single command that decides whether a change
to this library is safe to merge.
"""
from pricekit.money import CurrencyMismatch, InvalidMoney, Money, MoneyError
from pricekit.receipt import (
apply_discount,
format_receipt,
line_total,
subtotal,
with_tax,
)
__all__ = [
"CurrencyMismatch",
"InvalidMoney",
"Money",
"MoneyError",
"apply_discount",
"format_receipt",
"line_total",
"subtotal",
"with_tax",
]
examples/pricekit/money.py (1962 bytes)
"""Whole-cent money arithmetic.
Money is stored as an integer number of cents plus a three-letter currency
code, for the reason Day 70 argued: integer addition is exact, so a total can
never depend on which particular amounts a user happened to enter.
"""
from __future__ import annotations
from dataclasses import dataclass
class MoneyError(Exception):
"""Base class for every rule this module refuses to break."""
class InvalidMoney(MoneyError):
"""A money value that cannot exist."""
class CurrencyMismatch(MoneyError):
"""An operation that mixes two currencies."""
@dataclass(frozen=True)
class Money:
"""An exact amount of money: whole cents in one currency."""
cents: int
currency: str = "EUR"
def __post_init__(self) -> None:
if isinstance(self.cents, bool) or not isinstance(self.cents, int):
raise InvalidMoney(f"money is whole cents, got {self.cents!r}")
if self.cents < 0:
raise InvalidMoney(f"money cannot be negative, got {self.cents}")
if len(self.currency) != 3 or not self.currency.isupper():
raise InvalidMoney(f"currency must be a code like EUR, got {self.currency!r}")
def __add__(self, other: Money) -> Money:
if other.currency != self.currency:
raise CurrencyMismatch(f"cannot add {other.currency} to {self.currency}")
return Money(self.cents + other.cents, self.currency)
def times(self, quantity: int) -> Money:
"""Return this amount repeated `quantity` times."""
if isinstance(quantity, bool) or not isinstance(quantity, int):
raise InvalidMoney(f"quantity must be a whole number, got {quantity!r}")
if quantity < 0:
raise InvalidMoney(f"quantity cannot be negative, got {quantity}")
return Money(self.cents * quantity, self.currency)
def __str__(self) -> str:
return f"{self.cents // 100}.{self.cents % 100:02d} {self.currency}"
examples/pricekit/receipt.py (2643 bytes)
"""Receipt arithmetic built on top of `Money`.
Every function here is pure: values in, values out, no files and no printing.
That is what lets the whole module be measured, typed and tested by a gate
that finishes in under a second.
"""
from __future__ import annotations
from collections.abc import Sequence
from pricekit.money import InvalidMoney, Money
def line_total(unit_price: Money, quantity: int) -> Money:
"""Return the cost of `quantity` items at `unit_price`."""
return unit_price.times(quantity)
def subtotal(lines: Sequence[tuple[str, Money, int]]) -> Money:
"""Sum every line of a receipt.
Each line is a (description, unit price, quantity) triple. An empty
receipt totals zero in EUR, because there is no currency to infer.
"""
if not lines:
return Money(0)
total = line_total(lines[0][1], lines[0][2])
for _, unit_price, quantity in lines[1:]:
total = total + line_total(unit_price, quantity)
return total
def apply_discount(amount: Money, percent: int) -> Money:
"""Reduce `amount` by `percent`, rounding the discount down to a whole cent."""
if isinstance(percent, bool) or not isinstance(percent, int):
raise InvalidMoney(f"percent must be a whole number, got {percent!r}")
if percent < 0 or percent > 100:
raise InvalidMoney(f"percent must be between 0 and 100, got {percent}")
discount = amount.cents * percent // 100
return Money(amount.cents - discount, amount.currency)
def with_tax(amount: Money, rate_percent: int) -> Money:
"""Add `rate_percent` tax to `amount`, rounding the tax down to a whole cent."""
if isinstance(rate_percent, bool) or not isinstance(rate_percent, int):
raise InvalidMoney(f"rate must be a whole number, got {rate_percent!r}")
if rate_percent < 0:
raise InvalidMoney(f"rate cannot be negative, got {rate_percent}")
tax = amount.cents * rate_percent // 100
return Money(amount.cents + tax, amount.currency)
def format_receipt(lines: Sequence[tuple[str, Money, int]], tax_percent: int = 0) -> str:
"""Render a receipt as plain text, one line per item plus a total."""
rendered = [
f"{description:<12} {quantity:>3} x {unit_price} = {line_total(unit_price, quantity)}"
for description, unit_price, quantity in lines
]
net = subtotal(lines)
gross = with_tax(net, tax_percent)
rendered.append(f"{'subtotal':<12} {net}")
if tax_percent:
rendered.append(f"{'tax ' + str(tax_percent) + '%':<12} {gross.cents - net.cents} cents")
rendered.append(f"{'total':<12} {gross}")
return "\n".join(rendered)
examples/pyproject.toml (2285 bytes)
# One project, one configuration file.
#
# Every stage of the gate reads its settings from a table in this file:
# Ruff from [tool.ruff], mypy from [tool.mypy], pytest from
# [tool.pytest.ini_options], and coverage.py from [tool.coverage.*]. Nobody
# has to remember which dotfile a rule lives in, and a reviewer can see the
# whole standard in one screen.
[project]
name = "pricekit"
version = "0.1.0"
description = "A very small pricing library, used as the subject of a quality gate."
requires-python = ">=3.10"
# --- Stage 4: tests ---------------------------------------------------------
[tool.pytest.ini_options]
testpaths = ["tests"]
# Put the project root on sys.path so `import pricekit` works whether the
# suite is started as `pytest` or as `python -m pytest`. Without this the two
# spellings behave differently, which is exactly the kind of "works when I run
# it" difference a gate exists to eliminate.
pythonpath = ["."]
# -q keeps the passing run to a few lines; --strict-markers turns a misspelled
# marker into an error instead of a silent no-op.
addopts = "-q --strict-markers"
# --- Stage 3: types ---------------------------------------------------------
[tool.mypy]
files = ["pricekit"]
strict = true
# Show the rule name on every message, so a failure tells you which switch
# produced it and therefore how to argue with it.
show_error_codes = true
# --- Stages 1 and 2: format and lint ---------------------------------------
[tool.ruff]
line-length = 100
target-version = "py310"
[tool.ruff.lint]
# E/W: style errors, F: real mistakes (unused imports, undefined names),
# I: import ordering, UP: modern-Python rewrites, B: likely bugs.
select = ["E", "W", "F", "I", "UP", "B"]
[tool.ruff.lint.per-file-ignores]
# Tests deliberately assert on broad exception types and import fixtures they
# only use through pytest, so two rules are relaxed there and nowhere else.
"tests/*" = ["B017"]
# --- Stage 5: coverage ------------------------------------------------------
[tool.coverage.run]
# branch = true measures both directions of every if, which line coverage
# alone cannot see.
branch = true
source = ["pricekit"]
[tool.coverage.report]
show_missing = true
# The ratchet. Raise it when the real number rises; never lower it silently.
fail_under = 95
examples/tests/test_money.py (1892 bytes)
"""Tests for pricekit.money — Day 71's arrange/act/assert, Day 72's parametrize."""
import pytest
from pricekit.money import CurrencyMismatch, InvalidMoney, Money
def test_money_adds_within_a_currency() -> None:
assert Money(2900) + Money(4900) == Money(7800)
def test_money_refuses_to_mix_currencies() -> None:
with pytest.raises(CurrencyMismatch):
Money(100, "EUR") + Money(100, "USD")
@pytest.mark.parametrize(
"cents",
[-1, -95000],
)
def test_money_refuses_negative_amounts(cents: int) -> None:
with pytest.raises(InvalidMoney):
Money(cents)
@pytest.mark.parametrize(
"cents",
[1.5, "4215", True, None],
)
def test_money_refuses_amounts_that_are_not_whole_cents(cents: object) -> None:
with pytest.raises(InvalidMoney):
Money(cents) # type: ignore[arg-type]
@pytest.mark.parametrize(
"currency",
["eur", "EURO", "E", ""],
)
def test_money_refuses_bad_currency_codes(currency: str) -> None:
with pytest.raises(InvalidMoney):
Money(100, currency)
def test_times_repeats_an_amount() -> None:
assert Money(1250).times(3) == Money(3750)
def test_times_by_zero_is_zero() -> None:
assert Money(1250).times(0) == Money(0)
@pytest.mark.parametrize(
"quantity",
[-1, 2.5, True],
)
def test_times_refuses_bad_quantities(quantity: object) -> None:
with pytest.raises(InvalidMoney):
Money(1250).times(quantity) # type: ignore[arg-type]
@pytest.mark.parametrize(
("cents", "text"),
[
(0, "0.00 EUR"),
(7, "0.07 EUR"),
(1250, "12.50 EUR"),
(95000, "950.00 EUR"),
],
)
def test_str_formats_cents_as_a_decimal_amount(cents: int, text: str) -> None:
assert str(Money(cents)) == text
def test_money_is_frozen() -> None:
amount = Money(1250)
with pytest.raises(Exception):
amount.cents = 99 # type: ignore[misc]
examples/tests/test_receipt.py (2906 bytes)
"""Tests for pricekit.receipt — Day 72's fixtures, Day 73's failing-test-first habit."""
import pytest
from pricekit.money import InvalidMoney, Money
from pricekit.receipt import (
apply_discount,
format_receipt,
line_total,
subtotal,
with_tax,
)
Line = tuple[str, Money, int]
@pytest.fixture
def basket() -> list[Line]:
"""A three-line receipt whose arithmetic is easy to check by hand."""
return [
("coffee", Money(450), 2),
("bread", Money(320), 1),
("olive oil", Money(1195), 3),
]
def test_line_total_multiplies_price_by_quantity() -> None:
assert line_total(Money(450), 2) == Money(900)
def test_subtotal_of_an_empty_receipt_is_zero() -> None:
assert subtotal([]) == Money(0)
def test_subtotal_adds_every_line(basket: list[Line]) -> None:
# 900 + 320 + 3585 = 4805
assert subtotal(basket) == Money(4805)
def test_subtotal_refuses_to_mix_currencies() -> None:
mixed: list[Line] = [("a", Money(100, "EUR"), 1), ("b", Money(100, "USD"), 1)]
with pytest.raises(Exception):
subtotal(mixed)
@pytest.mark.parametrize(
("cents", "percent", "expected"),
[
(1000, 0, 1000),
(1000, 10, 900),
(1000, 100, 0),
(999, 33, 670), # 999 * 33 // 100 == 329, so 999 - 329 == 670
],
)
def test_apply_discount_rounds_the_discount_down(cents: int, percent: int, expected: int) -> None:
assert apply_discount(Money(cents), percent) == Money(expected)
@pytest.mark.parametrize("percent", [-1, 101, 2.5, True])
def test_apply_discount_refuses_impossible_percentages(percent: object) -> None:
with pytest.raises(InvalidMoney):
apply_discount(Money(1000), percent) # type: ignore[arg-type]
@pytest.mark.parametrize(
("cents", "rate", "expected"),
[
(1000, 0, 1000),
(1000, 21, 1210),
(4805, 21, 5814), # 4805 * 21 // 100 == 1009, so 4805 + 1009 == 5814
],
)
def test_with_tax_rounds_the_tax_down(cents: int, rate: int, expected: int) -> None:
assert with_tax(Money(cents), rate) == Money(expected)
@pytest.mark.parametrize("rate", [-1, 2.5, True])
def test_with_tax_refuses_bad_rates(rate: object) -> None:
with pytest.raises(InvalidMoney):
with_tax(Money(1000), rate) # type: ignore[arg-type]
def test_format_receipt_without_tax_omits_the_tax_line(basket: list[Line]) -> None:
text = format_receipt(basket)
assert "tax" not in text
assert text.splitlines()[-1] == "total 48.05 EUR"
def test_format_receipt_with_tax_shows_every_line(basket: list[Line]) -> None:
text = format_receipt(basket, tax_percent=21)
lines = text.splitlines()
assert len(lines) == 6
assert lines[0] == "coffee 2 x 4.50 EUR = 9.00 EUR"
assert lines[3] == "subtotal 48.05 EUR"
assert lines[4] == "tax 21% 1009 cents"
assert lines[5] == "total 58.14 EUR"
metadata.yml (1600 bytes)
lesson_id: D077
day: 77
kind: python-program
languages: [python, bash, toml, yaml]
setup_commands:
- cd labs/sections/programming-with-python/day-077-quality-gates-for-a-python-project
- python3 -m venv .venv
- .venv/bin/pip install -r requirements/requirements.txt
- .venv/bin/coverage --version
run_commands:
- cd examples && bash check.sh
- cd examples && bash check.sh --fail-fast
- cd examples && coverage run -m pytest && coverage report
- cd examples && pytest --cov=pricekit --cov-branch --cov-report=term-missing
- cd examples/coverage-demo && grep -cE '^[[:space:]]*assert ' test_promo_no_assertions.py
- cd examples/coverage-demo && coverage run --branch --source=promo -m pytest test_promo_no_assertions.py -q && coverage report --show-missing --fail-under=0
- cd examples/coverage-demo && pytest test_promo_with_assertions.py -q
- cat examples/ci-reference/quality-gate.yml
- cd starter && bash check.sh
test_commands:
- bash tests/run_tests.sh
cleanup_commands:
- rm -f examples/.coverage examples/coverage-demo/.coverage starter/.coverage
- rm -rf examples/.mypy_cache examples/.ruff_cache examples/.pytest_cache
- rm -rf starter/.mypy_cache starter/.ruff_cache starter/.pytest_cache
- 'git checkout -- starter/ # optional: reset your work'
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, bash 3.2.57, ruff 0.15.22, mypy 2.3.0, pytest 9.1.1, coverage 7.15.2, pytest-cov 7.1.0 — bash tests/run_tests.sh -> 39 checks, 0 failure(s), exit 0'
requirements/README.md (3746 bytes)
# Dependencies — Day 077 lab
Five packages, one for each stage of the gate plus one convenience. All are
free and open source, all install with `pip`, and none needs an account, an
API key, or a paid plan. After the one-time install everything in this lab
runs completely offline.
```
pytest==9.1.1
mypy==2.3.0
ruff==0.15.22
coverage==7.15.2
pytest-cov==7.1.0
```
| Package | Stage it powers | What it does | Licence |
| --- | --- | --- | --- |
| `ruff` | 1 (format) and 2 (lint) | One binary doing both jobs: `ruff format --check .` reports layout deviations without rewriting anything, `ruff check .` reports rule violations such as an unused import (`F401`). Introduced on Day 76. | MIT, per the Ruff documentation |
| `mypy` | 3 (types) | Reads the annotations Day 69 taught you to write and proves the claims they make, which Python never checks at runtime. Introduced on Day 75. | MIT, per the mypy documentation |
| `pytest` | 4 (tests) | Runs the suite built across Days 71 to 74. | MIT, per the pytest documentation |
| `coverage` | 4 and 5 (measurement and floor) | `coverage run -m pytest` records which lines and branches executed; `coverage report` prints the table and exits non-zero when the total is under `fail_under`. | Apache-2.0, per the package metadata |
| `pytest-cov` | optional | A pytest plugin that wires coverage into pytest itself, so `pytest --cov=pricekit` does in one command what `coverage run` plus `coverage report` does in two. The gate uses the two-command form; this package is here so you can compare them. | MIT, per the package metadata |
## Why the versions are pinned exactly
A gate whose tools can change underneath it is not a gate. If `requirements.txt`
said `ruff` rather than `ruff==0.15.22`, then a release that adds a new lint
rule would turn every open change red overnight, on a morning nobody chose.
With an exact pin, upgrading a tool is a one-line commit that somebody reviews
and that has its own green run — which is how it should be.
The versions above were installed and verified on the authoring machine on
2026-07-19. `coverage` reported itself as
`Coverage.py, version 7.15.2 with C extension`.
## One-time install
```bash
cd labs/sections/programming-with-python/day-077-quality-gates-for-a-python-project
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/pytest --version
.venv/bin/coverage --version
```
You created a virtual environment for the first time on Day 43; this is the
same procedure. `.venv/` is ignored by version control and never committed —
it is a build product of `requirements.txt`, which is the file that matters.
Both `check.sh` scripts find these tools automatically: they look for an
explicit override first (`RUFF=/path/to/ruff bash check.sh`), then this lab's
`.venv/bin/`, then whatever is on your `PATH`. If none of those finds a tool
the script stops with instructions rather than skipping the stage — a gate
that silently does nothing is more dangerous than no gate at all, because
people trust it.
## Deliberately not installed
- **pre-commit** — a free, open-source hook manager. A reference configuration
ships in `examples/ci-reference/pre-commit-config.yaml`, but installing it
writes a hook into a repository's `.git` directory, which a lab has no
business doing to yours.
- **tox** and **nox** — free, open-source multi-environment runners. Discussed
in the lesson's Alternatives section; both need several Python versions
installed to be worth running.
## Windows
Run everything inside WSL and follow the Linux path. The `.venv` layout
differs on native Windows (`.venv\Scripts\` rather than `.venv/bin/`), and
`check.sh` is a bash script, so WSL is the supported route.
requirements/requirements.txt (75 bytes)
pytest==9.1.1
mypy==2.3.0
ruff==0.15.22
coverage==7.15.2
pytest-cov==7.1.0
starter/check.sh (5676 bytes)
#!/usr/bin/env bash
# check.sh — YOUR quality gate. Right now it is a gate with no stages, which
# means it is a command that always says yes. Your job is to give it teeth.
#
# Build it one stage at a time, in cost order, running it after every stage so
# you watch it grow. The tool resolution and the stage runner are written for
# you; the stages are exercises 1 to 5.
#
# bash check.sh run every stage, report all failures
# bash check.sh --fail-fast stop at the first failing stage
#
# Exit 0 means "safe to merge". Anything else means it is not.
set -u
project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "${project_dir}" || exit 1
fail_fast="no"
if [ "${1:-}" = "--fail-fast" ]; then
fail_fast="yes"
fi
# --- Tool resolution (provided) --------------------------------------------
resolve_tool() {
local tool="$1" override="$2"
if [ -n "${override}" ] && [ -x "${override}" ]; then echo "${override}"; return 0; fi
if [ -x "${project_dir}/.venv/bin/${tool}" ]; then echo "${project_dir}/.venv/bin/${tool}"; return 0; fi
if [ -x "${project_dir}/../.venv/bin/${tool}" ]; then echo "${project_dir}/../.venv/bin/${tool}"; return 0; fi
if command -v "${tool}" >/dev/null 2>&1; then command -v "${tool}"; return 0; fi
return 1
}
require_tool() {
local tool="$1" override="$2" path
if ! path="$(resolve_tool "${tool}" "${override}")"; then
echo "FAIL: ${tool} not found." >&2
echo " Install the gate's tools with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
echo " Or point this script at existing ones, e.g. RUFF=/path/to/ruff bash check.sh" >&2
exit 1
fi
echo "${path}"
}
ruff_bin="$(require_tool ruff "${RUFF:-}")" || exit 1
mypy_bin="$(require_tool mypy "${MYPY:-}")" || exit 1
coverage_bin="$(require_tool coverage "${COVERAGE:-}")" || exit 1
# --- Stage runner (provided) ------------------------------------------------
failed_stages=""
stage_count=0
run_stage() {
local name="$1"
shift
stage_count=$((stage_count + 1))
printf '=== %s ===\n' "${name}"
if "$@"; then
printf 'PASS: %s\n\n' "${name}"
else
printf 'FAIL: %s\n\n' "${name}"
failed_stages="${failed_stages} ${name}"
if [ "${fail_fast}" = "yes" ]; then
printf 'stopping at first failure (--fail-fast)\n'
printf 'gate FAILED: %s\n' "${name}"
exit 1
fi
fi
}
# ---------------------------------------------------------------------------
# EXERCISE 1 — the format stage (cheapest, so it goes first).
#
# Add, on the line below this comment block:
# run_stage "format" "${ruff_bin}" format --check .
#
# Then add the matching config to pyproject.toml (exercise 1b there) and run
# `bash check.sh`. Ruff will tell you some files are not formatted; fix them
# with `ruff format .` and run the gate again. Note what --check buys you: the
# gate REPORTS, it never rewrites the code it is judging.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# EXERCISE 2 — the lint stage.
#
# Add:
# run_stage "lint" "${ruff_bin}" check .
#
# Then fill in [tool.ruff.lint] in pyproject.toml (exercise 2b) with
# select = ["E", "W", "F", "I", "UP", "B"] and run the gate again. To watch
# this stage do its job, add `import os` to the top of pricekit/money.py, run
# the gate, read the F401 message, and remove the import.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# EXERCISE 3 — the type stage.
#
# Add:
# run_stage "types" "${mypy_bin}"
#
# mypy reads [tool.mypy] from pyproject.toml, so it needs no arguments here.
# Fill in that table (exercise 3b) with files = ["pricekit"] and strict = true.
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# EXERCISE 4 — the test stage, run UNDER coverage measurement.
#
# Add:
# run_stage "tests" "${coverage_bin}" run -m pytest
#
# Running pytest through `coverage run` costs almost nothing and means
# exercise 5 needs no second test run. Fill in [tool.pytest.ini_options] and
# [tool.coverage.run] in pyproject.toml (exercise 4b).
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# EXERCISE 5 — the coverage floor.
#
# Add:
# run_stage "coverage" "${coverage_bin}" report
#
# Fill in [tool.coverage.report] (exercise 5b) with show_missing = true and
# fail_under = 95, then run the gate. It will FAIL, and it should: this starter
# ships tests for pricekit/money.py and none for pricekit/receipt.py. Read the
# Missing column, write the tests it points at in tests/test_receipt.py, and
# run the gate until it goes green. That loop — report, read, cover, re-run —
# is the only honest way to use a coverage number.
# ---------------------------------------------------------------------------
# --- Verdict (provided) -----------------------------------------------------
if [ -n "${failed_stages}" ]; then
printf '=== gate FAILED ===\n'
printf 'failing stages:%s\n' "${failed_stages}"
exit 1
fi
if [ "${stage_count}" -eq 0 ]; then
printf '=== gate PASSED (with no stages) ===\n'
printf 'A gate with nothing in it always says yes. Start with exercise 1.\n'
exit 0
fi
printf '=== gate PASSED ===\n'
printf 'all %d stages green — this change is safe to merge\n' "${stage_count}"
exit 0
starter/pricekit/__init__.py (665 bytes)
"""pricekit — a very small pricing library, used here as a subject for a quality gate.
The package is deliberately tiny. The interesting artefact in this lab is not
the library; it is `check.sh`, the single command that decides whether a change
to this library is safe to merge.
"""
from pricekit.money import CurrencyMismatch, InvalidMoney, Money, MoneyError
from pricekit.receipt import (
apply_discount,
format_receipt,
line_total,
subtotal,
with_tax,
)
__all__ = [
"CurrencyMismatch",
"InvalidMoney",
"Money",
"MoneyError",
"apply_discount",
"format_receipt",
"line_total",
"subtotal",
"with_tax",
]
starter/pricekit/money.py (1962 bytes)
"""Whole-cent money arithmetic.
Money is stored as an integer number of cents plus a three-letter currency
code, for the reason Day 70 argued: integer addition is exact, so a total can
never depend on which particular amounts a user happened to enter.
"""
from __future__ import annotations
from dataclasses import dataclass
class MoneyError(Exception):
"""Base class for every rule this module refuses to break."""
class InvalidMoney(MoneyError):
"""A money value that cannot exist."""
class CurrencyMismatch(MoneyError):
"""An operation that mixes two currencies."""
@dataclass(frozen=True)
class Money:
"""An exact amount of money: whole cents in one currency."""
cents: int
currency: str = "EUR"
def __post_init__(self) -> None:
if isinstance(self.cents, bool) or not isinstance(self.cents, int):
raise InvalidMoney(f"money is whole cents, got {self.cents!r}")
if self.cents < 0:
raise InvalidMoney(f"money cannot be negative, got {self.cents}")
if len(self.currency) != 3 or not self.currency.isupper():
raise InvalidMoney(f"currency must be a code like EUR, got {self.currency!r}")
def __add__(self, other: Money) -> Money:
if other.currency != self.currency:
raise CurrencyMismatch(f"cannot add {other.currency} to {self.currency}")
return Money(self.cents + other.cents, self.currency)
def times(self, quantity: int) -> Money:
"""Return this amount repeated `quantity` times."""
if isinstance(quantity, bool) or not isinstance(quantity, int):
raise InvalidMoney(f"quantity must be a whole number, got {quantity!r}")
if quantity < 0:
raise InvalidMoney(f"quantity cannot be negative, got {quantity}")
return Money(self.cents * quantity, self.currency)
def __str__(self) -> str:
return f"{self.cents // 100}.{self.cents % 100:02d} {self.currency}"
starter/pricekit/receipt.py (2643 bytes)
"""Receipt arithmetic built on top of `Money`.
Every function here is pure: values in, values out, no files and no printing.
That is what lets the whole module be measured, typed and tested by a gate
that finishes in under a second.
"""
from __future__ import annotations
from collections.abc import Sequence
from pricekit.money import InvalidMoney, Money
def line_total(unit_price: Money, quantity: int) -> Money:
"""Return the cost of `quantity` items at `unit_price`."""
return unit_price.times(quantity)
def subtotal(lines: Sequence[tuple[str, Money, int]]) -> Money:
"""Sum every line of a receipt.
Each line is a (description, unit price, quantity) triple. An empty
receipt totals zero in EUR, because there is no currency to infer.
"""
if not lines:
return Money(0)
total = line_total(lines[0][1], lines[0][2])
for _, unit_price, quantity in lines[1:]:
total = total + line_total(unit_price, quantity)
return total
def apply_discount(amount: Money, percent: int) -> Money:
"""Reduce `amount` by `percent`, rounding the discount down to a whole cent."""
if isinstance(percent, bool) or not isinstance(percent, int):
raise InvalidMoney(f"percent must be a whole number, got {percent!r}")
if percent < 0 or percent > 100:
raise InvalidMoney(f"percent must be between 0 and 100, got {percent}")
discount = amount.cents * percent // 100
return Money(amount.cents - discount, amount.currency)
def with_tax(amount: Money, rate_percent: int) -> Money:
"""Add `rate_percent` tax to `amount`, rounding the tax down to a whole cent."""
if isinstance(rate_percent, bool) or not isinstance(rate_percent, int):
raise InvalidMoney(f"rate must be a whole number, got {rate_percent!r}")
if rate_percent < 0:
raise InvalidMoney(f"rate cannot be negative, got {rate_percent}")
tax = amount.cents * rate_percent // 100
return Money(amount.cents + tax, amount.currency)
def format_receipt(lines: Sequence[tuple[str, Money, int]], tax_percent: int = 0) -> str:
"""Render a receipt as plain text, one line per item plus a total."""
rendered = [
f"{description:<12} {quantity:>3} x {unit_price} = {line_total(unit_price, quantity)}"
for description, unit_price, quantity in lines
]
net = subtotal(lines)
gross = with_tax(net, tax_percent)
rendered.append(f"{'subtotal':<12} {net}")
if tax_percent:
rendered.append(f"{'tax ' + str(tax_percent) + '%':<12} {gross.cents - net.cents} cents")
rendered.append(f"{'total':<12} {gross}")
return "\n".join(rendered)
starter/pyproject.toml (2700 bytes)
# YOUR project configuration — the other half of the gate.
#
# Every stage you add to check.sh reads its settings from a table in this one
# file. Fill each table in as you add the stage that uses it. Exercises 1b to
# 5b below pair with exercises 1 to 5 in check.sh.
#
# Why one file rather than five dotfiles: a reviewer can see the whole
# standard on one screen, a new contributor has exactly one place to look, and
# the settings cannot drift apart the way a .flake8 and a setup.cfg do.
[project]
name = "pricekit"
version = "0.1.0"
description = "A very small pricing library, used as the subject of a quality gate."
requires-python = ">=3.10"
# --- EXERCISE 1b: the formatter --------------------------------------------
# Add a [tool.ruff] table with:
# line-length = 100
# target-version = "py310"
# Ruff's formatter and linter both read it.
# --- EXERCISE 2b: the linter ------------------------------------------------
# Add a [tool.ruff.lint] table with:
# select = ["E", "W", "F", "I", "UP", "B"]
# E/W are style errors, F catches real mistakes such as an unused import,
# I sorts imports, UP modernises old syntax, B flags likely bugs.
#
# Then add a [tool.ruff.lint.per-file-ignores] table with:
# "tests/*" = ["B017"]
# because the tests deliberately assert on a broad exception type in one place,
# and a blanket exception is better than a rule nobody can satisfy.
# --- EXERCISE 3b: the type checker -----------------------------------------
# Add a [tool.mypy] table with:
# files = ["pricekit"]
# strict = true
# show_error_codes = true
# `files` is what lets check.sh call `mypy` with no arguments at all.
# --- EXERCISE 4b: the test runner and coverage measurement ------------------
# Add a [tool.pytest.ini_options] table with:
# testpaths = ["tests"]
# pythonpath = ["."]
# addopts = "-q --strict-markers"
# `pythonpath = ["."]` makes `import pricekit` work whether the suite is
# started as `pytest` or as `python -m pytest`. Leave it out and the two
# spellings behave differently — the exact class of surprise a gate exists to
# eliminate.
# and a [tool.coverage.run] table with:
# branch = true
# source = ["pricekit"]
# branch = true measures both directions of every if, which plain line
# coverage cannot see.
# --- EXERCISE 5b: the coverage floor ----------------------------------------
# Add a [tool.coverage.report] table with:
# show_missing = true
# fail_under = 95
# `fail_under` is what makes `coverage report` exit non-zero, and therefore
# what makes coverage a GATE rather than a number nobody reads. Treat it as a
# ratchet: raise it when the real figure rises, never lower it quietly.
starter/tests/test_money.py (1912 bytes)
"""Tests for pricekit.money — Day 71's arrange/act/assert, Day 72's parametrize."""
import pytest
from pricekit.money import CurrencyMismatch, InvalidMoney, Money
def test_money_adds_within_a_currency() -> None:
assert Money( 2900 ) + Money( 4900 ) == Money( 7800 )
def test_money_refuses_to_mix_currencies() -> None:
with pytest.raises(CurrencyMismatch):
Money(100, "EUR") + Money(100, "USD")
@pytest.mark.parametrize("cents",
[-1, -95000])
def test_money_refuses_negative_amounts(cents: int) -> None:
with pytest.raises(InvalidMoney):
Money(cents)
@pytest.mark.parametrize(
"cents",
[1.5, "4215", True, None],
)
def test_money_refuses_amounts_that_are_not_whole_cents(cents: object) -> None:
with pytest.raises(InvalidMoney):
Money(cents) # type: ignore[arg-type]
@pytest.mark.parametrize(
"currency",
["eur", "EURO", "E", ""],
)
def test_money_refuses_bad_currency_codes(currency: str) -> None:
with pytest.raises(InvalidMoney):
Money(100, currency)
def test_times_repeats_an_amount() -> None:
assert Money(1250).times(3) == Money(3750)
def test_times_by_zero_is_zero() -> None:
assert Money(1250).times(0) == Money(0)
@pytest.mark.parametrize(
"quantity",
[-1, 2.5, True],
)
def test_times_refuses_bad_quantities(quantity: object) -> None:
with pytest.raises(InvalidMoney):
Money(1250).times(quantity) # type: ignore[arg-type]
@pytest.mark.parametrize(
("cents", "text"),
[
(0, "0.00 EUR"),
(7, "0.07 EUR"),
(1250, "12.50 EUR"),
(95000, "950.00 EUR"),
],
)
def test_str_formats_cents_as_a_decimal_amount(cents: int, text: str) -> None:
assert str(Money(cents)) == text
def test_money_is_frozen() -> None:
amount = Money(1250)
with pytest.raises(Exception):
amount.cents = 99 # type: ignore[misc]
tests/fixtures/untested_addition.py (599 bytes)
def split_evenly(amount: Money, people: int) -> list[Money]:
"""Split an amount between people, giving the remainder cents to the first."""
if isinstance(people, bool) or not isinstance(people, int):
raise InvalidMoney(f"people must be a whole number, got {people!r}")
if people < 1:
raise InvalidMoney(f"cannot split between {people} people")
share = amount.cents // people
remainder = amount.cents - share * people
shares = [Money(share, amount.currency) for _ in range(people)]
shares[0] = Money(share + remainder, amount.currency)
return shares
tests/run_tests.sh (14891 bytes)
#!/usr/bin/env bash
# Tests for the Day 077 lab. Run from the lab directory:
# bash tests/run_tests.sh
#
# A lab about quality gates has exactly one obligation: prove the gate has
# TEETH. It is trivial to write a check.sh that prints five green lines and
# exits 0 no matter what the code does, and such a script is worse than
# nothing, because everyone downstream believes it.
#
# So this suite does not merely run the gate. It takes a temporary copy of the
# reference project, introduces ONE defect, and asserts the gate goes red —
# once for each of the five stages. If a stage were mis-wired, or the exit
# code were swallowed, exactly one of those five checks would go green when it
# should be red, and you would know which stage to look at.
#
# It also proves the lesson's most important claim mechanically: a test file
# containing zero assert statements still produces 100% coverage of a module
# that has a real bug in it.
#
# No network, non-interactive, deterministic. Exits 0 only if every check
# passes.
set -u
export PYTHONDONTWRITEBYTECODE=1
lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
examples_dir="${lab_dir}/examples"
starter_dir="${lab_dir}/starter"
failures=0
checks=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
}
# --- Tool resolution --------------------------------------------------------
# An explicit override, then this lab's .venv, then 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
}
need() {
local tool="$1" override="$2" path
if ! path="$(resolve_tool "${tool}" "${override}")"; then
echo "FAIL: ${tool} not found." >&2
echo " Install it with:" >&2
echo " python3 -m venv .venv" >&2
echo " .venv/bin/pip install -r requirements/requirements.txt" >&2
echo " Or point this suite at existing tools, e.g." >&2
echo " RUFF=/path/to/ruff MYPY=/path/to/mypy COVERAGE=/path/to/coverage \\" >&2
echo " PYTEST=/path/to/pytest bash tests/run_tests.sh" >&2
exit 1
fi
echo "${path}"
}
ruff_bin="$(need ruff "${RUFF:-}")" || exit 1
mypy_bin="$(need mypy "${MYPY:-}")" || exit 1
coverage_bin="$(need coverage "${COVERAGE:-}")" || exit 1
pytest_bin="$(need pytest "${PYTEST:-}")" || exit 1
export RUFF="${ruff_bin}" MYPY="${mypy_bin}" COVERAGE="${coverage_bin}"
# copy_project <destination>
# Make a throwaway copy of the reference project. Each defect check gets a
# fresh one, so the defects can never interact.
copy_project() {
local dest="$1"
cp -R "${examples_dir}/pricekit" "${dest}/pricekit"
cp -R "${examples_dir}/tests" "${dest}/tests"
cp "${examples_dir}/pyproject.toml" "${dest}/pyproject.toml"
cp "${examples_dir}/check.sh" "${dest}/check.sh"
}
# gate_exit <project_dir> [flag]
# Run the gate in a project copy and echo its exit code.
gate_exit() {
local dir="$1" flag="${2:-}" status
(cd "${dir}" && bash check.sh ${flag} >"${dir}/gate.log" 2>&1)
status=$?
echo "${status}"
}
# expect_gate_fails <label> <stage> <defect-command>
# Copy the project, run the defect command inside the copy, then assert BOTH
# that the gate exited non-zero AND that the named stage is the one that
# complained. Asserting only the exit code would pass even if the wrong stage
# failed for the wrong reason.
expect_gate_fails() {
local label="$1" stage="$2" defect="$3" work status log
work="$(mktemp -d "${TMPDIR:-/tmp}/gate-defect.XXXXXX")"
copy_project "${work}"
(cd "${work}" && eval "${defect}")
status="$(gate_exit "${work}")"
log="$(cat "${work}/gate.log" 2>/dev/null || true)"
if [ "${status}" -ne 0 ] && printf '%s' "${log}" | grep -q "FAIL: ${stage}"; then
check "${label}" "yes"
else
check "${label}" "no"
echo " (exit ${status}; expected non-zero with a failing '${stage}' stage)"
fi
rm -rf "${work}"
}
echo "Testing the reference gate ..."
# --- 1. The gate is green on clean code ------------------------------------
clean="$(mktemp -d "${TMPDIR:-/tmp}/gate-clean.XXXXXX")"
copy_project "${clean}"
clean_status="$(gate_exit "${clean}")"
if [ "${clean_status}" -eq 0 ]; then
check "check.sh exits 0 on the clean reference project" "yes"
else
check "check.sh exits 0 on the clean reference project" "no"
cat "${clean}/gate.log"
fi
for stage in format lint types tests coverage; do
if grep -q "PASS: ${stage}" "${clean}/gate.log"; then
check "the clean run reports PASS for the ${stage} stage" "yes"
else
check "the clean run reports PASS for the ${stage} stage" "no"
fi
done
if grep -q "gate PASSED" "${clean}/gate.log"; then
check "the clean run prints the gate PASSED verdict" "yes"
else
check "the clean run prints the gate PASSED verdict" "no"
fi
if grep -q "TOTAL .* 100%" "${clean}/gate.log"; then
check "the clean run reports 100% coverage of pricekit" "yes"
else
check "the clean run reports 100% coverage of pricekit" "no"
fi
rm -rf "${clean}"
# --- 2. Five defects, five red gates ---------------------------------------
# This block is the point of the whole lab. Each defect is one line, breaks
# exactly one property, and must be caught by exactly one stage.
echo "Proving each stage can actually fail ..."
expect_gate_fails \
"a formatting violation makes the gate fail at the format stage" \
"format" \
"python3 - <<'PY'
from pathlib import Path
p = Path('pricekit/receipt.py')
p.write_text(p.read_text().replace('return Money(0)', 'return Money( 0 )'))
PY"
expect_gate_fails \
"an unused import makes the gate fail at the lint stage" \
"lint" \
"python3 - <<'PY'
from pathlib import Path
p = Path('pricekit/money.py')
p.write_text(p.read_text().replace(
'from dataclasses import dataclass',
'from dataclasses import dataclass\nimport os',
))
PY"
expect_gate_fails \
"a wrong return annotation makes the gate fail at the types stage" \
"types" \
"python3 - <<'PY'
from pathlib import Path
p = Path('pricekit/money.py')
p.write_text(p.read_text().replace(
'def __str__(self) -> str:',
'def __str__(self) -> int:',
))
PY"
expect_gate_fails \
"a broken implementation makes the gate fail at the tests stage" \
"tests" \
"python3 - <<'PY'
from pathlib import Path
p = Path('pricekit/money.py')
p.write_text(p.read_text().replace(
'return Money(self.cents + other.cents, self.currency)',
'return Money(self.cents + other.cents + 1, self.currency)',
))
PY"
expect_gate_fails \
"untested new code makes the gate fail at the coverage stage" \
"coverage" \
"cat '${examples_dir}/../tests/fixtures/untested_addition.py' >> pricekit/receipt.py"
# --- 3. Fail-fast really stops early ---------------------------------------
# With --fail-fast the gate must stop at the first red stage. The proof is
# that the later stages are absent from the log, not merely that the exit code
# is non-zero.
ff="$(mktemp -d "${TMPDIR:-/tmp}/gate-ff.XXXXXX")"
copy_project "${ff}"
(cd "${ff}" && python3 - <<'PY'
from pathlib import Path
p = Path('pricekit/receipt.py')
p.write_text(p.read_text().replace('return Money(0)', 'return Money( 0 )'))
PY
)
ff_status="$(gate_exit "${ff}" "--fail-fast")"
if [ "${ff_status}" -ne 0 ] && ! grep -q "=== tests ===" "${ff}/gate.log"; then
check "--fail-fast stops before the later stages run" "yes"
else
check "--fail-fast stops before the later stages run" "no"
fi
if grep -q "=== tests ===" "${ff}/gate.log"; then
check "the default (report-all) mode does reach the tests stage" "no"
else
# Re-run the same defect WITHOUT --fail-fast and confirm the later stages
# do run, so the two modes are genuinely different.
gate_exit "${ff}" >/dev/null
if grep -q "=== coverage ===" "${ff}/gate.log"; then
check "the default (report-all) mode does reach the tests stage" "yes"
else
check "the default (report-all) mode does reach the tests stage" "no"
fi
fi
rm -rf "${ff}"
# --- 4. Coverage measures execution, not verification ----------------------
# The claim: a test file with ZERO assert statements produces 100% coverage of
# a module that is wrong. Both halves are asserted mechanically.
echo "Proving what coverage cannot see ..."
demo="$(mktemp -d "${TMPDIR:-/tmp}/gate-cov.XXXXXX")"
cp "${examples_dir}/coverage-demo/promo.py" "${demo}/"
cp "${examples_dir}/coverage-demo/test_promo_no_assertions.py" "${demo}/"
cp "${examples_dir}/coverage-demo/test_promo_with_assertions.py" "${demo}/"
assert_count="$(grep -cE '^[[:space:]]*assert ' "${demo}/test_promo_no_assertions.py" || true)"
if [ "${assert_count}" -eq 0 ]; then
check "the assertion-free test file contains zero assert statements" "yes"
else
check "the assertion-free test file contains zero assert statements" "no"
fi
(cd "${demo}" && "${coverage_bin}" run --branch --source=promo -m pytest \
test_promo_no_assertions.py -q >cov.log 2>&1 &&
"${coverage_bin}" report --show-missing --fail-under=0 >>cov.log 2>&1)
if grep -qE 'promo\.py +4 +0 +2 +0 +100%' "${demo}/cov.log"; then
check "a test file with no assertions still reports 100% coverage" "yes"
else
check "a test file with no assertions still reports 100% coverage" "no"
cat "${demo}/cov.log"
fi
# The same module, one assertion added: the tests now fail. Coverage did not
# change; only the verdict did.
if (cd "${demo}" && "${pytest_bin}" -p no:cacheprovider test_promo_with_assertions.py -q \
>assert.log 2>&1); then
check "adding one assertion catches the bug coverage could not see" "no"
else
if grep -q 'assert 100 == 900' "${demo}/assert.log"; then
check "adding one assertion catches the bug coverage could not see" "yes"
else
check "adding one assertion catches the bug coverage could not see" "no"
cat "${demo}/assert.log"
fi
fi
rm -rf "${demo}"
# --- 5. Reference files exist and are documentation, not decoration --------
echo "Checking the shipped reference files ..."
ci_yaml="${examples_dir}/ci-reference/quality-gate.yml"
if [ -f "${ci_yaml}" ]; then
check "the continuous-integration workflow reference is present" "yes"
else
check "the continuous-integration workflow reference is present" "no"
fi
if python3 -c "
import sys
text = open(sys.argv[1]).read()
required = ['actions/checkout', 'actions/setup-python', 'matrix:', 'bash check.sh', 'concurrency:']
sys.exit(0 if all(token in text for token in required) else 1)
" "${ci_yaml}" 2>/dev/null; then
check "the workflow reference contains checkout, setup, matrix and the gate" "yes"
else
check "the workflow reference contains checkout, setup, matrix and the gate" "no"
fi
if grep -qi 'REFERENCE' "${ci_yaml}" && grep -qi 'not executed by this lab' "${ci_yaml}"; then
check "the workflow reference says plainly that it is not executed here" "yes"
else
check "the workflow reference says plainly that it is not executed here" "no"
fi
pc_yaml="${examples_dir}/ci-reference/pre-commit-config.yaml"
if grep -q 'ruff-format' "${pc_yaml}" && ! grep -qE '^\s*-\s*id:\s*pytest' "${pc_yaml}"; then
check "the pre-commit reference holds fast hooks and no test-suite hook" "yes"
else
check "the pre-commit reference holds fast hooks and no test-suite hook" "no"
fi
# --- 6. One configuration file, not five -----------------------------------
if python3 -c "
import sys, tomllib
with open(sys.argv[1], 'rb') as handle:
data = tomllib.load(handle)
tool = data.get('tool', {})
needed = ['pytest', 'mypy', 'ruff', 'coverage']
missing = [name for name in needed if name not in tool]
if missing:
print('missing tool tables:', missing)
sys.exit(1)
if tool['coverage']['report']['fail_under'] != 95:
sys.exit(1)
if tool['coverage']['run']['branch'] is not True:
sys.exit(1)
" "${examples_dir}/pyproject.toml"; then
check "one pyproject.toml configures pytest, mypy, ruff and coverage" "yes"
else
check "one pyproject.toml configures pytest, mypy, ruff and coverage" "no"
fi
for dotfile in .flake8 setup.cfg .isort.cfg mypy.ini pytest.ini .coveragerc; do
if [ -e "${examples_dir}/${dotfile}" ]; then
check "the reference project ships no ${dotfile}" "no"
else
check "the reference project ships no ${dotfile}" "yes"
fi
done
# --- 7. The starter is a real skeleton -------------------------------------
echo "Checking the starter ..."
if bash -n "${starter_dir}/check.sh"; then
check "starter/check.sh is valid bash" "yes"
else
check "starter/check.sh is valid bash" "no"
fi
if python3 -c "
import sys, tomllib
with open(sys.argv[1], 'rb') as handle:
tomllib.load(handle)
" "${starter_dir}/pyproject.toml"; then
check "starter/pyproject.toml is valid TOML" "yes"
else
check "starter/pyproject.toml is valid TOML" "no"
fi
for n in 1 2 3 4 5; do
if grep -q "EXERCISE ${n} " "${starter_dir}/check.sh"; then
check "starter/check.sh states exercise ${n}" "yes"
else
check "starter/check.sh states exercise ${n}" "no"
fi
done
# The starter's gate must start with NO stages — that is the pedagogical
# point, and it is also what makes exercise 1 meaningful.
if grep -qE '^run_stage ' "${starter_dir}/check.sh"; then
check "starter/check.sh ships with no stages wired in yet" "no"
else
check "starter/check.sh ships with no stages wired in yet" "yes"
fi
# The starter deliberately ships tests for money.py and none for receipt.py,
# so the coverage floor genuinely bites when the learner reaches exercise 5.
if [ -f "${starter_dir}/tests/test_money.py" ] && [ ! -f "${starter_dir}/tests/test_receipt.py" ]; then
check "the starter's suite is deliberately incomplete (no receipt tests)" "yes"
else
check "the starter's suite is deliberately incomplete (no receipt tests)" "no"
fi
# And that incompleteness must be measurable: the starter's own coverage has
# to be UNDER the floor it will be asked to clear.
gap="$(mktemp -d "${TMPDIR:-/tmp}/gate-gap.XXXXXX")"
cp -R "${starter_dir}/pricekit" "${gap}/pricekit"
cp -R "${starter_dir}/tests" "${gap}/tests"
cp "${examples_dir}/pyproject.toml" "${gap}/pyproject.toml"
if (cd "${gap}" && "${coverage_bin}" run -m pytest >gap.log 2>&1 &&
"${coverage_bin}" report >>gap.log 2>&1); then
check "the starter's coverage starts below the 95% floor" "no"
else
if grep -q 'Coverage failure' "${gap}/gap.log" || grep -q 'fail_under' "${gap}/gap.log"; then
check "the starter's coverage starts below the 95% floor" "yes"
else
check "the starter's coverage starts below the 95% floor" "no"
tail -6 "${gap}/gap.log"
fi
fi
rm -rf "${gap}"
echo
echo "${checks} checks, ${failures} failure(s)."
[ "${failures}" -eq 0 ]
Troubleshooting
Troubleshooting — Day 077 lab
FAIL: ruff not found (or mypy, or coverage)
The gate refuses to run a stage it cannot run. That refusal is deliberate: a gate that silently skips a stage is worse than no gate, because everyone downstream believes it ran.
Install the tools once:
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
Or point the scripts at tools you already have:
RUFF=/path/to/ruff MYPY=/path/to/mypy COVERAGE=/path/to/coverage bash check.sh
check.sh looks for each tool in this order: the environment override, this
project's .venv/bin/, the parent directory's .venv/bin/, then PATH.
ModuleNotFoundError: No module named 'pricekit'
The tests import the package by name, so the project root has to be on
sys.path. The reference pyproject.toml handles this with
pythonpath = ["."] under [tool.pytest.ini_options]. If you are working in
starter/ and have not written that line yet, add it — and notice the failure
mode it fixes: without it, python -m pytest works and plain pytest does
not, which is precisely the "works when I run it my way" difference a gate
exists to eliminate.
bash check.sh prints gate PASSED (with no stages)
That is the shipped state of starter/check.sh. It has no stages, so it
approves everything. Start with exercise 1.
The format stage fails on files you never touched
Almost always a missing line-length setting. Ruff's default is 88; the
reference project sets 100 in [tool.ruff]. Until exercise 1b is done, the
formatter judges pricekit/money.py and pricekit/receipt.py against the
default and wants to rewrap them. Add the [tool.ruff] table and they stop
being flagged.
Fix what remains with ruff format . — but only ever from your own hand, never
from inside the gate. --check exists so the gate reports rather than
rewrites: a gate that silently edits the code it is judging can never be
trusted to have judged the code you wrote.
The lint stage reports I001 about imports that look fine
I001 is "import block is un-sorted or un-formatted". Ruff wants standard
library, third party and local imports in separate, alphabetised groups.
ruff check --fix . sorts them for you. This is a rule about consistency, not
correctness — which is exactly why mechanising it is worth it, because it is
the kind of thing a human reviewer should never spend attention on.
The types stage fails with [override] on a dunder method
You changed the return annotation of a method that Python's own object model
already types. __str__ must return str; annotating it -> int produces
both an [override] error (incompatible with object.__str__) and a
[return-value] error (the body returns a string). That is the defect the
test suite injects on purpose, so seeing it means the type stage is working.
The tests stage fails but the coverage stage still passes
Expected, and worth understanding. coverage run records what executed
regardless of whether the assertions held; coverage report then judges that
record against fail_under. A run can be fully covered and completely wrong —
which is the whole argument of this lesson. In report-everything mode the gate
shows you both facts; the verdict at the bottom is what decides the merge.
The coverage stage fails at 55% in the starter
Also expected. The starter ships tests for pricekit/money.py and none for
pricekit/receipt.py. Read the Missing column: each line range is a rule
nothing exercises. Write the tests, re-run, repeat. When you are done, read
what you wrote and ask whether each test would still fail if the function were
wrong — coverage will not ask that question for you.
coverage report says No data was collected
You ran coverage report without a preceding coverage run, or the source
setting points at a package that was never imported. Check [tool.coverage.run]
names the package you are actually testing.
--include is ignored because --source is set
A configuration conflict: [tool.coverage.run] source in pyproject.toml
wins over a --include flag on the command line. Either drop the flag or run
from a directory whose configuration you control — the coverage-demo
directory has no pyproject.toml for exactly this reason.
The gate is slow
Measure before you optimise. time bash check.sh will usually show the type
and test stages dominating. Then decide honestly: a gate that takes ten
seconds gets run constantly; a gate that takes ten minutes gets run once, at
the end, by someone who has already moved on. If yours is slow, the fixes in
order of payoff are to run the fast stages first (already done here), to use
--fail-fast locally while leaving CI in report-everything mode, and to move
genuinely slow checks out of the per-commit gate into a scheduled run.
The pre-commit hook keeps getting in the way
Then it is doing the wrong job. examples/ci-reference/pre-commit-config.yaml
deliberately holds only fast hooks. If yours runs the test suite, remove it —
a hook that makes git commit take twenty seconds will be bypassed with
git commit --no-verify within a week, and then you have neither the hook nor
the habit.
A test passes locally and fails in continuous integration
That is the clean checkout earning its keep. The usual causes, in order of
frequency: a file you never committed, a package installed globally on your
machine but absent from requirements.txt, a test that depends on the order
the suite happens to run in, and a path or an environment variable that exists
only on your machine. Reproduce it by cloning the repository into a fresh
directory and running check.sh there.
A test fails only sometimes
That is a flaky test, and it is the single most corrosive thing that can happen to a suite — not because of the failures, but because a suite that cries wolf teaches everyone to ignore it, including on the day it is right. Do not add a retry. Find the cause (a real clock, a real network call, a shared temporary file, dependence on dictionary or test ordering), fix it, and if you cannot fix it, delete the test and record why. A deleted flaky test is an honest gap; a retried flaky test is a lie with a green tick on it.
Windows
Run everything inside WSL. check.sh and tests/run_tests.sh are bash
scripts, and a .venv on native Windows puts its executables in
.venv\Scripts\ rather than .venv/bin/, which the resolver does not look
for.
Security notes
Security notes — Day 077 lab
What this lab runs
Five free, open-source Python packages, pinned to exact versions in
requirements/requirements.txt, installed into a virtual environment you
create and can delete. After the install, everything runs offline: the gate
reads your files, runs your tests, and writes a coverage data file. Nothing
opens a socket, nothing needs a credential, and nothing requires sudo.
Pinned versions are a security control, not just a stability one
ruff==0.15.22 rather than ruff is what stops your build from silently
executing a different piece of software tomorrow than it executed today.
Unpinned dependencies mean the code that judges your code can change without
anyone reviewing the change — and a compromised or simply careless release of
a build tool runs with your permissions, on your files, on every machine that
runs the gate. Pinning turns "upgrade" into a commit somebody reads.
The same argument applies with more force in continuous integration, where the gate runs automatically on a machine you cannot see. Pin the tools, pin the actions the workflow uses, and treat a version bump as a change like any other.
A gate is not a security scanner
Be precise about what today's five stages prove:
| Stage | What it can prove | What it cannot |
|---|---|---|
| format | the layout matches the agreed style | nothing about behaviour |
| lint | a rule from a fixed list was violated | that the code is safe or correct |
| types | the annotations are internally consistent | that the values arriving at runtime match them |
| tests | the cases you wrote behave as you asserted | anything about the cases you did not write |
| coverage | which lines and branches executed | that anything was verified |
None of them is a security review, a dependency-vulnerability scan, or a
secrets scan. Those are separate stages you can add to the same check.sh —
and the lesson's Alternatives section names the hosted dashboards that
specialise in them. What a gate gives you is the place to put such a check so
that it actually runs.
Continuous integration runs your code on someone else's machine
The workflow in examples/ci-reference/quality-gate.yml is shipped as a
documented reference and is not executed by this lab. If you adopt it, three
things are worth knowing before you do:
- The runner executes whatever the repository says. A workflow triggered by a pull request from a fork is running a stranger's configuration. Hosting services default to restricting what such runs can access; do not loosen that without understanding why it was tight.
- Secrets belong in the hosting service's secret store, never in the
workflow file, never in
pyproject.toml, and never in a test fixture. The gate in this lab needs no secrets at all, which is the safest position to be in. - Artifacts are downloadable. The reference workflow uploads coverage data. Coverage data contains file paths and line numbers from your project; that is usually harmless, but treat anything a workflow uploads as published to everyone who can read the repository.
The lab's own test suite
tests/run_tests.sh copies the reference project into directories made with
mktemp -d, introduces a defect there, and deletes the directory afterwards.
It never modifies examples/ or starter/, never writes outside the
temporary directories and the lab directory, and never uses a wildcard in a
delete. Read it before you run it — that is a reasonable habit for any script
that removes directories, and this one is short enough to read in full.
Personal data
None. pricekit computes prices from numbers you type into a test file. The
coverage data file (.coverage) records paths and line numbers from your own
project and nothing else; the cleanup command removes it.
Cleanup
rm -f .coverage
rm -rf .mypy_cache .ruff_cache .pytest_cache
rm -rf .venv # only if you want the tools gone as well