Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Change ValidationError serialization to {"loc": [], "msg": ""} #163

Merged
merged 1 commit into from
Jun 14, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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