I have a property that only gets computed once per instance and that uses None
as a guard value. This is a pretty common pattern with properties.
What's the best way to annotate it?
This question is about how to use mypy to validate my property's code. Not about how to refactor the code. The property's type is just the way I want it, as a non-Optional int. Assume, for example, that the downstream code will do print(book.the_answer+1)
.
The, incorrect, assignment of a string and a None are fully intentional to break the contract defined by the property's expectations and I wanted mypy to flag them.
class Book:
def __init__(self, title):
self.title = title
_the_answer = None #line 6
# _the_answer : int = None #line 7
def __str__(self):
return "%s => %s" % (self.title, self.the_answer)
@property
def the_answer(self)->int:
"""always should be an int. Not an Optional int"""
if self._the_answer is None:
if "Guide" in self.title:
#this is OK
self._the_answer = 42
elif "Woolf" in self.title:
#this is wrong, mypy should flag it.
self._the_answer = "?, I haven't read it" # line 21
else:
#mypy should also flag this
self._the_answer = None #line 24
return self._the_answer #line 26
print(Book("Hitchhiker's Guide"))
print(Book("Who's afraid of Virginia Woolf?"))
print(Book("War and Peace"))
Output:
Hitchhiker's Guide => 42
Who's afraid of Virginia Woolf? => ?, I haven't read it
War and Peace => None
Mypy's output:
test_prop2.py:21: error: Incompatible types in assignment (expression has type "str", variable has type "Optional[int]")
test_prop2.py:26: error: Incompatible return value type (got "Optional[int]", expected "int")
There's really nothing necessarily wrong with line 26, it all depends on what got assigned in the IFs. 21 and 24 are both incorrect, but mypy only catches 21.
Note: that if I change the property return to return cast(int, self._the_answer) #line 26
then it leaves that alone at least.
If I add typing to the guard value, _the_answer
:
class Book:
def __init__(self, title):
self.title = title
#_the_answer = None
_the_answer : int = None #line 7
def __str__(self):
return "%s => %s" % (self.title, self.the_answer)
@property
def the_answer(self)->int:
"""always should be an int. Not an Optional int"""
if self._the_answer is None:
if "Guide" in self.title:
#this is OK
self._the_answer = 42
elif "Woolf" in self.title:
#this is wrong. mypy flags it.
self._the_answer = "?, I haven't read it" # line 21
else:
#mypy should also flag this
self._the_answer = None #line 24
return self._the_answer #line 26
print(Book("Hitchhiker's Guide"))
print(Book("Who's afraid of Virginia Woolf?"))
print(Book("War and Peace"))
Same running output, but mypy has different errors:
test_prop2.py:7: error: Incompatible types in assignment (expression has type "None", variable has type "int")
and it doesn't type check lines 21, 24 and 26 (actually it did once but then I changed the code and it hasnt since).
And if I change line #7 to _the_answer : int = cast(int, None)
, then mypy is entirely silent and warns me about nothing.
mypy 0.720
Python 3.6.8
After a night's sleep I got the solution: splitting up the typing responsibilities from the guarding responsibilities.
the guard variable, self/cls._the_answer
is left untyped.
In line 15, right under the guard condition which tells me the instance hasn't had that property computed yet, I type-hint a new variable name, answer
.
Both incorrect assignments, in 21 and 24 are now flagged.
from typing import cast
class Book:
def __init__(self, title):
self.title = title
_the_answer = None
def __str__(self):
return "%s => %s" % (self.title, self.the_answer)
@property
def the_answer(self)->int:
"""always should be an int. Not an Optional int"""
if self._the_answer is None:
answer : int #line 15: 👈 this is where the typing happens
if "Guide" in self.title:
#this is OK ✅
answer = 42
elif "Woolf" in self.title:
#this is wrong ❌
answer = "?, I haven't read it" # line 21
else:
#mypy should also flag this ❌
answer = None #line 24
self._the_answer = answer
return cast(int, self._the_answer) #line 26
print(Book("Hitchhiker's Guide"))
print(Book("Who's afraid of Virginia Woolf?"))
print(Book("War and Peace"))
Same actual run output as before.
Now, both problem lines are flagged ;-)
test_prop4.py:21: error: Incompatible types in assignment (expression has type "str", variable has type "int")
test_prop4.py:24: error: Incompatible types in assignment (expression has type "None", variable has type "int")
What about the cast
in line 26? What if nothing assigns answer
in the if statements? Without the cast
, I'd probably be getting a warning. Could I have assigned a default value as in answer : int = 0
in line 15?
I could, but unless there really is a good application-level default, I'd rather have things blow up right away at execution (UnboundLocalError
on answer) rather than making mypy happy but having to chase down some weird runtime value.
That last bit is totally dependent on what your application is expecting, a default value may be the better choice.