Home > Software engineering >  Python inspect.Signature from typing.Callable
Python inspect.Signature from typing.Callable

Time:10-06

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.

CodePudding user response:

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.

  • Related