pythonpython-typingpyright

How to use a decorator to mark a keyword-only mandatory argument as optional in the call signature when statically checked by the IDE?


I have a bunch of functions that all have a certain keyword argument, let's call it kw, which is mandatory and has a specific type T. However, I want to be able to call the functions without necessarily providing that keyword argument, and if it's not present I want it to be generated, probably through a decorator. Furthermore, I want my IDE to show its signature as if kw were optional.

In other words, I have functions that have a signature kind of like this:

def f(*args, kw: T, **kwargs): ...

I want their tooltip signature as if it were defined like this:

def f(*args, kw: T | None=None, **kwargs): ...

tooltip in my IDE

Note, that each function might have other positional and/or keyword arguments.


It seems to me like I should be able to have some decorator sort of like this:

def decorator(func):
    sig = inspect.signature(func)
    hints = get_type_hints(func)
    orig_ann = hints.get("kw")
    new_ann = Optional[orig_ann]

    new_params = []
    for param in sig.parameters.values():
        if param.name == "kw":
            new_params.append(
                param.replace(
                    annotation=new_ann,
                    default=None,
                )
            )
        else:
            new_params.append(param)

    new_sig = sig.replace(parameters=new_params)
    
    @wraps(func)
    def wrapper(*args, **kwargs):
        if kwargs.get("kw") is None:
            kwargs["kw"] = some_function_or_something()
        return func(*args, **kwargs)

    "do something to update the signature"
    return wrapper

But I cannot figure out what I would need to do to update the static signature so that the tooltip changes. Is it possible? How would I do it if so?


Solution

  • With Pylance, which appears the one your are using in your screenshot this is currently not possible in the way you imagine.

    To modify a unspecific signature you need a ParamSpec, i.e. to catch and forward a signature you need to use (*args:P.args, **kwargs:P.kwargs) the problem is you are not allowed to put new types in between, i.e.:

    def outer[**P](*args:P.args, **kwargs:P.kwargs):
       def inner_invalid(*args:P.args, kw: int, **kwargs:P.kwargs): ... # not allowed
    
       def inner_valid(p: int, *args:P.args, **kwargs:P.kwargs): ... # OK
    

    For kwargs typing you can to some extend use TypedDicts however not as flexible as a ParamSpec.

    TL;DR:

    The closest option that I see you can currently get is to use overload to display two signatures. The decorator used two Protocols to transform the input signature to the output signature:

    from typing import overload, Protocol, cast
    
    class KwProtocol[T, R, **P](Protocol):
    
        @overload
        @staticmethod
        def __call__(*args, kw: T, **kwargs) -> R: ...
    
        @overload
        @staticmethod
        def __call__(*args:P.args, **kwargs:P.kwargs) -> R: ...
    
        # Anything between P.args and P.kwargs is not allowed
        #def __call__(*args:P.args, kw: T, **kwargs:P.kwargs) -> R: ...
    
    class KwDefaultProtocol[T, R, **P](Protocol):
        @overload
        @staticmethod
        def __call__(*args:P.args, **kwargs:P.kwargs) -> R: ...
    
        @overload
        @staticmethod
        def __call__(*args, kw: T | None = None, **kwargs) -> R:
            """kw is optional and None by default"""
    
        # This definition is not allowed:
        #def __call__(*args:P.args, kw: T | None = None, **kwargs:P.kwargs) -> R: ...
    
    
    def kw_optional[T, R, **P](func: KwProtocol[T, R, P]) -> KwDefaultProtocol[T, R, P]:
        """A decorator that can be used to wrap a function."""
        # Do runtime stuff
        return cast(KwDefaultProtocol[T, R, P], func)
    
    @kw_optional
    def foo(a: int, *, kw: str, other: int) -> str:
        """Foo doc"""
        ...
    
    foo
    

    Shows both signatures
    Signature 1 with Foo doc
    Signature 2 with kw doc