'Can I use inspect.signature to interpret a decorator's (*args, **kwargs) as the decorated function's variables would be?

Rather a tricky question to phrase. I hope I did all right. To clarify, here is a sample decorator:

class my_decorator:
    def __init__(self, option=None):
        self.option = option

    def __call__(self, fn):
        @functools.wraps(fn)
        def decorated(*args, **kwargs):
            fn_args = inspect.signature(fn).parameters
            
            # Code that creates local variables by taking the values that
            # arrived in *args and **kwargs in exactly the same way that
            # they are inside fn proper.

            return fn(*args, **kwargs)

        return decorated

If that is not clear as yet, we can add an explanatory level of detail to the proposal by looking at an application of said decorator:

@my_decorator()
def my_function(arg1, arg2, arg3, kwarg1=val1, kwarg2=val2)
    # In here we see the variables arg1, arg2, arg3, kwarg1 and kwarg2

The challenge is, can we see create variables arg1, arg2, arg3, kwarg1 and kwarg2 inside of def decorated(*args, **kwargs): by using fn_args.

I have a gut feel this can be done, but it would take some deep thinking, and I wonder if this isn't a wheel already invented. By which I mean, perhaps there is already a canonical method of applying a function signature to anonymous *args, **kwargs to produce local variables in accord with that signature.

I would like to think so. Namely, that I am not the first person to have discovered such a wish/desire. Of course, there is a real risk that I am. As general utility for that seems low to me. I have a very specific application in which the option passed in is a format string (of the style "{arg1}, {arg2} ...") that uses names from the parameter list of the decorated function.



Solution 1:[1]

Well, considerable effort researching existing solutions turned nothing up, so I have worked up a solution which works, though I'm not 100% happy with (as I'm using 'exec' which is optional really, the main trick inspect.signature.parameters that helps us interpret incoming arguments in the context of the decorated function's expectations):

from inspect import signature

... then inside 'decorated' ...

allargs = signature(fn).parameters

# allargs is an OrderedDict, the keys of which are the arguments of fn.
# We build therfrom, a list of arguments we are seeking in args and kwargs
seeking = list(allargs.keys())
found = {}

# Methods by convention have the first argument "self". Even if it's
# not a method, setting a local variable 'self' is fraught with issues
# And so we need to remap it. Also if we've explicitly decorated a method
# we remap the first argument. We call it 'selfie' interanally, but in
# provided key_patterns, accept 'self' as a reference.
if seeking[0] == 'self' or is_method:
    selfie = args[0]
    found['selfie'] = selfie
    seeking.pop(0)
    sarg = 1
    is_method = True
else:
    sarg = 0

# For classifying arguments see:
# https://docs.python.org/3/library/inspect.html#inspect.Parameter
#
# We start by consuming all the args.
if seeking:
    for arg in args[sarg:]:
        # This should never happen, but if someone calls the decorated function
        # with more args than the original function can accept that's clearly
        # an erroneous call.
        if len(seeking) == 0:
            raise TooManyArgs(f"Decorated function has been called with {len(args)} positional arguments when only {len(allargs)} args are accepted by the decorated function. ")

        # Set a local variable, feigning the conditions that fn would see
        # if seeking[0] is 'self' this exhibits odd dysfunctional behaviour
        # and so above we mapped 'self' to 'selfie' internall of this decorator.
        found[seeking[0]] = arg
        exec(f"{seeking[0]} = arg")
        seeking.pop(0)

    # If we did not find all that we seek by consuming args, consume kwargs
    if seeking:
        for kwarg, val in kwargs.items():
            if kwarg in seeking:
                # Should never happen, but if someone calls the decorated function
                # with more args than the original function can accept that's clearly
                # an erroneous call.
                if len(seeking) == 0:
                    raise TooManyKwargs(f"Decorated function has been called with {len(kwargs)} keyword arguments after {len(args)} positional arguments when only {len(allargs)} args are accepted by the decorated function. ")

                arg = seeking.index(kwarg)
                found[seeking[arg]] = val
                exec(f"{seeking[arg]} = val")
                seeking.pop(arg)

        if seeking:
            # Any that remain we can check for default values
            for arg in seeking:
                props = allargs[arg]
                if props.default != props.empty:
                    pos = seeking.index(arg)
                    found[seeking[pos]] = props.default
                    exec(f"{seeking[pos]} = props.default")
                    seeking.pop(pos)

            # If any remain, then clearly not all the argument fn needs have been supplied
            # to its decorated version.
            if seeking:
                raise TooFewArgs(f"Decorated function expects arguments ({', '.join(seeking)}), which it was not called with.")

That calls on a few custom exceptions:

class TooManyArgs(Exception):
    pass

class TooManyKwargs(Exception):
    pass

class TooFewArgs(Exception):
    pass

but works fine. The upshot is local variables in decorated which are just as they would be seen inside the decorated function.

While a spot need, it is utilised here:

It is used here:

https://pypi.org/project/django-cache-memoized/

and in action here:

https://github.com/bernd-wechner/django-cache-memoized/blob/master/src/django_cache_memoized/__init__.py

(We'll hope the specious vote to close a very sensible, well researched question doesn't find 2 supporters. It's a very interesting question IMHO and seems doable, though a canonical approach would be preferred.)

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1