Search code examples
pythonpython-3.xtype-hinting

Python inspect.Signature from typing.Callable


How can I convert a typing.Callable type hint into a inspect.Signature function signature object?

Use case

Let's say I have a custom type hint for a function signature hint:

hint = typing.Callable[[int], float]

I want to use it both for type hinting as well as finding functions which conform to the signature of hint. To achieve the latter, I could compare inspect.Signature objects from the functions in question to hint if I had a way of converting the type hint into a signature.


Solution

  • This is an interesting question. I think there are two possible approaches, one of which I would recommend.

    Construct Signature from Callable

    This is possible, but I would not recommend this.

    The problem is that the Callable annotation contains only a subset of the information that a Signature object can hold. Information not available on a specified Callable type includes:

    1. Parameter names
    2. Parameter kinds (e.g. positional only or keyword-only etc.)
    3. Default values

    This means you have to make a lot of arbitrary choices, when you construct a Parameter from a type.

    Here is an example implementation:

    from collections.abc import Callable
    from inspect import Parameter, Signature, signature
    from typing import get_args
    
    
    def callable_type_to_signature(callable_type: type) -> Signature:
        params, ret = get_args(callable_type)
        params = [
            Parameter(f"arg{i}", Parameter.POSITIONAL_ONLY, annotation=param)
            for i, param in enumerate(params)
        ]
        return Signature(params, return_annotation=ret)
    
    
    def foo(x: int, y: str, z: dict[str, bool]) -> float:
        return NotImplemented
    
    
    if __name__ == '__main__':
        hint_foo = Callable[[int, str, dict[str, bool]], float]
        sig = callable_type_to_signature(hint_foo)
        print(sig)
        print(signature(foo))
    

    Output:

    (arg0: int, arg1: str, arg2: dict[str, bool], /) -> float
    (x: int, y: str, z: dict[str, bool]) -> float
    

    Notice that I chose to define all parameters as positional only and give them all names like argX.

    You could still use this to compare some function signatures with the output of this callable_type_to_signature, but you would have to take care to not compare apples to oranges.

    I think there is a better way.

    Compare Signature to Callable

    Since you wanted to compare signatures to type hints anyway, I think you don't need that extra step of creating another "fake" Signature. We can try and compare the two objects directly. Here is a working example:

    from collections.abc import Callable
    from inspect import Parameter, Signature, signature
    from typing import get_args, get_origin
    
    
    def param_matches_type_hint(
        param: Parameter,
        type_hint: type,
        strict: bool = False,
    ) -> bool:
        """
        Returns `True` if the parameter annotation matches the type hint.
    
        For this to be the case:
        In `strict` mode, both must be exactly equal.
        If both are specified generic types, they must be exactly equal.
        If the parameter annotation is a specified generic type and
        the type hint is an unspecified generic type,
        the parameter type's origin must be that generic type.
        """
        param_origin = get_origin(param.annotation)
        type_hint_origin = get_origin(type_hint)
        if (
            strict or
            (param_origin is None and type_hint_origin is None) or
            (param_origin is not None and type_hint_origin is not None)
        ):
            return param.annotation == type_hint
        if param_origin is None and type_hint_origin is not None:
            return False
        return param_origin == type_hint
    
    
    def signature_matches_type_hint(
        sig: Signature,
        type_hint: type,
        strict: bool = False,
    ) -> bool:
        """
        Returns `True` if the function signature and `Callable` type hint match.
    
        For details about parameter comparison, see `param_matches_type_hint`.
        """
        if get_origin(type_hint) != Callable:
            raise TypeError("type_hint must be a `Callable` type")
        type_params, return_type = get_args(type_hint)
        if sig.return_annotation != return_type:
            return False
        if len(sig.parameters) != len(type_params):
            return False
        return all(
            param_matches_type_hint(sig_param, type_param, strict=strict)
            for sig_param, type_param
            in zip(sig.parameters.values(), type_params)
        )
    
    
    def foo(x: int, y: str, z: dict[str, bool]) -> float:
        return NotImplemented
    
    
    def bar(x: dict[str, int]) -> bool:
        return NotImplemented
    
    
    def baz(x: list) -> bool:
        return NotImplemented
    
    
    if __name__ == '__main__':
        hint_foo = Callable[[int, str, dict], float]
        hint_bar = Callable[[dict], bool]
        hint_baz = Callable[[list[str]], bool]
        print(signature_matches_type_hint(signature(foo), hint_foo))
        print(signature_matches_type_hint(signature(bar), hint_bar))
        print(signature_matches_type_hint(signature(baz), hint_baz))
        print(signature_matches_type_hint(signature(bar), hint_bar, strict=True))
    

    Output:

    True
    True
    False
    False
    

    Details and caveats:

    This is a rather simplistic implementation. For one thing, it doesn't handle more "exotic" signatures, such as those that contain arbitrary keyword arguments (**kwargs). It is not entirely clear how that should be annotated anyway.

    This assumes that a more general function signature like that of baz is not compatible with the more specific type hint hint_baz. The other way around however, the more specific function signature like that of bar is compatible with the more general type hint hint_bar.

    If you only want exact matches regarding types, you can use strict=True.

    Hope this helps a bit and puts you on the right track. Maybe if I find the time, I'll try to extend this and test it a bit.