简体   繁体   中英

How to require positional arguments when using decorators?

I need to a create a generic decorator that validates parameters passed to multiple python functions, that have similar arguments, but not necessarily in the same order.

The python functions are part of an SDK, so the arguments need to be readable (ie can't just be *args and **kwargs as that would require the user to dig through the code.)

Let's consider the following decorator, which enforces the constraint that a > b :

from functools import wraps

def check_args(f):
    @wraps(f)
    def decorated_function(self, *args, **kwargs):
        a = kwargs["a"]
        b = kwargs["b"]
        
        if a < b:
            raise ValueError("a must be strictly greater than b")

        return f(self, *args, **kwargs)

    return decorated_function

Now consider the following example:

class MyClass(object):
    
    @check_args
    def f(self, *, a, b):
        return a + b

Let's call the method f and pass in a and b as keyword-arguments:

MyClass().f(a=2, b=1)

This works as expected, no errors.

Now's let again call the method f , but this time using arguments:

MyClass().f(1, 2)

This raises a KeyError:

  ---------------------------------------------------------------------------
  KeyError                                  Traceback (most recent call last)
  Input In [15], in <cell line: 7>()
        3     @check_args
        4     def f(self, *, a, b):
        5         return a + b
  ----> 7 MyClass().f(1, 2)

  Input In [14], in check_args.<locals>.decorated_function(self, *args, **kwargs)
        4 @wraps(f)
        5 def decorated_function(self, *args, **kwargs):
  ----> 6     a = kwargs["a"]
        7     b = kwargs["b"]
        9     if a < b:

  KeyError: 'a'

The parameters are now coming into the decorator as args , which means I would need to reference them as args[0], args[1]. But then how would I make the decorator generic? What if I want to use the decorator on a different function, which has a different starting parameter?

Moreover, I added the * to the list of arguments for f, to force the user to use keyword arguments, but instead, the decorator raised a KeyError.

If I remove the decorator:

class MyClass(object):
    
    def f(self, *, a, b):
        return a + b
    
MyClass().f(2, 1)

I get a different error:

  ---------------------------------------------------------------------------
  TypeError                                 Traceback (most recent call last)
  Input In [19], in <cell line: 6>()
        3     def f(self, *, a, b):
        4         return a + b
  ----> 6 MyClass().f(2, 1)

  TypeError: f() takes 1 positional argument but 3 were given

which is the error I want, as that forces the user to use keyword arguments!

What's the proper solution for this problem? How do I force the user to use keyword arguments when using decorators?

Edit: A hack would be to examine the list of args and raise an error if this list is non-empty. But that sounds like a cheat, is there a proper solution?

Rewrite args to kwargs??? maybe it not looks good, but it's working, and if you realy must...

def check_decorator(f):
    def wrapper(self,  **kwargs):
        if "a" in kwargs.keys():
            a = kwargs["a"]
        if "b" in kwargs.keys():
            b = kwargs["b"]

        if a < b:
            raise ValueError("a must be strictly greater than b")
        f(self, a, b)
    return wrapper


def change_args_decorator(fun):
    def wrapper_f(self, *args, **kwargs):
        if len(args) > 0:
            kwargs["a"] = args[0]
        if len(args) > 1:
            kwargs["b"] = args[1]
        fun(self,  **kwargs)
    return wrapper_f


class MyClass(object):
    @change_args_decorator
    @check_decorator
    def f(self,  a, b):
        print(a + b)
        return a + b


MyClass().f(2, 1)
MyClass().f(a=2, b=1)
MyClass().f(b=2, a=5)
MyClass().f(2, b=1)

3
3
7
3

try inspect.getfullargspec()

from functools import wraps
import inspect

def check_args(f):
    @wraps(f)
    def decorated_function(self, *args, **kwargs):
        print(inspect.getfullargspec(f))
        a = kwargs["a"]
        b = kwargs["b"]
        
        if a < b:
            raise ValueError("a must be strictly greater than b")

        return f(self, *args, **kwargs)

    return decorated_function

class MyClass(object):
    
    @check_args
    def f(self, *, a, b):
        return a + b

MyClass().f(1, 2)

it see the names:

FullArgSpec(args=['self'], varargs=None, varkw=None, defaults=None, kwonlyargs=['a', 'b'], kwonlydefaults=None, annotations={})

Since a and b are required keyword parameters, why not explicitly mark them in the decorator?

from functools import wraps


def check_args(f):
    @wraps(f)
    def decorated_function(self, *args, a, b, **kwargs):
        if a < b:
            raise ValueError("a must be strictly greater than b")

        return f(self, *args, a=a, b=b, **kwargs)

    return decorated_function

Test:

In [16]: MyClass().f(2, 1)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Input In [16], in <cell line: 1>()
----> 1 MyClass().f(2, 1)

TypeError: MyClass.f() missing 2 required keyword-only arguments: 'a' and 'b'

In [17]: MyClass().f(a=2, b=1)
Out[17]: 3

I think this decorator could perfectly works. And allows:

  • to chose which parameters to compare
  • chose condition that need to be fullfill
  • if one of the parameters is missing raise an error indicating which one

decorator

from functools import partial, wraps

def check_args(func=None, *, paramters_to_eval: list[str], condition=">"):
    
    if not func:
        return partial(check_args, paramters_to_eval=paramters_to_eval,
                       condition=condition)
    @wraps(func)
    def decorated_function(*args, **kwargs):
        argnames = func.__code__.co_varnames # get the argument names of the function
        if len(argnames) > 0:
            if argnames[0] == 'self':
                argnames = argnames[1:]
        
        values = dict(zip(argnames, args), **kwargs) # get arguments plus kwargs
        if set(paramters_to_eval).issubset(list(values.keys())): # only if both argument are given check conditon
            # create a condition as string to use eval
            string = f"{values[paramters_to_eval[0]]} {condition} {values[paramters_to_eval[1]]}"
            out = eval(string) # evaluate the condition                              
            if not out:
                raise ValueError(f'param "{paramters_to_eval[0]}" must be {condition} than param "{paramters_to_eval[1]}"')
        else:
            # check which params are missing and raise an error
            missing = [name for name in paramters_to_eval if name not in values.keys()]
            func_name = func.__name__
            raise Exception(f'function "{func_name}" missing parameter: "{", ".join(missing)}"')
        return func(*args, **kwargs)
    return decorated_function

now to use it we need to declare what are the variables of the that we want to eval and pass the name of parameters and the conditon to eval.

paramters_to_eval=['b', 'c'], condition='<' # b < c
paramters_to_eval=['c', 'b'], condition='<' # c < b

example1

# this force b to be smaller than c always
@check_args(paramters_to_eval=['b', 'c'], condition='<')
def f1(a, b, c):
    return a + b + c

outputs

f1(1, 2, 1)
>>>
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In [162], line 1
----> 1 f1(1, 2, 1)

Cell In [147], line 20, in check_args.<locals>.decorated_function(*args, **kwargs)
     18     out = eval(string)                               
     19     if not out:
---> 20         raise ValueError(f'param "{paramters_to_eval[0]}" must be {condition} than param "{paramters_to_eval[1]}"')
     22 return func(*args, **kwargs)

ValueError: param "b" must be < than param "c"

f1(1, 2)
>>>
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In [179], line 1
----> 1 f1(1, 2)

Cell In [177], line 26, in check_args.<locals>.decorated_function(*args, **kwargs)
     24     missing = [name for name in paramters_to_eval if name not in values.keys()]
     25     func_name = func.__name__
---> 26     raise Exception(f'function "{func_name}" missing parameter: "{", ".join(missing)}"')
     27 return func(*args, **kwargs)

Exception: function "f1" missing parameter: "c"

f1(1, 2, 4)
>>>7

Example2

#c need to be smaller or equal to a
@check_args(paramters_to_eval=['c', 'a'], condition='<=')
def f2(a, b, c):
    return a + b + c 

outputs

f2(1, 2, 2)
>>>
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In [169], line 1
----> 1 f2(1, 2, 2)

Cell In [147], line 20, in check_args.<locals>.decorated_function(*args, **kwargs)
     18     out = eval(string)                               
     19     if not out:
---> 20         raise ValueError(f'param "{paramters_to_eval[0]}" must be {condition} than param "{paramters_to_eval[1]}"')
     22 return func(*args, **kwargs)

ValueError: param "c" must be <= than param "a"

f2(1, 2, 1)
>>> 4

Example3 This decorator works perfectly with classes

class MyClass(object):
    
    @check_args(paramters_to_eval=['a', 'b'], condition='>')
    def f(self, a, b):
        return a + b
MyClass.f(1,2)
>>>
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In [172], line 6
      3     @check_args(paramters_to_eval=['a', 'b'], condition='>')
      4     def f(self, a, b):
      5         return a + b
----> 6 MyClass.f(1,2)

Cell In [147], line 20, in check_args.<locals>.decorated_function(*args, **kwargs)
     18     out = eval(string)                               
     19     if not out:
---> 20         raise ValueError(f'param "{paramters_to_eval[0]}" must be {condition} than param "{paramters_to_eval[1]}"')
     22 return func(*args, **kwargs)

ValueError: param "a" must be > than param "b"

Note that if any of two parameters arent pass to fucntion decorated the decorator raise an error indicating which parameter is missing

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM