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.
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:
If you answer all questions with yes, then your function can be called with exactly one (positional) argument, otherwise not. In this context,
f(a)
, orf(a, /)
, orf(*args)
;f(a, *args)
, orf(a, **kwargs)
, orf(a, b=None)
.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
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:
**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.)