Skip to content

Commit 91b0367

Browse files
committed
Initial dataclass support, start of docs
1 parent 44f1118 commit 91b0367

13 files changed

+319
-63
lines changed

docs/validation.md

+64-3
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,75 @@
11
# Validation
22

3-
_cattrs_ has a detailed validation mode since version 22.1.0, and this mode is enabled by default.
4-
When running under detailed validation, the structuring hooks are slightly slower but produce richer and more precise error messages.
5-
Unstructuring hooks are not affected.
3+
_cattrs_ supports _structuring_ since its initial release, and _validation_ since release 24.1.
4+
5+
**Structuring** is the process of ensuring data matches a set of Python types;
6+
it can be thought of as validating data against structural constraints.
7+
Structuring ensures the shape of your data.
8+
Structuring ensures data typed as `list[int]` really contains a list of integers.
9+
10+
**Validation** is the process of ensuring data matches a set of user-provided constraints;
11+
it can be thought of as validating the value of data.
12+
Validation happens after the shape of the data has been ensured.
13+
Validation can ensure a `list[int]` contains at least one integer, and that all integers are positive.
14+
15+
## (Value) Validation
16+
17+
```{versionadded} 24.1.0
18+
19+
```
20+
```{note} _This API is still provisional; as such it is subject to breaking changes._
21+
22+
```
23+
24+
_cattrs_ can be configured to validate the values of your data (ensuring a list of integers has at least one member, and that all elements are positive).
25+
26+
The basic unit of value validation is a function that takes a value and, if the value is unacceptable, either raises an exception or returns exactly `False`.
27+
These functions are called _validators_.
28+
29+
The attributes of _attrs_ classes can be validated with the use of a helper function, {func}`cattrs.v.customize`, and a helper class, {class}`cattrs.v.V`.
30+
_V_ is the validation attribute, mapping to _attrs_ or _dataclass_ attributes.
31+
32+
```python
33+
from attrs import define
34+
from cattrs import Converter
35+
from cattrs.v import customize, V
36+
37+
@define
38+
class C:
39+
a: int
40+
41+
converter = Converter()
42+
43+
customize(converter, C, V("a").ensure(lambda a: a > 0))
44+
```
45+
46+
Now, every structuring of class `C` will run the provided validator(s).
47+
48+
```python
49+
converter.structure({"a": -1}, C)
50+
```
51+
52+
This process also works with dataclasses:
53+
54+
```python
55+
from dataclasses import dataclass
56+
57+
@dataclass
58+
class D:
59+
a: int
60+
61+
customize(converter, D, V("a").ensure(lambda a: a == 5))
62+
```
663

764
## Detailed Validation
865

966
```{versionadded} 22.1.0
1067
1168
```
69+
Detailed validation is enabled by default and can be disabled for a speed boost by creating a converter with `detailed_validation=False`.
70+
When running under detailed validation, the structuring hooks are slightly slower but produce richer and more precise error messages.
71+
Unstructuring hooks are not affected.
72+
1273
In detailed validation mode, any structuring errors will be grouped and raised together as a {class}`cattrs.BaseValidationError`, which is a [PEP 654 ExceptionGroup](https://www.python.org/dev/peps/pep-0654/).
1374
ExceptionGroups are special exceptions which contain lists of other exceptions, which may themselves be other ExceptionGroups.
1475
In essence, ExceptionGroups are trees of exceptions.

src/cattrs/_compat.py

+6-2
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,12 @@
3232
from typing import Sequence as TypingSequence
3333
from typing import Set as TypingSet
3434

35-
from attrs import NOTHING, Attribute, Factory, resolve_types
35+
from attrs import NOTHING, Attribute, AttrsInstance, Factory, resolve_types
3636
from attrs import fields as attrs_fields
3737
from attrs import fields_dict as attrs_fields_dict
3838

39+
from ._types import DataclassLike
40+
3941
__all__ = [
4042
"ANIES",
4143
"adapted_fields",
@@ -131,7 +133,9 @@ def fields(type):
131133
return dataclass_fields(type)
132134

133135

134-
def fields_dict(type) -> Dict[str, Union[Attribute, Field]]:
136+
def fields_dict(
137+
type: Union[Type[AttrsInstance], Type[DataclassLike]]
138+
) -> Dict[str, Union[Attribute, Field]]:
135139
"""Return the fields_dict for attrs and dataclasses."""
136140
if is_dataclass(type):
137141
return {f.name: f for f in dataclass_fields(type)}

src/cattrs/_types.py

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""Types for internal use."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import Field
6+
from types import FrameType, TracebackType
7+
from typing import (
8+
TYPE_CHECKING,
9+
Any,
10+
Callable,
11+
ClassVar,
12+
Tuple,
13+
Type,
14+
TypeVar,
15+
Union,
16+
final,
17+
)
18+
19+
from typing_extensions import LiteralString, Protocol, TypeAlias
20+
21+
ExcInfo: TypeAlias = Tuple[Type[BaseException], BaseException, TracebackType]
22+
OptExcInfo: TypeAlias = Union[ExcInfo, Tuple[None, None, None]]
23+
24+
# Superset of typing.AnyStr that also includes LiteralString
25+
AnyOrLiteralStr = TypeVar("AnyOrLiteralStr", str, bytes, LiteralString)
26+
27+
# Represents when str or LiteralStr is acceptable. Useful for string processing
28+
# APIs where literalness of return value depends on literalness of inputs
29+
StrOrLiteralStr = TypeVar("StrOrLiteralStr", LiteralString, str)
30+
31+
# Objects suitable to be passed to sys.setprofile, threading.setprofile, and similar
32+
ProfileFunction: TypeAlias = Callable[[FrameType, str, Any], object]
33+
34+
# Objects suitable to be passed to sys.settrace, threading.settrace, and similar
35+
TraceFunction: TypeAlias = Callable[[FrameType, str, Any], Union["TraceFunction", None]]
36+
37+
38+
# Copied over from https://github.com/hauntsaninja/useful_types/blob/main/useful_types/experimental.py
39+
# Might not work as expected for pyright, see
40+
# https://github.com/python/typeshed/pull/9362
41+
# https://github.com/microsoft/pyright/issues/4339
42+
@final
43+
class DataclassLike(Protocol):
44+
"""Abstract base class for all dataclass types.
45+
46+
Mainly useful for type-checking.
47+
"""
48+
49+
__dataclass_fields__: ClassVar[dict[str, Field[Any]]] = {}
50+
51+
# we don't want type checkers thinking this is a protocol member; it isn't
52+
if not TYPE_CHECKING:
53+
54+
def __init_subclass__(cls):
55+
raise TypeError(
56+
"Use the @dataclass decorator to create dataclasses, "
57+
"rather than subclassing dataclasses.DataclassLike"
58+
)

src/cattrs/errors.py

+4
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,7 @@ def __init__(
125125
message
126126
or f"Extra fields in constructor for {cln}: {', '.join(extra_fields)}"
127127
)
128+
129+
130+
class ValueValidationError(BaseValidationError):
131+
"""Raised when a custom value validator fails under detailed validation."""

src/cattrs/v/__init__.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
ClassValidationError,
1010
ForbiddenExtraKeysError,
1111
IterableValidationError,
12+
ValueValidationError,
1213
)
1314
from ._fluent import V, customize
1415
from ._validators import (
@@ -31,6 +32,7 @@
3132
"len_between",
3233
"transform_error",
3334
"V",
35+
"ValidatorFactory",
3436
]
3537

3638

@@ -62,8 +64,10 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str:
6264
if type is not None:
6365
tn = type.__name__ if hasattr(type, "__name__") else repr(type)
6466
res = f"invalid value for type, expected {tn} ({exc.args[0]})"
65-
else:
67+
elif exc.args:
6668
res = f"invalid value ({exc.args[0]})"
69+
else:
70+
res = "invalid value"
6771
elif isinstance(exc, TypeError):
6872
if type is None:
6973
if exc.args[0].endswith("object is not iterable"):
@@ -93,7 +97,12 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str:
9397

9498

9599
def transform_error(
96-
exc: Union[ClassValidationError, IterableValidationError, BaseException],
100+
exc: Union[
101+
ClassValidationError,
102+
IterableValidationError,
103+
ValueValidationError,
104+
BaseException,
105+
],
97106
path: str = "$",
98107
format_exception: Callable[
99108
[BaseException, Union[type, None]], str
@@ -137,6 +146,10 @@ def transform_error(
137146
errors.append(f"{format_exception(exc, note.type)} @ {p}")
138147
for exc in without:
139148
errors.append(f"{format_exception(exc, None)} @ {path}")
149+
elif isinstance(exc, ValueValidationError):
150+
# This is a value validation error, which we should just flatten.
151+
for inner in exc.exceptions:
152+
errors.append(f"{format_exception(inner, None)} @ {path}")
140153
elif isinstance(exc, ExceptionGroup):
141154
# Likely from a nested validator, needs flattening.
142155
errors.extend(

0 commit comments

Comments
 (0)