Search code examples
pythonpython-typingmypy

Property annotation that checks assignment to a guard value initially set to None


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?

edit/clarification:

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.

Try #1

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:

Try #2, type the guard value as well:

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.

Versions:

mypy   0.720
Python 3.6.8

Solution

  • 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.

    mypy output:

    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.