Skip to content

Commit 996def1

Browse files
committed
ignoring_none
1 parent b058e2d commit 996def1

File tree

6 files changed

+169
-66
lines changed

6 files changed

+169
-66
lines changed

src/cattrs/v/__init__.py

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,18 @@
88
IterableValidationError,
99
)
1010
from ._fluent import V, customize
11-
from ._validators import between, greater_than, is_unique, len_between
11+
from ._validators import between, greater_than, ignoring_none, is_unique, len_between
1212

1313
__all__ = [
14+
"between",
1415
"customize",
1516
"format_exception",
16-
"transform_error",
17-
"V",
18-
"between",
1917
"greater_than",
20-
"len_between",
18+
"ignoring_none",
2119
"is_unique",
20+
"len_between",
21+
"transform_error",
22+
"V",
2223
]
2324

2425

@@ -109,21 +110,21 @@ def transform_error(
109110
with_notes, without = exc.group_exceptions()
110111
for exc, note in with_notes:
111112
p = f"{path}.{note.name}"
112-
if isinstance(exc, (ClassValidationError, IterableValidationError)):
113+
if isinstance(exc, ExceptionGroup):
113114
errors.extend(transform_error(exc, p, format_exception))
114-
elif isinstance(exc, ExceptionGroup):
115-
# A bare ExceptionGroup is now used to group all validator failures.
116-
errors.extend(
117-
[
118-
line
119-
for inner in exc.exceptions
120-
for line in transform_error(inner, p, format_exception)
121-
]
122-
)
123115
else:
124116
errors.append(f"{format_exception(exc, note.type)} @ {p}")
125117
for exc in without:
126118
errors.append(f"{format_exception(exc, None)} @ {path}")
119+
elif isinstance(exc, ExceptionGroup):
120+
# Likely from a nested validator, needs flattening.
121+
errors.extend(
122+
[
123+
line
124+
for inner in exc.exceptions
125+
for line in transform_error(inner, path, format_exception)
126+
]
127+
)
127128
else:
128129
errors.append(f"{format_exception(exc, None)} @ {path}")
129130
return errors

src/cattrs/v/_fluent.py

Lines changed: 46 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,25 @@
88
except ImportError:
99
from typing_extensions import assert_never
1010

11+
try:
12+
from typing import TypeGuard
13+
except ImportError:
14+
from typing_extensions import TypeGuard
15+
16+
from inspect import signature
17+
1118
from attrs import Attribute, AttrsInstance, define
1219
from attrs import fields as f
1320

14-
from cattrs import BaseConverter
15-
from cattrs._compat import ExceptionGroup
16-
from cattrs.dispatch import StructureHook
17-
from cattrs.gen import make_dict_structure_fn, override
21+
from .. import BaseConverter
22+
from .._compat import ExceptionGroup, TypeAlias
23+
from ..dispatch import StructureHook
24+
from ..gen import make_dict_structure_fn, override
1825

1926
T = TypeVar("T")
2027

28+
ValidatorFactory: TypeAlias = Callable[[bool], Callable[[T], None]]
29+
2130

2231
@define
2332
class VOmitted:
@@ -33,16 +42,16 @@ class VOmitted:
3342
class VRenamed(Generic[T]):
3443
"""This attribute has been renamed.
3544
36-
This class has no `omit` and no `rename`..
45+
This class has no `omit` and no `rename`.
3746
"""
3847

3948
attr: Attribute[T]
4049
new_name: str
4150

4251
def ensure(
4352
self: VRenamed[T],
44-
validator: Callable[[T], None | bool],
45-
*validators: Callable[[T], None | bool],
53+
validator: Callable[[T], None | bool] | ValidatorFactory[T],
54+
*validators: Callable[[T], None | bool] | ValidatorFactory[T],
4655
) -> VCustomized[T]:
4756
return VCustomized(self.attr, self.new_name, (validator, *validators))
4857

@@ -56,7 +65,7 @@ class VCustomized(Generic[T]):
5665

5766
attr: Attribute[T]
5867
new_name: str | None
59-
hooks: tuple[Callable[[T], None | bool], ...] = ()
68+
validators: tuple[Callable[[T], None | bool] | ValidatorFactory[T], ...] = ()
6069

6170

6271
@define
@@ -73,18 +82,17 @@ class V(Generic[T]):
7382

7483
def __init__(self, attr: Attribute[T]) -> None:
7584
self.attr = attr
76-
self.hooks = ()
85+
self.validators = ()
7786

7887
attr: Attribute[T]
79-
hooks: tuple[Callable[[T], None], ...] = ()
88+
validators: tuple[Callable[[T], None | bool] | ValidatorFactory[T], ...] = ()
8089

8190
def ensure(
8291
self: V[T],
83-
validator: Callable[[T], None | bool],
84-
*validators: Callable[[T], None],
92+
validator: Callable[[T], None | bool] | ValidatorFactory[T],
93+
*validators: Callable[[T], None] | ValidatorFactory[T],
8594
) -> VCustomized[T]:
86-
hooks = (*self.hooks, validator, *validators)
87-
return VCustomized(self.attr, None, hooks)
95+
return VCustomized(self.attr, None, (*self.validators, validator, *validators))
8896

8997
def rename(self: V[T], new_name: str) -> VRenamed[T]:
9098
"""Rename the attribute after processing."""
@@ -94,32 +102,11 @@ def omit(self) -> VOmitted:
94102
"""Omit the attribute."""
95103
return VOmitted(self.attr)
96104

97-
def replace_with(self, value: T) -> VOmitted:
105+
def replace_on_structure(self, value: T) -> VOmitted:
98106
"""This attribute should be replaced with a value when structuring."""
99107
return VOmitted(self.attr)
100108

101109

102-
def ignoring_none(*validators: Callable[[T], None]) -> Callable[[T | None], None]:
103-
"""
104-
A validator for (f.e.) strings cannot be applied to `str | None`, but it can
105-
be wrapped with this to adapt it so it can.
106-
"""
107-
108-
def skip_none(val: T | None) -> None:
109-
if val is None:
110-
return
111-
errors = []
112-
for validator in validators:
113-
try:
114-
validator(val)
115-
except Exception as exc:
116-
errors.append(exc)
117-
if errors:
118-
raise ExceptionGroup("", errors)
119-
120-
return skip_none
121-
122-
123110
def all_elements_must(
124111
validator: Callable[[T], None | bool], *validators: Callable[[T], None | bool]
125112
) -> Callable[[Iterable[T]], None | bool]:
@@ -145,9 +132,22 @@ def assert_all_elements(val: Iterable[T]) -> None:
145132
return assert_all_elements
146133

147134

135+
def _is_validator_factory(
136+
validator: Callable[[Any], None | bool] | ValidatorFactory[T]
137+
) -> TypeGuard[ValidatorFactory[T]]:
138+
"""Figure out if this is a validator factory or not."""
139+
sig = signature(validator)
140+
ra = sig.return_annotation
141+
return (
142+
callable(ra)
143+
or isinstance(ra, str)
144+
and sig.return_annotation.startswith("Callable")
145+
)
146+
147+
148148
def _compose_validators(
149149
base_structure: StructureHook,
150-
validators: Sequence[Callable[[Any], None | bool]],
150+
validators: Sequence[Callable[[Any], None | bool] | ValidatorFactory],
151151
detailed_validation: bool,
152152
) -> Callable[[Any, Any], Any]:
153153
"""Produce a hook composing the base structuring hook and additional validators.
@@ -157,11 +157,17 @@ def _compose_validators(
157157
The new hook will raise an ExceptionGroup.
158158
"""
159159
bs = base_structure
160+
final_validators = []
161+
for val in validators:
162+
if _is_validator_factory(val):
163+
final_validators.append(val(detailed_validation))
164+
else:
165+
final_validators.append(val)
160166

161167
if detailed_validation:
162168

163169
def structure_hook(
164-
val: dict[str, Any], t: Any, _hooks=validators, _bs=bs
170+
val: dict[str, Any], t: Any, _hooks=final_validators, _bs=bs
165171
) -> Any:
166172
res = _bs(val, t)
167173
errors: list[Exception] = []
@@ -177,7 +183,7 @@ def structure_hook(
177183
else:
178184

179185
def structure_hook(
180-
val: dict[str, Any], t: Any, _hooks=validators, _bs=bs
186+
val: dict[str, Any], t: Any, _hooks=final_validators, _bs=bs
181187
) -> Any:
182188
res = _bs(val, t)
183189
for hook in _hooks:
@@ -221,7 +227,7 @@ def customize(
221227
overrides[field.attr.name] = override(rename=field.new_name)
222228
elif isinstance(field, VCustomized):
223229
base_hook = converter._structure_func.dispatch(field.attr.type)
224-
hook = _compose_validators(base_hook, field.hooks, detailed_validation)
230+
hook = _compose_validators(base_hook, field.validators, detailed_validation)
225231
overrides[field.attr.name] = override(
226232
rename=field.new_name, struct_hook=hook
227233
)

src/cattrs/v/_validators.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from collections.abc import Hashable
44
from typing import Callable, Collection, Protocol, Sized, TypeVar
55

6+
from ._fluent import ValidatorFactory
7+
68
T = TypeVar("T")
79

810

@@ -52,3 +54,42 @@ def is_unique(val: Collection[Hashable]) -> None:
5254
raise ValueError(
5355
f"Collection ({length} elem(s)) not unique, only {unique_length} unique elem(s)"
5456
)
57+
58+
59+
def ignoring_none(
60+
validator: Callable[[T], None], *validators: Callable[[T], None]
61+
) -> ValidatorFactory[T | None]:
62+
"""
63+
Wrap validators with this so they can be applied to types that include `None`.
64+
65+
Values that are equal to `None` are passed through.
66+
"""
67+
68+
validators = (validator, *validators)
69+
70+
def factory(detailed_validation: bool) -> Callable[[T | None], None]:
71+
if detailed_validation:
72+
73+
def skip_none(val: T | None, _validators=validators) -> None:
74+
if val is None:
75+
return
76+
errors = []
77+
for validator in _validators:
78+
try:
79+
validator(val)
80+
except Exception as exc:
81+
errors.append(exc)
82+
if errors:
83+
raise ExceptionGroup("", errors)
84+
85+
else:
86+
87+
def skip_none(val: T | None, _validators=validators) -> None:
88+
if val is None:
89+
return
90+
for validator in _validators:
91+
validator(val)
92+
93+
return skip_none
94+
95+
return factory
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,4 @@
4848
4949
c = Converter()
5050
51-
v.customize(c, A, v.V(f(A).a).ensure(v.len_between(0, 10))) # E: Argument 1 to "ensure" of "V" has incompatible type "Callable[[Sized], None]"; expected "Callable[[int], bool | None]" [arg-type]
51+
v.customize(c, A, v.V(f(A).a).ensure(v.len_between(0, 10))) # E: Argument 1 to "ensure" of "V" has incompatible type "Callable[[Sized], None]"; expected "Callable[[int], bool | None] | Callable[[bool], Callable[[int], None]]" [arg-type]
Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,31 @@
6161
6262
c = Converter()
6363
64-
v.customize(c, A, v.V(fields(A).a).ensure(v.is_unique)) # E: Argument 1 to "ensure" of "V" has incompatible type "Callable[[Collection[Hashable]], None]"; expected "Callable[[list[dict[Any, Any]]], bool | None]" [arg-type]
64+
v.customize(c, A, v.V(fields(A).a).ensure(v.is_unique)) # E: Argument 1 to "ensure" of "V" has incompatible type "Callable[[Collection[Hashable]], None]"; expected "Callable[[list[dict[Any, Any]]], bool | None] | Callable[[bool], Callable[[list[dict[Any, Any]]], None]]" [arg-type]
65+
66+
- case: ignoring_none
67+
main: |
68+
from attrs import define, fields
69+
from cattrs import v, Converter
70+
71+
@define
72+
class A:
73+
a: int | None
74+
75+
c = Converter()
76+
77+
v.customize(c, A, v.V(fields(A).a).ensure(v.ignoring_none(v.greater_than(5))))
78+
79+
- case: ignoring_none_missing
80+
main: |
81+
from attrs import define, fields
82+
from cattrs import v, Converter
83+
84+
@define
85+
class A:
86+
a: int | None
87+
88+
c = Converter()
89+
90+
v.customize(c, A, v.V(fields(A).a).ensure(v.greater_than(5))) # E: Argument 1 to "ensure" of "V" has incompatible type "Callable[[int], None]"; expected "Callable[[int | None], bool | None] | Callable[[bool], Callable[[int | None], None]]" [arg-type]
91+
v.customize(c, A, v.V(fields(A).a).ensure(v.ignoring_none(v.len_between(0, 5)))) # E: Argument 1 to "ignoring_none" has incompatible type "Callable[[Sized], None]"; expected "Callable[[int], None]" [arg-type]

0 commit comments

Comments
 (0)