Skip to content

Commit 1de6424

Browse files
author
Steve Ayers
committed
Bring your own matcher
1 parent 0c7db6c commit 1de6424

File tree

12 files changed

+122
-33
lines changed

12 files changed

+122
-33
lines changed

gen/tests/example/v1/validations_pb2.py

Lines changed: 5 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

gen/tests/example/v1/validations_pb2.pyi

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

proto/tests/example/v1/validations.proto

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ message MapKeys {
9393
message Embed {
9494
int64 val = 1 [(buf.validate.field).int64.gt = 0];
9595
}
96+
9697
message RepeatedEmbedSkip {
9798
repeated Embed val = 1 [(buf.validate.field).repeated.items.ignore = IGNORE_ALWAYS];
9899
}
100+
101+
message InvalidRESyntax {
102+
string value = 1 [(buf.validate.field).string.pattern = "^\\z"];
103+
}

protovalidate/config.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
# limitations under the License.
1414

1515
from dataclasses import dataclass
16+
from typing import Callable, Optional
1617

1718

1819
@dataclass
@@ -21,6 +22,10 @@ class Config:
2122
2223
Attributes:
2324
fail_fast (bool): If true, validation will stop after the first violation. Defaults to False.
25+
regex_matches_func: An optional regex matcher to use. If specified, this will be used to match
26+
on regex expressions instead of this library's `matches` logic.
2427
"""
2528

2629
fail_fast: bool = False
30+
31+
regex_matches_func: Optional[Callable[[str, str], bool]] = None

protovalidate/internal/extra_func.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@
2020
import celpy
2121
from celpy import celtypes
2222

23+
from protovalidate.config import Config
2324
from protovalidate.internal import string_format
24-
from protovalidate.internal.matches import cel_matches
25+
from protovalidate.internal.matches import matches as protovalidate_matches
2526
from protovalidate.internal.rules import MessageType, field_to_cel
2627

2728
# See https://html.spec.whatwg.org/multipage/input.html#valid-e-mail-address
@@ -1554,14 +1555,31 @@ def __peek(self, char: str) -> bool:
15541555
return self._index < len(self._string) and self._string[self._index] == char
15551556

15561557

1557-
def make_extra_funcs(locale: str) -> dict[str, celpy.CELFunction]:
1558-
# For now, ignoring the type.
1559-
string_fmt = string_format.StringFormat(locale) # type: ignore
1558+
def get_matches_func(matcher: typing.Optional[typing.Callable[[str, str], bool]]):
1559+
if matcher is None:
1560+
matcher = protovalidate_matches
1561+
1562+
def cel_matches(text: celtypes.Value, pattern: celtypes.Value) -> celpy.Result:
1563+
if not isinstance(text, celtypes.StringType):
1564+
msg = "invalid argument for text, expected string"
1565+
raise celpy.CELEvalError(msg)
1566+
if not isinstance(pattern, celtypes.StringType):
1567+
msg = "invalid argument for pattern, expected string"
1568+
raise celpy.CELEvalError(msg)
1569+
1570+
b = matcher(text, pattern)
1571+
return celtypes.BoolType(b)
1572+
1573+
return cel_matches
1574+
1575+
1576+
def make_extra_funcs(config: Config) -> dict[str, celpy.CELFunction]:
1577+
string_fmt = string_format.StringFormat()
15601578
return {
15611579
# Missing standard functions
15621580
"format": string_fmt.format,
15631581
# Overridden standard functions
1564-
"matches": cel_matches,
1582+
"matches": get_matches_func(config.regex_matches_func),
15651583
# protovalidate specific functions
15661584
"getField": cel_get_field,
15671585
"isNan": cel_is_nan,
@@ -1575,6 +1593,3 @@ def make_extra_funcs(locale: str) -> dict[str, celpy.CELFunction]:
15751593
"isHostAndPort": cel_is_host_and_port,
15761594
"unique": cel_unique,
15771595
}
1578-
1579-
1580-
EXTRA_FUNCS = make_extra_funcs("en_US")

protovalidate/internal/matches.py

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@
1515
import re
1616

1717
import celpy
18-
from celpy import celtypes
1918

2019
# Patterns that are supported in Python's re package and not in re2.
2120
# RE2: https://github.com/google/re2/wiki/syntax
@@ -30,10 +29,11 @@
3029
r"\\u[0-9a-fA-F]{4}", # UTF-16 code-unit
3130
r"\\0(?!\d)", # NUL
3231
r"\[\\b.*\]", # Backspace eg: [\b]
32+
r"\\Z", # End of text (only lowercase z is supported in re2)
3333
]
3434

3535

36-
def cel_matches(text: celtypes.Value, pattern: celtypes.Value) -> celpy.Result:
36+
def matches(text: str, pattern: str) -> bool:
3737
"""Return True if the given pattern matches text. False otherwise.
3838
3939
CEL uses RE2 syntax which diverges from Python re in various ways. Ideally, we
@@ -43,14 +43,10 @@ def cel_matches(text: celtypes.Value, pattern: celtypes.Value) -> celpy.Result:
4343
4444
Instead of foisting this issue on users, we instead mimic re2 syntax by failing
4545
to compile the regex for patterns not compatible with re2.
46-
"""
47-
if not isinstance(text, celtypes.StringType):
48-
msg = "invalid argument for text, expected string"
49-
raise celpy.CELEvalError(msg)
50-
if not isinstance(pattern, celtypes.StringType):
51-
msg = "invalid argument for pattern, expected string"
52-
raise celpy.CELEvalError(msg)
5346
47+
Raises:
48+
celpy.CELEvalError: If pattern contains invalid re2 syntax.
49+
"""
5450
# Simulate re2 by failing on any patterns not compatible with re2 syntax
5551
for invalid_pattern in invalid_patterns:
5652
r = re.search(invalid_pattern, pattern)
@@ -61,6 +57,7 @@ def cel_matches(text: celtypes.Value, pattern: celtypes.Value) -> celpy.Result:
6157
try:
6258
m = re.search(pattern, text)
6359
except re.error as ex:
64-
return celpy.CELEvalError("match error", ex.__class__, ex.args)
60+
msg = "match error"
61+
raise celpy.CELEvalError(msg, ex.__class__, ex.args) from ex
6562

66-
return celtypes.BoolType(m is not None)
63+
return m is not None

protovalidate/internal/string_format.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@
2424
class StringFormat:
2525
"""An implementation of string.format() in CEL."""
2626

27-
def __init__(self, locale: str):
28-
self.locale = locale
27+
def __init__(self):
2928
self.fmt = None
3029

3130
def format(self, fmt: celtypes.Value, args: celtypes.Value) -> celpy.Result:

protovalidate/validator.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,9 @@ class Validator:
3939
_cfg: Config
4040

4141
def __init__(self, config=None):
42-
self._factory = _rules.RuleFactory(extra_func.EXTRA_FUNCS)
4342
self._cfg = config if config is not None else Config()
43+
funcs = extra_func.make_extra_funcs(self._cfg)
44+
self._factory = _rules.RuleFactory(funcs)
4445

4546
def validate(
4647
self,

tests/config_test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,4 @@ class TestConfig(unittest.TestCase):
2121
def test_defaults(self):
2222
cfg = Config()
2323
self.assertFalse(cfg.fail_fast)
24+
self.assertIsNone(cfg.regex_matches_func)

tests/format_test.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from gen.cel.expr import eval_pb2
2525
from gen.cel.expr.conformance.test import simple_pb2
26+
from protovalidate.config import Config
2627
from protovalidate.internal import extra_func
2728
from protovalidate.internal.cel_field_presence import InterpretedRunner
2829

@@ -108,7 +109,7 @@ def test_format_successes(self):
108109
if test.name in skipped_tests:
109110
continue
110111
ast = self._env.compile(test.expr)
111-
prog = self._env.program(ast, functions=extra_func.EXTRA_FUNCS)
112+
prog = self._env.program(ast, functions=extra_func.make_extra_funcs(Config()))
112113

113114
bindings = build_variables(test.bindings)
114115
# Ideally we should use pytest parametrize instead of subtests, but
@@ -132,7 +133,7 @@ def test_format_errors(self):
132133
if test.name in skipped_error_tests:
133134
continue
134135
ast = self._env.compile(test.expr)
135-
prog = self._env.program(ast, functions=extra_func.EXTRA_FUNCS)
136+
prog = self._env.program(ast, functions=extra_func.make_extra_funcs(Config()))
136137

137138
bindings = build_variables(test.bindings)
138139
# Ideally we should use pytest parametrize instead of subtests, but

0 commit comments

Comments
 (0)