diff --git a/voluptuous/error.py b/voluptuous/error.py index aa87500..86c4e0a 100644 --- a/voluptuous/error.py +++ b/voluptuous/error.py @@ -187,3 +187,13 @@ class NotInInvalid(Invalid): class ExactSequenceInvalid(Invalid): pass + + +class NotEnoughValid(Invalid): + """The value did not pass enough validations.""" + pass + + +class TooManyValid(Invalid): + """The value passed more than expected validations.""" + pass diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index cc0ed61..06a9e26 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -10,7 +10,7 @@ Url, MultipleInvalid, LiteralInvalid, TypeInvalid, NotIn, Match, Email, Replace, Range, Coerce, All, Any, Length, FqdnUrl, ALLOW_EXTRA, PREVENT_EXTRA, validate, ExactSequence, Equal, Unordered, Number, Maybe, Datetime, Date, - Contains, Marker, IsDir, IsFile, PathExists) + Contains, Marker, IsDir, IsFile, PathExists, SomeOf, TooManyValid, raises) from voluptuous.humanize import humanize_error from voluptuous.util import u @@ -968,3 +968,40 @@ def test_description(): required = Required('key', description='Hello') assert required.description == 'Hello' + + +def test_SomeOf_min_validation(): + validator = All(Length(min=8), SomeOf( + min_valid=3, + validators=[Match(r'.*[A-Z]', 'no uppercase letters'), + Match(r'.*[a-z]', 'no lowercase letters'), + Match(r'.*[0-9]', 'no numbers'), + Match(r'.*[$@$!%*#?&^:;/<,>|{}()\-\'._+=]', 'no symbols')])) + + validator('ffe532A1!') + with raises(MultipleInvalid, 'length of value must be at least 8'): + validator('a') + + with raises(MultipleInvalid, 'no uppercase letters, no lowercase letters'): + validator('wqs2!#s111') + + with raises(MultipleInvalid, 'no lowercase letters, no symbols'): + validator('3A34SDEF5') + + +def test_SomeOf_max_validation(): + validator = SomeOf( + max_valid=2, + validators=[Match(r'.*[A-Z]', 'no uppercase letters'), + Match(r'.*[a-z]', 'no lowercase letters'), + Match(r'.*[0-9]', 'no numbers')], + msg='max validation test failed') + + validator('Aa') + with raises(TooManyValid, 'max validation test failed'): + validator('Aa1') + + +def test_SomeOf_on_bounds_assertion(): + with raises(AssertionError, 'when using "SomeOf" you should specify at least one of min_valid and max_valid'): + SomeOf(validators=[]) diff --git a/voluptuous/validators.py b/voluptuous/validators.py index fb2197f..138941a 100644 --- a/voluptuous/validators.py +++ b/voluptuous/validators.py @@ -9,7 +9,8 @@ from voluptuous.error import (MultipleInvalid, CoerceInvalid, TrueInvalid, FalseInvalid, BooleanInvalid, Invalid, AnyInvalid, AllInvalid, MatchInvalid, UrlInvalid, EmailInvalid, FileInvalid, DirInvalid, RangeInvalid, PathInvalid, ExactSequenceInvalid, LengthInvalid, DatetimeInvalid, - DateInvalid, InInvalid, TypeInvalid, NotInInvalid, ContainsInvalid) + DateInvalid, InInvalid, TypeInvalid, NotInInvalid, ContainsInvalid, NotEnoughValid, + TooManyValid) if sys.version_info >= (3,): import urllib.parse as urlparse @@ -933,3 +934,61 @@ def _get_precision_scale(self, number): raise Invalid(self.msg or 'Value must be a number enclosed with string') return (len(decimal_num.as_tuple().digits), -(decimal_num.as_tuple().exponent), decimal_num) + + +class SomeOf(object): + """Value must pass at least some validations, determined by the given parameter. + Optionally, number of passed validations can be capped. + + The output of each validator is passed as input to the next. + + :param min_valid: Minimum number of valid schemas. + :param validators: a list of schemas or validators to match input against + :param max_valid: Maximum number of valid schemas. + :param msg: Message to deliver to user if validation fails. + :param kwargs: All other keyword arguments are passed to the sub-Schema constructors. + + :raises NotEnoughValid: if the minimum number of validations isn't met + :raises TooManyValid: if the more validations than the given amount is met + + >>> validate = Schema(SomeOf(min_valid=2, validators=[Range(1, 5), Any(float, int), 6.6])) + >>> validate(6.6) + 6.6 + >>> validate(3) + 3 + >>> with raises(MultipleInvalid, 'value must be at most 5, not a valid value'): + ... validate(6.2) + """ + + def __init__(self, validators, min_valid=None, max_valid=None, **kwargs): + assert min_valid is not None or max_valid is not None, \ + 'when using "%s" you should specify at least one of min_valid and max_valid' % (type(self).__name__,) + self.min_valid = min_valid or 0 + self.max_valid = max_valid or len(validators) + self.validators = validators + self.msg = kwargs.pop('msg', None) + self._schemas = [Schema(val, **kwargs) for val in validators] + + def __call__(self, v): + errors = [] + for schema in self._schemas: + try: + v = schema(v) + except Invalid as e: + errors.append(e) + + passed_count = len(self._schemas) - len(errors) + if self.min_valid <= passed_count <= self.max_valid: + return v + + msg = self.msg + if not msg: + msg = ', '.join(map(str, errors)) + + if passed_count > self.max_valid: + raise TooManyValid(msg) + raise NotEnoughValid(msg) + + def __repr__(self): + return 'SomeOf(min_valid=%s, validators=[%s], max_valid=%s, msg=%r)' % ( + self.min_valid, ", ".join(repr(v) for v in self.validators), self.max_valid, self.msg)