Python type hints: use Mypy’s always-true boolean check detection

Sometimes code uses boolean checks on variables that can only be true. This is normally a sign of a mistake, either in the type hints or the implementation. Mypy has an optional check that can find such problematic boolean usage with its truthy-bool error code.
A cheesy example
Take this code:
class Cheese:
def __init__(self, name: str) -> None:
self.name = name
def consume(cheese: Cheese) -> None:
if cheese:
print(f"Yum, {cheese.name}!")
The if cheese: clause is redundant. To execute the if, Python will use its truth-checking process:
By default, an object is considered true unless its class defines either a__bool__()method that returnsFalseor a__len__()method that returns zero.
Cheese is a vanilla Python class, with no custom __bool__() or __len__() methods, so it will always be truthy. Hence, the body of the if clause always be executed.
Mypy has an opt-in error for such redundant boolean checks. You can activate the error by adding truthy-bool to the enable_error_code setting. For example in pyproject.toml:
[tool.mypy]
enable_error_code = [
"truthy-bool",
]
Mypy then reports an error at the if line:
$ mypy example.py
example.py:7: error: "cheese" has type "Cheese" which does not implement __bool__ or __len__ so it could always be true in boolean context
Found 1 error in 1 file (checked 1 source file)
Resolving the problem requires judgment. There are two possible causes for the redundant check:
The type hint is wrong.
For example, perhaps
cheeseshould be allowed to beNone. Maybe some callers passNone, but aren’t (yet) type-checked, so you haven’t seen errors there. If so, the signature could be expanded:def consume(cheese: Cheese | None) -> None: ...
The implementation is wrong.
Probably the
ifblock is indeed redundant, perhaps due to some refactoring. It should be removed to clarify the code:def consume(cheese: Cheese) -> None: print(f"Yum, {cheese.name}!")
Or, it may be the case that
Cheese.__bool__()should be defined.
Okay then.
More subtly wrong: misuse of Iterable
Consider this multi-cheese version of consume():
from collections.abc import Iterable
class Cheese:
def __init__(self, name: str) -> None:
self.name = name
def consume(cheeses: Iterable[Cheese]) -> None:
if not cheeses:
print("hungry!")
return
for cheese in cheeses:
print(f"Yum, {cheese.name}")
It looks reasonable, and one can call it with lists and get the expected result:
In [1]: consume([])
Hungry!
In [2]: consume([Cheese("Camembert"), Cheese("Gruyère")])
Yum, Camembert
Yum, Gruyère
But it has a subtle wrongness, the worst kind of wrongness.
With truthy-bool active, Mypy highlights that cheeses “could always be true in boolean context”:
$ mypy example.py
example.py:10: error: "cheeses" has type "Iterable[Cheese]" which does not implement __bool__ or __len__ so it could always be true in boolean context
Found 1 error in 1 file (checked 1 source file)
So, what is the issue here?
Well, Iterable just declares that the variable has an __iter__() method. It doesn’t require that the passed in-object has a __bool__() or __len__(), and thus it could always be true.
The above test uses a list iterable, which has a __bool__() method, so it won’t always be true. But, generator expressions only have __iter__() and are otherwise always true. If you call this bad consume() with an empty generator expression, it simply does nothing:
In [3]: zero_cheeses = (Cheese("Cheddar") for _ in range(0))
In [4]: consume(zero_cheeses)
In [5]:
Oh no!
The if not cheeses clause is skipped for the empty generator expression.
As above, there are two possible causes:
The type hint is wrong.
If the function doesn’t actually need to handle any iterable, you could update it to use
Sequencein its type hint. A sequence is an iterable with__getitem__()and__len__()methods, hence it is not always-true.lists andtuples are sequences, but not generator expressions.from collections.abc import Sequence ... def consume(cheeses: Sequence[Cheese]) -> None: ...
The implementation is wrong.
If any iterable does need to be handled, the function can be updated to do so:
def consume(cheeses: Iterable[Cheese]) -> None: yummed = False for cheese in cheeses: yummed = True print(f"Yum, {cheese.name}") if not yummed: print("Hungry!")
Phew.
It’s not just ifs
Mypy applies this check anywhere Python would interpret the variable in a boolean context. Here are a couple more bad examples that it would flag for.
In an or expression, where the right hand side would never be executed:
def mature(cheese: Cheese) -> None:
cheese = cheese or Cheese("Cheddar")
...
In an while which would be an infinite loop:
cheese = Cheese("Gorgonzola")
while cheese:
...
Fun.
😸😸😸 Check out my new book on using GitHub effectively, Boost Your GitHub DX! 😸😸😸
One summary email a week, no spam, I pinky promise.
Related posts: