diff --git a/src/validators/utils.py b/src/validators/utils.py index 77937f68..29ed2079 100644 --- a/src/validators/utils.py +++ b/src/validators/utils.py @@ -2,8 +2,7 @@ # standard from functools import wraps -from inspect import getfullargspec -from itertools import chain +from inspect import signature from os import environ from typing import Any, Callable, Dict @@ -20,8 +19,9 @@ def __init__(self, function: Callable[..., Any], arg_dict: Dict[str, Any], messa def __repr__(self): """Repr Validation Failure.""" + func_name = getattr(self.func, "__name__", type(self.func).__name__) return ( - f"ValidationError(func={self.func.__name__}, " + f"ValidationError(func={func_name}, " + f"args={ ({k: v for (k, v) in self.__dict__.items() if k != 'func'}) })" ) @@ -36,10 +36,12 @@ def __bool__(self): def _func_args_as_dict(func: Callable[..., Any], *args: Any, **kwargs: Any): """Return function's positional and key value arguments as an ordered dictionary.""" - return dict( - list(zip(dict.fromkeys(chain(getfullargspec(func)[0], kwargs.keys())), args)) - + list(kwargs.items()) - ) + try: + return dict(signature(func).bind_partial(*args, **kwargs).arguments) + except (TypeError, ValueError): + # Some builtins have no inspectable signature. Retain the raw call + # instead of letting error reporting raise another exception. + return {"positional_args": args, "keyword_args": kwargs} def validator(func: Callable[..., Any]): diff --git a/tests/test_validation_failure.py b/tests/test_validation_failure.py index df308410..ca954bf2 100644 --- a/tests/test_validation_failure.py +++ b/tests/test_validation_failure.py @@ -1,13 +1,14 @@ """Test validation Failure.""" # standard +from functools import partial from typing import Any, Callable # third-party import pytest # local -from validators import ValidationError, between, slug, uuid +from validators import ValidationError, between, slug, uuid, validator @pytest.mark.parametrize("function,value", [(slug, "invalid_"), (uuid, "not-a-uuid")]) @@ -69,3 +70,72 @@ def test_arguments_as_properties(self): assert self.is_in_between.__dict__["value"] == 3 assert self.is_in_between.__dict__["min_val"] == 4 assert self.is_in_between.__dict__["max_val"] == 5 + + +def test_partial_validation_error_representation(): + """Validation errors support partial callables without a name attribute.""" + + def above(value: int, minimum: int) -> bool: + return value > minimum + + check = validator(partial(above, minimum=3)) + error = check(1) + assert isinstance(error, ValidationError) + assert repr(error) == "ValidationError(func=partial, args={'value': 1})" + assert str(error) == repr(error) + assert check(4) is True + + +def test_callable_instance_validation_error_representation(): + """Callable instance errors use their type name.""" + + class Positive: + def __call__(self, *, value: int) -> bool: + return value > 0 + + error = validator(Positive())(value=-1) + assert isinstance(error, ValidationError) + assert repr(error) == "ValidationError(func=Positive, args={'value': -1})" + + +def test_builtin_validation_error_keeps_raw_arguments(): + """An unavailable builtin signature must not hide its validation error.""" + check = validator(int) + error = check("invalid") + assert isinstance(error, ValidationError) + assert "func=int" in repr(error) + assert error.__dict__["positional_args"] == ("invalid",) + assert error.__dict__["keyword_args"] == {} + assert "invalid literal" in error.reason + assert check("42") is True + + with pytest.raises(ValidationError, match="func=int"): + check("invalid", r_ve=True) + + +def test_callable_instance_positional_validation_error_arguments(): + """Positional callable instance arguments exclude the bound instance.""" + + class Positive: + def __call__(self, value: int) -> bool: + return value > 0 + + error = validator(Positive())(-1) + assert isinstance(error, ValidationError) + assert error.__dict__["value"] == -1 + assert "self" not in error.__dict__ + assert repr(error) == "ValidationError(func=Positive, args={'value': -1})" + + +def test_bound_method_positional_validation_error_arguments(): + """Positional bound method arguments exclude the bound instance.""" + + class Positive: + def check(self, value: int) -> bool: + return value > 0 + + error = validator(Positive().check)(-1) + assert isinstance(error, ValidationError) + assert error.__dict__["value"] == -1 + assert "self" not in error.__dict__ + assert repr(error) == "ValidationError(func=check, args={'value': -1})"