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

Catching all errors #14

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
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
28 changes: 27 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ You may run the unit tests with::

OK


Basic Usage
-----------

Expand Down Expand Up @@ -181,6 +182,32 @@ typecheck_)::
>>> get_total_price(product2)
ValidationError: Invalid value {'price': 123, 'id': 1} (dict): missing required properties: ['name'] (at product)

Catching all errors
###################

The ``validate`` method raises a ``ValidationError`` at the first encountered
input error. In case one wants to catch all input errors (for example, to show
them all to the user after validating a web form), the ``full_validate`` method
raises a ``MultipleValidationError``, a ``ValidationError`` subclass whose
``errors`` attribute is a (flat) list of all individual errors::

>>> product3 = {
>>> "id": "1",
>>> "price": -10,
>>> "tags": "foo",
>>> "stock": {
>>> "retail": True,
>>> }
>>> }

>>> validator.full_validate(product3)
MultipleValidationError:
- Invalid value {'price': -10, 'stock': {'retail': True}, 'id': '1', 'tags': 'foo'} (dict): missing required properties: ['name']
- Invalid value '1' (str): must be number (at id)
- Invalid value -10 (int): must not be less than 0 (at price)
- Invalid value True (bool): must be number (at stock['retail'])
- Invalid value 'foo' (str): must be Sequence (at tags)

Adaptation
##########

Expand Down Expand Up @@ -273,7 +300,6 @@ optional are allowed by default. This default can be overriden by calling
... V.parse(schema).validate(data)
ValidationError: Invalid value 12 (int): must be string (at duration['seconds'])


Explicit Instantiation
######################

Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
author="George Sakkis",
author_email="[email protected]",
packages=find_packages(),
install_requires=["decorator"],
install_requires=["decorator", "six"],
test_suite="valideer.tests",
platforms=["any"],
keywords="validation adaptation typechecking jsonschema",
Expand Down
3 changes: 2 additions & 1 deletion valideer/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .base import *
from .validators import *
from .errors import *
from .validators import *
143 changes: 78 additions & 65 deletions valideer/base.py
Original file line number Diff line number Diff line change
@@ -1,62 +1,23 @@
import inspect
import itertools
from contextlib import contextmanager
from threading import RLock
from decorator import decorator
from .compat import with_metaclass

from six import with_metaclass
from .errors import SchemaError, ValidationError, MultipleValidationError


__all__ = [
"ValidationError", "SchemaError", "Validator", "accepts", "returns", "adapts",
"parse", "parsing", "register", "register_factory",
"set_name_for_types", "reset_type_names",
"Validator", "accepts", "returns", "adapts",
]

_NAMED_VALIDATORS = {}
_VALIDATOR_FACTORIES = []
_VALIDATOR_FACTORIES_LOCK = RLock()


class SchemaError(Exception):
"""An object cannot be parsed as a validator."""


class ValidationError(ValueError):
"""A value is invalid for a given validator."""

_UNDEFINED = object()

def __init__(self, msg, value=_UNDEFINED):
self.msg = msg
self.value = value
self.context = []
super(ValidationError, self).__init__()

def __str__(self):
return self.to_string()

@property
def message(self):
return self.to_string()

@property
def args(self):
return (self.to_string(),)

def to_string(self, repr_value=repr):
msg = self.msg
if self.value is not self._UNDEFINED:
msg = "Invalid value %s (%s): %s" % (repr_value(self.value),
get_type_name(self.value.__class__),
msg)
if self.context:
msg += " (at %s)" % "".join("[%r]" % context if i > 0 else str(context)
for i, context in enumerate(reversed(self.context)))
return msg

def add_context(self, context):
self.context.append(context)
return self


def parse(obj, required_properties=None, additional_properties=None):
"""Try to parse the given ``obj`` as a validator instance.

Expand Down Expand Up @@ -206,8 +167,7 @@ def __new__(mcs, name, bases, attrs): # @NoSelf
return validator_type


@with_metaclass(_MetaValidator)
class Validator(object):
class Validator(with_metaclass(_MetaValidator)):
"""Abstract base class of all validators.

Concrete subclasses must implement :py:meth:`validate`. A subclass may optionally
Expand All @@ -218,7 +178,9 @@ class Validator(object):
name = None

def validate(self, value, adapt=True):
"""Check if ``value`` is valid and if so adapt it.
"""
Check if ``value`` is valid and if so adapt it, otherwise raise a
``ValidationError`` for the first encountered error.

:param adapt: If ``False``, it indicates that the caller is interested
only on whether ``value`` is valid, not on adapting it. This is
Expand All @@ -230,6 +192,27 @@ def validate(self, value, adapt=True):
"""
raise NotImplementedError

def full_validate(self, value, adapt=True):
"""
Same as :py:meth:`validate` but raise :py:class:`MultipleValidationError`
that holds all validation errors if ``value`` is invalid.

The default implementation simply calls :py:meth:`validate` and wraps a
:py:class:`ValidationError` into a :py:class:`MultipleValidationError`.

:param adapt: If ``False``, it indicates that the caller is interested
only on whether ``value`` is valid, not on adapting it. This is
essentially an optimization hint for cases that validation can be
done more efficiently than adaptation.

:raises MultipleValidationError: If ``value`` is invalid.
:returns: The adapted value if ``adapt`` is ``True``, otherwise anything.
"""
try:
return self.validate(value, adapt)
except ValidationError as ex:
raise MultipleValidationError([ex])

def is_valid(self, value):
"""Check if the value is valid.

Expand Down Expand Up @@ -260,6 +243,53 @@ def humanized_name(self):
register_factory = staticmethod(register_factory)


class ContainerValidator(Validator):
"""
Convenient abstract base class for validators of container-like values that
need to report multiple errors for their items without duplicating the logic
between :py:meth:`validate` and :py:meth:`full_validate` or making the
former less efficient than necessary by delegating to the latter.

Concrete subclasses have to implement :py:meth:`_iter_errors_and_items` as a
generator that yields all validation errors and items of the container value.
If there are no validation errors and `adapt=True`, the final adapted value
is produced by passing the yielded items to :py:meth:`_reduce_items`. The
default :py:meth:`_reduce_items` instantiates `value.__class__` with the
iterator of items but subclasses can override it if necessary.
"""

def validate(self, value, adapt=True):
return self._validate(value, adapt, full=False)

def full_validate(self, value, adapt=True):
return self._validate(value, adapt, full=True)

def _validate(self, value, adapt, full):
iterable = self._iter_errors_and_items(value, adapt, full)
t1, t2 = itertools.tee(iterable)
iter_errors = (x for x in t1 if isinstance(x, ValidationError))
if full:
multi_error = MultipleValidationError(iter_errors)
if multi_error.errors:
raise multi_error
else:
error = next(iter_errors, None)
if error:
raise error

if adapt:
iter_items = (x for x in t2 if not isinstance(x, ValidationError))
return self._reduce_items(iter_items, value)

return value

def _reduce_items(self, iterable, value):
return value.__class__(iterable)

def _iter_errors_and_items(self, value, adapt, full):
raise NotImplementedError # pragma: no cover


def accepts(**schemas):
"""Create a decorator for validating function parameters.

Expand Down Expand Up @@ -335,20 +365,3 @@ def adapting(func, *args, **kwargs):
return func(*adapted_posargs, **adapted_keywords)

return adapting


_TYPE_NAMES = {}


def set_name_for_types(name, *types):
"""Associate one or more types with an alternative human-friendly name."""
for t in types:
_TYPE_NAMES[t] = name


def reset_type_names():
_TYPE_NAMES.clear()


def get_type_name(type):
return _TYPE_NAMES.get(type) or type.__name__
32 changes: 0 additions & 32 deletions valideer/compat.py

This file was deleted.

89 changes: 89 additions & 0 deletions valideer/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
__all__ = [
"SchemaError", "ValidationError", "MultipleValidationError",
"set_name_for_types", "reset_type_names",
]

_TYPE_NAMES = {}


def set_name_for_types(name, *types):
"""Associate one or more types with an alternative human-friendly name."""
for t in types:
_TYPE_NAMES[t] = name


def reset_type_names():
_TYPE_NAMES.clear()


def get_type_name(type):
return _TYPE_NAMES.get(type) or type.__name__


class SchemaError(Exception):
"""An object cannot be parsed as a validator."""


class ValidationError(ValueError):
"""A value is invalid for a given validator."""

_UNDEFINED = object()

def __init__(self, msg, value=_UNDEFINED):
self.msg = msg
self.value = value
self.context = []

def __str__(self):
return self.to_string()

@property
def message(self):
return self.to_string()

@property
def args(self):
return (self.to_string(),)

def to_string(self, repr_value=repr):
msg = self.msg
if self.value is not self._UNDEFINED:
msg = "Invalid value %s (%s): %s" % (repr_value(self.value),
get_type_name(self.value.__class__),
msg)
if self.context:
msg += " (at %s)" % "".join("[%r]" % context if i > 0 else str(context)
for i, context in enumerate(reversed(self.context)))
return msg

def add_context(self, context):
self.context.append(context)
return self


class MultipleValidationError(ValidationError):
"""Encapsulates multiple validation errors for a given value."""

def __init__(self, errors):
self.errors = []
self.add_errors(errors)

def to_string(self, repr_value=repr):
lines = [""]
lines.extend("- " + e.to_string(repr_value) for e in self.errors)
return "\n".join(lines)

def add_context(self, context):
for error in self.errors:
error.add_context(context)
return self

def add_errors(self, errors):
for error in errors:
if isinstance(error, MultipleValidationError):
self.errors.extend(error.errors)
elif isinstance(error, ValidationError):
self.errors.append(error)
else:
raise TypeError("ValidationError instance expected, %r given"
% error.__class__.__name__)
Loading