diff --git a/README.rst b/README.rst index 265ee6e..1c1360a 100644 --- a/README.rst +++ b/README.rst @@ -73,6 +73,7 @@ You may run the unit tests with:: OK + Basic Usage ----------- @@ -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 ########## @@ -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 ###################### diff --git a/setup.py b/setup.py index 7f6ae03..40ecf20 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ author="George Sakkis", author_email="george.sakkis@gmail.com", packages=find_packages(), - install_requires=["decorator"], + install_requires=["decorator", "six"], test_suite="valideer.tests", platforms=["any"], keywords="validation adaptation typechecking jsonschema", diff --git a/valideer/__init__.py b/valideer/__init__.py index 83cdcf6..c106dac 100644 --- a/valideer/__init__.py +++ b/valideer/__init__.py @@ -1,2 +1,3 @@ from .base import * -from .validators import * +from .errors import * +from .validators import * diff --git a/valideer/base.py b/valideer/base.py index 75d31d6..b14e962 100644 --- a/valideer/base.py +++ b/valideer/base.py @@ -1,13 +1,16 @@ 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 = {} @@ -15,48 +18,6 @@ _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. @@ -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 @@ -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 @@ -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. @@ -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. @@ -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__ diff --git a/valideer/compat.py b/valideer/compat.py deleted file mode 100644 index d98b19a..0000000 --- a/valideer/compat.py +++ /dev/null @@ -1,32 +0,0 @@ -import sys - -PY2 = sys.version_info[0] == 2 -PY3 = sys.version_info[0] == 3 - -if PY2: # pragma: no cover - string_types = basestring - int_types = (int, long) - from itertools import izip, imap - long = long - unicode = unicode - xrange = xrange - iteritems = dict.iteritems -else: # pragma: no cover - string_types = str - int_types = (int,) - izip = zip - imap = map - long = int - unicode = str - iteritems = dict.items - xrange = range - - -def with_metaclass(mcls): - def decorator(cls): - body = vars(cls).copy() - # clean out class body - body.pop('__dict__', None) - body.pop('__weakref__', None) - return mcls(cls.__name__, cls.__bases__, body) - return decorator diff --git a/valideer/errors.py b/valideer/errors.py new file mode 100644 index 0000000..7821f07 --- /dev/null +++ b/valideer/errors.py @@ -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__) diff --git a/valideer/tests/test_validators.py b/valideer/tests/test_validators.py index 23ee462..40ccda2 100644 --- a/valideer/tests/test_validators.py +++ b/valideer/tests/test_validators.py @@ -5,8 +5,13 @@ import json import re import unittest +from six import string_types, integer_types, binary_type, text_type, PY3 +from six.moves import xrange, zip + import valideer as V -from valideer.compat import long, unicode, xrange, string_types, int_types + +if PY3: + long = int class Fraction(V.Type): @@ -29,7 +34,7 @@ class TestValidator(unittest.TestCase): def setUp(self): V.Object.REQUIRED_PROPERTIES = True - V.base.reset_type_names() + V.reset_type_names() self.complex_validator = self.parse({ "n": "number", "?i": V.Nullable("integer", 0), @@ -39,7 +44,7 @@ def setUp(self): "?s": V.String(min_length=1, max_length=8), "?p": V.Nullable(re.compile(r"\d{1,4}$")), "?l": [{"+s2": "string"}], - "?t": (unicode, "number"), + "?t": (text_type, "number"), "?h": V.Mapping(int, ["string"]), "?o": V.NonNullable({"+i2": "integer"}), }) @@ -47,7 +52,7 @@ def setUp(self): def test_none(self): for obj in ["boolean", "integer", "number", "string", V.HomogeneousSequence, V.HeterogeneousSequence, - V.Mapping, V.Object, int, float, str, unicode, + V.Mapping, V.Object, int, float, binary_type, text_type, Fraction, Fraction(), Gender, Gender()]: self.assertFalse(self.parse(obj).is_valid(None)) @@ -762,7 +767,7 @@ def test_complex_adaptation(self): ]: adapted = self.complex_validator.validate(value) self.assertTrue(isinstance(adapted["n"], (int, long, float, Decimal))) - self.assertTrue(isinstance(adapted["i"], int_types)) + self.assertTrue(isinstance(adapted["i"], integer_types)) self.assertTrue(adapted.get("b") is None or isinstance(adapted["b"], bool)) self.assertTrue(adapted.get("d") is None or isinstance(adapted["d"], (date, datetime))) self.assertTrue(adapted.get("e") is None or adapted["e"] in "rgb") @@ -775,7 +780,7 @@ def test_complex_adaptation(self): for item in adapted["l"])) if adapted.get("t") is not None: self.assertEqual(len(adapted["t"]), 2) - self.assertTrue(isinstance(adapted["t"][0], unicode)) + self.assertTrue(isinstance(adapted["t"][0], text_type)) self.assertTrue(isinstance(adapted["t"][1], float)) if adapted.get("h") is not None: self.assertTrue(all(isinstance(key, int) @@ -784,15 +789,12 @@ def test_complex_adaptation(self): for value in adapted["h"].values() for value_item in value)) if adapted.get("o") is not None: - self.assertTrue(isinstance(adapted["o"]["i2"], int_types)) + self.assertTrue(isinstance(adapted["o"]["i2"], integer_types)) def test_humanized_names(self): class DummyValidator(V.Validator): name = "dummy" - def validate(self, value, adapt=True): - return value - self.assertEqual(DummyValidator().humanized_name, "dummy") self.assertEqual(V.Nullable(DummyValidator()).humanized_name, "dummy or null") self.assertEqual(V.AnyOf("boolean", DummyValidator()).humanized_name, @@ -846,7 +848,7 @@ def test_error_message_json_type_names(self): V.set_name_for_types("null", type(None)) V.set_name_for_types("integer", int, long) V.set_name_for_types("number", float) - V.set_name_for_types("string", str, unicode) + V.set_name_for_types("string", binary_type, text_type) V.set_name_for_types("array", list, collections.Sequence) V.set_name_for_types("object", dict, collections.Mapping) @@ -871,6 +873,115 @@ def test_error_message_json_type_names(self): ({"foo": 3, "opt": 12}, "Invalid value 12 (integer): must be string (at opt)")]) + def test_multiple_validation_error_message(self): + ex = V.MultipleValidationError([ + V.ValidationError('More cowbell', 'moo').add_context(0), + V.MultipleValidationError([ + V.ValidationError('Less blink', 'blink').add_context('x').add_context('1'), + V.ValidationError('More cowbell', 'mooooo').add_context('y').add_context('1'), + ]), + V.ValidationError('Boring', 'stuff').add_context(2), + ]) + self.assertEqual(len(ex.errors), 4) + self.assertEqual(str(ex), "\n" + "- Invalid value 'moo' (str): More cowbell (at 0)\n" + "- Invalid value 'blink' (str): Less blink (at 1['x'])\n" + "- Invalid value 'mooooo' (str): More cowbell (at 1['y'])\n" + "- Invalid value 'stuff' (str): Boring (at 2)") + with self.assertRaises(TypeError): + V.MultipleValidationError([ + V.ValidationError('More cowbell'), + V.MultipleValidationError([ + ValueError('oops'), + V.ValidationError('Less cowbell'), + ]), + ]) + + def test_full_validate_single_error(self): + obj = {"+foo": "number", "?bar": ["integer"]} + self._testFullValidationErrors(obj, 42, + ["Invalid value 42 (int): must be Mapping"]) + self._testFullValidationErrors(obj, {}, + ["Invalid value {} (dict): missing required properties: ['foo']"]) + self._testFullValidationErrors(obj, {"foo": "3"}, + ["Invalid value '3' (str): must be number (at foo)"]) + self._testFullValidationErrors(obj, {"foo": 3, "bar": None}, + ["Invalid value None (NoneType): must be Sequence (at bar)"]) + self._testFullValidationErrors(obj, {"foo": 3, "bar": [1, "2", 3]}, + ["Invalid value '2' (str): must be integer (at bar[1])"]) + + def test_full_validate_homogeneous_sequence(self): + obj = [{"foo": ["string"]}] + value = [1, {"foo": 2.5}, {"foo": ["x", True, "y"]}] + self._testFullValidationErrors(obj, value, errors=[ + "Invalid value 1 (int): must be Mapping (at 0)", + "Invalid value 2.5 (float): must be Sequence (at 1['foo'])", + "Invalid value True (bool): must be string (at 2['foo'][1])", + ]) + + def test_full_validate_heterogeneous_sequence(self): + obj = ("integer", ("string", "number")) + value = ("3", (4, True)) + self._testFullValidationErrors(obj, value, errors=[ + "Invalid value '3' (str): must be integer (at 0)", + "Invalid value 4 (int): must be string (at 1[0])", + "Invalid value True (bool): must be number (at 1[1])", + ]) + self._testFullValidationErrors(obj, {}, errors=[ + "Invalid value {} (dict): must be Sequence", + ]) + + def test_full_validate_mapping(self): + obj = V.Mapping("string", V.Mapping("integer", ["number"])) + value = { + 1: { + "a": [] + }, + "x": { + 0: [4.2, -1], + True: [1, "z", 2] + } + } + self._testFullValidationErrors(obj, value, errors=[ + "Invalid value 1 (int): must be string", + "Invalid value 'a' (str): must be integer (at 1)", + "Invalid value True (bool): must be integer (at x)", + "Invalid value 'z' (str): must be number (at x[True][1])", + ]) + self._testFullValidationErrors(obj, [], errors=[ + "Invalid value [] (list): must be Mapping", + ]) + + def test_full_validate_object(self): + obj = { + "+foo": "number", + "+bar": "string", + "?baz": { + "?a": "boolean", + "?b": V.Nullable("integer", 1), + } + } + value = {"baz": {"a": 1, "x": 4.5}} + common_errors = [ + "Invalid value {'baz': {'a': 1, 'x': 4.5}} (dict): missing required properties: ['foo', 'bar']", + "Invalid value 1 (int): must be boolean (at baz['a'])", + ] + + self._testFullValidationErrors(obj, value, errors=common_errors) + + with V.parsing(additional_properties=V.Object.REMOVE): + self._testFullValidationErrors(obj, value, errors=common_errors) + + with V.parsing(additional_properties=False): + self._testFullValidationErrors(obj, value, errors=common_errors + [ + "Invalid value {'a': 1, 'x': 4.5} (dict): additional properties: ['x'] (at baz)" + ]) + + with V.parsing(additional_properties="string"): + self._testFullValidationErrors(obj, value, errors=common_errors + [ + "Invalid value 4.5 (float): must be string (at baz['x'])" + ]) + def _testValidation(self, obj, invalid=(), valid=(), adapted=(), errors=(), error_value_repr=repr): validator = self.parse(obj) @@ -890,7 +1001,20 @@ def _testValidation(self, obj, invalid=(), valid=(), adapted=(), errors=(), validator.validate(value) except V.ValidationError as ex: error_repr = ex.to_string(error_value_repr) - self.assertEqual(error_repr, error, "Actual error: %r" % error_repr) + self.assertEqual(error_repr, error) + + def _testFullValidationErrors(self, obj, value, errors, error_value_repr=repr): + validator = self.parse(obj) + found_errors = [] + try: + validator.full_validate(value) + except V.MultipleValidationError as ex: + found_errors.extend(ex.errors) + + self.assertEqual(len(found_errors), len(errors)) + for found_error, error in zip(found_errors, errors): + found_error_repr = found_error.to_string(error_value_repr) + self.assertEqual(found_error_repr, error) class TestValidatorModuleParse(TestValidator): @@ -912,7 +1036,7 @@ def setUp(self): "s": V.String(min_length=1, max_length=8), "p": V.Nullable(re.compile(r"\d{1,4}$")), "l": [{"+s2": "string"}], - "t": (unicode, "number"), + "t": (text_type, "number"), "h": V.Mapping(int, ["string"]), "o": V.NonNullable({"+i2": "integer"}), }) @@ -926,7 +1050,3 @@ def test_required_properties_global(self): {"foo": 3}, {"bar": False, "baz": "yo"}, {"bar": True, "foo": 3.1}]) - - -if __name__ == '__main__': - unittest.main() diff --git a/valideer/validators.py b/valideer/validators.py index d48fd07..e7e44e1 100644 --- a/valideer/validators.py +++ b/valideer/validators.py @@ -1,10 +1,14 @@ -from .base import Validator, ValidationError, parse, get_type_name -from .compat import string_types, izip, imap, iteritems import collections import datetime import inspect import numbers import re +from six import string_types, iteritems +from six.moves import zip, map + +from .base import Validator, ContainerValidator, ValidationError, parse +from .errors import get_type_name + __all__ = [ "AnyOf", "AllOf", "ChainOf", "Nullable", "NonNullable", @@ -24,7 +28,7 @@ class AnyOf(Validator): """ def __init__(self, *schemas): - self._validators = list(imap(parse, schemas)) + self._validators = list(map(parse, schemas)) def validate(self, value, adapt=True): msgs = [] @@ -48,7 +52,7 @@ class AllOf(Validator): """ def __init__(self, *schemas): - self._validators = list(imap(parse, schemas)) + self._validators = list(map(parse, schemas)) def validate(self, value, adapt=True): result = value @@ -68,7 +72,7 @@ class ChainOf(Validator): """ def __init__(self, *schemas): - self._validators = list(imap(parse, schemas)) + self._validators = list(map(parse, schemas)) def validate(self, value, adapt=True): for validator in self._validators: @@ -192,7 +196,7 @@ def validate(self, value, adapt=True): @property def humanized_name(self): - return "one of {%s}" % ", ".join(list(imap(repr, self.values))) + return "one of {%s}" % ", ".join(list(map(repr, self.values))) class Condition(Validator): @@ -463,18 +467,16 @@ def _PatternFactory(obj): return Pattern(obj) -class HomogeneousSequence(Type): +class HomogeneousSequence(ContainerValidator): """A validator that accepts homogeneous, non-fixed size sequences.""" - accept_types = collections.Sequence - reject_types = string_types - def __init__(self, item_schema=None, min_length=None, max_length=None): """Instantiate a :py:class:`HomogeneousSequence` validator. :param item_schema: If not None, the schema of the items of the list. """ - super(HomogeneousSequence, self).__init__() + self._type_validator = Type(accept_types=collections.Sequence, + reject_types=string_types) if item_schema is not None: self._item_validator = parse(item_schema) else: @@ -482,28 +484,35 @@ def __init__(self, item_schema=None, min_length=None, max_length=None): self._min_length = min_length self._max_length = max_length - def validate(self, value, adapt=True): - super(HomogeneousSequence, self).validate(value) + def _iter_errors_and_items(self, value, adapt, full): + try: + self._type_validator.validate(value) + except ValidationError as ex: + yield ex + return + if self._min_length is not None and len(value) < self._min_length: - raise ValidationError("must contain at least %d elements" % + yield ValidationError("must contain at least %d elements" % self._min_length, value) + if self._max_length is not None and len(value) > self._max_length: - raise ValidationError("must contain at most %d elements" % + yield ValidationError("must contain at most %d elements" % self._max_length, value) + if self._item_validator is None: - return value - if adapt: - return value.__class__(self._iter_validated_items(value, adapt)) - for _ in self._iter_validated_items(value, adapt): - pass + for item in value: + yield item + else: + if full: + validate_item = self._item_validator.full_validate + else: + validate_item = self._item_validator.validate - def _iter_validated_items(self, value, adapt): - validate_item = self._item_validator.validate - for i, item in enumerate(value): - try: - yield validate_item(item, adapt) - except ValidationError as ex: - raise ex.add_context(i) + for i, item in enumerate(value): + try: + yield validate_item(item, adapt=adapt) + except ValidationError as ex: + yield ex.add_context(i) @HomogeneousSequence.register_factory @@ -516,36 +525,35 @@ def _HomogeneousSequenceFactory(obj): return HomogeneousSequence(*obj) -class HeterogeneousSequence(Type): +class HeterogeneousSequence(ContainerValidator): """A validator that accepts heterogeneous, fixed size sequences.""" - accept_types = collections.Sequence - reject_types = string_types - def __init__(self, *item_schemas): """Instantiate a :py:class:`HeterogeneousSequence` validator. :param item_schemas: The schema of each element of the the tuple. """ - super(HeterogeneousSequence, self).__init__() - self._item_validators = list(imap(parse, item_schemas)) + self._type_validator = Type(accept_types=collections.Sequence, + reject_types=string_types) + self._item_validators = list(map(parse, item_schemas)) + + def _iter_errors_and_items(self, value, adapt, full): + try: + self._type_validator.validate(value) + except ValidationError as ex: + yield ex + return - def validate(self, value, adapt=True): - super(HeterogeneousSequence, self).validate(value) if len(value) != len(self._item_validators): - raise ValidationError("%d items expected, %d found" % + yield ValidationError("%d items expected, %d found" % (len(self._item_validators), len(value)), value) - if adapt: - return value.__class__(self._iter_validated_items(value, adapt)) - for _ in self._iter_validated_items(value, adapt): - pass - def _iter_validated_items(self, value, adapt): - for i, (validator, item) in enumerate(izip(self._item_validators, value)): + method = 'full_validate' if full else 'validate' + for i, (validator, item) in enumerate(zip(self._item_validators, value)): try: - yield validator.validate(item, adapt) + yield getattr(validator, method)(item, adapt=adapt) except ValidationError as ex: - raise ex.add_context(i) + yield ex.add_context(i) @HeterogeneousSequence.register_factory @@ -558,18 +566,16 @@ def _HeterogeneousSequenceFactory(obj): return HeterogeneousSequence(*obj) -class Mapping(Type): +class Mapping(ContainerValidator): """A validator that accepts mappings (:py:class:`collections.Mapping` instances).""" - accept_types = collections.Mapping - def __init__(self, key_schema=None, value_schema=None): """Instantiate a :py:class:`Mapping` validator. :param key_schema: If not None, the schema of the dict keys. :param value_schema: If not None, the schema of the dict values. """ - super(Mapping, self).__init__() + self._type_validator = Type(accept_types=collections.Mapping) if key_schema is not None: self._key_validator = parse(key_schema) else: @@ -579,39 +585,50 @@ def __init__(self, key_schema=None, value_schema=None): else: self._value_validator = None - def validate(self, value, adapt=True): - super(Mapping, self).validate(value) - if adapt: - return dict(self._iter_validated_items(value, adapt)) - for _ in self._iter_validated_items(value, adapt): - pass - - def _iter_validated_items(self, value, adapt): - validate_key = validate_value = None - if self._key_validator is not None: + def _iter_errors_and_items(self, value, adapt, full): + try: + self._type_validator.validate(value) + except ValidationError as ex: + yield ex + return + + if self._key_validator is None: + validate_key = None + elif full: + validate_key = self._key_validator.full_validate + else: validate_key = self._key_validator.validate - if self._value_validator is not None: + + if self._value_validator is None: + validate_value = None + elif full: + validate_value = self._value_validator.full_validate + else: validate_value = self._value_validator.validate + for k, v in iteritems(value): + if validate_key is not None: + try: + k = validate_key(k, adapt) + except ValidationError as ex: + yield ex + if validate_value is not None: try: v = validate_value(v, adapt) except ValidationError as ex: - raise ex.add_context(k) - if validate_key is not None: - k = validate_key(k, adapt) + yield ex.add_context(k) + yield (k, v) -class Object(Type): +class Object(ContainerValidator): """A validator that accepts json-like objects. A ``json-like object`` here is meant as a dict with a predefined set of "properties", i.e. string keys. """ - accept_types = collections.Mapping - REQUIRED_PROPERTIES = False ADDITIONAL_PROPERTIES = True REMOVE = object() @@ -633,7 +650,7 @@ def __init__(self, optional={}, required={}, additional=None): - ``None`` to use the value of the ``ADDITIONAL_PROPERTIES`` class attribute. """ - super(Object, self).__init__() + self._type_validator = Type(accept_types=collections.Mapping) if additional is None: additional = self.ADDITIONAL_PROPERTIES if not isinstance(additional, bool) and additional is not self.REMOVE: @@ -646,49 +663,51 @@ def __init__(self, optional={}, required={}, additional=None): self._all_keys = set(name for name, _ in self._named_validators) self._additional = additional - def validate(self, value, adapt=True): - super(Object, self).validate(value) + def _iter_errors_and_items(self, value, adapt, full): + try: + self._type_validator.validate(value) + except ValidationError as ex: + yield ex + return + missing_required = self._required_keys.difference(value) if missing_required: - raise ValidationError("missing required properties: %s" % + yield ValidationError("missing required properties: %s" % list(missing_required), value) - result = dict(value) if adapt else None + method = 'full_validate' if full else 'validate' for name, validator in self._named_validators: if name in value: try: - adapted = validator.validate(value[name], adapt) - if result is not None: - result[name] = adapted + adapted = getattr(validator, method)(value[name], adapt=adapt) + if adapt: + yield (name, adapted) except ValidationError as ex: - raise ex.add_context(name) - elif result is not None and isinstance(validator, Nullable): + yield ex.add_context(name) + elif adapt and isinstance(validator, Nullable): default = validator.default_object_property if default is not Nullable._UNDEFINED: - result[name] = default - - if self._additional is not True: - all_keys = self._all_keys - additional_properties = [k for k in value if k not in all_keys] - if additional_properties: - if self._additional is False: - raise ValidationError("additional properties: %s" % - additional_properties, value) - elif self._additional is self.REMOVE: - if result is not None: - for name in additional_properties: - del result[name] - else: - additional_validate = self._additional.validate - for name in additional_properties: - try: - adapted = additional_validate(value[name], adapt) - if result is not None: - result[name] = adapted - except ValidationError as ex: - raise ex.add_context(name) - - return result + yield (name, default) + + additional = self._additional + all_keys = self._all_keys + additional_properties = [k for k in value if k not in all_keys] + if additional_properties and additional is not self.REMOVE: + if additional is False: + yield ValidationError("additional properties: %s" % + additional_properties, value) + elif additional is True: + for name in additional_properties: + yield (name, value[name]) + else: + for name in additional_properties: + try: + adapted = getattr(additional, method)(value[name], + adapt=adapt) + if adapt: + yield (name, adapted) + except ValidationError as ex: + yield ex.add_context(name) @Object.register_factory @@ -726,7 +745,7 @@ def _ObjectFactory(obj, required_properties=None, additional_properties=None): def _format_types(types): if inspect.isclass(types): types = (types,) - names = list(imap(get_type_name, types)) + names = list(map(get_type_name, types)) s = names[-1] if len(names) > 1: s = ", ".join(names[:-1]) + " or " + s