diff --git a/README.md b/README.md index 58989059..3e6f812f 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,41 @@ Examples: - `any(str(min=3, max=3),str(min=5, max=5),str(min=7, max=7))`: validates to a string that is exactly 3, 5, or 7 characters long - `any()`: Allows any value. +### All - `all([validators])` + +Validates against a conjunction of validators. Use when a value must satisfy **all** of the specified validators. Unlike the `Any` validator which requires just one validator to pass, this validator requires that **every** validator passes for the value to be considered valid. +* arguments: validators to test values with (at least one should be provided) + +The `all()` validator is particularly valuable when combining heterogeneous validators that cannot be easily combined using existing syntax, especially when custom validators are involved. + +Examples: +* `all(str(), regex('^[A-Z][a-z]+$'))`: Validates that the value is both a string and matches the pattern for capitalized words. +* `all(num(min=0), num(max=100))`: Ensures a number is both non-negative and less than or equal to 100. + +Here's a more realistic example with custom validators: + +```yaml +# Schema +username: all( + str(min=3, max=20), # Basic string length requirements + regex('^[a-zA-Z0-9_]+$'), # Only alphanumeric and underscore allowed + not_blacklisted() # Custom validator to check against blacklisted names +) +password: all( + str(min=8), # Minimum length requirement + has_uppercase(), # Custom validator for at least one uppercase character + has_digit(), # Custom validator for at least one digit + has_special_char() # Custom validator for at least one special character +) +email: all( + str(), # Must be a string + regex('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'), # Basic email format validation + email_domain_exists() # Custom validator that checks if the domain exists +) +``` + +Where `not_blacklisted()`, `has_uppercase()`, `has_digit()`, `has_special_char()`, and `email_domain_exists()` would be custom validators you define for your specific requirements. + ### Subset - `subset([validators], allow_empty=False)` Validates against a subset of types. Unlike the `Any` validator, this validators allows **one or more** of several types. As such, it *automatically validates against a list*. It is valid if all values can be validated against at least one diff --git a/yamale/schema/schema.py b/yamale/schema/schema.py index cee1f67e..48bbe25f 100644 --- a/yamale/schema/schema.py +++ b/yamale/schema/schema.py @@ -106,6 +106,9 @@ def _validate(self, validator, data, path, strict): elif isinstance(validator, val.Any): errors += self._validate_any(validator, data, path, strict) + elif isinstance(validator, val.All): + errors += self._validate_all(validator, data, path, strict) + elif isinstance(validator, val.Subset): errors += self._validate_subset(validator, data, path, strict) @@ -177,6 +180,19 @@ def _validate_any(self, validator, data, path, strict): return errors + def _validate_all(self, validator, data, path, strict): + if not validator.validators: + return [] + + errors = [] + # With 'all', every validator must succeed + for v in validator.validators: + err = self._validate(v, data, path, strict) + if err: + errors += err + + return errors + def _validate_subset(self, validator, data, path, strict): def _internal_validate(internal_data): sub_errors = [] diff --git a/yamale/syntax/parser.py b/yamale/syntax/parser.py index ae71ef9b..2ce132a2 100644 --- a/yamale/syntax/parser.py +++ b/yamale/syntax/parser.py @@ -25,7 +25,7 @@ def _validate_expr(call_node, validators): elif isinstance(base_arg, ast.Call): _validate_expr(base_arg, validators) else: - raise SyntaxError("Argument values must either be constant literals, or else " "reference other validators.") + raise SyntaxError("Argument values must either be constant literals, or else reference other validators.") def parse(validator_string, validators=None): diff --git a/yamale/tests/fixtures/all.yaml b/yamale/tests/fixtures/all.yaml new file mode 100644 index 00000000..e337a09b --- /dev/null +++ b/yamale/tests/fixtures/all.yaml @@ -0,0 +1,6 @@ +number_range: all(num(min=5), num(max=10)) +password: all(str(min=8), regex('^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*$')) +email: all(str(), regex('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')) +mixed_list: list(all(num(min=0), num(max=100))) +nested: + value: all(int(min=10), int(max=20)) \ No newline at end of file diff --git a/yamale/tests/fixtures/all_bad.yaml b/yamale/tests/fixtures/all_bad.yaml new file mode 100644 index 00000000..2302af36 --- /dev/null +++ b/yamale/tests/fixtures/all_bad.yaml @@ -0,0 +1,9 @@ +number_range: 12 # Over max limit +password: "pass" # Too short and missing required characters +email: "invalid-email" # Not a valid email format +mixed_list: + - 42 + - 150 # Over max limit + - 15 +nested: + value: 5 # Under min limit \ No newline at end of file diff --git a/yamale/tests/fixtures/all_good.yaml b/yamale/tests/fixtures/all_good.yaml new file mode 100644 index 00000000..dde18795 --- /dev/null +++ b/yamale/tests/fixtures/all_good.yaml @@ -0,0 +1,9 @@ +number_range: 7 +password: "Password123" +email: "test@example.com" +mixed_list: + - 42 + - 78 + - 15 +nested: + value: 15 \ No newline at end of file diff --git a/yamale/tests/test_functional.py b/yamale/tests/test_functional.py index 805b1db2..09dc2040 100644 --- a/yamale/tests/test_functional.py +++ b/yamale/tests/test_functional.py @@ -40,6 +40,7 @@ semver = {"schema": "semver_schema.yaml", "good": "semver_good.yaml", "bad": "semver_bad.yaml"} +alls = {"schema": "all.yaml", "good": "all_good.yaml", "bad": "all_bad.yaml"} include_validator = { "schema": "include_validator.yaml", @@ -117,6 +118,7 @@ map_key_constraint, numeric_bool_coercion, semver, + alls, subset, subset_empty, ] @@ -205,6 +207,10 @@ def test_bad_semver(): assert count_exception_lines(semver["schema"], semver["bad"]) == 1 +def test_bad_all(): + assert count_exception_lines(alls["schema"], alls["bad"]) == 6 + + def test_bad_regexes(): assert count_exception_lines(regexes["schema"], regexes["bad"]) == 4 diff --git a/yamale/validators/tests/test_validate.py b/yamale/validators/tests/test_validate.py index 630ba3ed..e20f6a5d 100644 --- a/yamale/validators/tests/test_validate.py +++ b/yamale/validators/tests/test_validate.py @@ -224,3 +224,25 @@ def test_semver(): assert not v.is_valid("+justmeta") assert not v.is_valid("9.8.7+meta+meta") assert not v.is_valid("9.8.7-whatever+meta+meta") + + +def test_all(): + """Test the All validator""" + # Test with numeric validators + v = val.All(val.Number(min=5), val.Number(max=10)) + assert v.is_valid(7) # Should pass both validators + assert not v.is_valid(4) # Fails min=5 + assert not v.is_valid(11) # Fails max=10 + + # Test with string validators + v = val.All(val.String(min=2), val.String(max=5)) + assert v.is_valid("abc") # Passes both + assert not v.is_valid("a") # Fails min=2 + assert not v.is_valid("abcdef") # Fails max=5 + + # Test with mixed validators + v = val.All(val.String(), val.Regex(r"^[a-z]+$")) + assert v.is_valid("abc") # String that matches regex + assert not v.is_valid(123) # Not a string + assert not v.is_valid("123") # String but fails regex + assert not v.is_valid("abc123") # String but fails regex diff --git a/yamale/validators/validators.py b/yamale/validators/validators.py index 16886de8..b6bab867 100644 --- a/yamale/validators/validators.py +++ b/yamale/validators/validators.py @@ -157,6 +157,48 @@ def _is_valid(self, value): return True +class All(Validator): + """All validators must succeed validator""" + + tag = "all" + + def __init__(self, *args, **kwargs): + self.validators = [val for val in args if isinstance(val, Validator)] + super(All, self).__init__(*args, **kwargs) + + def _is_valid(self, value): + return True + + def validate(self, value): + """ + Override to validate against all validators. + Returns a list of all errors from all validators. + """ + # First check base validator conditions + errors = [] + + # Make sure the type validates first + valid = self._is_valid(value) + if not valid: + errors.append(self.fail(value)) + return errors + + # Then validate all the constraints + for constraint in self._constraints_inst: + error = constraint.is_valid(value) + if error: + if isinstance(error, list): + errors.extend(error) + else: + errors.append(error) + + # Now validate against all child validators + for validator in self.validators: + errors.extend(validator.validate(value)) + + return errors + + class Subset(Validator): """Subset of several types validator"""