Repository: allrod5/parameters-validation

Issue:
Patching the validate_parameters decorator
### Problem description
With the actual state of the library it is not intuitive how to write tests for methods that use the validate_parameters decorator. Because decorators are called at the moment that the python module is imported, to patch the decorator we need to disrupt the usual "good programming guidelines" and import the module in the middle of the code after patching it.

### Possible solution strategy

#### Disable the type checking
Since the decorator doesn't change the value of the inputs, every testing needed for the validation part can be done separately. So probably just having a way to deactivate the validate_parameters when running tests is good enough for most cases.

#### Postpone the evaluation
I took a brief look at a form of postponing the decorator evaluation so we could patch the method the usual way. Looks like the Python community is aware of the problems that the evaluation of annotations at function definition time can cause since I fond a [PEP for this](https://www.python.org/dev/peps/pep-0563/) discussing that.


Comments:

Patch:
diff --git a/README.md b/README.md
index 49dd10f..c44f8e9 100644
--- a/README.md
+++ b/README.md
@@ -69,6 +69,51 @@ def foo(df: log_to_debug(str)):
     # do something
 ```
 
+## Skipping validations
+
+For whatever reason, if one wants to skip validations a method `skip_validations` is
+appended to the decorated method. When called it will return the original method as if
+it wasn't decorated with `@validate_parameters`:
+
+```python
+from parameters_validation import no_whitespaces, validate_parameters
+
+@validate_parameters
+def foo(arg: no_whitespaces(str)):
+    print(arg)
+
+foo.skip_validations()("white   spaces")
+# prints: white   spaces
+```
+
+Note that, in the example, `foo.skip_validations()` does not changes `foo` itself but
+actually returns another function without the validation behaviour.
+
+## Testing
+
+In general, unit and integration tests should be fine with parameters validation
+validating input parameters though it might be the case one wants to mock some or all
+of the validations.
+
+Functions decorated with `@validate_parameters` are appended with a `mock_validations`
+method that accepts a dictionary mapping parameters to mock validations:
+
+```python
+from parameters_validation import no_whitespaces, validate_parameters
+
+@validate_parameters
+def foo(arg: no_whitespaces(str)):
+    print(arg)
+
+foo.mock_validations({"arg": lambda *_, **__: print("mocked")})("white   spaces")
+# prints: mocked
+# prints: white   spaces
+```
+
+Note that mock functions **must not** be decorated with `@parameter_validation`.
+Also, note that, in the example, `foo.mock_validations(...)` does not changes `foo`
+itself but actually returns another function with mocked behaviour. 
+
 ## When to validate parameters
 
 It is a pythonic convention follow the [EAFP](https://docs.python.org/3/glossary.html#term-eafp) principle whenever possible. There are cases however that skipping validations leads to silent errors and big headaches. Let's use an illustrative example:
diff --git a/parameters_validation/builtin_validations.py b/parameters_validation/builtin_validations.py
index c56476e..7c8a52b 100644
--- a/parameters_validation/builtin_validations.py
+++ b/parameters_validation/builtin_validations.py
@@ -210,4 +210,4 @@ def _build_arg(arg_name, arg_type):
             arg += " <{t}>".format(t=arg_type.__name__)
         except AttributeError:
             arg += " <{t}>".format(t=arg_type._name)
-    return arg
\ No newline at end of file
+    return arg
diff --git a/parameters_validation/validate_parameters_decorator.py b/parameters_validation/validate_parameters_decorator.py
index fa843a6..b59ed56 100644
--- a/parameters_validation/validate_parameters_decorator.py
+++ b/parameters_validation/validate_parameters_decorator.py
@@ -1,7 +1,55 @@
 import inspect
+from copy import deepcopy
 from functools import wraps
 
 
+def _get_parameter_value_dict(specs, args, kwargs):
+    parameters = kwargs.copy()
+    for arg_value, parameter in zip(args, specs.args):
+        parameters[parameter] = arg_value
+    if specs.defaults:
+        for default_parameter, default_value in zip(
+                specs.args[len(specs.args) - len(specs.defaults):], specs.defaults
+        ):
+            if default_parameter in parameters:
+                continue
+            parameters[default_parameter] = default_value
+    if specs.kwonlydefaults:
+        for default_parameter, default_value in specs.kwonlydefaults.items():
+            if default_parameter in parameters:
+                continue
+            parameters[default_parameter] = default_value
+    return parameters
+
+
+def _get_wrapper(f: callable, specs: inspect.FullArgSpec, validations: dict = None):
+    if validations is None:
+        validations = specs.annotations
+
+    @wraps(f)
+    def wrapper(*args, **kwargs):
+        parameters = _get_parameter_value_dict(specs, args, kwargs)
+        for parameter, annotation in validations.items():
+            if not hasattr(annotation, "_parameter_validation"):
+                continue
+            annotation(parameters[parameter], parameter)
+
+        return f(*args, **kwargs)
+
+    def parameter_validation_mock(pseudo_validation_function: callable):
+        mock = deepcopy(pseudo_validation_function)
+        mock._parameter_validation = True
+        return mock
+
+    def mock_validations(mocks: dict):
+        valid_mocks = {p: parameter_validation_mock(v) for p, v in mocks.items()}
+        return _get_wrapper(f, specs, {**validations, **valid_mocks})
+    wrapper.mock_validations = mock_validations
+    wrapper.skip_validations = lambda: f
+
+    return wrapper
+
+
 def validate_parameters(func):
     """
     Decorator to apply validations in the parameters type hints before executing the
@@ -18,36 +66,28 @@ def validate_parameters(func):
     ... foo("")                 # invalid, empty
     ... foo(None)               # invalid, none
 
+    Validations can be skipped with `.skip_validations()`:
+
+    >>> from parameters_validation import non_blank
+    ...
+    ... @validate_parameters
+    ... def foo(s: non_blank(str)):
+    ...     pass
+    ...
+    ... foo.skip_validations()("")  # does not throw since validations are skipped
+
+    Validations can be mocked for testing purposes with `.mock_validations({...})`:
+
+    >>> from parameters_validation import non_blank
+    ...
+    ... @validate_parameters
+    ... def foo(s: non_blank(str)):
+    ...     pass
+    ...
+    ... foo.mock_validations({"s": lambda *_: print("mocked")})("")  # prints "mocked"
+
     :param func: decorated function
     :return: wrapped function
     """
     specs = inspect.getfullargspec(func)
-    @wraps(func)
-    def wrapper(*args, **kwargs):
-        parameters = get_parameter_value_dict(args, kwargs)
-        for parameter, annotation in specs.annotations.items():
-            if not hasattr(annotation, "_parameter_validation"):
-                continue
-            annotation(parameters[parameter], parameter)
-
-        return func(*args, **kwargs)
-
-    def get_parameter_value_dict(args, kwargs):
-        parameters = kwargs.copy()
-        for arg_value, parameter in zip(args, specs.args):
-            parameters[parameter] = arg_value
-        if specs.defaults:
-            for default_parameter, default_value in zip(
-                    specs.args[len(specs.args)-len(specs.defaults):], specs.defaults
-            ):
-                if default_parameter in parameters:
-                    continue
-                parameters[default_parameter] = default_value
-        if specs.kwonlydefaults:
-            for default_parameter, default_value in specs.kwonlydefaults.items():
-                if default_parameter in parameters:
-                    continue
-                parameters[default_parameter] = default_value
-        return parameters
-
-    return wrapper
+    return _get_wrapper(func, specs)
diff --git a/setup.py b/setup.py
index 128adc3..99d95d4 100644
--- a/setup.py
+++ b/setup.py
@@ -16,7 +16,7 @@ extras = {
 
 setup(
     name='parameters-validation',
-    version='1.1.5',
+    version='1.2.0',
     packages=['parameters_validation'],
     url='https://github.com/allrod5/parameters-validation',
     license='MIT',


Test patch:
diff --git a/tests/unit/test_bultin_validations.py b/tests/unit/test_bultin_validations.py
index 8d5fb55..674c7e4 100644
--- a/tests/unit/test_bultin_validations.py
+++ b/tests/unit/test_bultin_validations.py
@@ -52,3 +52,19 @@ class TestBuiltinValidations:
     def test_strongly_typed_incorrect_usage(self):
         with pytest.raises(RuntimeError):
             bar("")
+
+    def test_unable_to_validate_non_blank(self):
+        with pytest.raises(RuntimeError):
+            foo(42, "", [None], "", 42, [1])
+
+    def test_unable_to_validate_non_empty(self):
+        with pytest.raises(RuntimeError):
+            foo("non-blank", "", None, "", 42, [1])
+
+    def test_unable_to_validate_no_whitespaces(self):
+        with pytest.raises(RuntimeError):
+            foo("non-blank", "", [None], None, 42, [1])
+
+    def test_unable_to_validate_non_negative(self):
+        with pytest.raises(RuntimeError):
+            foo("non-blank", "", [None], "", None, [1])
diff --git a/tests/unit/test_mock_validations.py b/tests/unit/test_mock_validations.py
new file mode 100644
index 0000000..d3eb050
--- /dev/null
+++ b/tests/unit/test_mock_validations.py
@@ -0,0 +1,36 @@
+import pytest
+
+from parameters_validation import validate_parameters, parameter_validation, non_blank
+
+
+@parameter_validation
+def type_error(*args, **kwargs):
+    raise TypeError
+
+
+def value_error(*args, **kwargs):
+    raise ValueError
+
+
+@validate_parameters
+def foo(arg: type_error(str)):
+    pass
+
+
+@validate_parameters
+def bar(arg: non_blank(str)):
+    pass
+
+
+class TestValidateParametersDecoratorMock:
+    def test_setup(self):
+        with pytest.raises(TypeError):
+            foo("anything")
+
+    def test_mock_validation_replaces_original(self):
+        with pytest.raises(ValueError):
+            foo.mock_validations({"arg": value_error})("anything")
+
+    def test_unmatched_mock_raises_key_error(self):
+        with pytest.raises(KeyError):
+            bar.mock_validations({"unmatched": value_error})("non_blank")
diff --git a/tests/unit/test_skip_validations.py b/tests/unit/test_skip_validations.py
new file mode 100644
index 0000000..da7d697
--- /dev/null
+++ b/tests/unit/test_skip_validations.py
@@ -0,0 +1,16 @@
+from parameters_validation import validate_parameters, parameter_validation
+
+
+@parameter_validation
+def custom_validation(param, arg_name, arg_type):
+    raise Exception
+
+
+@validate_parameters
+def foo(arg: custom_validation(str)):
+    pass
+
+
+class TestValidateParametersDecoratorSkip:
+    def test_skip_validation(self):
+        foo.skip_validations()("anything")