Search code examples
pythonmypypython-typing

How to type a function with Callable without losing keyword argument?


I have many functions and I want to add a stringify option on each function: if stringify=True, the functions returns str(result) instead of result.

Instead of adding the optional argument and refactoring each function (which would force me to duplicate the stringify logic across all my functions) I used a decorator:

from typing import Callable, Union


def WithStringifyOption(func: Callable[[int], int]) -> Callable[[int, bool], Union[int, str]]:
    def wrapper(value: int, stringify: bool = False) -> Union[int, str]:
        result = func(value)
        return str(result) if stringify else result
    return wrapper

# my old function
def func0(value: int) -> int:
    return value * 2

# How it looks now
@WithStringifyOption
def func1(value: int) -> int:
    return value * 2


func0(value=2)
func1(value=3, stringify=True)

The code works, but by doing this, I get typing error (and I lose autocomplete for keyword arguments): Unexpected keyword argument "value" for "func1"mypy(error) and Unexpected keyword argument "stringify" for "func1"mypy(error)

According to typing documentation about typing.Callable:

There is no syntax to indicate optional or keyword arguments; such function types are rarely used as callback types.

So how can I fix this typing error ?

Note: this is a simplified example on my real project; i want to add a much more complex logic on each one of my functions, so refactoring all my functions by adding and duplicating this complex logic is not an option. I also don't want to add type: ignore everywhere on the project and i don't want to replace all keyword arguments by positional arguments everywhere on the project


Solution

  • You can use Protocols:

    from typing import Protocol
    # if sys.version_info < (3, 8): from typing_extensions import Protocol
    
    
    class CallableIn(Protocol):
        def __call__(self, value: int) -> int: ...
    
    
    class CallableOut(Protocol):
        def __call__(self, value: int, stringify: bool = False) -> int | str: ...
    
    
    def WithStringifyOption(func: CallableIn) -> CallableOut:
        def wrapper(value: int, stringify: bool = False) -> int | str:
            result = func(value)
            return str(result) if stringify else result
        return wrapper
    

    Try out your snippet in mypy playground to check.

    Update: narrowing return type using typing.Literals

    If you have a recent version of Python (3.8 or newer), you can improve the CallableOut signature by overloading the __call__ method with specific literal values for stringify, leading to more specific return type:

    from typing import overload
    
    
    class CallableOut(Protocol):
        @overload
        def __call__(self, value: int, stringify: Literal[False] = ...) -> int: ...
    
        @overload
        def __call__(self, value: int, stringify: Literal[True] = ...) -> str: ...
    
        def __call__(self, value: int, stringify: bool = False) -> int | str: ...
    

    Continuing your example, you will now get the correct return types for the decorated func1 based on the stringify values:

    reveal_type(func1(value=3))  # note: Revealed type is "builtins.int"
    reveal_type(func1(value=3, stringify=False))  # note: Revealed type is "builtins.int"
    reveal_type(func1(value=3, stringify=True))  # note: Revealed type is "builtins.str"