1
\$\begingroup\$

I have a list of functions whose parameters in the signature should be validated with the same criteria each time.

def sum_first_positive(a=1,b=1,c=1): assert a > 0 return a + b + c def sum_second_positive(a=1,b=1,c=1): assert b > 0 return a + b + c def sum_third_positive(a=1,b=1,c=1): assert c > 0 return a + b + c def sum_all_positives(a=1,b=1,c=1): assert a > 0 assert b > 0 assert c > 0 return a + b + c 

Rather than copy pasting the same validator inside each function, I want to use a decorator instead.

Here is my (working) attempt:

def decorator(*decorator_arguments): def inner_decorator(function): def wrapper(**kwargs): for val in decorator_arguments: if kwargs[val] <= 0: raise ValueError(f"Value '{val}' should be strictly positive. You passed {kwargs[val]}") return function(**kwargs) return wrapper return inner_decorator @decorator("c") def sum_third_positive(a=0, b=0, c=1): return a + b + c @decorator("a", "b", "c") def sum_all_positives(a=0, b=0, c=0): return a + b + c # Fails sum_all_positives(a=-1,b=1,c=1) # OK sum_third_positive(a=-1,b=1,c=10) # Fails sum_third_positive(a=-1,b=1,c=-10) 

The problem is, the user will have to pass all the arguments by name as I can not pass positional arguments to the wrapper.

I can define the function as:

@decorator("c") def sum_third_positive(*, a=0, b=0, c=1): return a + b + c 

to not allow for positional arguments, but this is not ideal.

Is there a solution to pass positional arguments, by position to the decorator arguments and check on their values?

\$\endgroup\$
1

3 Answers 3

2
\$\begingroup\$

The user-facing name should be substantive. Give it a name to convey its behavior or purpose: require_positive_params() in the illustration below. On a more technical level, the user-facing function is not exactly a decorator so much as it is a decorator factory: it returns the actual decorator that replaces the original function. Similar thoughts can be applied to decorator_arguments.

The error message can convey the parameter name. It's more helpful to the reader and not a difficult change.

Use functools.wraps(). It's easy and useful.

Use inspect.signature() to support positionals from callers. Requires only a small addition.

from functools import wraps from inspect import signature def require_positive_params(*param_names): def decorator(func): @wraps(func) def wrapper(*xs, **kws): params = signature(func).bind(*xs, **kws).arguments for p in param_names: val = params[p] if val <= 0: msg = f'Parameter should be positive: got {p}={val}' raise ValueError(msg) return func(*xs, **kws) return wrapper return decorator 

Next step: consider pulling the validation behavior out of the decorator. It's not too difficult to support a usage pattern like the one sketched below.

def require(validator, *param_names): def decorator(func): @wraps(func) ... def is_positive(name, val): if val > 0: return True else: msg = f'Parameter should be positive: got {name}={val}' raise ValueError(msg) def is_even(name, val): ... @require(is_positive, 'c') def sum1(a, b, c = 99): return a + b + c @require(is_even, 'a', 'b') def sum2(a, b = 0, c = 0): return a + b + c 
\$\endgroup\$
    1
    \$\begingroup\$

    I am against decorators or partial wrappers and recommend type hints to restrict the expected types' values.

    This way, a source code checker with a reasonable type hinting feature can detect issues regarding the type constraints already while programming instead of during runtime. Since you speak of the user, I suspect your (pseudo) code is part of a library intended for use by others. When using such a library, I'd prefer that the IDE already detects such an issue instead of the unit tests.

    NB: In your case you want to use Ge instead of Gt.

    \$\endgroup\$
      0
      \$\begingroup\$

      The decorator approach seems somewhat over-engineered and I am going to recommend against it. Consider a simple partial:

      from functools import partial def check_and_sum(positive_idx: tuple[int, ...], a: int = 1, b: int = 1, c: int = 1) -> int: addends = a, b, c for idx in positive_idx: val = addends[idx] if val < 1: raise ValueError(f'Addend {idx} with value {val} must be at least 1') return sum(addends) sum_first_positive = partial(check_and_sum, (0,)) sum_second_positive = partial(check_and_sum, (1,)) sum_third_positive = partial(check_and_sum, (2,)) sum_all_positive = partial(check_and_sum, (0, 1, 2)) 

      Tested as:

      def test() -> None: assert sum_first_positive(1, 2, -3) == 0 assert sum_second_positive(1, 2, -3) == 0 assert sum_third_positive(1, -2, 3) == 2 assert sum_third_positive(1, -2) == 0 assert sum_all_positive(1, 2, 3) == 6 try: sum_first_positive(0, 2, 3) raise AssertionError() except ValueError: pass try: sum_second_positive(1, 0, 2) raise AssertionError() except ValueError: pass try: sum_third_positive(1, 2, 0) raise AssertionError() except ValueError: pass for bad_index in range(3): addends = list(range(1, 4)) addends[bad_index] = 0 try: sum_all_positive(*addends) raise AssertionError() except ValueError: pass if __name__ == '__main__': test() 
      \$\endgroup\$
      1
      • \$\begingroup\$"The decorator approach seems somewhat over-engineered and I am going to recommend against it." And you recommend a closure which doesn't fit in the decorator pattern in a decoratoresque pattern? I think your psudo-decorator is far worse than a simple decorator.\$\endgroup\$
        – Peilonrayz
        CommentedJun 13, 2023 at 21:51

      Start asking to get answers

      Find the answer to your question by asking.

      Ask question

      Explore related questions

      See similar questions with these tags.