Programming with Python › Testing and Code Quality › Day 75
Hands-on lab — Day 75: Static Typing with mypy
- ← Back to the Day 75 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-075-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-mypywhen 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
Optionalbug (X | Noneused 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
--strictchanges about that. - Configure mypy with a real
[tool.mypy]table inpyproject.toml, including per-module overrides. - Demonstrate that
Anydisables checking, and that a# type: ignorecarrying 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 venvand installing packages withpip. - 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
pythonforpython3and.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
python3— 3.10 or newer. The lab usesX | Noneunion syntax in annotations. Tested on 3.14.0.mypy2.3.0 andpytest9.1.1, installed fromrequirements/requirements.txt.bashfor 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— reportsSuccess: no issues found in 1 source fileon 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--strictreports 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, wheredescribe()reads attributes off a value that may beNone, and one[arg-type]on line 85, where a float produced by/is handed to a parameter annotatedint.mypy --strict starter/catalog.py— the same three plus[no-untyped-def]for the unannotatedformat_price,[no-untyped-call]for the typed function that calls it, and[no-any-return]for the loader that handsjson.load'sAnystraight out under adict[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 theNonepath 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:Optionalnarrowing,isinstancenarrowing, unions,Literal,TypedDict,Final,NewType, aTypeVargeneric, aProtocolsatisfied without inheritance, andcast. Every line of that file also passesmypy --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, thatexamples/is clean under both--strictand the config file, that the buggydescribe()genuinely raises at runtime, that theAnyedit makes the error vanish while the crash remains, and that atype: ignorewith 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
.venv/bin/mypy --versionprintsmypy 2.3.0 (compiled: yes)and.venv/bin/pytest --versionprintspytest 9.1.1.PYTHONPATH=starter .venv/bin/pytest -q starter/test_catalog.pyreports8 passedand exits 0 — before you change anything..venv/bin/mypy starter/catalog.pyreports exactly three errors and exits- Two carry the code
[union-attr]and one[arg-type].
- Two carry the code
- You can state, for each error, the file, the line, and the code, without reading the message text.
- After fixing bug A, the
[union-attr]errors are gone and the tests still report8 passed— the fix changed nothing on any tested path. - After fixing bug B,
.venv/bin/mypy starter/catalog.pyprintsSuccess. .venv/bin/mypy --strict starter/catalog.pystill reports[no-untyped-def],[no-untyped-call]and[no-any-return]; after you annotateformat_priceand check the loaded object inload_settings, strict mode printsSuccesstoo..venv/bin/mypy starter/any_demo.pyreports[union-attr]; after changinglookup's return annotation toAnyit printsSuccess, andpython3 -c "import any_demo; any_demo.shout('zzz')"still raisesAttributeError. Nothing got safer.PYTHONPATH=examples python3 examples/demo.pyexits 0 and section 2 showsdescribe('enormous'): enormous: unknown model.bash tests/run_tests.shends with0 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
- Type the untyped module. Annotate every function in
starter/untyped_first_run.pyuntilmypy --strictreportsSuccess.load_pricesis the interesting one: what type isrows, and what does the function really return? - Write the
Protocolversion of a dependency. Day 74 injected a clock and a repository to make code testable. Take theClockprotocol fromexamples/typing_tour.py, add aRepositoryprotocol withgetandsavemethods, write a real and a fake implementation of each, and check that both satisfy the protocol with no inheritance anywhere. - Make an ignore rot. Add
# type: ignore[arg-type]to a line inexamples/catalog.pywhere nothing is wrong, then runmypy --strict. You get[unused-ignore]. Now delete thewarn_unused_ignoresline fromexamples/pyproject.tomland run again with--config-file. Silence. That is how a codebase accumulates hundreds of ignores nobody can safely remove. - Find a limit. Write a function annotated
def send(to: str) -> Nonethat is meant to take an email address, and call it withsend("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. - Adopt mypy on your own code. Take any Python file you have written
since Day 43, run
mypyon it, thenmypy --strict, and fix what you find. Record how many errors each mode reported and how many were real bugs rather than missing annotations.
Navigation
- 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:
- Install the stubs if they exist. Many popular packages have a companion
types-<name>distribution in typeshed; mypy's message usually names it, andmypy --install-typeswill fetch what it can. - Check whether the package itself is typed — a package that ships a
py.typedmarker file is announcing that its own annotations are usable, and you may simply need a newer version. - 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.