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\$
-1
\$\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
    Jun 13, 2023 at 21:51

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.