2
votes

At the core, what I'm trying to do is take a number of functions that look like this undecorated validation function:

def f(k: bool):
    def g(n):
        # check that n is valid
        return n
    return g

And make them look like this decorated validation function:

@k
def f():
    def g(n):
        # check that n is valid
        return n
    return g

The idea here being that k is describing the same functionality across all of the implementing functions.

Specifically, these functions are all returning 'validation' functions for use with the voluptuous validation framework. So all the functions of type f() are returning a function that is later executed by Schema(). k is actually allow_none, which is to say a flag that determines if a None value is ok. A very simple example might be this sample use code:

x = "Some input value."
y = None
input_validator = Schema(f(allow_none=True))
x = input_validator(x)  # succeeds, returning x
y = input_validator(y)  # succeeds, returning None
input_validator_no_none = Schema(f(allow_none=False))
x = input_validator(x)  # succeeds, returning x
y = input_validator(y)  # raises an Invalid

Without changing the sample use code I am attempting to achieve the same result by changing the undecorated validation functions to decorated validation functions. To give a concrete example, changing this:

def valid_identifier(allow_none: bool=True):
    min_range = Range(min=1)
    validator = Any(All(int, min_range), All(Coerce(int), min_range))
    return Any(validator, None) if allow_none else validator

To this:

@allow_none(default=True)
def valid_identifier():
    min_range = Range(min=1)
    return Any(All(int, min_range), All(Coerce(int), min_range))

The function returned from these two should be equivalent.

What I've tried to write is this, utilizing the decorator library:

from decorator import decorator

@decorator
def allow_none(default: bool=True):
    def decorate_validator(wrapped_validator, allow_none: bool=default):
        @wraps(wrapped_validator)
        def validator_allowing_none(*args, **kwargs):
            if allow_none:
                return Any(None, wrapped_validator)
            else:
                return wrapped_validator(*args, **kwargs)
        return validator_allowing_none
    return decorate_validator

And I have a unittest.TestCase in order to test if this works as expected:

@allow_none()
def test_wrapped_func():
    return Schema(str)

class TestAllowNone(unittest.TestCase):

    def test_allow_none__success(self):
        test_string = "blah"

        validation_function = test_wrapped_func(allow_none=False)
        self.assertEqual(test_string, validation_function(test_string))
        self.assertEqual(None, validation_function(None))

But my test returns the following failure:

    def validate_callable(path, data):
        try:
>           return schema(data)
E           TypeError: test_wrapped_func() takes 0 positional arguments but 1 was given

I tried debugging this, but couldn't get the debugger to actually enter the decoration. I suspect that because of naming issues, such as raised in this (very lengthy) blog post series, that test_wrapped_func isn't getting it's argument list properly set, and so the decorator is never even executed, but it may also be something else entirely.

I tried some other variations. By removing the function parentheses from @allow_none:

@allow_none
def test_wrapped_func():
    return Schema(str)

I get a different error:

>       validation_function = test_wrapped_func(allow_none=False)
E       TypeError: test_wrapped_func() got an unexpected keyword argument 'allow_none'

Dropping the @decorator fails with:

>       validation_function = test_wrapped_func(allow_none=False)
E       TypeError: decorate_validator() missing 1 required positional argument: 'wrapped_validator'

Which makes sense because @allow_none takes an argument, and so the parentheses would logically be needed. Replacing them gives the original error.

Decorators are subtle, and I'm clearly missing something here. This is similar to currying a function, but it's not quite working. What am I missing about how this should be implemented?

1

1 Answers

2
votes

I think you are putting your allow_none=default argument at the wrong nesting level. It should be on the innermost function (the wrapper), rather than the decorator (the middle level).

Try something like this:

def allow_none(default=True):    # this is the decorator factory
    def decorator(validator):    # this is the decorator
        @wraps(validator)
        def wrapper(*args, allow_none=default, **kwargs):    # this is the wrapper
            if allow_none:
                return Any(None, validator)
            else:
                return validator(*args, **kwargs)
        return wrapper
    return decorator

If you don't need the default to be settable, you can get rid of the outermost layer of nesting and just make the default value a constant in the wrapper function (or omit it if your callers will always pass a value). Note that as I wrote it above, the allow_none argument to the wrapper is a keyword-only argument. If you want to pass it as a positional parameter, you can move it ahead of *args, but that requires that it be the first positional argument, which may not be desireable from an API standpoint. More sophisticated solutions are probably possible, but overkill for this answer.