diff --git a/monk/errors.py b/monk/errors.py index 7a84eda..67c8775 100644 --- a/monk/errors.py +++ b/monk/errors.py @@ -28,6 +28,13 @@ class ValidationError(Exception): """ +class ExpectationError(ValidationError): + """ + Raised when a value does not match given expectation defined by + a validator. + """ + + class StructureSpecificationError(ValidationError): """ Raised when malformed document structure is detected. diff --git a/monk/validators.py b/monk/validators.py index c3b0708..b6bd98f 100644 --- a/monk/validators.py +++ b/monk/validators.py @@ -52,7 +52,8 @@ from . import compat from .errors import ( CombinedValidationError, AtLeastOneFailed, AllFailed, ValidationError, - NoDefaultValue, InvalidKey, MissingKey, StructureSpecificationError + NoDefaultValue, InvalidKey, MissingKey, StructureSpecificationError, + ExpectationError, ) @@ -104,6 +105,8 @@ def get_default_for(self, value, silent=True): class BaseCombinator(BaseValidator): error_class = CombinedValidationError break_on_first_fail = False + #: separator for error messages of nested validators + err_sep = '; ' def __init__(self, specs, default=None, first_is_default=False): assert specs @@ -133,13 +136,13 @@ def __call__(self, value): if not self.can_tolerate(errors): def fmt_err(e): # only display error class if it's not obvious - if type(e) is ValidationError: + if type(e) in (ValidationError, ExpectationError): tmpl = '{err}' else: tmpl = '{cls}: {err}' return tmpl.format(cls=e.__class__.__name__, err=e) - errors_str = '; '.join((fmt_err(e) for e in errors)) + errors_str = self.err_sep.join((fmt_err(e) for e in errors)) raise self.error_class( '{value!r} ({errors})'.format(value=value, errors=errors_str)) @@ -181,6 +184,7 @@ class All(BaseCombinator): """ error_class = AtLeastOneFailed break_on_first_fail = True + err_sep = ' and ' def can_tolerate(self, errors): # TODO: fail early, work as `or` does @@ -194,6 +198,7 @@ class Any(BaseCombinator): Requires that the value passes at least one of nested validators. """ error_class = AllFailed + err_sep = ' or ' def can_tolerate(self, errors): if len(errors) < len(self._specs): @@ -248,8 +253,8 @@ def __init__(self, expected_type, default=None): def _check(self, value): if not isinstance(value, self.expected_type): - raise ValidationError('must be {type}' - .format(type=self.expected_type.__name__)) + raise ExpectationError('is {type}' + .format(type=self.expected_type.__name__)) def _represent(self): if self._default: @@ -269,8 +274,8 @@ def __init__(self, expected_value): def _check(self, value): if self._expected_value != value: - raise ValidationError('!= {expected!r}' - .format(expected=self._expected_value)) + raise ExpectationError('equals {expected!r}' + .format(expected=self._expected_value)) def _represent(self): return repr(self._expected_value) @@ -291,7 +296,7 @@ def __init__(self, default=None): def _check(self, value): if value is not MISSING: - raise ValidationError('must not exist') + raise ExpectationError('does not exist') def _represent(self): return '' @@ -308,8 +313,8 @@ class ListOf(BaseRequirement): >>> v([123, 'hello', 5.5]) Traceback (most recent call last): ... - ValidationError: #2: 5.5 (ValidationError: must be int; - ValidationError: must be str) + ValidationError: #2: 5.5 (ValidationError: is int; + ValidationError: is str) """ is_recursive = True @@ -382,13 +387,13 @@ class DictOf(BaseRequirement): >>> v({'name': 'John', 'age': 25.5}) Traceback (most recent call last): ... - monk.errors.ValidationError: 'age': must be int + monk.errors.ValidationError: 'age': is int >>> v({'name': 'John', 'age': 25, 'note': 'custom field'}) >>> v({'name': 'John', 'age': 25, 'note': 5.5}) Traceback (most recent call last): ... - AllFailed: 'note': 5.5 (ValidationError: must be str; - ValidationError: must be int) + AllFailed: 'note': 5.5 (ValidationError: is str; + ValidationError: is int) Note that this validator supports :class:`NotExists` to mark keys that can be missing. @@ -502,11 +507,11 @@ def _check(self, value): if value is MISSING: raise InvalidKey(value) if self._min is not None and self._min > value: - raise ValidationError('must be ≥ {expected}' - .format(expected=self._min)) + raise ExpectationError('≥ {expected}' + .format(expected=self._min)) if self._max is not None and self._max < value: - raise ValidationError('must be ≤ {expected}' - .format(expected=self._max)) + raise ExpectationError('≤ {expected}' + .format(expected=self._max)) def _represent(self): def _fmt(x): @@ -523,7 +528,7 @@ def _check(self, value): try: super(Length, self)._check(len(value)) except ValidationError as e: - raise ValidationError('length ' + str(e)) + raise ExpectationError('length ' + str(e)) def translate(value): diff --git a/tests/rules_tests.py b/tests/rules_tests.py index 1cea31b..7beddb1 100644 --- a/tests/rules_tests.py +++ b/tests/rules_tests.py @@ -91,7 +91,7 @@ def test_flat(self): schema('foo') with pytest.raises(errors.ValidationError) as excinfo: schema({}) - assert "AllFailed: {} (must be int; must be str)" in excinfo.exconly() + assert "AllFailed: {} (is int or is str)" in excinfo.exconly() def test_nested(self): schema = Any([ @@ -103,11 +103,11 @@ def test_nested(self): with pytest.raises(errors.ValidationError) as excinfo: schema({'foo': 'hi'}) assert ("AllFailed: {'foo': 'hi'} " - "('foo': must be int; InvalidKey: 'foo')") in excinfo.exconly() + "('foo': is int or InvalidKey: 'foo')") in excinfo.exconly() with pytest.raises(errors.ValidationError) as excinfo: schema({'bar': 123}) assert ("AllFailed: {'bar': 123} " - "(InvalidKey: 'bar'; 'bar': must be str)") in excinfo.exconly() + "(InvalidKey: 'bar' or 'bar': is str)") in excinfo.exconly() class TestShortcuts: @@ -121,7 +121,7 @@ def test_one_of(self): v('foo') with pytest.raises(errors.ValidationError) as excinfo: v('quux') - assert "AllFailed: 'quux' (!= 'foo'; != 'bar')" in excinfo.exconly() + assert "AllFailed: 'quux' (equals 'foo' or equals 'bar')" in excinfo.exconly() # non-literals → rules (behaviour explicitly turned on) @@ -134,7 +134,7 @@ def test_one_of(self): v(456) with pytest.raises(errors.ValidationError) as excinfo: v(5.5) - assert 'AllFailed: 5.5 (must be str; must be int)' in excinfo.exconly() + assert 'AllFailed: 5.5 (is str or is int)' in excinfo.exconly() def test_optional(self): assert optional(str) == IsA(str) | NotExists() diff --git a/tests/validation_tests.py b/tests/validation_tests.py index aed7389..2728975 100644 --- a/tests/validation_tests.py +++ b/tests/validation_tests.py @@ -148,7 +148,7 @@ def test_list_instance(self): validate({'a': []}, {'a': []}) validate({'a': []}, {'a': ['b', 123]}) - with raises_regexp(ValidationError, "'a': missing element: must be int"): + with raises_regexp(ValidationError, "'a': missing element: is int"): validate({'a': [int]}, {'a': []}) validate({'a': [int]}, {'a': [123]}) @@ -289,11 +289,11 @@ def test_typed_required(self): spec(1) # value is present but does not match datatype - with raises_regexp(ValidationError, 'must be int'): + with raises_regexp(ValidationError, 'is int'): spec('bogus') # value is missing - with raises_regexp(ValidationError, 'must be int'): + with raises_regexp(ValidationError, 'is int'): spec(None) def test_typed_optional(self): @@ -305,7 +305,7 @@ def test_typed_optional(self): spec(1) # value is present but does not match datatype - with raises_regexp(AllFailed, "'bogus' \(must be int; != None\)"): + with raises_regexp(AllFailed, "'bogus' \(is int or equals None\)"): spec('bogus') @@ -321,7 +321,7 @@ def test_typed_required_dict(self): spec({}) # value is missing - with raises_regexp(ValidationError, 'must be dict'): + with raises_regexp(ValidationError, 'is dict'): spec(None) def test_typed_optional_dict(self): @@ -344,11 +344,11 @@ def test_typed_required_list(self): spec([]) - with raises_regexp(ValidationError, 'must be list'): + with raises_regexp(ValidationError, 'is list'): spec('bogus') # value is missing - with raises_regexp(ValidationError, 'must be list'): + with raises_regexp(ValidationError, 'is list'): spec(None) def test_typed_optional_list(self): @@ -377,7 +377,7 @@ def test_int_in_dict(self): # key is present, value is missing - with raises_regexp(ValidationError, "'foo': must be int"): + with raises_regexp(ValidationError, "'foo': is int"): spec({'foo': None}) # key is present, value is present @@ -396,7 +396,7 @@ def test_dict_in_dict(self): # key is present, value is missing - with raises_regexp(ValidationError, "'foo': must be dict"): + with raises_regexp(ValidationError, "'foo': is dict"): spec({'foo': None}) # value is present @@ -415,7 +415,7 @@ def test_int_in_dict_in_dict(self): # inner key is present, inner value is missing - with raises_regexp(ValidationError, "'foo': 'bar': must be int"): + with raises_regexp(ValidationError, "'foo': 'bar': is int"): spec({'foo': {'bar': None}}) # inner value is present @@ -433,12 +433,12 @@ def test_int_in_optional_dict(self): # outer optional value is present, inner key is missing - with raises_regexp(AllFailed, "{} \(MissingKey: Equals\('foo'\); != None\)"): + with raises_regexp(AllFailed, "{} \(MissingKey: Equals\('foo'\) or equals None\)"): spec({}) # inner key is present, inner value is missing - with raises_regexp(AllFailed, "{'foo': None} \('foo': must be int; != None\)"): + with raises_regexp(AllFailed, "{'foo': None} \('foo': is int or equals None\)"): spec({'foo': None}) # inner value is present @@ -450,12 +450,12 @@ def test_int_in_list(self): # outer value is missing - with raises_regexp(ValidationError, 'must be list'): + with raises_regexp(ValidationError, 'is list'): spec(None) # outer value is present, inner value is missing - with raises_regexp(ValidationError, 'missing element: must be int'): + with raises_regexp(ValidationError, 'missing element: is int'): spec([]) # outer value is present, inner optional value is missing @@ -465,7 +465,7 @@ def test_int_in_list(self): # inner value is present but is None - with raises_regexp(ValidationError, '#0: must be int'): + with raises_regexp(ValidationError, '#0: is int'): spec([None]) # inner value is present @@ -478,7 +478,7 @@ def test_int_in_list(self): # one of the inner values is of a wrong type - with raises_regexp(ValidationError, '#1: must be int'): + with raises_regexp(ValidationError, '#1: is int'): spec([123, 'bogus']) def test_freeform_dict_in_list(self): @@ -495,7 +495,7 @@ def test_freeform_dict_in_list(self): # one of the inner values is of a wrong type - with raises_regexp(ValidationError, '#1: must be dict'): + with raises_regexp(ValidationError, '#1: is dict'): spec([{}, 'bogus']) def test_schemed_dict_in_list(self): @@ -511,10 +511,10 @@ def test_schemed_dict_in_list(self): # dict in list: missing value - with raises_regexp(ValidationError, "#0: 'foo': must be int"): + with raises_regexp(ValidationError, "#0: 'foo': is int"): spec([{'foo': None}]) - with raises_regexp(ValidationError, "#1: 'foo': must be int"): + with raises_regexp(ValidationError, "#1: 'foo': is int"): spec([{'foo': 123}, {'foo': None}]) # multiple innermost values are present @@ -524,29 +524,29 @@ def test_schemed_dict_in_list(self): # one of the innermost values is of a wrong type - with raises_regexp(ValidationError, "#2: 'foo': must be int"): + with raises_regexp(ValidationError, "#2: 'foo': is int"): spec([{'foo': 123}, {'foo': 456}, {'foo': 'bogus'}]) def test_int_in_list_in_dict_in_list_in_dict(self): spec = translate({'foo': [{'bar': [int]}]}) - with raises_regexp(ValidationError, "'foo': must be list"): + with raises_regexp(ValidationError, "'foo': is list"): spec({'foo': None}) - with raises_regexp(ValidationError, "'foo': #0: 'bar': must be list"): + with raises_regexp(ValidationError, "'foo': #0: 'bar': is list"): spec({'foo': [{'bar': None}]}) - with raises_regexp(ValidationError, "'foo': missing element: must be dict"): + with raises_regexp(ValidationError, "'foo': missing element: is dict"): spec({'foo': []}) with raises_regexp(ValidationError, - "'foo': #0: 'bar': missing element: must be int"): + "'foo': #0: 'bar': missing element: is int"): spec({'foo': [{'bar': []}]}) spec({'foo': [{'bar': [1]}]}) spec({'foo': [{'bar': [1, 2]}]}) - with raises_regexp(ValidationError, "'foo': #0: 'bar': #1: must be int"): + with raises_regexp(ValidationError, "'foo': #0: 'bar': #1: is int"): spec({'foo': [{'bar': [1, 'bogus']}]}) @@ -640,7 +640,7 @@ def test_list_type(self): v = translate(list) - with raises_regexp(ValidationError, 'must be list'): + with raises_regexp(ExpectationError, 'is list'): v(None) v([]) v(['hi']) @@ -650,7 +650,7 @@ def test_list_obj_empty(self): v = translate([]) - with raises_regexp(ValidationError, 'must be list'): + with raises_regexp(ExpectationError, 'is list'): v(None) v([]) v([None]) @@ -661,25 +661,25 @@ def test_list_with_req_elem(self): v = translate([str]) - with raises_regexp(ValidationError, 'must be list'): + with raises_regexp(ExpectationError, 'is list'): v(None) - with raises_regexp(ValidationError, 'missing element: must be str'): + with raises_regexp(ValidationError, 'missing element: is str'): v([]) - with raises_regexp(ValidationError, '#0: must be str'): + with raises_regexp(ValidationError, '#0: is str'): v([None]) v(['hi']) - with raises_regexp(ValidationError, '#0: must be str'): + with raises_regexp(ValidationError, '#0: is str'): v([1234]) def test_list_with_opt_elem(self): v = translate([optional(str)]) - with raises_regexp(ValidationError, 'must be list'): + with raises_regexp(ValidationError, 'is list'): v(None) v([]) - with raises_regexp(ValidationError, 'must be str; must not exist'): + with raises_regexp(ValidationError, 'is str or does not exist'): v([None]) v(['hi']) - with raises_regexp(ValidationError, 'must be str'): + with raises_regexp(ValidationError, 'is str'): v([1234]) diff --git a/tests/validators_tests.py b/tests/validators_tests.py index 4428eac..50bfd06 100644 --- a/tests/validators_tests.py +++ b/tests/validators_tests.py @@ -26,7 +26,7 @@ from monk import ( All, Any, Anything, IsA, Equals, InRange, Length, ListOf, DictOf, NotExists, MISSING, translate, - ValidationError, MissingKey, InvalidKey, + ValidationError, ExpectationError, MissingKey, InvalidKey, StructureSpecificationError, optional, ) @@ -50,7 +50,7 @@ def test_isa(): v('foo') - with raises_regexp(ValidationError, '^must be str'): + with raises_regexp(ExpectationError, '^is str'): v(123) @@ -61,7 +61,7 @@ def test_equals(): v('foo') - with raises_regexp(ValidationError, "^!= 'foo'"): + with raises_regexp(ExpectationError, "^equals 'foo'"): v('bar') @@ -75,9 +75,9 @@ def test_inrange(): assert repr(range_max_4) == 'InRange(..4)' # below limit - with raises_regexp(ValidationError, '^must be ≥ 2'): + with raises_regexp(ExpectationError, '^≥ 2'): range_min_2(1) - with raises_regexp(ValidationError, '^must be ≥ 2'): + with raises_regexp(ExpectationError, '^≥ 2'): range_2_to_4(1) range_max_4(1) @@ -98,9 +98,9 @@ def test_inrange(): # above limit range_min_2(5) - with raises_regexp(ValidationError, '^must be ≤ 4'): + with raises_regexp(ExpectationError, '^≤ 4'): range_2_to_4(5) - with raises_regexp(ValidationError, '^must be ≤ 4'): + with raises_regexp(ExpectationError, '^≤ 4'): range_max_4(5) @@ -114,9 +114,9 @@ def test_length(): assert repr(len_max_4) == 'Length(..4)' # below limit - with raises_regexp(ValidationError, '^length must be ≥ 2'): + with raises_regexp(ExpectationError, '^length ≥ 2'): len_min_2('a') - with raises_regexp(ValidationError, '^length must be ≥ 2'): + with raises_regexp(ExpectationError, '^length ≥ 2'): len_2_to_4('a') len_max_4('a') @@ -137,9 +137,9 @@ def test_length(): # above limit len_min_2('aaaaa') - with raises_regexp(ValidationError, '^length must be ≤ 4'): + with raises_regexp(ExpectationError, '^length ≤ 4'): len_2_to_4('aaaaa') - with raises_regexp(ValidationError, '^length must be ≤ 4'): + with raises_regexp(ExpectationError, '^length ≤ 4'): len_max_4('aaaaa') @@ -148,17 +148,17 @@ def test_listof(): assert repr(v) == 'ListOf(IsA(str))' - with raises_regexp(ValidationError, '^must be list'): + with raises_regexp(ExpectationError, '^is list'): v('foo') - with raises_regexp(ValidationError, '^missing element: must be str'): + with raises_regexp(ValidationError, '^missing element: is str'): v([]) v(['foo']) v(['foo', 'bar']) - with raises_regexp(ValidationError, '^#2: must be str'): + with raises_regexp(ValidationError, '^#2: is str'): v(['foo', 'bar', 123]) @@ -183,7 +183,7 @@ def test_dictof(): with raises_regexp(InvalidKey, '123'): dict_of_str_to_int({'foo': 123, 'bar': 456, 123: 'quux'}) - with raises_regexp(ValidationError, "'quux': must be int"): + with raises_regexp(ExpectationError, "'quux': is int"): dict_of_str_to_int({'foo': 123, 'bar': 456, 'quux': 4.2}) @@ -194,10 +194,10 @@ def test_notexists(): v(MISSING) # because the validator is for a special case — this one - with raises_regexp(ValidationError, 'must not exist'): + with raises_regexp(ExpectationError, 'does not exist'): v(None) - with raises_regexp(ValidationError, 'must not exist'): + with raises_regexp(ExpectationError, 'does not exist'): v('foo') @@ -208,7 +208,7 @@ def test_combinator_any(): v('foo') v(123) - with raises_regexp(ValidationError, '^4.5 \(must be str; must be int\)'): + with raises_regexp(ValidationError, '^4.5 \(is str or is int\)'): v(4.5) @@ -217,11 +217,11 @@ def test_combinator_all(): assert repr(v) == 'All[Length(2..), Length(..3)]' - with raises_regexp(ValidationError, 'length must be ≥ 2'): + with raises_regexp(ExpectationError, 'length ≥ 2'): v('f') v('fo') v('foo') - with raises_regexp(ValidationError, 'length must be ≤ 3'): + with raises_regexp(ExpectationError, 'length ≤ 3'): v('fooo')