Skip to content

Commit

Permalink
Initial dataclass support, start of docs
Browse files Browse the repository at this point in the history
  • Loading branch information
Tinche committed Mar 17, 2024
1 parent 44f1118 commit 91b0367
Show file tree
Hide file tree
Showing 13 changed files with 319 additions and 63 deletions.
67 changes: 64 additions & 3 deletions docs/validation.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,75 @@
# Validation

_cattrs_ has a detailed validation mode since version 22.1.0, and this mode is enabled by default.
When running under detailed validation, the structuring hooks are slightly slower but produce richer and more precise error messages.
Unstructuring hooks are not affected.
_cattrs_ supports _structuring_ since its initial release, and _validation_ since release 24.1.

**Structuring** is the process of ensuring data matches a set of Python types;
it can be thought of as validating data against structural constraints.
Structuring ensures the shape of your data.
Structuring ensures data typed as `list[int]` really contains a list of integers.

**Validation** is the process of ensuring data matches a set of user-provided constraints;
it can be thought of as validating the value of data.
Validation happens after the shape of the data has been ensured.
Validation can ensure a `list[int]` contains at least one integer, and that all integers are positive.

## (Value) Validation

```{versionadded} 24.1.0
```
```{note} _This API is still provisional; as such it is subject to breaking changes._
```

_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).

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`.
These functions are called _validators_.

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`.
_V_ is the validation attribute, mapping to _attrs_ or _dataclass_ attributes.

```python
from attrs import define
from cattrs import Converter
from cattrs.v import customize, V

@define
class C:
a: int

converter = Converter()

customize(converter, C, V("a").ensure(lambda a: a > 0))
```

Now, every structuring of class `C` will run the provided validator(s).

```python
converter.structure({"a": -1}, C)
```

This process also works with dataclasses:

```python
from dataclasses import dataclass

@dataclass
class D:
a: int

customize(converter, D, V("a").ensure(lambda a: a == 5))
```

## Detailed Validation

```{versionadded} 22.1.0
```
Detailed validation is enabled by default and can be disabled for a speed boost by creating a converter with `detailed_validation=False`.
When running under detailed validation, the structuring hooks are slightly slower but produce richer and more precise error messages.
Unstructuring hooks are not affected.

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/).
ExceptionGroups are special exceptions which contain lists of other exceptions, which may themselves be other ExceptionGroups.
In essence, ExceptionGroups are trees of exceptions.
Expand Down
8 changes: 6 additions & 2 deletions src/cattrs/_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@
from typing import Sequence as TypingSequence
from typing import Set as TypingSet

from attrs import NOTHING, Attribute, Factory, resolve_types
from attrs import NOTHING, Attribute, AttrsInstance, Factory, resolve_types
from attrs import fields as attrs_fields
from attrs import fields_dict as attrs_fields_dict

from ._types import DataclassLike

__all__ = [
"ANIES",
"adapted_fields",
Expand Down Expand Up @@ -131,7 +133,9 @@ def fields(type):
return dataclass_fields(type)


def fields_dict(type) -> Dict[str, Union[Attribute, Field]]:
def fields_dict(
type: Union[Type[AttrsInstance], Type[DataclassLike]]
) -> Dict[str, Union[Attribute, Field]]:
"""Return the fields_dict for attrs and dataclasses."""
if is_dataclass(type):
return {f.name: f for f in dataclass_fields(type)}
Expand Down
58 changes: 58 additions & 0 deletions src/cattrs/_types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Types for internal use."""

from __future__ import annotations

from dataclasses import Field
from types import FrameType, TracebackType
from typing import (
TYPE_CHECKING,
Any,
Callable,
ClassVar,
Tuple,
Type,
TypeVar,
Union,
final,
)

from typing_extensions import LiteralString, Protocol, TypeAlias

ExcInfo: TypeAlias = Tuple[Type[BaseException], BaseException, TracebackType]
OptExcInfo: TypeAlias = Union[ExcInfo, Tuple[None, None, None]]

# Superset of typing.AnyStr that also includes LiteralString
AnyOrLiteralStr = TypeVar("AnyOrLiteralStr", str, bytes, LiteralString)

# Represents when str or LiteralStr is acceptable. Useful for string processing
# APIs where literalness of return value depends on literalness of inputs
StrOrLiteralStr = TypeVar("StrOrLiteralStr", LiteralString, str)

# Objects suitable to be passed to sys.setprofile, threading.setprofile, and similar
ProfileFunction: TypeAlias = Callable[[FrameType, str, Any], object]

# Objects suitable to be passed to sys.settrace, threading.settrace, and similar
TraceFunction: TypeAlias = Callable[[FrameType, str, Any], Union["TraceFunction", None]]


# Copied over from https://github.com/hauntsaninja/useful_types/blob/main/useful_types/experimental.py
# Might not work as expected for pyright, see
# https://github.com/python/typeshed/pull/9362
# https://github.com/microsoft/pyright/issues/4339
@final
class DataclassLike(Protocol):
"""Abstract base class for all dataclass types.
Mainly useful for type-checking.
"""

__dataclass_fields__: ClassVar[dict[str, Field[Any]]] = {}

# we don't want type checkers thinking this is a protocol member; it isn't
if not TYPE_CHECKING:

def __init_subclass__(cls):
raise TypeError(
"Use the @dataclass decorator to create dataclasses, "
"rather than subclassing dataclasses.DataclassLike"
)
4 changes: 4 additions & 0 deletions src/cattrs/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,3 +125,7 @@ def __init__(
message
or f"Extra fields in constructor for {cln}: {', '.join(extra_fields)}"
)


class ValueValidationError(BaseValidationError):
"""Raised when a custom value validator fails under detailed validation."""
17 changes: 15 additions & 2 deletions src/cattrs/v/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
ClassValidationError,
ForbiddenExtraKeysError,
IterableValidationError,
ValueValidationError,
)
from ._fluent import V, customize
from ._validators import (
Expand All @@ -31,6 +32,7 @@
"len_between",
"transform_error",
"V",
"ValidatorFactory",
]


Expand Down Expand Up @@ -62,8 +64,10 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str:
if type is not None:
tn = type.__name__ if hasattr(type, "__name__") else repr(type)
res = f"invalid value for type, expected {tn} ({exc.args[0]})"
else:
elif exc.args:
res = f"invalid value ({exc.args[0]})"
else:
res = "invalid value"
elif isinstance(exc, TypeError):
if type is None:
if exc.args[0].endswith("object is not iterable"):
Expand Down Expand Up @@ -93,7 +97,12 @@ def format_exception(exc: BaseException, type: Union[type, None]) -> str:


def transform_error(
exc: Union[ClassValidationError, IterableValidationError, BaseException],
exc: Union[
ClassValidationError,
IterableValidationError,
ValueValidationError,
BaseException,
],
path: str = "$",
format_exception: Callable[
[BaseException, Union[type, None]], str
Expand Down Expand Up @@ -137,6 +146,10 @@ def transform_error(
errors.append(f"{format_exception(exc, note.type)} @ {p}")
for exc in without:
errors.append(f"{format_exception(exc, None)} @ {path}")
elif isinstance(exc, ValueValidationError):
# This is a value validation error, which we should just flatten.
for inner in exc.exceptions:
errors.append(f"{format_exception(inner, None)} @ {path}")
elif isinstance(exc, ExceptionGroup):
# Likely from a nested validator, needs flattening.
errors.extend(
Expand Down
Loading

0 comments on commit 91b0367

Please sign in to comment.