Search code examples
pythonsignatureintrospection

How to use inspect.signature to check that a function needs one and only one parameter?


I want to validate (at runtime, this is not a typing question), that a function passed as an argument takes only 1 positional variable (basically that function will be called with a string as input and returns a truthy).

Naively, this is what I had:

def check(v_in : Callable):
    """check that the function can be called with 1 positional parameter supplied"""
    sig = signature(v_in)
    len_param = len(sig.parameters)
    if not len_param == 1:
        raise ValueError(
            f"expecting 1 parameter, of type `str` for {v_in}.  got {len_param}"
        )
    return v_in

If I check the following function, it is OK, which is good:

def f_ok1_1param(v : str):
    pass

but the next fails, though the *args would receive 1 param just fine and **kwargs would be empty

def f_ok4_vargs_kwargs(*args,**kwargs):
    pass
from rich import inspect as rinspect

try:
    check(f_ok1_1param)
    print("\n\npasses:") 
    rinspect(f_ok1_1param, title=False,docs=False)

except (Exception,) as e: 
    print("\n\nfails:") 
    rinspect(f_ok1_1param, title=False,docs=False)

try:
    check(f_ok4_vargs_kwargs)
    print("\n\npasses:") 
    rinspect(f_ok4_vargs_kwargs, title=False,docs=False)
except (Exception,) as e: 
    print("\n\nfails:") 
    rinspect(f_ok4_vargs_kwargs, title=False,docs=False)

which passes the first, and fails the second, instead of passing both:

passes:
╭─────────── <function f_ok1_1param at 0x1013c0f40> ───────────╮
│ def f_ok1_1param(v: str):                                    │
│                                                              │
│ 37 attribute(s) not shown. Run inspect(inspect) for options. │
╰──────────────────────────────────────────────────────────────╯


fails:
╭──────── <function f_ok4_vargs_kwargs at 0x101512200> ────────╮
│ def f_ok4_vargs_kwargs(*args, **kwargs):                     │
│                                                              │
│ 37 attribute(s) not shown. Run inspect(inspect) for options. │
╰──────────────────────────────────────────────────────────────╯

all the different combination of signatures are defined below:

def f_ok1_1param(v : str):
    pass

def f_ok2_1param(v):
    pass

def f_ok3_vargs(*v):
    pass

def f_ok4_p_vargs(p, *v):
    pass

def f_ok4_vargs_kwargs(*args,**kwargs):
    pass

def f_ok5_p_varg_kwarg(param,*args,**kwargs):
    pass

def f_bad1_2params(p1, p2):
    pass

def f_bad2_kwargs(**kwargs):
    pass

def f_bad3_noparam():
    pass

Now, I did already check a bit more about the Parameters:

rinspect(signature(f_ok4_vargs_kwargs).parameters["args"])
╭─────────────────── <class 'inspect.Parameter'> ───────────────────╮
│ Represents a parameter in a function signature.                   │
│                                                                   │
│ ╭───────────────────────────────────────────────────────────────╮ │
│ │ <Parameter "*args">                                           │ │
│ ╰───────────────────────────────────────────────────────────────╯ │
│                                                                   │
│          KEYWORD_ONLY = <_ParameterKind.KEYWORD_ONLY: 3>          │
│                  kind = <_ParameterKind.VAR_POSITIONAL: 2>        │
│                  name = 'args'                                    │
│       POSITIONAL_ONLY = <_ParameterKind.POSITIONAL_ONLY: 0>       │
│ POSITIONAL_OR_KEYWORD = <_ParameterKind.POSITIONAL_OR_KEYWORD: 1> │
│           VAR_KEYWORD = <_ParameterKind.VAR_KEYWORD: 4>           │
│        VAR_POSITIONAL = <_ParameterKind.VAR_POSITIONAL: 2>        │
╰───────────────────────────────────────────────────────────────────╯

I suppose checking Parameter.kind vs _ParameterKind enumeration of each parameter is how this needs to be approached, but I wonder if I am overthinking this or if something already exists to do this, either in inspect or if the typing protocol support can be used to do, but at runtime.

Note, theoretically def f_ok_cuz_default(p, p2 = None): would also work, but let's ignore that for now.

p.s. The motivation is providing a custom callback function in a validation framework. The call location is deep in the framework and that particular argument can also be a string (which gets converted to a regex). It can even be None. Easiest here is just to stick a def myfilter(*args,**kwargs): breakpoint. Or myfilter(foo). Then look at what you get from the framework and adjust body. It’s one thing to have exceptions in your function, another for the framework to accept it but then error before calling into it. So a quick “will this work when we call it?” is more user friendly.


Solution

  • I don't think your problem is trivial, at all. And I am not aware of any given implementation, so I followed your train of thoughts and came to the conclusion that in the end, the problem boils down to answering the following questions:

    1. Does the callable have any arguments, at all?
    2. If so, is the first argument positional?
    3. If so, are all other arguments optional?

    If you answer all questions with yes, then your function can be called with exactly one (positional) argument, otherwise not. In this context,

    • positional means:
      • a single positional-or-keyword argument, as in f(a), or
      • a single positional-only argument, as in f(a, /), or
      • a variable list of positional arguments, as in f(*args);
    • optional means:
      • a variable list of positional arguments, as in f(a, *args), or
      • a variable mapping of keyword arguments, as in f(a, **kwargs), or
      • an argument with a default value, as in f(a, b=None).

    Implementation

    You can implement the corresponding check as follows:

    from inspect import Parameter, signature
    from typing import Callable
    
    def _is_positional(param: Parameter) -> bool:
        return param.kind in [
            Parameter.POSITIONAL_OR_KEYWORD,
            Parameter.POSITIONAL_ONLY,
            Parameter.VAR_POSITIONAL]
    
    def _is_optional(param: Parameter) -> bool:
        return (param.kind in [
            Parameter.VAR_POSITIONAL,
            Parameter.VAR_KEYWORD] or
            param.default is not Parameter.empty)
    
    def has_one_positional_only(fct: Callable) -> bool:
        args = list(signature(fct).parameters.values())
        return (len(args) > 0 and  # 1. We have one or more args
                _is_positional(args[0]) and  # 2. First is positional
                all(_is_optional(a) for a in args[1:]))  # 3. Others are optional
    

    Test cases

    This will return the correct results for your test cases (which I extended a bit):

    def f_ok1_1param(v : str): pass
    def f_ok2_1param(v): pass
    def f_ok3_vargs(*v): pass
    def f_ok4_p_vargs(p, *v): pass
    def f_ok4_vargs_kwargs(*args, **kwargs): pass
    def f_ok5_p_varg_kwarg(param,*args,**kwargs): pass
    def f_ok6_pos_only(v, /): pass  # also ok: explicitly positional only
    def f_ok7_defaults(p, d=None): pass  # also ok: with default value
    def f_bad1_2params(p1, p2): pass
    def f_bad2_kwargs(**kwargs): pass
    def f_bad3_noparam(): pass
    def f_bad4_kwarg_after_args(*args, v): pass  # also not ok: v after *args is keyword-only
    def f_bad5_kwarg_only(*, v): pass  # also not ok: explicitly keyword-only
    
    print(has_one_positional_only(f_ok1_1param))             # True
    print(has_one_positional_only(f_ok2_1param))             # True
    print(has_one_positional_only(f_ok3_vargs))              # True
    print(has_one_positional_only(f_ok4_p_vargs))            # True
    print(has_one_positional_only(f_ok5_p_varg_kwarg))       # True
    print(has_one_positional_only(f_ok6_pos_only))           # True
    print(has_one_positional_only(f_ok7_defaults))           # True
    print(has_one_positional_only(f_bad1_2params))           # False
    print(has_one_positional_only(f_bad2_kwargs))            # False
    print(has_one_positional_only(f_bad3_noparam))           # False
    print(has_one_positional_only(f_bad4_kwarg_after_args))  # False
    print(has_one_positional_only(f_bad5_kwarg_only))        # False
    

    Two final thoughts:

    1. At first I thought that the 2nd question needs to be formulated more generally, namely: Is any argument positional? However, I did not come up with any combination where it is not the first argument that must be the positional one (according to the given definition of positional, that is), and I am quite confident it is not possible with current Python syntax rules.
    2. The solution for sure is not the most efficient one, given that with the knowledge about the parameters that you already checked, certain others are impossible to follow and thus actually don't need to be checked again (e.g. if the first optional argument was **kwargs then an optional *args cannot follow, any more).

    Update for clarification: The provided solution will "work" in the sense that it checks whether a given callable's signature is compatible with calling it with exactly one positional argument. What it cannot ensure is that one given positional argument satisfies the internal logic of the callable. For example, if f(*args) internally tries to access an element after the 1st element in args, it will fail if called with one argument only, even though the proposed check lets it pass. There is not much to do to check the internal logic from the outside this way, other than running the callable or inspecting its actual code. (Also, to my understanding, the latter is not asked for in the question.)