Skip to content

Commit

Permalink
feat: ✨ add CheckErrorMatcher object (#972)
Browse files Browse the repository at this point in the history
## Description

This PR adds a `CheckErrorMatcher` object. This is/will be used to
filter out unwanted errors easily.
Example usage:
```python
errors = exclude_errors(
        errors,
        [
            CheckErrorMatcher(validator="required", json_path="data"),
            CheckErrorMatcher(validator="type", json_path="path", message="not of type 'array'"),
        ],
    )
```
I'm going to use this to replace the `check_required` flag in Sprout's
check functions.

<!-- Select quick/in-depth as necessary -->
This PR needs an in-depth review.

## Checklist

- [x] Added or updated tests
- [x] Updated documentation
- [x] Ran `just run-all`

---------

Co-authored-by: Luke W. Johnston <[email protected]>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Signe Kirk Brødbæk <[email protected]>
  • Loading branch information
4 people authored Jan 23, 2025
1 parent cf98f16 commit 9e0899e
Show file tree
Hide file tree
Showing 4 changed files with 237 additions and 0 deletions.
66 changes: 66 additions & 0 deletions seedcase_sprout/core/checks/check_error_matcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
from dataclasses import dataclass

from seedcase_sprout.core.checks.check_error import CheckError


@dataclass
class CheckErrorMatcher:
"""A class that helps to filter `CheckError`s based on their attributes."""

message: str | None = None
json_path: str | None = None
validator: str | None = None

def matches(self, error: CheckError) -> bool:
"""Determines if this matcher matches the given `CheckError`.
Args:
error: The `CheckError` to match.
Returns:
If there was a match.
"""
return (
self.message_matches(error)
and self.json_path_matches(error)
and self.validator_matches(error)
)

def message_matches(self, error: CheckError) -> bool:
"""Determines if this matcher matches the message of the given `CheckError`.
Args:
error: The `CheckError` to match.
Returns:
If there was a match.
"""
return self.message is None or self.message in error.message

def json_path_matches(self, error: CheckError) -> bool:
"""Determines if this matcher matches the `json_path` of the given `CheckError`.
Matching on the full `json_path` and matching on the field name are supported.
Args:
error: The `CheckError` to match.
Returns:
If there was a match.
"""
if self.json_path is None:
return True
if self.json_path == "" or self.json_path.startswith("$"):
return self.json_path == error.json_path
return error.json_path.endswith(f".{self.json_path}")

def validator_matches(self, error: CheckError) -> bool:
"""Determines if this matcher matches the validator of the given `CheckError`.
Args:
error: The `CheckError` to match.
Returns:
If there was a match.
"""
return self.validator is None or self.validator == error.validator
21 changes: 21 additions & 0 deletions seedcase_sprout/core/checks/exclude_matching_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from seedcase_sprout.core.checks.check_error import CheckError
from seedcase_sprout.core.checks.check_error_matcher import CheckErrorMatcher


def exclude_matching_errors(
errors: list[CheckError], matchers: list[CheckErrorMatcher]
) -> list[CheckError]:
"""Returns a new list of errors, with errors matched by any `matchers` filtered out.
Args:
errors: The errors to exclude matching errors from.
matchers: The matches to exclude.
Returns:
A list of errors without any errors that matched.
"""
return [
error
for error in errors
if not any(matcher.matches(error) for matcher in matchers)
]
99 changes: 99 additions & 0 deletions tests/core/checks/test_check_error_matcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
from pytest import mark

from seedcase_sprout.core.checks.check_error import CheckError
from seedcase_sprout.core.checks.check_error_matcher import CheckErrorMatcher


@mark.parametrize(
"message,matcher,expected",
[
("", "", True),
("Match!", None, True),
("Match!", "", True),
("Complete match", "Complete match", True),
("Beginning matches", "Beginning", True),
("End matches", "d matches", True),
("", "No match", False),
("No match", "something else", False),
],
)
def test_matches_message(message, matcher, expected):
"""Should match if the matcher's message is a substring of the error's message."""
assert (
CheckErrorMatcher(message=matcher).matches(
CheckError(message=message, json_path="$.name", validator="")
)
is expected
)


@mark.parametrize(
"json_path,matcher,expected",
[
("$.match", None, True),
("$.no.match", "", False),
("", "", True),
("$.match", "$.match", True),
("$.no.match", "$.no", False),
("$.match", "match", True),
("$.no.match", "no", False),
("$.no.match", "other", False),
],
)
def test_matches_json_path(json_path, matcher, expected):
"""Should match if the matcher's `json_path` matches the full `json_path` of the
error or its field name."""
assert (
CheckErrorMatcher(json_path=matcher).matches(
CheckError(message="Hello", json_path=json_path, validator="")
)
is expected
)


@mark.parametrize(
"validator,matcher,expected",
[
("match", None, True),
("match", "match", True),
("", "", True),
("no-match", "match", False),
("no-match", "", False),
("", "no-match", False),
],
)
def test_matches_validator(validator, matcher, expected):
"""Should match if the matcher's validator is the same as the error's."""
assert (
CheckErrorMatcher(validator=matcher).matches(
CheckError(message="Hello", json_path="", validator=validator)
)
is expected
)


@mark.parametrize(
"message,json_path,validator,expected",
[
("name' is a", "name", "required", True),
("name' is a", "name", "no-match", False),
("name' is a", "no.match", "required", False),
("no match", "name", "required", False),
("no match", "name", "no-match", False),
("no match", "no.match", "no-match", False),
],
)
def test_matches_on_all_fields(message, json_path, validator, expected):
"""Should only match if all fields match."""
assert (
CheckErrorMatcher(
message=message, json_path=json_path, validator=validator
).matches(
CheckError(
message="'name' is a required property",
json_path="$.name",
validator="required",
)
)
is expected
)
51 changes: 51 additions & 0 deletions tests/core/checks/test_exclude_matching_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from seedcase_sprout.core.checks.check_error import CheckError
from seedcase_sprout.core.checks.check_error_matcher import CheckErrorMatcher
from seedcase_sprout.core.checks.exclude_matching_errors import exclude_matching_errors

errors = [
CheckError("'path' is a required property", "$.path", "required"),
CheckError("'name' is a required property", "$.name", "required"),
CheckError("123 is not of type 'string'", "$.resources[0].name", "type"),
CheckError("pattern 'xyz' doesn't match", "$.created", "pattern"),
CheckError("pattern 'xyz' doesn't match", "$.version", "pattern"),
]


def test_empty_matchers_have_no_effect():
"""An empty list as a list of matchers should have no effect."""
assert exclude_matching_errors(errors, []) == errors


def test_not_matching_matchers_have_no_effect():
"""If no matchers match, no errors should be excluded."""
assert (
exclude_matching_errors(
errors,
[
CheckErrorMatcher(validator="no-match"),
CheckErrorMatcher(
validator="required", json_path="path", message="no match!"
),
CheckErrorMatcher(
validator="type", json_path="$no.match", message="123 is not"
),
],
)
== errors
)


def test_matched_errors_are_excluded():
"""If any matchers match, the error should be excluded."""
assert (
exclude_matching_errors(
errors,
[
CheckErrorMatcher(json_path="name", validator="required"),
CheckErrorMatcher(validator="pattern"),
CheckErrorMatcher(validator="type"),
CheckErrorMatcher(message="type 'string'"),
],
)
== errors[:1]
)

0 comments on commit 9e0899e

Please sign in to comment.