diff --git a/gen/tests/example/v1/validations_pb2.py b/gen/tests/example/v1/validations_pb2.py index 6c88b1ca..dc397ffa 100644 --- a/gen/tests/example/v1/validations_pb2.py +++ b/gen/tests/example/v1/validations_pb2.py @@ -40,7 +40,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"tests/example/v1/validations.proto\x12\x10tests.example.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"T\n\x13MultipleValidations\x12 \n\x05title\x18\x01 \x01(\tB\n\xbaH\x07r\x05:\x03\x66ooR\x05title\x12\x1b\n\x04name\x18\x02 \x01(\tB\x07\xbaH\x04r\x02\x10\x05R\x04name\")\n\x0c\x44oubleFinite\x12\x19\n\x03val\x18\x01 \x01(\x01\x42\x07\xbaH\x04\x12\x02@\x01R\x03val\";\n\x0eSFixed64ExLTGT\x12)\n\x03val\x18\x01 \x01(\x10\x42\x17\xbaH\x14\x62\x12\x11\x00\x00\x00\x00\x00\x00\x00\x00!\n\x00\x00\x00\x00\x00\x00\x00R\x03val\")\n\x0cTestOneofMsg\x12\x19\n\x03val\x18\x01 \x01(\x08\x42\x07\xbaH\x04j\x02\x08\x01R\x03val\"q\n\x05Oneof\x12\x1a\n\x01x\x18\x01 \x01(\tB\n\xbaH\x07r\x05:\x03\x66ooH\x00R\x01x\x12\x17\n\x01y\x18\x02 \x01(\x05\x42\x07\xbaH\x04\x1a\x02 \x00H\x00R\x01y\x12.\n\x01z\x18\x03 \x01(\x0b\x32\x1e.tests.example.v1.TestOneofMsgH\x00R\x01zB\x03\n\x01o\"[\n\x12ProtovalidateOneof\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\x0b\xbaH\x08\"\x06\n\x01\x61\n\x01\x62\"e\n\x1aProtovalidateOneofRequired\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\r\xbaH\n\"\x08\n\x01\x61\n\x01\x62\x10\x01\"p\n\"ProtovalidateOneofUnknownFieldName\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\x10\xbaH\r\"\x0b\n\x01\x61\n\x01\x62\n\x03xxx\"H\n\x0eTimestampGTNow\x12\x36\n\x03val\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x08\xbaH\x05\xb2\x01\x02@\x01R\x03val\"\x87\x01\n\tMapMinMax\x12\x42\n\x03val\x18\x01 \x03(\x0b\x32$.tests.example.v1.MapMinMax.ValEntryB\n\xbaH\x07\x9a\x01\x04\x08\x02\x10\x04R\x03val\x1a\x36\n\x08ValEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\x08R\x05value:\x02\x38\x01\"\x85\x01\n\x07MapKeys\x12\x42\n\x03val\x18\x01 \x03(\x0b\x32\".tests.example.v1.MapKeys.ValEntryB\x0c\xbaH\t\x9a\x01\x06\"\x04\x42\x02\x10\x00R\x03val\x1a\x36\n\x08ValEntry\x12\x10\n\x03key\x18\x01 \x01(\x12R\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\"\n\x05\x45mbed\x12\x19\n\x03val\x18\x01 \x01(\x03\x42\x07\xbaH\x04\"\x02 \x00R\x03val\"K\n\x11RepeatedEmbedSkip\x12\x36\n\x03val\x18\x01 \x03(\x0b\x32\x17.tests.example.v1.EmbedB\x0b\xbaH\x08\x92\x01\x05\"\x03\xd8\x01\x03R\x03valB\x8a\x01\n\x14\x63om.tests.example.v1B\x10ValidationsProtoP\x01\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"tests/example/v1/validations.proto\x12\x10tests.example.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"T\n\x13MultipleValidations\x12 \n\x05title\x18\x01 \x01(\tB\n\xbaH\x07r\x05:\x03\x66ooR\x05title\x12\x1b\n\x04name\x18\x02 \x01(\tB\x07\xbaH\x04r\x02\x10\x05R\x04name\")\n\x0c\x44oubleFinite\x12\x19\n\x03val\x18\x01 \x01(\x01\x42\x07\xbaH\x04\x12\x02@\x01R\x03val\";\n\x0eSFixed64ExLTGT\x12)\n\x03val\x18\x01 \x01(\x10\x42\x17\xbaH\x14\x62\x12\x11\x00\x00\x00\x00\x00\x00\x00\x00!\n\x00\x00\x00\x00\x00\x00\x00R\x03val\")\n\x0cTestOneofMsg\x12\x19\n\x03val\x18\x01 \x01(\x08\x42\x07\xbaH\x04j\x02\x08\x01R\x03val\"q\n\x05Oneof\x12\x1a\n\x01x\x18\x01 \x01(\tB\n\xbaH\x07r\x05:\x03\x66ooH\x00R\x01x\x12\x17\n\x01y\x18\x02 \x01(\x05\x42\x07\xbaH\x04\x1a\x02 \x00H\x00R\x01y\x12.\n\x01z\x18\x03 \x01(\x0b\x32\x1e.tests.example.v1.TestOneofMsgH\x00R\x01zB\x03\n\x01o\"[\n\x12ProtovalidateOneof\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\x0b\xbaH\x08\"\x06\n\x01\x61\n\x01\x62\"e\n\x1aProtovalidateOneofRequired\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\r\xbaH\n\"\x08\n\x01\x61\n\x01\x62\x10\x01\"p\n\"ProtovalidateOneofUnknownFieldName\x12\x0c\n\x01\x61\x18\x01 \x01(\tR\x01\x61\x12\x0c\n\x01\x62\x18\x02 \x01(\tR\x01\x62\x12\x1c\n\tunrelated\x18\x03 \x01(\x08R\tunrelated:\x10\xbaH\r\"\x0b\n\x01\x61\n\x01\x62\n\x03xxx\"H\n\x0eTimestampGTNow\x12\x36\n\x03val\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.TimestampB\x08\xbaH\x05\xb2\x01\x02@\x01R\x03val\"\x87\x01\n\tMapMinMax\x12\x42\n\x03val\x18\x01 \x03(\x0b\x32$.tests.example.v1.MapMinMax.ValEntryB\n\xbaH\x07\x9a\x01\x04\x08\x02\x10\x04R\x03val\x1a\x36\n\x08ValEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\x08R\x05value:\x02\x38\x01\"\x85\x01\n\x07MapKeys\x12\x42\n\x03val\x18\x01 \x03(\x0b\x32\".tests.example.v1.MapKeys.ValEntryB\x0c\xbaH\t\x9a\x01\x06\"\x04\x42\x02\x10\x00R\x03val\x1a\x36\n\x08ValEntry\x12\x10\n\x03key\x18\x01 \x01(\x12R\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\"\n\x05\x45mbed\x12\x19\n\x03val\x18\x01 \x01(\x03\x42\x07\xbaH\x04\"\x02 \x00R\x03val\"K\n\x11RepeatedEmbedSkip\x12\x36\n\x03val\x18\x01 \x03(\x0b\x32\x17.tests.example.v1.EmbedB\x0b\xbaH\x08\x92\x01\x05\"\x03\xd8\x01\x03R\x03val\"3\n\x0fInvalidRESyntax\x12 \n\x05value\x18\x01 \x01(\tB\n\xbaH\x07r\x05\x32\x03^\\zR\x05valueB\x8a\x01\n\x14\x63om.tests.example.v1B\x10ValidationsProtoP\x01\xa2\x02\x03TEX\xaa\x02\x10Tests.Example.V1\xca\x02\x10Tests\\Example\\V1\xe2\x02\x1cTests\\Example\\V1\\GPBMetadata\xea\x02\x12Tests::Example::V1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -82,6 +82,8 @@ _globals['_EMBED'].fields_by_name['val']._serialized_options = b'\272H\004\"\002 \000' _globals['_REPEATEDEMBEDSKIP'].fields_by_name['val']._loaded_options = None _globals['_REPEATEDEMBEDSKIP'].fields_by_name['val']._serialized_options = b'\272H\010\222\001\005\"\003\330\001\003' + _globals['_INVALIDRESYNTAX'].fields_by_name['value']._loaded_options = None + _globals['_INVALIDRESYNTAX'].fields_by_name['value']._serialized_options = b'\272H\007r\0052\003^\\z' _globals['_MULTIPLEVALIDATIONS']._serialized_start=118 _globals['_MULTIPLEVALIDATIONS']._serialized_end=202 _globals['_DOUBLEFINITE']._serialized_start=204 @@ -112,4 +114,6 @@ _globals['_EMBED']._serialized_end=1158 _globals['_REPEATEDEMBEDSKIP']._serialized_start=1160 _globals['_REPEATEDEMBEDSKIP']._serialized_end=1235 + _globals['_INVALIDRESYNTAX']._serialized_start=1237 + _globals['_INVALIDRESYNTAX']._serialized_end=1288 # @@protoc_insertion_point(module_scope) diff --git a/gen/tests/example/v1/validations_pb2.pyi b/gen/tests/example/v1/validations_pb2.pyi index e255c703..88aec111 100644 --- a/gen/tests/example/v1/validations_pb2.pyi +++ b/gen/tests/example/v1/validations_pb2.pyi @@ -131,3 +131,9 @@ class RepeatedEmbedSkip(_message.Message): VAL_FIELD_NUMBER: _ClassVar[int] val: _containers.RepeatedCompositeFieldContainer[Embed] def __init__(self, val: _Optional[_Iterable[_Union[Embed, _Mapping]]] = ...) -> None: ... + +class InvalidRESyntax(_message.Message): + __slots__ = ("value",) + VALUE_FIELD_NUMBER: _ClassVar[int] + value: str + def __init__(self, value: _Optional[str] = ...) -> None: ... diff --git a/proto/tests/example/v1/validations.proto b/proto/tests/example/v1/validations.proto index 7e51bdee..fbf0467b 100644 --- a/proto/tests/example/v1/validations.proto +++ b/proto/tests/example/v1/validations.proto @@ -93,6 +93,11 @@ message MapKeys { message Embed { int64 val = 1 [(buf.validate.field).int64.gt = 0]; } + message RepeatedEmbedSkip { repeated Embed val = 1 [(buf.validate.field).repeated.items.ignore = IGNORE_ALWAYS]; } + +message InvalidRESyntax { + string value = 1 [(buf.validate.field).string.pattern = "^\\z"]; +} diff --git a/protovalidate/config.py b/protovalidate/config.py index 1e21683b..d376a49c 100644 --- a/protovalidate/config.py +++ b/protovalidate/config.py @@ -12,7 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. +from collections.abc import Callable from dataclasses import dataclass +from typing import Optional @dataclass @@ -21,6 +23,10 @@ class Config: Attributes: fail_fast (bool): If true, validation will stop after the first violation. Defaults to False. + regex_matches_func: An optional regex matcher to use. If specified, this will be used to match + on regex expressions instead of this library's `matches` logic. """ fail_fast: bool = False + + regex_matches_func: Optional[Callable[[str, str], bool]] = None diff --git a/protovalidate/internal/extra_func.py b/protovalidate/internal/extra_func.py index 442cdb77..5f233105 100644 --- a/protovalidate/internal/extra_func.py +++ b/protovalidate/internal/extra_func.py @@ -15,13 +15,15 @@ import math import re import typing +from collections.abc import Callable from urllib import parse as urlparse import celpy from celpy import celtypes +from protovalidate.config import Config from protovalidate.internal import string_format -from protovalidate.internal.matches import cel_matches +from protovalidate.internal.matches import matches as protovalidate_matches from protovalidate.internal.rules import MessageType, field_to_cel # See https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address @@ -1554,14 +1556,31 @@ def __peek(self, char: str) -> bool: return self._index < len(self._string) and self._string[self._index] == char -def make_extra_funcs(locale: str) -> dict[str, celpy.CELFunction]: - # For now, ignoring the type. - string_fmt = string_format.StringFormat(locale) # type: ignore +def get_matches_func(matcher: typing.Optional[Callable[[str, str], bool]]): + if matcher is None: + matcher = protovalidate_matches + + def cel_matches(text: celtypes.Value, pattern: celtypes.Value) -> celpy.Result: + if not isinstance(text, celtypes.StringType): + msg = "invalid argument for text, expected string" + raise celpy.CELEvalError(msg) + if not isinstance(pattern, celtypes.StringType): + msg = "invalid argument for pattern, expected string" + raise celpy.CELEvalError(msg) + + b = matcher(text, pattern) + return celtypes.BoolType(b) + + return cel_matches + + +def make_extra_funcs(config: Config) -> dict[str, celpy.CELFunction]: + string_fmt = string_format.StringFormat() return { # Missing standard functions "format": string_fmt.format, # Overridden standard functions - "matches": cel_matches, + "matches": get_matches_func(config.regex_matches_func), # protovalidate specific functions "getField": cel_get_field, "isNan": cel_is_nan, @@ -1575,6 +1594,3 @@ def make_extra_funcs(locale: str) -> dict[str, celpy.CELFunction]: "isHostAndPort": cel_is_host_and_port, "unique": cel_unique, } - - -EXTRA_FUNCS = make_extra_funcs("en_US") diff --git a/protovalidate/internal/matches.py b/protovalidate/internal/matches.py index 72728582..27e29f9e 100644 --- a/protovalidate/internal/matches.py +++ b/protovalidate/internal/matches.py @@ -15,7 +15,6 @@ import re import celpy -from celpy import celtypes # Patterns that are supported in Python's re package and not in re2. # RE2: https://github.com/google/re2/wiki/syntax @@ -30,10 +29,11 @@ r"\\u[0-9a-fA-F]{4}", # UTF-16 code-unit r"\\0(?!\d)", # NUL r"\[\\b.*\]", # Backspace eg: [\b] + r"\\Z", # End of text (only lowercase z is supported in re2) ] -def cel_matches(text: celtypes.Value, pattern: celtypes.Value) -> celpy.Result: +def matches(text: str, pattern: str) -> bool: """Return True if the given pattern matches text. False otherwise. CEL uses RE2 syntax which diverges from Python re in various ways. Ideally, we @@ -43,14 +43,13 @@ def cel_matches(text: celtypes.Value, pattern: celtypes.Value) -> celpy.Result: Instead of foisting this issue on users, we instead mimic re2 syntax by failing to compile the regex for patterns not compatible with re2. - """ - if not isinstance(text, celtypes.StringType): - msg = "invalid argument for text, expected string" - raise celpy.CELEvalError(msg) - if not isinstance(pattern, celtypes.StringType): - msg = "invalid argument for pattern, expected string" - raise celpy.CELEvalError(msg) + Users can choose to override this behavior by providing their own custom matches + function via the Config. + + Raises: + celpy.CELEvalError: If pattern contains invalid re2 syntax or if an re.error is raised during matching. + """ # Simulate re2 by failing on any patterns not compatible with re2 syntax for invalid_pattern in invalid_patterns: r = re.search(invalid_pattern, pattern) @@ -61,6 +60,7 @@ def cel_matches(text: celtypes.Value, pattern: celtypes.Value) -> celpy.Result: try: m = re.search(pattern, text) except re.error as ex: - return celpy.CELEvalError("match error", ex.__class__, ex.args) + msg = "match error" + raise celpy.CELEvalError(msg, ex.__class__, ex.args) from ex - return celtypes.BoolType(m is not None) + return m is not None diff --git a/protovalidate/internal/rules.py b/protovalidate/internal/rules.py index 989abf96..7a6758c0 100644 --- a/protovalidate/internal/rules.py +++ b/protovalidate/internal/rules.py @@ -15,6 +15,7 @@ import dataclasses import datetime import typing +from collections.abc import Callable import celpy from celpy import celtypes @@ -44,7 +45,7 @@ def unwrap(msg: message.Message) -> celtypes.Value: return field_to_cel(msg, msg.DESCRIPTOR.fields_by_name["value"]) -_MSG_TYPE_URL_TO_CTOR: dict[str, typing.Callable[..., celtypes.Value]] = { +_MSG_TYPE_URL_TO_CTOR: dict[str, Callable[..., celtypes.Value]] = { "google.protobuf.Duration": make_duration, "google.protobuf.Timestamp": make_timestamp, "google.protobuf.StringValue": unwrap, diff --git a/protovalidate/internal/string_format.py b/protovalidate/internal/string_format.py index 922aff2e..0755d7bf 100644 --- a/protovalidate/internal/string_format.py +++ b/protovalidate/internal/string_format.py @@ -24,8 +24,7 @@ class StringFormat: """An implementation of string.format() in CEL.""" - def __init__(self, locale: str): - self.locale = locale + def __init__(self): self.fmt = None def format(self, fmt: celtypes.Value, args: celtypes.Value) -> celpy.Result: diff --git a/protovalidate/validator.py b/protovalidate/validator.py index 30d3fd2d..5c1850ba 100644 --- a/protovalidate/validator.py +++ b/protovalidate/validator.py @@ -39,8 +39,9 @@ class Validator: _cfg: Config def __init__(self, config=None): - self._factory = _rules.RuleFactory(extra_func.EXTRA_FUNCS) self._cfg = config if config is not None else Config() + funcs = extra_func.make_extra_funcs(self._cfg) + self._factory = _rules.RuleFactory(funcs) def validate( self, diff --git a/tests/config_test.py b/tests/config_test.py index 71f33af7..16d7eca3 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -21,3 +21,4 @@ class TestConfig(unittest.TestCase): def test_defaults(self): cfg = Config() self.assertFalse(cfg.fail_fast) + self.assertIsNone(cfg.regex_matches_func) diff --git a/tests/format_test.py b/tests/format_test.py index 353a8478..e8609656 100644 --- a/tests/format_test.py +++ b/tests/format_test.py @@ -23,6 +23,7 @@ from gen.cel.expr import eval_pb2 from gen.cel.expr.conformance.test import simple_pb2 +from protovalidate.config import Config from protovalidate.internal import extra_func from protovalidate.internal.cel_field_presence import InterpretedRunner @@ -108,7 +109,7 @@ def test_format_successes(self): if test.name in skipped_tests: continue ast = self._env.compile(test.expr) - prog = self._env.program(ast, functions=extra_func.EXTRA_FUNCS) + prog = self._env.program(ast, functions=extra_func.make_extra_funcs(Config())) bindings = build_variables(test.bindings) # Ideally we should use pytest parametrize instead of subtests, but @@ -132,7 +133,7 @@ def test_format_errors(self): if test.name in skipped_error_tests: continue ast = self._env.compile(test.expr) - prog = self._env.program(ast, functions=extra_func.EXTRA_FUNCS) + prog = self._env.program(ast, functions=extra_func.make_extra_funcs(Config())) bindings = build_variables(test.bindings) # Ideally we should use pytest parametrize instead of subtests, but diff --git a/tests/matches_test.py b/tests/matches_test.py index 7e3a91aa..7d730852 100644 --- a/tests/matches_test.py +++ b/tests/matches_test.py @@ -15,9 +15,8 @@ import unittest import celpy -from celpy import celtypes -from protovalidate.internal import extra_func +from protovalidate.internal.matches import matches invalid_patterns = [ r"\1", @@ -30,15 +29,15 @@ r"\u0041", r"\0 \01 \0a \012", r"[\b]", + r"^\Z", ] class TestMatches(unittest.TestCase): def test_invalid_re2_syntax(self): for pattern in invalid_patterns: - cel_pattern = celtypes.StringType(pattern) try: - extra_func.cel_matches(celtypes.StringType("test"), cel_pattern) - self.fail(f"expected an error on pattern {cel_pattern}") + matches("test", pattern) + self.fail(f"expected an error on pattern {pattern}") except celpy.CELEvalError as e: - self.assertEqual(str(e), f"error evaluating pattern {cel_pattern}, invalid RE2 syntax") + self.assertEqual(str(e), f"error evaluating pattern {pattern}, invalid RE2 syntax") diff --git a/tests/validate_test.py b/tests/validate_test.py index 792aeb08..c1761f99 100644 --- a/tests/validate_test.py +++ b/tests/validate_test.py @@ -12,8 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re import unittest +import celpy from google.protobuf import message import protovalidate @@ -209,6 +211,42 @@ def test_fail_fast(self): violations = validator.collect_violations(msg) self._compare_violations(violations, [expected_violation]) + def test_custom_matcher(self): + r"""Tests usage of the custom regex_matches_func in the config + + A bit of a contrived example, but this exercises the code path + for specifying a custom regex matches function when writing regex rules. + + Usage of the pattern \z is not supported in Python's re engine, only \Z is supported. + However, the inverse is true with re2 (\Z is _not_ supported and \z is supported). + + This test shows using a custom matcher that converts any re2-compliant usage of \z + to \Z so that Python's re engine can execute it. + """ + msg = validations_pb2.InvalidRESyntax() + + def matcher(text: str, pattern: str) -> bool: + pattern = pattern.replace("z", "Z") + try: + m = re.search(pattern, text) + except re.error as ex: + msg = "match error" + raise celpy.CELEvalError(msg, ex.__class__, ex.args) from ex + return m is not None + + cfg = Config(regex_matches_func=matcher) + validator = protovalidate.Validator(config=cfg) + + # Test validate + try: + validator.validate(msg) + except Exception: + self.fail("unexpected validation failure") + + # Test collect_violations + violations = validator.collect_violations(msg) + self.assertEqual(len(violations), 0) + def _run_valid_tests(self, msg: message.Message): """A helper function for testing successful validation on a given message @@ -257,12 +295,12 @@ def _run_compilation_error_tests(self, msg: message.Message, expected: str): # Test validate with self.assertRaises(protovalidate.CompilationError) as vce: v.validate(msg) - self.assertEqual(str(vce.exception), expected) + self.assertEqual(str(vce.exception), expected) # Test collect_violations with self.assertRaises(protovalidate.CompilationError) as cvce: v.collect_violations(msg) - self.assertEqual(str(cvce.exception), expected) + self.assertEqual(str(cvce.exception), expected) def _compare_violations(self, actual: list[rules.Violation], expected: list[rules.Violation]) -> None: """Compares two lists of violations. The violations are expected to be in the expected order also."""