Pro Q
Pro Q

Reputation: 5038

How to get Mypy to realize that the default value won't be used in certain cases

I have the following function:

#!/usr/bin/env python3
from typing import Union

def foo(b: Union[str, int]) -> int:
    def bar(a: int = b) -> int: # Incompatible default for argument "a" (default has type "Union[str, int]", argument has type "int")
        return a + 1

    if isinstance(b, str):
        return bar(0)
    else:
        return bar()

print(foo(3)) # 4
print(foo("hello")) # 1

On the line where I define bar, Mypy says that setting b as the default won't work.

However, due to how the program works, the only way that the default b will be used is if b is an integer. So this should work fine.

But Mypy doesn't realize that.

How can I

  1. Get Mypy to realize that int is the correct type for a or
  2. Fix this is some way that doesn't cause too much code duplication.

(For example, I know I could write two foo functions with different signatures, but that would be too much code duplication.)

TL;DR below is just my real-life use case, since at least one answer relied on how simple my MCVE above was.

It's a function that takes a dictionary. The function returns a decorator that, when used, will add the decorated function (the decorated function is a TypeChecker) to the dictionary. The decorator allows for a parameter that specifies the name/key that the decorated function (the TypeChecker) is placed under in the dictionary. If a name is not specified, then it will use a different function (StringHelper.str_function) to figure out a name from the properties of the function itself.

Due to how decorator parameters work, the decorator creator needs to take in either the name (or nothing) or the function. If it takes just the function, then no name was specified, and it should grab a name from the function. If it takes just the name, then it will be called again on the function, and the name should be used. If it takes nothing, then it will be called again on the function, and it should grab a name from the function.

def get_type_checker_decorator(type_checkers: Dict[str, TypeChecker]) -> Callable[[Union[Optional[str], TypeChecker]], Union[Callable[[TypeChecker], TypeChecker], TypeChecker]]:
    @overload
    def type_checker_decorator(name: Optional[str]) -> Callable[[TypeChecker], TypeChecker]:
        pass
    @overload
    def type_checker_decorator(name: TypeChecker) -> TypeChecker:
        pass
    def type_checker_decorator(name: Union[Optional[str], TypeChecker] = None) -> Union[Callable[[TypeChecker], TypeChecker], TypeChecker]:
        # if name is a function, then the default will never be used
        def inner_decorator(function: TypeChecker, name: Optional[str] = name) -> TypeChecker: # this gives the Mypy error
            if name is None:
                name = StringHelper.str_function(function)
            type_checkers[name] = function
            def wrapper(string: str) -> bool:
                return function(string)
            return wrapper

        if callable(name):
            # they just gave us the function right away without a name
            function = name
            name = None
            return inner_decorator(function, name)
        else:
            assert isinstance(name, str) or name is None
            # should be called with just the function as a parameter
            # the name will default to the given name (which may be None)
            return inner_decorator

    return type_checker_decorator

Upvotes: 3

Views: 2839

Answers (3)

user2357112
user2357112

Reputation: 281643

This default value shouldn't even exist. The safe way to write this code would be

def foo(b: Union[str, int]) -> int:
    def bar(a) -> int:
        return a + 1

    if isinstance(b, str):
        return bar(0)
    else:
        return bar(b)

I don't know what situation motivated you to ask this question, but whatever program you really want to write, you probably shouldn't have a default argument value either.


Your code is very similar to trying to do

def f(x: Union[int, str]) -> int:
    y: int = x
    if isinstance(x, str):
        return 1
    else:
        return y + 1

Written like that, it seems obvious that y is wrong. We shouldn't be assigning something to a variable of static type int unless we actually know at the point of assignment that it's an int. It would be unreasonable to expect the type checker to examine all code paths that could lead to y's use to determine that this is safe. Default argument value type checking follows a similar principle; it's checked based on information available when the default value is set, not on the code paths where it could be used.

For an even more extreme example, consider

def f(x: Union[int, str]) -> int:
    def g(y: int = x):
        pass
    return 4

y will never be used. The type checker still reports the type mismatch, and there would be bug reports about it if the type checker didn't report it.

Upvotes: 1

Daniel
Daniel

Reputation: 12026

It feels awkward to force a type signature if that's not really what the function is expecting. Your bar function clearly expects an int, and forcing a Union on the type hint just to later assert that you actually only accept ints shouldn't be necessary in order to silence mypy.

Since you are accepting b as a default in bar, you should take care of the str case inside of bar, because the type signature of b has already been specified in foo. Two alternative solutions that I would've considered more appropriate to the issue at hand:

def foo(b: Union[str, int]) -> int:

    # bar takes care of the str case. Type of b already documented
    def bar(a=b) -> int:
        if isinstance(b, str):
            return bar(0)
        return a + 1

    return bar()

Defining a default value before defining bar:

def foo(b: Union[str, int]) -> int:

    x: int = 0 if isinstance(b, str) else b

    # bar does not take a default type it won't use.
    def bar(a: int = x) -> int:
        return a + 1

    return bar()

Upvotes: 3

Pro Q
Pro Q

Reputation: 5038

While writing this question, I think I answered it myself, so I might as well share.

The solution is slightly awkward, but it's the best I could think of, and it seems to work. First, type a as being either an integer or a string. Then, as the first line in the function, assert that a is an int.

Together, it looks like

#!/usr/bin/env python3
from typing import Union

def foo(b: Union[str, int]) -> int:
    def bar(a: Union[int, str] = b) -> int: # Incompatible default for argument "a" (default has type "Union[str, int]", argument has type "int")
        assert isinstance(a, int)
        return a + 1

    if isinstance(b, str):
        return bar(0)
    else:
        return bar()

print(foo(3)) # 4
print(foo("hello")) # 1

This solution isn't perfect, though, since if you do follow the function signature and pass it a string, it will fail (due to the assert). So it requires some introspection of the function itself to determine what can actually get passed.

Upvotes: 0

Related Questions