Programming with PythonTesting and Code Quality › Day 75

Hands-on lab — Day 75: Static Typing with mypy

Commands

Setup

cd labs/sections/programming-with-python/day-075-static-typing-with-mypy
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/mypy --version
.venv/bin/pytest --version

Run

PYTHONPATH=starter .venv/bin/pytest -q starter/test_catalog.py
.venv/bin/mypy starter/catalog.py
.venv/bin/mypy --strict starter/catalog.py
.venv/bin/mypy starter/untyped_first_run.py
.venv/bin/mypy --strict starter/untyped_first_run.py
.venv/bin/mypy starter/any_demo.py
PYTHONPATH=examples python3 examples/demo.py
PYTHONPATH=examples python3 examples/typing_tour.py
.venv/bin/mypy --strict examples/catalog.py examples/typing_tour.py
.venv/bin/mypy --config-file examples/pyproject.toml examples/catalog.py

Test

bash tests/run_tests.sh

File tree

examples/catalog.py
examples/demo.py
examples/pyproject.toml
examples/settings.json
examples/test_catalog.py
examples/typing_tour.py
expected-output/sample-run.txt
expected-output/test-run.txt
metadata.yml
README.md
requirements/README.md
requirements/requirements.txt
security.md
starter/any_demo.py
starter/catalog.py
starter/settings.json
starter/test_catalog.py
starter/untyped_first_run.py
tests/run_tests.sh
troubleshooting.md

Lab README

Day 075 lab — Catch the Bug the Tests Missed

Lesson

  • Lesson title: Static Typing with mypy
  • Day number: 75 of 365
  • Lesson article: https://ai-roadmap-365.github.io/day-075-static-typing-with-mypy
  • 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-075-static-typing-with-mypy when the site is running.

Purpose

You spent four days learning to write tests that pass. This lab is about what a passing test suite does not tell you.

starter/catalog.py is a small, fully annotated module: a catalogue of model records, a lookup, a cost estimate, a formatter, a settings loader. It ships with starter/test_catalog.py, eight tests, all green. It also contains two real bugs. The tests do not find them, because a test only exercises the paths it walks, and neither bug is on one of those paths.

Then you run mypy over the same file and get three errors in under a second, each naming a file, a line, and an error code. One of them, [union-attr], is the bug that would crash in production the first time somebody asked for a model that is not in the catalogue.

Over seven exercises you run both tools, learn to read an error by its code rather than its prose, fix the code, turn strictness up and handle what that adds, and finish by proving mechanically that adding Any to one signature makes a caught error disappear while the code stays exactly as broken.

Day 69 introduced type annotations and had to be honest that no checker was installed, so it proved Python ignores annotations at runtime by inspecting __annotations__ instead. This lab is the other half of that story: the tool that reads them.

Learning objectives

  • Run a pytest suite and a type checker over the same module and describe, in your own words, which bug class each one catches.
  • Read a mypy error properly: file, line, message, and the bracketed error code — and explain why the code is the part that matters.
  • Recognise the Optional bug (X | None used without a guard) and fix it with narrowing rather than with an ignore comment.
  • Explain why mypy is silent on a fully unannotated file, and what --strict changes about that.
  • Configure mypy with a real [tool.mypy] table in pyproject.toml, including per-module overrides.
  • Demonstrate that Any disables checking, and that a # type: ignore carrying the wrong error code suppresses nothing.
  • State the honest limits: what mypy cannot check, and why the tests from Days 071–074 are not replaced by any of this.

Prerequisites

  • The Day 75 lesson (read it first — it walks these exact error codes).
  • Day 69: type annotations, X | None, builtin generics, and the fact that Python stores annotations without enforcing them.
  • Days 071–074: pytest, writing and running a test suite.
  • Day 43: python3 -m venv and installing packages with pip.
  • Day 66: raising exceptions deliberately; Day 65: json.load.
  • A text editor, a terminal, and one-time network access to install two packages.

Supported operating systems

  • macOS — fully supported (tested on macOS 26.5.1, Apple Silicon, Python 3.14.0, mypy 2.3.0, pytest 9.1.1).
  • Linux — fully supported (any distribution with Python 3.10+, bash, and pip).
  • Windows — use WSL and follow the Linux path. Native Windows works too: substitute python for python3 and .venv\Scripts\ for .venv/bin/. Nothing in this lab depends on path separators or line endings, and mypy's error text is identical on all three.

Hardware requirements

Any computer that runs Python 3. The source files total a few kilobytes; mypy analysing them takes well under a second and a few tens of megabytes. No GPU, no special memory, no large downloads beyond the two packages themselves.

Required software

  • python33.10 or newer. The lab uses X | None union syntax in annotations. Tested on 3.14.0.
  • mypy 2.3.0 and pytest 9.1.1, installed from requirements/requirements.txt.
  • bash for the test runner (preinstalled on macOS and Linux).
  • Standard library only in the lab code itself: json, dataclasses, typing, pathlib.

Free and open-source options

Everything here is free and open source. Python, bash and the standard library cost nothing. mypy and pytest are both MIT-licensed, developed in the open, and free for personal and commercial use with no account and no key.

If you would rather not install anything at all, two free alternatives check the same annotations: Pyright, Microsoft's checker, which is free and open source and can be installed separately from any editor; and the Pylance extension for Visual Studio Code, which is free to use and runs Pyright for you as you type. The lesson's Alternatives section compares them. This lab pins mypy because it is the reference implementation and because its error codes are the ones the exercises teach you to read.

Installation

cd labs/sections/programming-with-python/day-075-static-typing-with-mypy
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/mypy --version
.venv/bin/pytest --version

Expect mypy 2.3.0 (compiled: yes) and pytest 9.1.1. The install needs the network once; nothing after it does.

If you already have both tools available on your PATH, you can skip the virtual environment — the test runner finds them either way.

File structure

day-075-static-typing-with-mypy/
├── README.md                    ← you are here
├── metadata.yml                 ← machine-readable lab metadata
├── starter/
│   ├── catalog.py               ← YOUR working file: annotated, tested, and wrong
│   ├── test_catalog.py          ← the green suite that misses both bugs
│   ├── untyped_first_run.py     ← exercise 0: what mypy says about unannotated code
│   ├── any_demo.py              ← exercise 7: Any switching the checker off
│   └── settings.json            ← sample data for the settings loader
├── examples/
│   ├── catalog.py               ← the fixed module: clean under --strict
│   ├── test_catalog.py          ← the same suite, plus the test the bug suggested
│   ├── typing_tour.py           ← every construct in the lesson, strict-clean
│   ├── demo.py                  ← deterministic tour of the fixed module
│   ├── pyproject.toml           ← a real [tool.mypy] table with per-module overrides
│   └── settings.json            ← sample data
├── tests/
│   └── run_tests.sh             ← 33 behaviour checks; exits 0 only if all pass
├── expected-output/
│   ├── sample-run.txt           ← real captured output of every command below
│   └── test-run.txt             ← real captured run of the test suite
├── requirements/
│   ├── requirements.txt         ← mypy==2.3.0, pytest==9.1.1
│   └── README.md                ← what each dependency is for, and the licences
├── troubleshooting.md
└── security.md

This lab writes no data files. mypy may leave a .mypy_cache/ directory if you run it by hand; see Cleanup.

How to run

From the lab directory. Replace .venv/bin/ with nothing if the tools are already on your PATH.

## 0. What does a checker say about code with no annotations at all?
##    The first answer is "success". Read untyped_first_run.py to see why
##    that is the most misleading result mypy can give you.
.venv/bin/mypy starter/untyped_first_run.py
.venv/bin/mypy --strict starter/untyped_first_run.py

## 1. Run the tests on the buggy module. Eight tests, all green.
PYTHONPATH=starter .venv/bin/pytest -q starter/test_catalog.py

## 2. Run mypy on the same file. Three errors, none of which the tests found.
.venv/bin/mypy starter/catalog.py

## 3. Read the errors: file, line, message, code. Write down the three codes.

## 4-5. Fix bug A and bug B in starter/catalog.py, then re-run step 2
##      until it prints "Success".

## 6. Turn strictness up and handle what it adds.
.venv/bin/mypy --strict starter/catalog.py

## 7. Prove the Any point for yourself.
.venv/bin/mypy starter/any_demo.py
##    Now edit the return annotation of lookup() to `-> Any:` and run again.

## See the finished reference at any point.
PYTHONPATH=examples python3 examples/demo.py
PYTHONPATH=examples python3 examples/typing_tour.py
.venv/bin/mypy --strict examples/catalog.py examples/typing_tour.py
.venv/bin/mypy --config-file examples/pyproject.toml examples/catalog.py

## Check your work.
bash tests/run_tests.sh

What the commands do

  • mypy starter/untyped_first_run.py — reports Success: no issues found in 1 source file on a module with no annotations whatsoever. That is gradual typing working as designed: an unannotated function is one mypy declines to check. The same command with --strict reports six errors on the same unchanged file. Meeting that contrast first is what stops you reading a clean run as a clean bill of health.
  • pytest starter/test_catalog.py — eight tests, all passing, over a module with two real bugs. Read each test and ask which line it never reaches.
  • mypy starter/catalog.py — three errors in the same file the tests just blessed: two [union-attr] on line 63, where describe() reads attributes off a value that may be None, and one [arg-type] on line 85, where a float produced by / is handed to a parameter annotated int.
  • mypy --strict starter/catalog.py — the same three plus [no-untyped-def] for the unannotated format_price, [no-untyped-call] for the typed function that calls it, and [no-any-return] for the loader that hands json.load's Any straight out under a dict[str, float] promise.
  • mypy starter/any_demo.py — one [union-attr]. Change the one annotation the file's docstring names, and the error vanishes without a single line of logic changing. That is the cautionary demonstration of the day.
  • python3 examples/demo.py — a deterministic tour of the fixed module, showing the None path handled, floor division keeping a token count an integer, and a settings loader that checks values at the boundary rather than merely annotating them.
  • python3 examples/typing_tour.py — runs one worked example of every construct the lesson covers: Optional narrowing, isinstance narrowing, unions, Literal, TypedDict, Final, NewType, a TypeVar generic, a Protocol satisfied without inheritance, and cast. Every line of that file also passes mypy --strict, which is the real proof it is correct.
  • mypy --config-file examples/pyproject.toml examples/catalog.py — runs the checker from a real configuration file instead of command-line flags, which is how a project actually does it.
  • bash tests/run_tests.sh — 33 checks. It asserts pytest passes on the buggy code, that mypy exits non-zero on it and emits the specific bracketed codes, that strict mode adds exactly the codes default settings let through, that examples/ is clean under both --strict and the config file, that the buggy describe() genuinely raises at runtime, that the Any edit makes the error vanish while the crash remains, and that a type: ignore with the wrong code suppresses nothing. Every code assertion greps for the bracketed code rather than the message text, so a future release rewording a message will not break the suite.

Expected output

Captured from a real run on the authoring machine; the full session is in expected-output/sample-run.txt.

The contrast the whole lab is built around — the same file, two tools:

$ python3 -m pytest starter/test_catalog.py
........                                                                 [100%]
8 passed in 0.00s
(exit 0)

$ python3 -m mypy starter/catalog.py
starter/catalog.py:63: error: Item "None" of "Model | None" has no attribute "name"  [union-attr]
starter/catalog.py:63: error: Item "None" of "Model | None" has no attribute "context_tokens"  [union-attr]
starter/catalog.py:85: error: Argument 2 to "estimate_cost" has incompatible type "float"; expected "int"  [arg-type]
Found 3 errors in 1 file (checked 1 source file)
(exit 1)

What strict mode adds to the same unchanged file:

$ python3 -m mypy --strict starter/catalog.py
starter/catalog.py:63: error: Item "None" of "Model | None" has no attribute "name"  [union-attr]
starter/catalog.py:63: error: Item "None" of "Model | None" has no attribute "context_tokens"  [union-attr]
starter/catalog.py:85: error: Argument 2 to "estimate_cost" has incompatible type "float"; expected "int"  [arg-type]
starter/catalog.py:88: error: Function is missing a type annotation  [no-untyped-def]
starter/catalog.py:103: error: Call to untyped function "format_price" in typed context  [no-untyped-call]
starter/catalog.py:117: error: Returning Any from function declared to return "dict[str, float]"  [no-any-return]
Found 6 errors in 1 file (checked 1 source file)
(exit 1)

And the result that surprises people most — an entirely unannotated module, default settings:

$ python3 -m mypy starter/untyped_first_run.py
Success: no issues found in 1 source file
(exit 0)

$ python3 -m mypy --strict starter/untyped_first_run.py
starter/untyped_first_run.py:25: error: Function is missing a type annotation  [no-untyped-def]
starter/untyped_first_run.py:33: error: Function is missing a type annotation  [no-untyped-def]
starter/untyped_first_run.py:37: error: Function is missing a return type annotation  [no-untyped-def]
starter/untyped_first_run.py:37: note: Use "-> None" if function does not return a value
starter/untyped_first_run.py:38: error: Call to untyped function "load_prices" in typed context  [no-untyped-call]
starter/untyped_first_run.py:39: error: Call to untyped function "total" in typed context  [no-untyped-call]
starter/untyped_first_run.py:43: error: Call to untyped function "main" in typed context  [no-untyped-call]
Found 6 errors in 1 file (checked 1 source file)
(exit 1)

The fixed reference, and the tests still green:

$ python3 -m mypy --strict examples/catalog.py examples/typing_tour.py
Success: no issues found in 2 source files
(exit 0)

$ python3 -m pytest examples/test_catalog.py
.........                                                                [100%]
9 passed in 0.00s
(exit 0)

Everything except pytest's timing line is deterministic, so your output will match character for character. The timing (in 0.00s) depends on your machine.

Validation steps

  1. .venv/bin/mypy --version prints mypy 2.3.0 (compiled: yes) and .venv/bin/pytest --version prints pytest 9.1.1.
  2. PYTHONPATH=starter .venv/bin/pytest -q starter/test_catalog.py reports 8 passed and exits 0 — before you change anything.
  3. .venv/bin/mypy starter/catalog.py reports exactly three errors and exits
    1. Two carry the code [union-attr] and one [arg-type].
  4. You can state, for each error, the file, the line, and the code, without reading the message text.
  5. After fixing bug A, the [union-attr] errors are gone and the tests still report 8 passed — the fix changed nothing on any tested path.
  6. After fixing bug B, .venv/bin/mypy starter/catalog.py prints Success.
  7. .venv/bin/mypy --strict starter/catalog.py still reports [no-untyped-def], [no-untyped-call] and [no-any-return]; after you annotate format_price and check the loaded object in load_settings, strict mode prints Success too.
  8. .venv/bin/mypy starter/any_demo.py reports [union-attr]; after changing lookup's return annotation to Any it prints Success, and python3 -c "import any_demo; any_demo.shout('zzz')" still raises AttributeError. Nothing got safer.
  9. PYTHONPATH=examples python3 examples/demo.py exits 0 and section 2 shows describe('enormous'): enormous: unknown model.
  10. bash tests/run_tests.sh ends with 0 failure(s). and exits 0.

Tests

bash tests/run_tests.sh

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

The suite finds both tools the same way the rest of Week 11 does: an explicit override first, then this lab's .venv/, then your PATH. If neither is available it fails loudly with install instructions rather than skipping quietly. To point it at specific binaries:

MYPY=/path/to/mypy PYTEST=/path/to/pytest bash tests/run_tests.sh

These are behaviour checks, not file-existence checks. Delete the None guard from examples/catalog.py and six of the 33 fail.

Cleanup

The lab writes no data files. Three kinds of cache may appear:

rm -rf .mypy_cache examples/.mypy_cache starter/.mypy_cache
rm -rf .pytest_cache examples/__pycache__ starter/__pycache__

.mypy_cache/ is mypy's incremental cache — it appears in whatever directory you ran mypy from, it is excluded from version control, and deleting it only costs you a slightly slower next run. The test suite never creates one: it points mypy at a temporary cache directory and removes it on exit.

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

Troubleshooting

See troubleshooting.md for the full list: mypy: command not found, Cannot find implementation or library stub for module, ModuleNotFoundError: No module named 'catalog' when running pytest, mypy reporting nothing at all on an unannotated file, X | None failing on older Python versions, a type: ignore that does not suppress what you expected, unused-ignore errors appearing after you fix code, stale results from the incremental cache, and what to do when mypy and your editor disagree.

Security notes

See security.md. Short version: a type checker reads your source, it does not run it, so checking a file is a safe operation — but the result is a claim about your code, never about the data that will arrive. name: str does not mean the string is a safe filename, a valid email, or free of injection; --ignore-missing-imports and Any are the two settings that silently turn checking off, and both deserve a comment saying why. mypy runs entirely on your machine and sends nothing anywhere.

Extension exercises

  1. Type the untyped module. Annotate every function in starter/untyped_first_run.py until mypy --strict reports Success. load_prices is the interesting one: what type is rows, and what does the function really return?
  2. Write the Protocol version of a dependency. Day 74 injected a clock and a repository to make code testable. Take the Clock protocol from examples/typing_tour.py, add a Repository protocol with get and save methods, write a real and a fake implementation of each, and check that both satisfy the protocol with no inheritance anywhere.
  3. Make an ignore rot. Add # type: ignore[arg-type] to a line in examples/catalog.py where nothing is wrong, then run mypy --strict. You get [unused-ignore]. Now delete the warn_unused_ignores line from examples/pyproject.toml and run again with --config-file. Silence. That is how a codebase accumulates hundreds of ignores nobody can safely remove.
  4. Find a limit. Write a function annotated def send(to: str) -> None that is meant to take an email address, and call it with send("not an email"). mypy is perfectly happy. Then write the check that would catch it — a validation, at runtime, of the kind Day 70 pointed at pydantic for — and write one sentence on why no type system was ever going to do that job.
  5. Adopt mypy on your own code. Take any Python file you have written since Day 43, run mypy on it, then mypy --strict, and fix what you find. Record how many errors each mode reported and how many were real bugs rather than missing annotations.
  • Previous day: Day 74 — Mocking and Testing Boundaries (labs/sections/programming-with-python/day-074-mocking-and-testing-boundaries/).
  • Next day: Day 76 — Linting and Formatting with Ruff (labs/sections/programming-with-python/day-076-linting-and-formatting-with-ruff/).
  • Week 11 project: the Tested Utility Library. The [tool.mypy] table you read here is one of the gates that project assembles.

Expected output

sample-run.txt

$ python3 -m pytest starter/test_catalog.py
........                                                                 [100%]
8 passed in 0.00s
(exit 0)

$ python3 -m mypy starter/catalog.py
starter/catalog.py:63: error: Item "None" of "Model | None" has no attribute "name"  [union-attr]
starter/catalog.py:63: error: Item "None" of "Model | None" has no attribute "context_tokens"  [union-attr]
starter/catalog.py:85: error: Argument 2 to "estimate_cost" has incompatible type "float"; expected "int"  [arg-type]
Found 3 errors in 1 file (checked 1 source file)
(exit 1)

$ python3 -m mypy --strict starter/catalog.py
starter/catalog.py:63: error: Item "None" of "Model | None" has no attribute "name"  [union-attr]
starter/catalog.py:63: error: Item "None" of "Model | None" has no attribute "context_tokens"  [union-attr]
starter/catalog.py:85: error: Argument 2 to "estimate_cost" has incompatible type "float"; expected "int"  [arg-type]
starter/catalog.py:88: error: Function is missing a type annotation  [no-untyped-def]
starter/catalog.py:103: error: Call to untyped function "format_price" in typed context  [no-untyped-call]
starter/catalog.py:117: error: Returning Any from function declared to return "dict[str, float]"  [no-any-return]
Found 6 errors in 1 file (checked 1 source file)
(exit 1)

$ python3 -m mypy starter/untyped_first_run.py
Success: no issues found in 1 source file
(exit 0)

$ python3 -m mypy --strict starter/untyped_first_run.py
starter/untyped_first_run.py:25: error: Function is missing a type annotation  [no-untyped-def]
starter/untyped_first_run.py:33: error: Function is missing a type annotation  [no-untyped-def]
starter/untyped_first_run.py:37: error: Function is missing a return type annotation  [no-untyped-def]
starter/untyped_first_run.py:37: note: Use "-> None" if function does not return a value
starter/untyped_first_run.py:38: error: Call to untyped function "load_prices" in typed context  [no-untyped-call]
starter/untyped_first_run.py:39: error: Call to untyped function "total" in typed context  [no-untyped-call]
starter/untyped_first_run.py:43: error: Call to untyped function "main" in typed context  [no-untyped-call]
Found 6 errors in 1 file (checked 1 source file)
(exit 1)

$ python3 -m mypy starter/any_demo.py
starter/any_demo.py:46: error: Item "None" of "str | None" has no attribute "upper"  [union-attr]
Found 1 error in 1 file (checked 1 source file)
(exit 1)

$ python3 -m mypy --strict examples/catalog.py examples/typing_tour.py
Success: no issues found in 2 source files
(exit 0)

$ python3 -m mypy --config-file examples/pyproject.toml examples/catalog.py
Success: no issues found in 1 source file
(exit 0)

$ python3 -m pytest examples/test_catalog.py
.........                                                                [100%]
9 passed in 0.00s
(exit 0)

$ python3 examples/demo.py
1. The lookup that can fail
---------------------------
find_model('small'):    Model(name='small', context_tokens=8000, price_per_million_input=0.25)
find_model('enormous'): None

2. describe() — the function the tests never asked about
--------------------------------------------------------
describe('small'):    small: 8,000 token context
describe('large'):    large: 200,000 token context
describe('enormous'): enormous: unknown model
  ^ the buggy version raised AttributeError on that last line

3. split_cost() — an int really is an int now
---------------------------------------------
split_cost('medium', 1000000, 2): 1.5
split_cost('medium', 1000000, 3): 0.999999
  ^ 1000000 // 3 is 333333 tokens; 1000000 / 3 would have been 333333.33...

4. price_line() — an annotated helper, callable under strict
------------------------------------------------------------
  small: $0.25 per million tokens
  medium: $3.00 per million tokens
  large: $15.00 per million tokens
  enormous: unknown model

5. load_settings() — checked at the boundary, not merely annotated
------------------------------------------------------------------
  max_output_tokens = 1024.0
  temperature = 0.2
  top_p = 0.95
  every value is a real float, because the loader converted it

$ python3 examples/typing_tour.py
mean of arithmetic: 0.9000
best suite: arithmetic at 0.9000
first_word(None): (nothing)
first_word('  '): (blank)
first_word('hello there'): hello
parse_port('8080'): 8080
token_count('a b c'): 3
token_count(['a b', 'c']): 3
search_label('keyword'): search mode: keyword
pass_rate: 0.7000
MAX_RETRIES: 3
user=u-1 session=s-9
first_or([], 'fallback'): fallback
first_or([4, 5], 0): 4
[1234.5] structural typing works
config_name: production

test-run.txt

Day 075 lab tests
=================
pytest: pytest 9.1.1
mypy:   mypy 2.3.0 (compiled: yes)

1. The tests pass on the buggy code (this is the whole point)
  ok: pytest exits 0 on the buggy starter/catalog.py
  ok: all 8 starter tests pass — a green suite over a broken module

2. mypy finds what the tests missed, and names the codes
  ok: mypy exits non-zero on starter/catalog.py
  ok: default mypy reports [union-attr] on starter/catalog.py
  ok: default mypy reports [arg-type] on starter/catalog.py
  ok: [union-attr] is reported on line 63 — the unguarded lookup in describe()

3. Strict mode adds the errors default settings let through
  ok: mypy --strict exits non-zero on starter/catalog.py
  ok: strict mode reports [no-untyped-def]
  ok: default settings do NOT report [no-untyped-def]
  ok: strict mode reports [no-untyped-call]
  ok: default settings do NOT report [no-untyped-call]
  ok: strict mode reports [no-any-return]
  ok: default settings do NOT report [no-any-return]
  ok: default mypy is SILENT on a fully unannotated file
  ok: --strict on the same file reports [no-untyped-def]

4. The fixed reference is clean, and the behaviour really was broken
  ok: mypy --strict exits 0 on every file in examples/
  ok: the real [tool.mypy] table in examples/pyproject.toml also passes
  ok: the fixed module passes all 9 tests, including the new None case
  ok: buggy describe('enormous') raises at runtime
  ok: fixed describe('enormous') returns a sensible string

5. Any switches the checker off — proved, not asserted
  ok: the Any edit changed exactly one annotation
  ok: before: mypy reports [union-attr] on the unguarded call
  ok: after: the same code with Any reports NOTHING and exits 0
  ok: the Any version still crashes at runtime — nothing got safer

6. A type: ignore with the wrong code suppresses nothing
  ok: ignore[arg-type] does NOT suppress a [union-attr] error
  ok: ignore[union-attr] suppresses exactly that error and nothing else
  ok: warn_unused_ignores reports a stale ignore as [unused-ignore]

7. The reference scripts run and produce their documented output
  ok: python3 examples/demo.py exits 0
  ok: demo shows the None path handled
  ok: python3 examples/typing_tour.py exits 0
  ok: typing tour prints: first_word(None): (nothing)
  ok: typing tour prints: structural typing works
  ok: typing tour prints: first_or([], 'fallback'): fallback

-----------------------------------------------------------
33 checks, 0 failure(s).

Source files

examples/catalog.py (4182 bytes)
"""The fixed catalogue: identical behaviour on the tested paths, clean under
mypy --strict.

Compare this file with starter/catalog.py line by line. Four things changed:

  1. describe() now handles the None the lookup can return, so the checker
     can narrow `Model | None` down to `Model` before the attributes are read.
  2. split_cost() uses floor division, so the value handed to estimate_cost
     really is an int.
  3. format_price() is annotated, so strict mode accepts both the definition
     and the calls to it.
  4. load_settings() checks the loaded object at the boundary instead of
     passing Any through, so the returned dict genuinely is dict[str, float].

Note what did NOT change: the tests. Every test in test_catalog.py passes
before and after. The bugs were never on a tested path — which is exactly why
a checker is not a substitute for tests, and tests are not a substitute for a
checker.
"""

from __future__ import annotations

import json
from dataclasses import dataclass


@dataclass(frozen=True)
class Model:
    """One row of the catalogue."""

    name: str
    context_tokens: int
    price_per_million_input: float


CATALOG: dict[str, Model] = {
    "small": Model(name="small", context_tokens=8_000, price_per_million_input=0.25),
    "medium": Model(name="medium", context_tokens=128_000, price_per_million_input=3.0),
    "large": Model(name="large", context_tokens=200_000, price_per_million_input=15.0),
}


def find_model(name: str) -> Model | None:
    """Look a model up by name, or return None when the name is unknown."""
    return CATALOG.get(name)


def describe(name: str) -> str:
    """One-line human summary of a model.

    FIX A. The early return is the narrowing. After `if model is None:
    return ...`, mypy knows that on every remaining line `model` is a Model,
    not a `Model | None`, so reading .name and .context_tokens is safe. The
    guard is for the reader, the runtime and the checker at once.
    """
    model = find_model(name)
    if model is None:
        return f"{name}: unknown model"
    return f"{model.name}: {model.context_tokens:,} token context"


def estimate_cost(name: str, tokens: int) -> float:
    """Dollar cost of sending `tokens` input tokens to `name`."""
    model = find_model(name)
    if model is None:
        raise KeyError(name)
    return tokens / 1_000_000 * model.price_per_million_input


def split_cost(name: str, tokens: int, parts: int) -> float:
    """Cost of one part when a job of `tokens` tokens is split into `parts`.

    FIX B. Floor division keeps the value an int, which is what a token count
    is. `/` would have produced a float, and a fractional token is not a
    thing that exists.
    """
    per_part = tokens // parts
    return estimate_cost(name, per_part)


def format_price(value: float) -> str:
    """Format a price in dollars. FIX: annotated, so strict mode is happy."""
    return f"${value:.2f}"


def price_line(name: str) -> str:
    """Human-readable price for one model."""
    model = find_model(name)
    if model is None:
        return f"{name}: unknown model"
    return f"{name}: {format_price(model.price_per_million_input)} per million tokens"


def load_settings(path: str) -> dict[str, float]:
    """Read numeric settings from a JSON file.

    FIX: json.load returns Any, and returning Any from a function that
    promises dict[str, float] is a promise you have not kept. Binding the
    result to a name annotated `object` throws away the Any immediately, and
    then the isinstance check narrows it to a dict the checker will let you
    iterate. The conversion to float is the value check that no type system
    can do for you.
    """
    with open(path, encoding="utf-8") as handle:
        raw: object = json.load(handle)
    if not isinstance(raw, dict):
        raise ValueError(f"{path}: expected a JSON object at the top level")
    settings: dict[str, float] = {}
    for key, value in raw.items():
        if not isinstance(value, (int, float)) or isinstance(value, bool):
            raise ValueError(f"{path}: setting {key!r} is not a number")
        settings[str(key)] = float(value)
    return settings
examples/demo.py (2198 bytes)
"""A deterministic tour of the fixed catalogue.

    python3 examples/demo.py

Every line printed here is fixed text or arithmetic — no clocks, no random
numbers, no memory addresses — so it matches expected-output/sample-run.txt
character for character.
"""

from __future__ import annotations

from pathlib import Path

import catalog

HERE = Path(__file__).resolve().parent


def main() -> None:
    print("1. The lookup that can fail")
    print("---------------------------")
    print(f"find_model('small'):    {catalog.find_model('small')}")
    print(f"find_model('enormous'): {catalog.find_model('enormous')}")
    print()

    print("2. describe() — the function the tests never asked about")
    print("--------------------------------------------------------")
    print(f"describe('small'):    {catalog.describe('small')}")
    print(f"describe('large'):    {catalog.describe('large')}")
    print(f"describe('enormous'): {catalog.describe('enormous')}")
    print("  ^ the buggy version raised AttributeError on that last line")
    print()

    print("3. split_cost() — an int really is an int now")
    print("---------------------------------------------")
    print(f"split_cost('medium', 1000000, 2): {catalog.split_cost('medium', 1_000_000, 2)}")
    print(f"split_cost('medium', 1000000, 3): {catalog.split_cost('medium', 1_000_000, 3)}")
    print("  ^ 1000000 // 3 is 333333 tokens; 1000000 / 3 would have been 333333.33...")
    print()

    print("4. price_line() — an annotated helper, callable under strict")
    print("------------------------------------------------------------")
    for name in ("small", "medium", "large"):
        print(f"  {catalog.price_line(name)}")
    print(f"  {catalog.price_line('enormous')}")
    print()

    print("5. load_settings() — checked at the boundary, not merely annotated")
    print("------------------------------------------------------------------")
    settings = catalog.load_settings(str(HERE / "settings.json"))
    for key in sorted(settings):
        print(f"  {key} = {settings[key]!r}")
    print("  every value is a real float, because the loader converted it")


if __name__ == "__main__":
    main()
examples/pyproject.toml (2532 bytes)
# A real mypy configuration, and the file mypy looks for by default.
#
# Run it explicitly from the lab directory:
#
#     python3 -m mypy --config-file examples/pyproject.toml examples/catalog.py
#
# In a real project this file sits at the repository root and you simply run
# `mypy .`; mypy finds the [tool.mypy] table on its own. It lives in
# examples/ here so the lab can point at it without changing anything above
# this directory.

[tool.mypy]
# The one setting that matters most: what Python version's rules to apply.
python_version = "3.10"

# `strict` is a bundle. Turning it on enables about a dozen individual flags,
# including the three spelled out below. Those three are written out anyway
# because they are the ones worth understanding by name.
strict = true

# Every function must be annotated. This is the flag that stops a codebase
# from quietly drifting back to unchecked, because an unannotated function's
# body is not checked at all.
disallow_untyped_defs = true

# Returning Any from a function that promises a real type is a broken
# promise. This is what catches json.load() flowing straight out of a loader.
warn_return_any = true

# A `# type: ignore` that no longer suppresses anything becomes an error.
# Without this, ignores accumulate for years and nobody ever removes one.
warn_unused_ignores = true

# Report unreachable code — usually a sign a narrowing guard is wrong.
warn_unreachable = true

# Make error codes visible in every message. They are on by default in
# recent mypy; setting it explicitly means the output does not depend on the
# version somebody happens to have installed.
show_error_codes = true

# --- Per-module overrides ---------------------------------------------------
# This is how you adopt mypy in an existing codebase: strict everywhere, with
# named exceptions that shrink over time. The list is a work queue, and the
# fact that it is checked into the repository is what makes it one.
#
# The module below does not exist in this lab; it is here to show the shape.
# A real entry names a real package, and a comment says who will remove it.

[[tool.mypy.overrides]]
module = "legacy_importer.*"
disallow_untyped_defs = false
warn_return_any = false

# Third-party packages that ship no type information at all. Without an
# override, mypy reports [import-untyped] for each one. Silencing it is
# honest ONLY when you have checked that no stubs exist — see the lesson.

[[tool.mypy.overrides]]
module = "some_untyped_dependency.*"
ignore_missing_imports = true
examples/settings.json (71 bytes)
{
  "temperature": 0.2,
  "top_p": 0.95,
  "max_output_tokens": 1024
}
examples/test_catalog.py (1711 bytes)
"""The same suite, run against the fixed catalogue.

The first eight tests are character for character the ones in
starter/test_catalog.py, and that is the point: fixing both type errors
changed no observable behaviour on any path a test walks. They were green
before and are green after.

The ninth test is new. It asks the question the old suite never asked — what
does describe() do with a name that is not in the catalogue? — and it is the
test you would only think to write after a checker pointed at that line.
"""

import catalog


def test_find_known_model() -> None:
    model = catalog.find_model("small")
    assert model is not None
    assert model.context_tokens == 8_000


def test_find_unknown_model_returns_none() -> None:
    assert catalog.find_model("enormous") is None


def test_describe_known_model() -> None:
    assert catalog.describe("small") == "small: 8,000 token context"


def test_describe_large_model() -> None:
    assert catalog.describe("large") == "large: 200,000 token context"


def test_estimate_cost_of_one_million_tokens() -> None:
    assert catalog.estimate_cost("medium", 1_000_000) == 3.0


def test_estimate_cost_rejects_unknown_model() -> None:
    try:
        catalog.estimate_cost("enormous", 1_000)
    except KeyError:
        return
    raise AssertionError("expected a KeyError for an unknown model")


def test_split_cost_divides_evenly() -> None:
    assert catalog.split_cost("medium", 1_000_000, 2) == 1.5


def test_price_line() -> None:
    assert catalog.price_line("large") == "large: $15.00 per million tokens"


def test_describe_unknown_model_no_longer_crashes() -> None:
    assert catalog.describe("enormous") == "enormous: unknown model"
examples/typing_tour.py (7285 bytes)
"""A tour of the type system, every line of which passes mypy --strict.

This file is a reference, not an exercise. Read it beside the lesson: each
section is one construct, written the way you would actually use it. Run it
to see the values, and check it to see that the annotations hold:

    python3 examples/typing_tour.py
    python3 -m mypy --strict examples/typing_tour.py
"""

from __future__ import annotations

from typing import Final, Literal, NewType, Protocol, TypedDict, TypeVar, cast

# --- Builtin generics -------------------------------------------------------
# list[int], dict[str, float], tuple[int, ...] are the modern spelling. The
# capitalised List/Dict imports from typing mean the same thing and belong to
# older code.

Scores = dict[str, list[float]]  # a type alias is just a name for a type


def mean(values: list[float]) -> float:
    """Arithmetic mean. Empty input is a value question, not a type question."""
    if not values:
        raise ValueError("mean of no values")
    return sum(values) / len(values)


def best_suite(scores: Scores) -> tuple[str, float]:
    """Return the suite with the highest mean score, and that mean."""
    ranked = sorted(((name, mean(vals)) for name, vals in scores.items()), key=lambda pair: -pair[1])
    return ranked[0]


# --- Optional, and the narrowing that makes it usable -----------------------
# `X | None` is the modern spelling of Optional[X]. They are the same type.


def first_word(text: str | None) -> str:
    """The first word of `text`, or a placeholder when there is no text.

    The two guards are the whole idea. Before them mypy knows `text` is
    `str | None` and refuses `.split()`. After `if text is None: return ...`
    it knows `text` is `str` on every remaining line.
    """
    if text is None:
        return "(nothing)"
    words = text.split()
    if not words:
        return "(blank)"
    return words[0]


def parse_port(raw: object) -> int:
    """Narrowing with isinstance rather than a None check.

    `raw` arrives as `object`, which mypy will let you do almost nothing
    with. Each isinstance branch narrows it to something usable.
    """
    if isinstance(raw, int) and not isinstance(raw, bool):
        return raw
    if isinstance(raw, str):
        return int(raw)
    raise TypeError(f"cannot read a port from {type(raw).__name__}")


# --- Union of two real types ------------------------------------------------


def token_count(item: str | list[str]) -> int:
    """Accept either a string or a list of strings and count the words."""
    if isinstance(item, str):
        return len(item.split())
    return sum(len(part.split()) for part in item)


# --- Literal ----------------------------------------------------------------
# Literal pins a value to a fixed set. A typo in a call site becomes an error
# instead of a shrug at runtime.

Mode = Literal["semantic", "keyword"]


def search_label(mode: Mode) -> str:
    return f"search mode: {mode}"


# --- TypedDict --------------------------------------------------------------
# For JSON-shaped data you must keep as a dict, TypedDict says which keys
# exist and what each holds.


class RunRecord(TypedDict):
    suite: str
    passed: int
    failed: int


def pass_rate(record: RunRecord) -> float:
    total = record["passed"] + record["failed"]
    if total == 0:
        return 0.0
    return record["passed"] / total


# --- Final and NewType ------------------------------------------------------
# Final says "this name is never rebound". NewType makes a distinct type out
# of an existing one, so two things that are both strings stop being
# interchangeable.

MAX_RETRIES: Final = 3

UserId = NewType("UserId", str)
SessionId = NewType("SessionId", str)


def audit_line(user: UserId, session: SessionId) -> str:
    """Both parameters are strings at runtime, and distinct types to mypy.

    Call this with the arguments the wrong way round and mypy reports it,
    which a plain `(str, str)` signature could never do.
    """
    return f"user={user} session={session}"


# --- TypeVar and generic functions ------------------------------------------
# A TypeVar is a placeholder that means "whatever type came in, that same type
# goes out" — which `Any` cannot express, because Any forgets.

T = TypeVar("T")


def first_or(items: list[T], fallback: T) -> T:
    """Return the first item, or the fallback when the list is empty."""
    return items[0] if items else fallback


# --- Protocol: structural typing --------------------------------------------
# A Protocol describes a shape. Anything with matching methods satisfies it,
# with no inheritance and no registration — which is exactly what you want
# for an injected dependency.


class Clock(Protocol):
    """Anything that can tell you the time, in seconds, as a float."""

    def now(self) -> float: ...


class FrozenClock:
    """A test double. It inherits from nothing and mentions Clock nowhere."""

    def __init__(self, value: float) -> None:
        self.value = value

    def now(self) -> float:
        return self.value


def stamp(clock: Clock, message: str) -> str:
    """Depends on the shape, not on a base class."""
    return f"[{clock.now():.1f}] {message}"


# --- cast: the escape hatch, used sparingly ---------------------------------


def config_name(config: dict[str, object]) -> str:
    """cast asserts a type to the checker and does NOTHING at runtime.

    The values in this dict are `object`, so mypy will not let you return one
    as a str. `cast` tells it to believe you anyway. It generates no check and
    no conversion — if the value is really an int, the cast succeeds silently
    and the wrongness surfaces somewhere else entirely. Prefer isinstance,
    which narrows AND checks; reach for cast only where you know something
    the checker cannot, and say so in a comment.

    (If you write a cast that mypy could already prove — casting a value it
    has narrowed with isinstance — it tells you so, with [redundant-cast].)
    """
    return cast(str, config["name"])


def main() -> None:
    scores: Scores = {"arithmetic": [0.9, 0.8, 1.0], "geometry": [0.5, 0.6]}
    print(f"mean of arithmetic: {mean(scores['arithmetic']):.4f}")
    print(f"best suite: {best_suite(scores)[0]} at {best_suite(scores)[1]:.4f}")
    print(f"first_word(None): {first_word(None)}")
    print(f"first_word('  '): {first_word('  ')}")
    print(f"first_word('hello there'): {first_word('hello there')}")
    print(f"parse_port('8080'): {parse_port('8080')}")
    print(f"token_count('a b c'): {token_count('a b c')}")
    print(f"token_count(['a b', 'c']): {token_count(['a b', 'c'])}")
    print(f"search_label('keyword'): {search_label('keyword')}")
    record: RunRecord = {"suite": "arithmetic", "passed": 7, "failed": 3}
    print(f"pass_rate: {pass_rate(record):.4f}")
    print(f"MAX_RETRIES: {MAX_RETRIES}")
    print(audit_line(UserId("u-1"), SessionId("s-9")))
    print(f"first_or([], 'fallback'): {first_or([], 'fallback')}")
    print(f"first_or([4, 5], 0): {first_or([4, 5], 0)}")
    print(stamp(FrozenClock(1234.5), "structural typing works"))
    print(f"config_name: {config_name({'name': 'production', 'retries': 3})}")


if __name__ == "__main__":
    main()
metadata.yml (1568 bytes)
lesson_id: D075
day: 75
kind: python-program
languages: [python, bash]
setup_commands:
  - cd labs/sections/programming-with-python/day-075-static-typing-with-mypy
  - python3 -m venv .venv
  - .venv/bin/pip install -r requirements/requirements.txt
  - .venv/bin/mypy --version
  - .venv/bin/pytest --version
run_commands:
  - PYTHONPATH=starter .venv/bin/pytest -q starter/test_catalog.py
  - .venv/bin/mypy starter/catalog.py
  - .venv/bin/mypy --strict starter/catalog.py
  - .venv/bin/mypy starter/untyped_first_run.py
  - .venv/bin/mypy --strict starter/untyped_first_run.py
  - .venv/bin/mypy starter/any_demo.py
  - PYTHONPATH=examples python3 examples/demo.py
  - PYTHONPATH=examples python3 examples/typing_tour.py
  - .venv/bin/mypy --strict examples/catalog.py examples/typing_tour.py
  - .venv/bin/mypy --config-file examples/pyproject.toml examples/catalog.py
test_commands:
  - bash tests/run_tests.sh
cleanup_commands:
  - rm -rf .mypy_cache examples/.mypy_cache starter/.mypy_cache
  - rm -rf .pytest_cache examples/__pycache__ starter/__pycache__
  - 'rm -rf .venv  # optional: removes the lab virtual environment'
  - '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, mypy 2.3.0 (compiled: yes), pytest 9.1.1, bash 3.2.57 — bash tests/run_tests.sh -> 33 checks, 0 failure(s), exit 0. Network is needed once to install mypy and pytest; every command in the lab runs offline afterwards.'
requirements/README.md (2569 bytes)
# Dependencies for the Day 075 lab

This lab needs two third-party tools. Both are free and open source, both
install from the Python Package Index with `pip`, and both run entirely on
your machine — nothing here sends code anywhere.

| Package | Pinned version | Why this lab needs it |
| --- | --- | --- |
| `mypy` | `2.3.0` | The static type checker the whole lesson is about. It reads your annotated source without executing it and reports where a value flows somewhere its annotation does not allow. |
| `pytest` | `9.1.1` | The test runner from Days 071–074. This lab needs it because the entire point is the contrast: a green pytest suite over a module mypy immediately rejects. |

Versions are pinned exactly so that error messages, error codes and exit
statuses match the captured output in `expected-output/`. A newer mypy may
reword a message; the bracketed error codes are far more stable, which is why
the test suite greps for the codes rather than the prose.

## Licences

mypy and pytest are both distributed under the MIT licence, stated on each
project's own documentation site (linked from the lesson's source list). Both
are maintained in the open and cost nothing to use, personally or
commercially.

## One-time install

From the lab directory:

```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt
.venv/bin/mypy --version
.venv/bin/pytest --version
```

Day 43 covered `python3 -m venv` in full; this is the same pattern. The
virtual environment lives in `.venv/` inside the lab, is already excluded
from version control, and can be deleted at any time with `rm -rf .venv`.

## Network

Installing needs the network, once. After that the lab runs completely
offline: mypy analyses local files, pytest runs local tests, and no script
here opens a socket.

## Running without a lab-local environment

If you already have mypy and pytest available — a project virtual environment
you have activated, or a system installation — the test runner will find them
on your `PATH`. You can also point it at specific binaries:

```bash
MYPY=/path/to/mypy PYTEST=/path/to/pytest bash tests/run_tests.sh
```

## Cache directories

mypy writes an incremental cache so repeated runs are fast. Run it by hand and
you get a `.mypy_cache/` directory in whatever folder you ran it from; it is
excluded from version control and safe to delete. The test suite avoids the
question entirely by pointing mypy at a temporary cache directory that is
removed when the run finishes, so `bash tests/run_tests.sh` leaves nothing
behind.
requirements/requirements.txt (26 bytes)
mypy==2.3.0
pytest==9.1.1
starter/any_demo.py (1420 bytes)
"""Exercise 7 — watch `Any` switch the checker off.

Run mypy on this file as it stands:

    python3 -m mypy starter/any_demo.py

You get one error, with the code [union-attr]: `lookup` may return None, and
`shout` calls .upper() on the result without checking.

Now change ONE character sequence. Replace the return annotation of `lookup`

    def lookup(name: str) -> str | None:

with

    def lookup(name: str) -> Any:

and run mypy again. The error is gone. Nothing about the code got safer —
`lookup` still returns None for an unknown name, and `shout` will still raise
AttributeError at runtime. All that changed is that you told the checker to
stop looking.

That is what `Any` means: not "some type", but "stop checking here". Every
Any you write is a hole, and the hole is not local — it spreads to every
value that flows out of it.

Change it back when you are done.
"""

from typing import Any

WORDS: dict[str, str] = {"a": "alpha", "b": "bravo"}

# `Any` is imported ready for the exercise; it is unused until you make the
# edit described above. mypy does not complain about unused imports — that is
# a linter's job, and it is tomorrow's lesson.


def lookup(name: str) -> str | None:
    """Return the word for a letter, or None when the letter is unknown."""
    return WORDS.get(name)


def shout(name: str) -> str:
    """Upper-case the word for a letter."""
    return lookup(name).upper()
starter/catalog.py (4482 bytes)
"""A small model catalogue — annotated, tested, and quietly wrong.

This module is the subject of the whole lab. Every function carries type
annotations, and the pytest suite in `test_catalog.py` passes green. It still
contains real bugs, because a passing test suite only proves that the paths
the tests walk behave as expected. mypy reads every path.

Your exercises are numbered below. Do NOT fix anything before you have run
both tools once and seen the difference for yourself.

  Exercise 1 — run the tests and watch them pass:
      python3 -m pytest starter/test_catalog.py
  Exercise 2 — run mypy on this file and read the report:
      python3 -m mypy starter/catalog.py
  Exercise 3 — write down each error's file, line, message and error code.
  Exercise 4 — fix bug A (marked below) so [union-attr] disappears.
  Exercise 5 — fix bug B (marked below) so [arg-type] disappears.
  Exercise 6 — run mypy in strict mode and handle what it adds:
      python3 -m mypy --strict starter/catalog.py
  Exercise 7 — prove the Any cautionary point with starter/any_demo.py.

The finished, strict-clean version is in examples/catalog.py. Compare only
after you have tried.
"""

from __future__ import annotations

import json
from dataclasses import dataclass


@dataclass(frozen=True)
class Model:
    """One row of the catalogue."""

    name: str
    context_tokens: int
    price_per_million_input: float


CATALOG: dict[str, Model] = {
    "small": Model(name="small", context_tokens=8_000, price_per_million_input=0.25),
    "medium": Model(name="medium", context_tokens=128_000, price_per_million_input=3.0),
    "large": Model(name="large", context_tokens=200_000, price_per_million_input=15.0),
}


def find_model(name: str) -> Model | None:
    """Look a model up by name, or return None when the name is unknown."""
    return CATALOG.get(name)


def describe(name: str) -> str:
    """One-line human summary of a model.

    BUG A. find_model returns `Model | None`, and this function reads
    attributes off the result without ever asking whether it is None. Every
    test calls describe() with a name that exists, so the suite never walks
    the None path. Exercise 4: teach the checker (and the reader) that the
    None case is handled, by returning early when the lookup fails.
    """
    model = find_model(name)
    return f"{model.name}: {model.context_tokens:,} token context"


def estimate_cost(name: str, tokens: int) -> float:
    """Dollar cost of sending `tokens` input tokens to `name`."""
    model = find_model(name)
    if model is None:
        raise KeyError(name)
    return tokens / 1_000_000 * model.price_per_million_input


def split_cost(name: str, tokens: int, parts: int) -> float:
    """Cost of one part when a job of `tokens` tokens is split into `parts`.

    BUG B. In Python 3 the `/` operator always produces a float, even when
    both operands are integers and the division is exact. estimate_cost is
    annotated to take an int. The test happens to use numbers that divide
    evenly, so the arithmetic is right and the test is green — but the type
    is wrong, and the day it stops dividing evenly you get a fractional token
    count nobody asked for. Exercise 5: use floor division instead.
    """
    per_part = tokens / parts
    return estimate_cost(name, per_part)


def format_price(value):
    """Format a price in dollars.

    Exercise 6: this function has no annotations at all. Under default
    settings mypy simply skips it. Under --strict it is an error, and so is
    calling it from an annotated function. Annotate it.
    """
    return f"${value:.2f}"


def price_line(name: str) -> str:
    """Human-readable price for one model."""
    model = find_model(name)
    if model is None:
        return f"{name}: unknown model"
    return f"{name}: {format_price(model.price_per_million_input)} per million tokens"


def load_settings(path: str) -> dict[str, float]:
    """Read numeric settings from a JSON file.

    Exercise 6 (continued): json.load is annotated to return Any, so this
    function hands back Any while promising dict[str, float]. Default mypy
    says nothing. --strict turns on warn_return_any and reports it. The fix
    is not to silence the warning but to check the value at the boundary:
    narrow the loaded object with isinstance, then build the dict you
    promised.
    """
    with open(path, encoding="utf-8") as handle:
        return json.load(handle)
starter/settings.json (71 bytes)
{
  "temperature": 0.2,
  "top_p": 0.95,
  "max_output_tokens": 1024
}
starter/test_catalog.py (1343 bytes)
"""The green suite. Every one of these tests passes on the buggy catalog.py.

That is the point of the lab, so do not "improve" this file until the very
last exercise. Read it and ask, for each test, which line of catalog.py it
never reaches. The two bugs live on exactly those lines.
"""

import catalog


def test_find_known_model() -> None:
    model = catalog.find_model("small")
    assert model is not None
    assert model.context_tokens == 8_000


def test_find_unknown_model_returns_none() -> None:
    assert catalog.find_model("enormous") is None


def test_describe_known_model() -> None:
    assert catalog.describe("small") == "small: 8,000 token context"


def test_describe_large_model() -> None:
    assert catalog.describe("large") == "large: 200,000 token context"


def test_estimate_cost_of_one_million_tokens() -> None:
    assert catalog.estimate_cost("medium", 1_000_000) == 3.0


def test_estimate_cost_rejects_unknown_model() -> None:
    try:
        catalog.estimate_cost("enormous", 1_000)
    except KeyError:
        return
    raise AssertionError("expected a KeyError for an unknown model")


def test_split_cost_divides_evenly() -> None:
    assert catalog.split_cost("medium", 1_000_000, 2) == 1.5


def test_price_line() -> None:
    assert catalog.price_line("large") == "large: $15.00 per million tokens"
starter/untyped_first_run.py (1415 bytes)
"""Exercise 0 — what mypy says about code that carries no annotations at all.

This module is correct, ordinary, working Python. It has no type hints. Run
mypy over it twice and compare:

    python3 -m mypy starter/untyped_first_run.py
    python3 -m mypy --strict starter/untyped_first_run.py

The first run reports success. That is not praise. A function with no
annotations is a function mypy declines to check — it has nothing to compare
anything against, so it checks nothing and says so by saying nothing. This is
gradual typing working exactly as designed, and it is the single most
misleading result a newcomer can get: "mypy passes" on an unannotated
codebase means "mypy did not look".

The second run refuses to be quiet about it. Under --strict, an unannotated
function is itself an error, and so is calling one from typed code.

That contrast is the whole argument for turning strictness up: without it,
the checker's silence tells you nothing about your code and everything about
your annotations.
"""


def load_prices(rows):
    prices = {}
    for row in rows:
        name, value = row.split("=")
        prices[name] = float(value)
    return prices


def total(prices, names):
    return sum(prices[name] for name in names)


def main():
    prices = load_prices(["small=0.25", "large=15.0"])
    print(f"total: {total(prices, ['small', 'large'])}")


if __name__ == "__main__":
    main()
tests/run_tests.sh (13538 bytes)
#!/usr/bin/env bash
# Tests for the Day 075 lab. Run from the lab directory:
#   bash tests/run_tests.sh
#
# These checks are the argument of the lesson, made mechanical:
#
#   * the pytest suite passes on the BUGGY starter code — proving that a
#     green suite is not proof of correctness;
#   * mypy exits non-zero on the same file and names specific error CODES;
#   * every assertion greps for the bracketed code, never the prose, so the
#     suite survives a wording change in a future mypy release;
#   * the fixed reference in examples/ is clean under --strict, and clean
#     again when driven by the real [tool.mypy] table in examples/pyproject.toml;
#   * adding Any to one signature makes a caught error vanish while the code
#     stays exactly as broken — the Any warning, proved rather than asserted;
#   * a `type: ignore` carrying the wrong error code does not suppress
#     anything, which is why you always write the code.
#
# No network at test time. mypy writes its cache into a temporary directory
# that is removed on exit, so nothing appears in the lab.
# Exits 0 only if every check passes.
set -u

export PYTHONDONTWRITEBYTECODE=1

lab_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${lab_dir}" || exit 1

failures=0
checks=0

work_dir="$(mktemp -d)"
cache_dir="${work_dir}/cache"
cleanup() { rm -rf "${work_dir}"; }
trap cleanup EXIT

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

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

install_hint() {
  local tool="$1"
  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 an existing ${tool}: ${2}=/path/to/${tool} bash tests/run_tests.sh" >&2
}

pytest_bin="$(resolve_tool pytest "${PYTEST:-}")" || {
  echo "FAIL: pytest not found." >&2
  install_hint pytest PYTEST
  exit 1
}

mypy_bin="$(resolve_tool mypy "${MYPY:-}")" || {
  echo "FAIL: mypy not found." >&2
  install_hint mypy MYPY
  exit 1
}

echo "Day 075 lab tests"
echo "================="
echo "pytest: $("${pytest_bin}" --version 2>&1 | head -1)"
echo "mypy:   $("${mypy_bin}" --version 2>&1 | head -1)"
echo

# run_mypy <output-file> <extra args...>
# Runs mypy with colour and the cache pinned so output is stable and nothing
# is written into the lab. Records the exit status in mypy_status.
mypy_status=0
run_mypy() {
  local out="$1"
  shift
  "${mypy_bin}" --no-color-output --no-error-summary --cache-dir "${cache_dir}" "$@" >"${out}" 2>&1
  mypy_status=$?
}

# ---------------------------------------------------------------------------
echo "1. The tests pass on the buggy code (this is the whole point)"
# ---------------------------------------------------------------------------

pytest_out="${work_dir}/pytest-starter.txt"
if PYTHONPATH="${lab_dir}/starter" "${pytest_bin}" -q -p no:cacheprovider \
    starter/test_catalog.py >"${pytest_out}" 2>&1; then
  check "pytest exits 0 on the buggy starter/catalog.py" "yes"
else
  check "pytest exits 0 on the buggy starter/catalog.py" "no"
  sed -n '$p' "${pytest_out}"
fi

if grep -q "8 passed" "${pytest_out}"; then
  check "all 8 starter tests pass — a green suite over a broken module" "yes"
else
  check "all 8 starter tests pass — a green suite over a broken module" "no"
fi

# ---------------------------------------------------------------------------
echo
echo "2. mypy finds what the tests missed, and names the codes"
# ---------------------------------------------------------------------------

mypy_starter="${work_dir}/mypy-starter.txt"
run_mypy "${mypy_starter}" starter/catalog.py
if [ "${mypy_status}" -ne 0 ]; then
  check "mypy exits non-zero on starter/catalog.py" "yes"
else
  check "mypy exits non-zero on starter/catalog.py" "no"
fi

# Grep for the bracketed CODE, not the prose. Messages get reworded between
# releases; the codes are the stable interface.
for code in union-attr arg-type; do
  if grep -q "\[${code}\]" "${mypy_starter}"; then
    check "default mypy reports [${code}] on starter/catalog.py" "yes"
  else
    check "default mypy reports [${code}] on starter/catalog.py" "no"
  fi
done

if grep -q "^starter/catalog.py:63: error:.*\[union-attr\]" "${mypy_starter}"; then
  check "[union-attr] is reported on line 63 — the unguarded lookup in describe()" "yes"
else
  check "[union-attr] is reported on line 63 — the unguarded lookup in describe()" "no"
fi

# ---------------------------------------------------------------------------
echo
echo "3. Strict mode adds the errors default settings let through"
# ---------------------------------------------------------------------------

mypy_strict="${work_dir}/mypy-starter-strict.txt"
run_mypy "${mypy_strict}" --strict starter/catalog.py
if [ "${mypy_status}" -ne 0 ]; then
  check "mypy --strict exits non-zero on starter/catalog.py" "yes"
else
  check "mypy --strict exits non-zero on starter/catalog.py" "no"
fi

for code in no-untyped-def no-untyped-call no-any-return; do
  if grep -q "\[${code}\]" "${mypy_strict}"; then
    check "strict mode reports [${code}]" "yes"
  else
    check "strict mode reports [${code}]" "no"
  fi
  if grep -q "\[${code}\]" "${mypy_starter}"; then
    check "default settings do NOT report [${code}]" "no"
  else
    check "default settings do NOT report [${code}]" "yes"
  fi
done

# A file with no annotations at all: mypy's silence means "nothing to check",
# not "nothing wrong". Strict mode says so out loud.
mypy_untyped="${work_dir}/mypy-untyped.txt"
run_mypy "${mypy_untyped}" starter/untyped_first_run.py
if [ "${mypy_status}" -eq 0 ]; then
  check "default mypy is SILENT on a fully unannotated file" "yes"
else
  check "default mypy is SILENT on a fully unannotated file" "no"
fi

run_mypy "${mypy_untyped}" --strict starter/untyped_first_run.py
if [ "${mypy_status}" -ne 0 ] && grep -q "\[no-untyped-def\]" "${mypy_untyped}"; then
  check "--strict on the same file reports [no-untyped-def]" "yes"
else
  check "--strict on the same file reports [no-untyped-def]" "no"
fi

# ---------------------------------------------------------------------------
echo
echo "4. The fixed reference is clean, and the behaviour really was broken"
# ---------------------------------------------------------------------------

mypy_examples="${work_dir}/mypy-examples.txt"
run_mypy "${mypy_examples}" --strict \
  examples/catalog.py examples/typing_tour.py examples/demo.py examples/test_catalog.py
if [ "${mypy_status}" -eq 0 ]; then
  check "mypy --strict exits 0 on every file in examples/" "yes"
else
  check "mypy --strict exits 0 on every file in examples/" "no"
  cat "${mypy_examples}"
fi

mypy_config="${work_dir}/mypy-config.txt"
run_mypy "${mypy_config}" --config-file examples/pyproject.toml examples/catalog.py
if [ "${mypy_status}" -eq 0 ]; then
  check "the real [tool.mypy] table in examples/pyproject.toml also passes" "yes"
else
  check "the real [tool.mypy] table in examples/pyproject.toml also passes" "no"
  cat "${mypy_config}"
fi

pytest_examples="${work_dir}/pytest-examples.txt"
if PYTHONPATH="${lab_dir}/examples" "${pytest_bin}" -q -p no:cacheprovider \
    examples/test_catalog.py >"${pytest_examples}" 2>&1 && grep -q "9 passed" "${pytest_examples}"; then
  check "the fixed module passes all 9 tests, including the new None case" "yes"
else
  check "the fixed module passes all 9 tests, including the new None case" "no"
  sed -n '$p' "${pytest_examples}"
fi

# The type error was a real bug, not a bookkeeping complaint: prove it.
if PYTHONPATH="${lab_dir}/starter" python3 -c \
    "import catalog; catalog.describe('enormous')" >/dev/null 2>&1; then
  check "buggy describe('enormous') raises at runtime" "no"
else
  check "buggy describe('enormous') raises at runtime" "yes"
fi

fixed_describe="$(PYTHONPATH="${lab_dir}/examples" python3 -c \
  "import catalog; print(catalog.describe('enormous'))" 2>&1)"
if [ "${fixed_describe}" = "enormous: unknown model" ]; then
  check "fixed describe('enormous') returns a sensible string" "yes"
else
  check "fixed describe('enormous') returns a sensible string" "no"
  echo "    got: ${fixed_describe}"
fi

# ---------------------------------------------------------------------------
echo
echo "5. Any switches the checker off — proved, not asserted"
# ---------------------------------------------------------------------------

any_dir="${work_dir}/any"
mkdir -p "${any_dir}"
cp starter/any_demo.py "${any_dir}/before.py"
sed 's/-> str | None:/-> Any:/' "${any_dir}/before.py" >"${any_dir}/after.py"

if ! cmp -s "${any_dir}/before.py" "${any_dir}/after.py"; then
  check "the Any edit changed exactly one annotation" "yes"
else
  check "the Any edit changed exactly one annotation" "no"
fi

any_before="${work_dir}/any-before.txt"
run_mypy "${any_before}" "${any_dir}/before.py"
if [ "${mypy_status}" -ne 0 ] && grep -q "\[union-attr\]" "${any_before}"; then
  check "before: mypy reports [union-attr] on the unguarded call" "yes"
else
  check "before: mypy reports [union-attr] on the unguarded call" "no"
fi

any_after="${work_dir}/any-after.txt"
run_mypy "${any_after}" "${any_dir}/after.py"
if [ "${mypy_status}" -eq 0 ]; then
  check "after: the same code with Any reports NOTHING and exits 0" "yes"
else
  check "after: the same code with Any reports NOTHING and exits 0" "no"
  cat "${any_after}"
fi

# The code is exactly as broken as it was. Only the checking changed.
if PYTHONPATH="${any_dir}" python3 -c \
    "import after; after.shout('zzz')" >/dev/null 2>&1; then
  check "the Any version still crashes at runtime — nothing got safer" "no"
else
  check "the Any version still crashes at runtime — nothing got safer" "yes"
fi

# ---------------------------------------------------------------------------
echo
echo "6. A type: ignore with the wrong code suppresses nothing"
# ---------------------------------------------------------------------------

ignore_dir="${work_dir}/ignore"
mkdir -p "${ignore_dir}"
sed 's|return lookup(name).upper()|return lookup(name).upper()  # type: ignore[arg-type]|' \
  "${any_dir}/before.py" >"${ignore_dir}/wrong_code.py"
sed 's|return lookup(name).upper()|return lookup(name).upper()  # type: ignore[union-attr]|' \
  "${any_dir}/before.py" >"${ignore_dir}/right_code.py"

ignore_wrong="${work_dir}/ignore-wrong.txt"
run_mypy "${ignore_wrong}" "${ignore_dir}/wrong_code.py"
if [ "${mypy_status}" -ne 0 ] && grep -q "\[union-attr\]" "${ignore_wrong}"; then
  check "ignore[arg-type] does NOT suppress a [union-attr] error" "yes"
else
  check "ignore[arg-type] does NOT suppress a [union-attr] error" "no"
fi

ignore_right="${work_dir}/ignore-right.txt"
run_mypy "${ignore_right}" "${ignore_dir}/right_code.py"
if [ "${mypy_status}" -eq 0 ]; then
  check "ignore[union-attr] suppresses exactly that error and nothing else" "yes"
else
  check "ignore[union-attr] suppresses exactly that error and nothing else" "no"
  cat "${ignore_right}"
fi

# warn_unused_ignores turns a stale ignore into an error of its own.
cp examples/catalog.py "${ignore_dir}/stale.py"
printf '\n\nSTALE: int = 1  # type: ignore[assignment]\n' >>"${ignore_dir}/stale.py"
ignore_stale="${work_dir}/ignore-stale.txt"
run_mypy "${ignore_stale}" --strict "${ignore_dir}/stale.py"
if [ "${mypy_status}" -ne 0 ] && grep -q "\[unused-ignore\]" "${ignore_stale}"; then
  check "warn_unused_ignores reports a stale ignore as [unused-ignore]" "yes"
else
  check "warn_unused_ignores reports a stale ignore as [unused-ignore]" "no"
  cat "${ignore_stale}"
fi

# ---------------------------------------------------------------------------
echo
echo "7. The reference scripts run and produce their documented output"
# ---------------------------------------------------------------------------

demo_out="${work_dir}/demo.txt"
if PYTHONPATH="${lab_dir}/examples" python3 examples/demo.py >"${demo_out}" 2>&1; then
  check "python3 examples/demo.py exits 0" "yes"
else
  check "python3 examples/demo.py exits 0" "no"
fi

if grep -q "describe('enormous'): enormous: unknown model" "${demo_out}"; then
  check "demo shows the None path handled" "yes"
else
  check "demo shows the None path handled" "no"
fi

tour_out="${work_dir}/tour.txt"
if PYTHONPATH="${lab_dir}/examples" python3 examples/typing_tour.py >"${tour_out}" 2>&1; then
  check "python3 examples/typing_tour.py exits 0" "yes"
else
  check "python3 examples/typing_tour.py exits 0" "no"
fi

for expected in "first_word(None): (nothing)" "structural typing works" "first_or([], 'fallback'): fallback"; do
  if grep -qF "${expected}" "${tour_out}"; then
    check "typing tour prints: ${expected}" "yes"
  else
    check "typing tour prints: ${expected}" "no"
  fi
done

# ---------------------------------------------------------------------------
echo
echo "-----------------------------------------------------------"
echo "${checks} checks, ${failures} failure(s)."
if [ "${failures}" -ne 0 ]; then
  exit 1
fi
exit 0

Troubleshooting

Troubleshooting — Day 075 lab

Every message below was produced on the authoring machine or is quoted from mypy's own documentation. If you hit something not listed here, read the error code in brackets first — it is the searchable part.

mypy: command not found / pytest: command not found

The tools are not installed, or not on your PATH. Install them into a lab-local virtual environment:

python3 -m venv .venv
.venv/bin/pip install -r requirements/requirements.txt

Then call them by path (.venv/bin/mypy), or activate the environment first. The test runner searches an explicit override, then .venv/, then your PATH, so bash tests/run_tests.sh works either way. To point it at binaries you already have:

MYPY=/path/to/mypy PYTEST=/path/to/pytest bash tests/run_tests.sh

ModuleNotFoundError: No module named 'catalog' when running pytest

pytest does not put arbitrary directories on the import path. The lab's modules live in starter/ and examples/, so tell Python where to look:

PYTHONPATH=starter .venv/bin/pytest -q starter/test_catalog.py

In a real project you would instead have a package directory and an installed project; that is Day 077's territory.

mypy reports nothing at all, and the file is clearly wrong

Almost always one of three things.

The functions have no annotations. mypy will not check the body of an unannotated function, because it has nothing to check against. This is gradual typing behaving exactly as designed, and it is why Success: no issues found on an unannotated codebase means "mypy did not look", not "your code is fine". Run mypy --strict and the same file reports [no-untyped-def] for every function. starter/untyped_first_run.py exists to let you see this.

Something in the path is typed Any. A value of type Any can be passed anywhere and have anything called on it, so checking stops at it and stays stopped for everything downstream. starter/any_demo.py demonstrates this in one edit.

You are checking a different file than you think. mypy takes paths, not patterns, and follows imports from there. mypy . checks the tree from the current directory.

Cannot find implementation or library stub for module named "..." [import-untyped] / [import-not-found]

The package you imported ships no type information. Three honest responses, in order of preference:

  1. Install the stubs if they exist. Many popular packages have a companion types-<name> distribution in typeshed; mypy's message usually names it, and mypy --install-types will fetch what it can.
  2. Check whether the package itself is typed — a package that ships a py.typed marker file is announcing that its own annotations are usable, and you may simply need a newer version.
  3. Only if neither applies, silence it for that module specifically, in configuration, with a comment saying why:
[[tool.mypy.overrides]]
module = "some_untyped_dependency.*"
ignore_missing_imports = true

Do not reach for the global --ignore-missing-imports flag as a first move. It silences the message for every dependency at once, including the ones that were about to tell you about a real mistake.

X | None raises TypeError: unsupported operand type(s) for |

You are on a Python older than 3.10 and evaluating the annotation at runtime. Either upgrade — this lab requires 3.10 or newer — or add from __future__ import annotations at the top of the file, which makes Python store annotations as strings instead of evaluating them. The lab's modules do exactly that.

My # type: ignore did not suppress the error

Check the code in the brackets. An ignore only suppresses the code it names, and mypy says so explicitly:

error: Item "None" of "str | None" has no attribute "upper"  [union-attr]
note: Error code "union-attr" not covered by "type: ignore[arg-type]" comment

That note is the tool being helpful: you guessed a code, and it told you the right one. Copy the code from the error you actually got. A bare # type: ignore with no brackets suppresses everything on that line, including errors you have not seen yet — which is why this lab never uses one.

Unused "type: ignore" comment [unused-ignore]

You fixed the code and left the ignore behind. Delete the comment. This error only appears when warn_unused_ignores is on, which is why it is on in examples/pyproject.toml: without it, ignore comments quietly outlive the problems they were added for, and after a year nobody can tell which are still load-bearing.

Returning Any from function declared to return "..." [no-any-return]

Something in the expression you returned is typed Any — very often json.load, which is annotated to return Any because it genuinely can return anything. The fix is not to widen the return annotation. Check the value at the boundary: bind it to a name annotated object, narrow it with isinstance, and build the type you promised. examples/catalog.py's load_settings shows the pattern.

Function is missing a type annotation [no-untyped-def] on a function I do not want to annotate

Strict mode requires annotations everywhere. That is the point of it, but you do not have to accept it everywhere at once. Turn it on globally and grant named exemptions per module, so the exemption list is a visible work queue:

[[tool.mypy.overrides]]
module = "legacy_importer.*"
disallow_untyped_defs = false

The results look stale

mypy caches analysis in .mypy_cache/ for speed. If you suspect it is out of date, delete it or run with --no-incremental:

rm -rf .mypy_cache
.venv/bin/mypy --no-incremental starter/catalog.py

The test suite sidesteps the question by using a temporary cache directory it removes on exit, so it never gives you a stale answer and never leaves a cache in the lab.

mypy and my editor disagree

Your editor is probably running a different checker. Visual Studio Code's Python support uses Pylance, which runs Pyright, not mypy. The two implement the same specification and agree on the great majority of code, but they are separate programs with different defaults and they do diverge at the edges. Pick one as the authority — the one your project's continuous integration runs — and configure the other to match, rather than chasing both.

The tests fail after I fix starter/catalog.py

Expected, and worth reading carefully. Several checks assert that the buggy starter still produces [union-attr] and [arg-type], because proving the tools disagree is the lesson. Once you have finished the exercises, restore the original with git checkout -- starter/ before running the suite, or read each failure and confirm it is the one you caused on purpose.

AttributeError: 'NoneType' object has no attribute 'name'

You ran the buggy describe() with a name that is not in the catalogue. That is not a problem with the lab — it is the bug, arriving the way it would arrive in production. mypy told you about it before you ran anything.

Security notes

Security notes — Day 075 lab

What this lab does to your machine

It installs two packages, reads local files, and runs local Python. mypy analyses source without executing it; pytest executes the lab's own tests. No script here opens a network connection, reads an environment secret, or writes outside the lab directory. No API key is needed and none should be supplied.

The one networked moment is pip install, which fetches mypy and pytest from the Python Package Index. Pinned versions in requirements/requirements.txt are what make that reproducible: an unpinned install silently gives different people different tools, and different tools give different answers.

A type checker does not run your code — mostly

This is worth stating precisely, because it is a genuine safety property and it has one genuine exception. mypy parses and analyses; it does not execute your module bodies, so checking a file you have not read is far safer than running it. The exception is --install-types, which downloads stub packages from the index. That is an install, with all the trust that implies, and it is why the flag is opt-in rather than automatic.

The security claim types do NOT make

An annotation is a claim about a value's type, never about its contents. Every one of these passes a type checker without comment:

def open_report(path: str) -> str: ...      # path may be "../../etc/passwd"
def send_mail(to: str) -> None: ...         # to may be "not an email"
def run_query(sql: str) -> list[str]: ...   # sql may be an injection

str means "this is a sequence of characters". It does not mean safe, validated, escaped, in range, or belonging to this user. A checker verifies that values flow where their annotations permit; it has no opinion at all about what the values are. Validation is a separate, runtime job — the __post_init__ checks from Day 69, an explicit guard, or a library like pydantic that checks values at a boundary.

The most common expensive mistake here is treating a typed signature as an input-validation layer at a trust boundary. It is not one, and it never was.

Two settings that silently turn checking off

Both are legitimate tools and both deserve a comment explaining why they are there.

Any. Not "some type" but "stop checking". A value typed Any can be passed anywhere and have anything called on it, and the effect spreads downstream to everything that touches it. starter/any_demo.py shows a real [union-attr] error vanishing when one return annotation becomes Any, while the code stays exactly as broken — the same call still raises AttributeError at runtime. If an Any sits on a function that handles untrusted input, the checking you thought you had over that whole path does not exist.

--ignore-missing-imports (and its per-module form). It tells mypy to treat an unresolvable import as Any, which is sometimes the only practical option — and which means every value from that dependency is now unchecked. Scope it to the specific module in configuration rather than applying it globally, and re-check periodically whether stubs have since appeared.

cast is an assertion, not a conversion

cast(str, value) emits no runtime check and performs no conversion. It tells the checker to believe you. If you are wrong, nothing objects at the cast and the wrong type travels onward to fail somewhere with no obvious connection to the mistake. Prefer isinstance, which narrows and checks. Where a cast is genuinely necessary, write the reason beside it.

# type: ignore hygiene

Always name the code: # type: ignore[union-attr], never a bare # type: ignore. A bare ignore suppresses every error on that line forever, including ones introduced later by an edit nobody connected to it. Turn on warn_unused_ignores so an ignore that has outlived its problem becomes an error of its own — that setting is the difference between a small, current set of documented exemptions and a large, stale one nobody dares touch.

Privacy

mypy runs entirely on your machine. It reads your source, writes a cache into .mypy_cache/, and prints to your terminal. That cache contains analysed information derived from your source, so treat it exactly as you treat the source: it is excluded from version control here, and you should not commit it or attach it to a bug report without looking at it first.

If you use a hosted editor or an extension that runs a checker as a service, that is a different arrangement with different data flow, and it is worth reading what it sends before you enable it on a private codebase. Nothing in this lab requires one.

Continuous integration

mypy exits non-zero when it finds errors, which is what makes it usable as a gate. Two cautions when you wire it into a pipeline. Pin the version, or a release with new checks will fail a build nobody changed. And do not let the gate be bypassed by adding ignores — a build that passes because the exemption list grew is not a build that passed.