skip to Main Content

Problem description

Suppose a following test

class Foo:

    def __init__(self):
        self.value: int | None = None

    def set_value(self, value: int | None):
        self.value = value


def test_foo():

    foo = Foo()
    assert foo.value is None
    foo.set_value(1)
    assert isinstance(foo.value, int)
    assert foo.value == 1 # unreachable

The test:

  • First, checks that foo.value is something
  • Then, sets the value using a method.
  • Then it checks that the foo.value has changed.

When running the test with mypy version 1.9.0 (latest at the time of writing), and having warn_unreachable set to True, one gets:

(venv) niko@niko-ubuntu-home:~/code/myproj$ python -m mypy tests/test_foo.py 
tests/test_foo.py:16: error: Statement is unreachable  [unreachable]
Found 1 error in 1 file (checked 1 source file)

What I have found

from safe_assert import safe_assert

def test_foo():

    foo = Foo()
    safe_assert(foo.value is None)
    foo.set_value(1)
    safe_assert(isinstance(foo.value, int))
    assert foo.value == 1

the problem persists (safe-assert 0.4.0). This time, both mypy and VS Code Pylance think that foo.set_value(1) two lines above is not reachable.

Question

How can I say to mypy that the foo.value has changed to int and that it should continue checking also everything under the assert isinstance(foo.value, int) line?

2

Answers


  1. Chosen as BEST ANSWER

    While writing the question and playing around with safe_assert I noticed that the v. 0.4.0 uses NoReturn as the return type. Removing that makes mypy happy. So in essence, one may use this as safe_assert:

    def safe_assert(
        expression: bool,
        message: Optional[str] = None,
    ):
        if not expression:
            if message:
                raise AssertionError(message)
            raise AssertionError
    

  2. You can explicitly control type narrowing with the TypeGuard special form (PEP 647). Although normally you would use TypeGuard to farther narrow a type than what has already been inferred, you can use it to ‘narrow’ to whatever type you choose, even if it is different or broader than the type checker has already inferred.

    In this case, we’ll write a function _value_is_set which is annotated with a return type of TypeGuard[int] such that type checkers like mypy will infer type of int for values ‘type guarded’ under calls to this function (e.g., an assert of if expression).

    from typing import TypeGuard, Any
    
    # ...
    
    def _value_is_set(value: Any) -> TypeGuard[int]:
        if isinstance(value, int):
            return True
        return False
    
    def test_foo():
        foo = Foo()
        assert foo.value is None
        foo.set_value(1)
        assert _value_is_set(foo.value)
        # the next line is redundant now, but can be kept without issue
        assert isinstnace(foo.value, int) 
        assert foo.value == 1 # now reachable, according to mypy
    

    Normally, mypy should treat assert isinstance(...) or if isinstance(...) in a similar way. But for whatever reason, it doesn’t in this case. Using TypeGuard, we can coarse type checkers into doing the correct thing.

    With this change applied, mypy will not think this code is unreachable.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search