Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions yamale/schema/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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 = []
Expand Down
2 changes: 1 addition & 1 deletion yamale/syntax/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions yamale/tests/fixtures/all.yaml
Original file line number Diff line number Diff line change
@@ -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))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it looks like each of these can be accomplished without all(), eg:

  • num(min=5, max=10)
  • str(min=8, matches={regex})

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree the examples in the tests might give the impression that all() is redundant with existing validators. My goal with those cases was more to confirm that the implementation was working correctly and integrating cleanly with the existing system, rather than to showcase complex validation logic.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. To be clear, I don't think it's necessary to "showcase complexity", but rather to demonstrate utility. I agree there's a use case here involving composition of custom validators -- thanks for adding that to the README.

9 changes: 9 additions & 0 deletions yamale/tests/fixtures/all_bad.yaml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions yamale/tests/fixtures/all_good.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
number_range: 7
password: "Password123"
email: "test@example.com"
mixed_list:
- 42
- 78
- 15
nested:
value: 15
6 changes: 6 additions & 0 deletions yamale/tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -117,6 +118,7 @@
map_key_constraint,
numeric_bool_coercion,
semver,
alls,
subset,
subset_empty,
]
Expand Down Expand Up @@ -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

Expand Down
22 changes: 22 additions & 0 deletions yamale/validators/tests/test_validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
42 changes: 42 additions & 0 deletions yamale/validators/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps better to delegate everything above to the base class, ie something like:

errors = super().validate(value)

# Now validate against all child validators
for validator in self.validators:
    errors.extend(validator.validate(value))
return errors

@sambowry sambowry May 15, 2025

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the collection of error messages seems strange, especially when we look the NotAny() validator which should print out success messages. ("NotAny() failed because X matches Y")


# 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"""

Expand Down