Skip to content

Commit 75f7021

Browse files
committed
Handle validation errors for callable objects and builtins
1 parent 22786cc commit 75f7021

2 files changed

Lines changed: 80 additions & 8 deletions

File tree

‎src/validators/utils.py‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
# standard
44
from functools import wraps
5-
from inspect import getfullargspec
6-
from itertools import chain
5+
from inspect import signature
76
from os import environ
87
from typing import Any, Callable, Dict
98

@@ -20,8 +19,9 @@ def __init__(self, function: Callable[..., Any], arg_dict: Dict[str, Any], messa
2019

2120
def __repr__(self):
2221
"""Repr Validation Failure."""
22+
func_name = getattr(self.func, "__name__", type(self.func).__name__)
2323
return (
24-
f"ValidationError(func={self.func.__name__}, "
24+
f"ValidationError(func={func_name}, "
2525
+ f"args={ ({k: v for (k, v) in self.__dict__.items() if k != 'func'}) })"
2626
)
2727

@@ -36,10 +36,12 @@ def __bool__(self):
3636

3737
def _func_args_as_dict(func: Callable[..., Any], *args: Any, **kwargs: Any):
3838
"""Return function's positional and key value arguments as an ordered dictionary."""
39-
return dict(
40-
list(zip(dict.fromkeys(chain(getfullargspec(func)[0], kwargs.keys())), args))
41-
+ list(kwargs.items())
42-
)
39+
try:
40+
return dict(signature(func).bind_partial(*args, **kwargs).arguments)
41+
except (TypeError, ValueError):
42+
# Some builtins have no inspectable signature. Retain the raw call
43+
# instead of letting error reporting raise another exception.
44+
return {"positional_args": args, "keyword_args": kwargs}
4345

4446

4547
def validator(func: Callable[..., Any]):

‎tests/test_validation_failure.py‎

Lines changed: 71 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""Test validation Failure."""
22

33
# standard
4+
from functools import partial
45
from typing import Any, Callable
56

67
# third-party
78
import pytest
89

910
# local
10-
from validators import ValidationError, between, slug, uuid
11+
from validators import ValidationError, between, slug, uuid, validator
1112

1213

1314
@pytest.mark.parametrize("function,value", [(slug, "invalid_"), (uuid, "not-a-uuid")])
@@ -69,3 +70,72 @@ def test_arguments_as_properties(self):
6970
assert self.is_in_between.__dict__["value"] == 3
7071
assert self.is_in_between.__dict__["min_val"] == 4
7172
assert self.is_in_between.__dict__["max_val"] == 5
73+
74+
75+
def test_partial_validation_error_representation():
76+
"""Validation errors support partial callables without a name attribute."""
77+
78+
def above(value: int, minimum: int) -> bool:
79+
return value > minimum
80+
81+
check = validator(partial(above, minimum=3))
82+
error = check(1)
83+
assert isinstance(error, ValidationError)
84+
assert repr(error) == "ValidationError(func=partial, args={'value': 1})"
85+
assert str(error) == repr(error)
86+
assert check(4) is True
87+
88+
89+
def test_callable_instance_validation_error_representation():
90+
"""Callable instance errors use their type name."""
91+
92+
class Positive:
93+
def __call__(self, *, value: int) -> bool:
94+
return value > 0
95+
96+
error = validator(Positive())(value=-1)
97+
assert isinstance(error, ValidationError)
98+
assert repr(error) == "ValidationError(func=Positive, args={'value': -1})"
99+
100+
101+
def test_builtin_validation_error_keeps_raw_arguments():
102+
"""An unavailable builtin signature must not hide its validation error."""
103+
check = validator(int)
104+
error = check("invalid")
105+
assert isinstance(error, ValidationError)
106+
assert "func=int" in repr(error)
107+
assert error.__dict__["positional_args"] == ("invalid",)
108+
assert error.__dict__["keyword_args"] == {}
109+
assert "invalid literal" in error.reason
110+
assert check("42") is True
111+
112+
with pytest.raises(ValidationError, match="func=int"):
113+
check("invalid", r_ve=True)
114+
115+
116+
def test_callable_instance_positional_validation_error_arguments():
117+
"""Positional callable instance arguments exclude the bound instance."""
118+
119+
class Positive:
120+
def __call__(self, value: int) -> bool:
121+
return value > 0
122+
123+
error = validator(Positive())(-1)
124+
assert isinstance(error, ValidationError)
125+
assert error.__dict__["value"] == -1
126+
assert "self" not in error.__dict__
127+
assert repr(error) == "ValidationError(func=Positive, args={'value': -1})"
128+
129+
130+
def test_bound_method_positional_validation_error_arguments():
131+
"""Positional bound method arguments exclude the bound instance."""
132+
133+
class Positive:
134+
def check(self, value: int) -> bool:
135+
return value > 0
136+
137+
error = validator(Positive().check)(-1)
138+
assert isinstance(error, ValidationError)
139+
assert error.__dict__["value"] == -1
140+
assert "self" not in error.__dict__
141+
assert repr(error) == "ValidationError(func=check, args={'value': -1})"

0 commit comments

Comments
 (0)