We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Pattern Matching, f-Strings and Newer Syntax step 13 of 23
Let mypy Catch a Missing Case with assert_never
Your app logs four kinds of event, and describe turns each one into a line
of text. Next month someone adds a fifth kind to the Event union and
forgets describe. If describe ends with case _: return "unknown", the
new event logs as "unknown", and mypy has nothing to report. You want mypy
to point at describe instead.
from typing import assert_never
def describe(event: Event) -> str:
match event:
case Created(entity):
return f"created {entity}"
# ... one case for each of the other three classes ...
case _ as unhandled:
assert_never(unhandled)
How the check works
mypy narrows the type of event as it reads the cases. After each case, it
removes the class that case handled from the types event can still have.
Once all four classes have a case, no type is left, and mypy gives
unhandled the type Never, the type with no values.
assert_never from typing accepts only an argument of type Never. If a
class has no case, unhandled still has that class's type, and mypy reports
an error at the call:
error: Argument 1 to "assert_never" has incompatible type "Archived"; expected "Never"
At runtime, assert_never raises AssertionError if Python ever reaches it.
mypy checks against the Event union, so a new class counts only after you
add it to that union.
The diagram shows each case narrowing event until nothing is left:
Loading visualization…
What to write
parse(raw) turns a dictionary into an event. The fields hold strings:
"kind" |
Fields | Returns |
|---|---|---|
"created" |
entity |
Created(entity) |
"updated" |
entity, field |
Updated(entity, field) |
"deleted" |
entity |
Deleted(entity) |
"archived" |
entity, reason |
Archived(entity, reason) |
Extra keys and empty strings are fine. For a missing field, a field that is
not a string, or an unknown kind, raise
ValueError(f"unknown event: {raw!r}").
describe(event) returns "created {entity}", "updated {entity}.{field}",
"deleted {entity}" or "archived {entity} ({reason})", with the fields
filled in. Give each class a case, and end with the assert_never case.
severity(level) maps Level.DEBUG, INFO, WARNING and ERROR to 10,
20, 30 and 40. Match the members with dotted names, and end with
assert_never.
solve(events, probe_unknown) handles each dictionary in order:
- Read
"level". If the key is missing, use"info". - The level must be a string that
Levelaccepts:"debug","info","warning"or"error". So"DEBUG"and an explicitNoneare both invalid. - Parse the event and add
f"{severity(level)}|{describe(event)}". - For an invalid level or event, add
"invalid"and go on to the next. - After all events, if
probe_unknownis true, add the result ofprobe_backstop().
A created event for "order" with no level gives "20|created order".
With no events, solve returns [], or ["assert_never"] when
probe_unknown is true.
probe_backstop() shows the runtime side. mypy rejects
describe(Rogue("ghost")), because Rogue is not in Event. The starter's
erased: Callable[..., str] accepts any arguments, so call
erased(Rogue("ghost")) and return "assert_never" when it raises
AssertionError.
Going further
Run mypy with --enable-error-code exhaustive-match, and it reports any
match that leaves a type unhandled, even one with no assert_never case.
Your code, saved here
Sign in to access your accepted solutions and saved draft history. You can keep reading the lesson without an account.
Sign in to your accountStuck?
Python reference solution
Sign in to track your attempts toward unlocking the reference solution.