Skip to content

Commit

Permalink
Change ValidationError serialization to {"loc": [], "msg": ""} (#163)
Browse files Browse the repository at this point in the history
  • Loading branch information
wyfo authored Jun 14, 2021
1 parent 0a3b707 commit f8c761c
Show file tree
Hide file tree
Showing 29 changed files with 176 additions and 165 deletions.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ assert serialize(Resource, resource) == data
# Validate during deserialization
with raises(ValidationError) as err: # pytest checks exception is raised
deserialize(Resource, {"id": "42", "name": "wyfo"})
assert serialize(err.value) == [ # ValidationError is serializable
{"loc": ["id"], "err": ["badly formed hexadecimal UUID string"]}
assert err.value.errors == [
{"loc": ["id"], "msg": "badly formed hexadecimal UUID string"}
]
# Generate JSON Schema
assert deserialization_schema(Resource) == {
Expand Down
4 changes: 2 additions & 2 deletions apischema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,8 @@ def register_default_conversions():
"""Handle standard library + internal types"""
from . import std_types # noqa: F401

deserializer(ValidationError.deserialize)
serializer(ValidationError.serialize)
deserializer(ValidationError.from_errors)
serializer(ValidationError.errors)


register_default_conversions()
Expand Down
46 changes: 26 additions & 20 deletions apischema/deserialization/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,19 +126,6 @@ def get_constraints(schema: Optional[Schema]) -> Optional[Constraints]:
return schema.constraints if schema is not None else None


def with_validators(
method: DeserializationMethod, validators: Sequence[Validator]
) -> DeserializationMethod:
if not validators:
return method

@wraps(method)
def wrapper(data: Any) -> Any:
return validate(method(data), validators)

return wrapper


def get_constraint_errors(
constraints: Optional[Constraints], cls: type
) -> Optional[Callable[[Any], Sequence[str]]]:
Expand Down Expand Up @@ -201,6 +188,19 @@ def annotated(
)
return factory

def _with_validators(
self, method: DeserializationMethod, validators: Sequence[Validator]
) -> DeserializationMethod:
if not validators:
return method
aliaser = self.aliaser

@wraps(method)
def wrapper(data: Any) -> Any:
return validate(method(data), validators, aliaser=aliaser)

return wrapper

def any(self) -> DeserializationMethodFactory:
def factory(
constraints: Optional[Constraints], validators: Sequence[Validator]
Expand All @@ -214,7 +214,7 @@ def method(data: Any) -> Any:
raise ValidationError(errors)
return data

return with_validators(method, validators)
return self._with_validators(method, validators)

return DeserializationMethodFactory(factory)

Expand Down Expand Up @@ -244,7 +244,7 @@ def method(data: Any) -> Any:
raise ValidationError(errors, elt_errors)
return elts if cls is LIST_TYPE else COLLECTION_TYPES[cls](elts)

return with_validators(method, validators)
return self._with_validators(method, validators)

return DeserializationMethodFactory(factory, list)

Expand Down Expand Up @@ -312,7 +312,7 @@ def method(data: Any) -> Any:
raise ValidationError(errors, item_errors)
return items if cls is DICT_TYPE else MAPPING_TYPES[cls](items)

return with_validators(method, validators)
return self._with_validators(method, validators)

return DeserializationMethodFactory(factory, dict)

Expand Down Expand Up @@ -411,6 +411,7 @@ def factory(
flattened_fields or pattern_fields or (additional_field is not None)
)
constraint_errors = get_constraint_errors(constraints, dict)
aliaser = self.aliaser

def method(data: Any) -> Any:
if not isinstance(data, dict):
Expand Down Expand Up @@ -521,7 +522,12 @@ def method(data: Any) -> Any:
if not v.dependencies & invalid_fields
]
try:
validate(ValidatorMock(cls, values), validators2, **init)
validate(
ValidatorMock(cls, values),
validators2,
init,
aliaser=aliaser,
)
except ValidationError as err:
error = merge_errors(error, err)
raise error
Expand All @@ -541,7 +547,7 @@ def method(data: Any) -> Any:
except Exception as err:
raise ValidationError([str(err)])
if validators2:
validate(res, validators2, **init)
validate(res, validators2, init, aliaser=aliaser)
return res

return method
Expand Down Expand Up @@ -577,7 +583,7 @@ def method(data: Any) -> Any:
else:
raise bad_type(data, cls)

return with_validators(method, validators)
return self._with_validators(method, validators)

return DeserializationMethodFactory(factory, cls)

Expand Down Expand Up @@ -627,7 +633,7 @@ def method(data: Any) -> Any:
raise ValidationError(errors, elt_errors)
return tuple(elts)

return with_validators(method, validators)
return self._with_validators(method, validators)

return DeserializationMethodFactory(factory, list)

Expand Down
12 changes: 2 additions & 10 deletions apischema/graphql/resolvers.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,7 @@
from apischema.deserialization import deserialization_method
from apischema.objects import ObjectField
from apischema.schemas import Schema
from apischema.serialization import (
SerializationMethod,
SerializationMethodVisitor,
serialize,
)
from apischema.serialization import SerializationMethod, SerializationMethodVisitor
from apischema.serialization.serialized_methods import (
ErrorHandler,
SerializedMethod,
Expand Down Expand Up @@ -292,11 +288,7 @@ def resolve(__self, __info, **kwargs):
values[param_name] = None

if errors:
raise TypeError(
serialize(
ValidationError, ValidationError(children=errors), aliaser=aliaser
)
)
raise ValueError(ValidationError(children=errors).errors)
if info_parameter:
values[info_parameter] = __info
try:
Expand Down
71 changes: 35 additions & 36 deletions apischema/validation/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
import re
import sys
import warnings
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from functools import reduce, wraps
from inspect import isgeneratorfunction
from typing import (
Any,
Callable,
Collection,
Dict,
Generator,
Iterable,
Expand All @@ -19,10 +20,10 @@
Tuple,
TypeVar,
Union,
cast,
overload,
)

from apischema.aliases import Aliaser
from apischema.objects import AliasedStr
from apischema.typing import get_args, is_annotated
from apischema.utils import get_args2, get_origin2, merge_opts
Expand All @@ -36,22 +37,20 @@
Error = Union[ErrorMsg, Tuple[Any, ErrorMsg]]
# where Any = Union[Field, int, str, Iterable[Union[Field, int, str,]]]
# but Field being kind of magic not understood by type checkers, it's hidden behind Any
ErrorKey = Union[AliasedStr, str, int]
ErrorKey = Union[str, int]
T = TypeVar("T")
ValidatorResult = Generator[Error, None, T]

try:
from apischema.typing import TypedDict

@dataclass
class LocalizedError:
loc: Sequence[ErrorKey]
err: Sequence[ErrorMsg]
class LocalizedError(TypedDict):
loc: List[ErrorKey]
msg: ErrorMsg

def nested(self, index=0) -> "ValidationError":
if index == len(self.loc):
return ValidationError(self.err)
else:
assert index < len(self.loc)
return ValidationError(children={self.loc[index]: self.nested(index + 1)})

except ImportError:
LocalizedError = Mapping[str, Any] # type: ignore


@dataclass
Expand All @@ -62,20 +61,23 @@ class ValidationError(Exception):
def __str__(self):
return repr(self)

def flat(self) -> Iterator[Tuple[Tuple[ErrorKey, ...], Sequence[ErrorMsg]]]:
if self.messages:
yield (), self.messages
def _errors(self) -> Iterator[Tuple[List[ErrorKey], ErrorMsg]]:
for msg in self.messages:
yield [], msg
for child_key in sorted(self.children):
for path, errors in self.children[child_key].flat():
yield (child_key, *path), errors
for path, error in self.children[child_key]._errors():
yield [child_key, *path], error

def serialize(self) -> Sequence[LocalizedError]:
return [LocalizedError(loc, err) for loc, err in self.flat()]
@property
def errors(self) -> List[LocalizedError]:
return [{"loc": path, "msg": error} for path, error in self._errors()]

@staticmethod
def deserialize(errors: Sequence[LocalizedError]) -> "ValidationError":
def from_errors(errors: Sequence[LocalizedError]) -> "ValidationError":
return reduce(
merge_errors, map(LocalizedError.nested, errors), ValidationError()
merge_errors,
[_rec_build_error(err["loc"], err["msg"]) for err in errors],
ValidationError(),
)


Expand Down Expand Up @@ -117,8 +119,14 @@ def merge_errors(err1: ValidationError, err2: ValidationError) -> ValidationErro
)


def exception(err: Exception) -> str:
return str(err)
def apply_aliaser(error: ValidationError, aliaser: Aliaser) -> ValidationError:
aliased_children = {
str(aliaser(key))
if isinstance(key, AliasedStr)
else key: apply_aliaser(child, aliaser)
for key, child in error.children.items()
}
return replace(error, children=aliased_children)


def _rec_build_error(path: Sequence[ErrorKey], msg: ErrorMsg) -> ValidationError:
Expand All @@ -128,17 +136,6 @@ def _rec_build_error(path: Sequence[ErrorKey], msg: ErrorMsg) -> ValidationError
return ValidationError(children={path[0]: _rec_build_error(path[1:], msg)})


def _check_error_path(path) -> Sequence[ErrorKey]:
if isinstance(path, (int, str)):
path = (path,)
for i, elt in enumerate(path):
if not isinstance(path[i], (str, int)):
raise TypeError(
f"Bad error path, expected Field, int or str," f" found {type(i)}"
)
return cast(Sequence[ErrorKey], path)


def build_validation_error(errors: Iterable[Error]) -> ValidationError:
messages: List[ErrorMsg] = []
children: Dict[ErrorKey, ValidationError] = {}
Expand All @@ -150,7 +147,9 @@ def build_validation_error(errors: Iterable[Error]) -> ValidationError:
if not path:
messages.append(msg)
else:
key, *remain = _check_error_path(path)
if isinstance(path, str) or not isinstance(path, Collection):
path = (path,)
key, *remain = path
children[key] = merge_errors(
children.get(key), _rec_build_error(remain, msg)
)
Expand Down
44 changes: 27 additions & 17 deletions apischema/validation/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,17 @@
Dict,
Iterable,
List,
Mapping,
Optional,
Sequence,
Type,
TypeVar,
overload,
)

from apischema.aliases import Aliaser
from apischema.conversions.dataclass_models import get_model_origin, has_model_origin
from apischema.objects import object_fields
from apischema.objects import get_alias
from apischema.objects.fields import FieldOrName, check_field_or_name, get_field_name
from apischema.types import AnyType
from apischema.typing import get_type_hints
Expand All @@ -31,6 +33,7 @@
from apischema.validation.dependencies import find_all_dependencies
from apischema.validation.errors import (
ValidationError,
apply_aliaser,
build_validation_error,
merge_errors,
)
Expand Down Expand Up @@ -116,22 +119,30 @@ def __set_name__(self, owner, name):
T = TypeVar("T")


def validate(__obj: T, __validators: Iterable[Validator] = None, **kwargs) -> T:
if __validators is None:
__validators = get_validators(type(__obj))
def validate(
obj: T,
validators: Iterable[Validator] = None,
kwargs: Mapping[str, Any] = None,
*,
aliaser: Aliaser = lambda s: s,
) -> T:
if validators is None:
validators = get_validators(type(obj))
error: Optional[ValidationError] = None
__validators = iter(__validators)
for validator in __validators:
validators = iter(validators)
for validator in validators:
try:
if kwargs and validator.params != kwargs.keys():
if not kwargs:
validator.validate(obj)
elif validator.params == kwargs.keys():
validator.validate(obj, **kwargs)
else:
if any(k not in kwargs for k in validator.params):
raise RuntimeError(
f"Missing parameters {kwargs.keys() - validator.params}"
f" for validator {validator.func}"
)
validator.validate(__obj, **{k: kwargs[k] for k in validator.params})
else:
validator.validate(__obj, **kwargs)
validator.validate(obj, **{k: kwargs[k] for k in validator.params})
except ValidationError as e:
err = e
except NonTrivialDependency as exc:
Expand All @@ -143,24 +154,23 @@ def validate(__obj: T, __validators: Iterable[Validator] = None, **kwargs) -> T:
err = ValidationError([str(e)])
else:
continue
err = apply_aliaser(err, aliaser)
if validator.field is not None:
alias = object_fields(validator.owner)[
get_field_name(validator.field)
].alias
err = ValidationError(children={alias: err})
alias = getattr(get_alias(validator.owner), get_field_name(validator.field))
err = ValidationError(children={aliaser(alias): err})
error = merge_errors(error, err)
if validator.discard:
try:
discarded = set(map(get_field_name, validator.discard))
next_validators = (
v for v in __validators if not discarded & v.dependencies
v for v in validators if not discarded & v.dependencies
)
validate(__obj, next_validators, **kwargs)
validate(obj, next_validators, kwargs, aliaser=aliaser)
except ValidationError as err:
error = merge_errors(error, err)
if error is not None:
raise error
return __obj
return obj


V = TypeVar("V", bound=Callable)
Expand Down
Loading

0 comments on commit f8c761c

Please sign in to comment.