Search code examples
pythontype-hintingmypy

mypy: function definition in isinstance-condition: why is the type in the condition not narrowed down?


I'm defining functions inside a condition based on a variables type, a Union of other types. mypy infers that the type is narrowed down in this context, but seems to throw this knowledge away.

I don't understand why. Is this a bug in mypy?

Consider this:

from typing import Union, Callable

def test(a: Union[None, Callable]):
    if a is None:
        pass
    else:
        def test0():
            a()
        def test1(b: str):
            a(b)
        callable_a = a
        def test2(b: str):
            callable_a(b)
        a()
        a('foo')

mypy's output:

test.py:9: error: "None" not callable

Found 1 error in 1 file (checked 1 source file)

line 9 is the body of test1. Seems that here the whole Union[None, Callable] is applied
("the whole union" is not clear from this example, in the actual code it's Union[None, Sequence, Callable] and mypy objects that both None and Sequence are not callable).

outside function definitions, the problem does not appear.

Some thoughts:

  • callable_a can only ever be a callable, so I'm a bit more willing to accept that mypy doesn't complain about this. I don't understand why it's complaining anyway.
  • both test0 and test1 call a, the difference is only the argument. Why does this make a difference to mypy, especially here when the signature of a is undefined?

I'm running mypy 0.950 if it makes a difference.


Solution

  • This is correct behavior which is well explained in the mypy docs. Inference of a would be simply wrong because of late binding.

    Here's the example given in the docs:

    from typing import Callable, Optional
    
    def foo(x: Optional[int]) -> Callable[[], int]:
        if x is None:
            x = 5
        print(x + 1)  # mypy correctly deduces x must be an int here
        def inner() -> int:
            return x + 1  # but (correctly) complains about this line
    
        x = None  # because x could later be assigned None
        return inner
    
    inner = foo(5)
    inner()  # this will raise an error when called
    

    Now about your code: First, functions without annotations are not checked, so you don't see all real errors. After adding type hints, we have the following:

    from typing import Union, Callable
    
    def test(a: Union[None, Callable]):
        if a is None:
            pass
        else:
            def test0() -> None:
                a()  # E: "None" not callable
                
            def test1(b: str) -> None:
                a(b)  # E: "None" not callable
            
            callable_a = a
            reveal_type(callable_a)  # N: Revealed type is "def (*Any, **Any) -> Any"
            def test2(b: str) -> None:
                callable_a(b)
            
            a()
            a('foo')
    

    Now only test2 is valid, which is expected (see revealed type). callable_a is inferred to be Callable only from is None type guard, so you cannot assign None to it later. You can fix this with assert callable(a) inside test0 and test1 (dirty way) or use the same pattern as with test2. Default argument binding (using def test0(a: Callable = a) should be valid too but mypy doesn't like it for some reason - it is a false positive, but probably too hard to deal with or devs don't have time for it now.

    Look through this issue if you are interested in mypy's opinion on that. Here's a playground to play with this online.