This function passes mypy --strict. So does every caller that forgets it
can raise:
def parse_count(raw: str) -> int:
return int(raw) # raises ValueError for "abc"
A return type describes the value a function returns. It does not list the exceptions the function raises, and Python has no syntax for that. This article shows what you can do instead, and two places where a type hint decides how mypy treats an error.
Write the exceptions down, and test them
Name the exceptions callers should expect in the docstring, and say when each one happens:
def load_count(path: str) -> int:
'''Read a count from the first line of a file.
Raises:
OSError: if the file cannot be read.
ValueError: if the first line is not an integer.
'''
with open(path) as f:
return int(f.readline())
Then write a test for each one, so the docstring stays true when the code changes.
Return an expected failure as a value
When failure is a normal outcome, such as bad input from a user, you can
return it instead of raising it. The type checker then makes every caller
that uses the result check which case it got. @dataclass writes
__init__, __repr__ and __eq__ for you from a class's annotated fields, and frozen=True makes those fields
read-only.
from dataclasses import dataclass
@dataclass(frozen=True)
class Parsed:
value: int
@dataclass(frozen=True)
class Invalid:
message: str
def parse_count(raw: str) -> Parsed | Invalid:
try:
return Parsed(int(raw))
except ValueError:
return Invalid("expected an integer")
def display_count(raw: str) -> str:
result = parse_count(raw)
if isinstance(result, Invalid):
return result.message
return str(result.value)
assert display_count("12") == "12"
assert display_count("unknown") == "expected an integer"
This is a Result type. The function returns either a success or a
failure, and the caller checks which one it got. Read result.value before
the isinstance check and mypy stops you:
error: Item "Invalid" of "Parsed | Invalid" has no attribute "value" [union-attr]
mypy still lets a caller ignore the return value completely. A line that
calls parse_count(raw) and keeps nothing loses the failure. And the function
can still raise errors that it does not turn into an Invalid.
Check your understanding
A function three calls down returns Parsed | Invalid. Does every function above it have to deal with Invalid?
Show answer
Hide answer
Each one has to pass it on. A function that only forwards the result can
return it as it is, with the same return type. A function that needs the
number has to check with isinstance first. An exception needs none of this,
because it travels up through the middle functions by itself. Use a Result
when the caller handles the failure nearby, and an exception when a handler
far up the call stack does.
Say whether a with block can swallow an error
If a context manager's __exit__ method returns True, the exception from
the with block disappears. mypy reads the return type of __exit__ to see
whether that can happen. With -> bool, it assumes the block may swallow an
exception:
from types import TracebackType
class Quiet:
def __enter__(self) -> None:
return None
def __exit__(
self,
exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None,
) -> bool:
return True
def to_int(raw: str) -> int:
with Quiet():
return int(raw)
$ mypy --strict quiet.py
quiet.py:17: error: Missing return statement [return]
mypy is right. to_int("abc") returns None, because Quiet swallows the
ValueError. If your manager never swallows exceptions, annotate __exit__
with -> None and return nothing. Keep -> bool for a manager that chooses
to swallow some exceptions.
Keep a decorator from hiding types
A decorator typed with Callable[..., Any] hides the decorated function's
types. The ... accepts any arguments, and Any switches off checks on the
result. With this decorator, mypy accepts a call with both arguments wrong:
from collections.abc import Callable
from functools import wraps
from typing import Any
def logged(fn: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn)
def wrapped(*args: Any, **kwargs: Any) -> Any:
print(f"calling {fn.__name__}")
return fn(*args, **kwargs)
return wrapped
@logged
def label(count: int, *, prefix: str) -> str:
return f"{prefix}: {count}"
label("3", prefix=1) # wrong types, and mypy says nothing
Give the decorator type parameters instead:
from collections.abc import Callable
from functools import wraps
def logged[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@wraps(fn)
def wrapped(*args: P.args, **kwargs: P.kwargs) -> R:
print(f"calling {fn.__name__}")
return fn(*args, **kwargs)
return wrapped
[**P, R] declares two type parameters, names that mypy fills in at each use.
R stands for the decorated function's return type. **P is a
ParamSpec, a type parameter that stands for the function's whole list of
parameters, and P.args and P.kwargs pass that list through. Now mypy
checks every call to label against its real signature and reports both
wrong arguments. wraps copies the name and docstring of label onto
wrapped. Neither wraps nor the type hints check types while the code runs.
The next exercise, Return Errors as Values with a Result Type, builds a
reusable Result with Ok and Err classes.