Skip to content

Commit 4eda832

Browse files
committed
Add optional ecma-regex backend for strict OpenAPI pattern validation
1 parent 4e63595 commit 4eda832

File tree

10 files changed

+342
-7
lines changed

10 files changed

+342
-7
lines changed

.github/workflows/python-tests.yml

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,46 @@ jobs:
6262
- name: Upload coverage
6363
uses: codecov/codecov-action@v5
6464

65+
tests_no_extras:
66+
name: "py3.14 no extras"
67+
runs-on: ubuntu-latest
68+
steps:
69+
- uses: actions/checkout@v6
70+
71+
- name: Set up Python 3.14
72+
uses: actions/setup-python@v6
73+
with:
74+
python-version: "3.14"
75+
76+
- name: Get full Python version
77+
id: full-python-version
78+
run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))")
79+
80+
- name: Set up poetry
81+
uses: Gr1N/setup-poetry@v9
82+
83+
- name: Configure poetry
84+
run: poetry config virtualenvs.in-project true
85+
86+
- name: Set up cache
87+
uses: actions/cache@v5
88+
id: cache
89+
with:
90+
path: .venv
91+
key: venv-no-extras-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }}
92+
93+
- name: Ensure cache is healthy
94+
if: steps.cache.outputs.cache-hit == 'true'
95+
run: timeout 10s poetry run pip --version || rm -rf .venv
96+
97+
- name: Install dependencies
98+
run: poetry install
99+
100+
- name: Test fallback regex behavior
101+
env:
102+
PYTEST_ADDOPTS: "--color=yes"
103+
run: poetry run pytest tests/integration/test_validators.py -k pattern
104+
65105
static_checks:
66106
name: "Static checks"
67107
runs-on: ubuntu-latest

README.rst

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,6 @@ unresolved-metaschema fallback warnings.
127127
assert validator_for(schema) is OAS31Validator
128128
assert validator_for(schema32) is OAS32Validator
129129
130-
131130
Binary Data Semantics
132131
=====================
133132

@@ -189,6 +188,19 @@ Quick Reference
189188
- Fail
190189
- Same semantics as OAS 3.1
191190

191+
192+
Regex Behavior
193+
==============
194+
195+
By default, ``pattern`` handling follows host Python regex behavior.
196+
For ECMAScript-oriented regex validation and matching (via ``regress``),
197+
install the optional extra:
198+
199+
.. code-block:: console
200+
201+
pip install "openapi-schema-validator[ecma-regex]"
202+
203+
192204
For more details read about `Validation <https://openapi-schema-validator.readthedocs.io/en/latest/validation.html>`__.
193205

194206

docs/validation.rst

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,8 @@ Malformed schema values (for example an invalid regex in ``pattern``) raise
138138
If you instantiate a validator class directly and call ``.validate(...)``,
139139
schema checking is not performed automatically, matching
140140
``jsonschema`` validator-class behavior.
141-
For malformed regex patterns this may raise a lower-level regex error.
141+
For malformed regex patterns this may raise a lower-level regex error
142+
(default mode) or ``ValidationError`` from the validator (ECMAScript mode).
142143

143144
Use ``<ValidatorClass>.check_schema(schema)`` first when you need deterministic
144145
schema-validation errors with direct validator usage.
@@ -245,6 +246,21 @@ Quick Reference
245246
- Fail
246247
- Same semantics as OAS 3.1
247248

249+
Regex Behavior
250+
--------------
251+
252+
Pattern validation follows one of two modes:
253+
254+
- default installation: follows host Python regex behavior
255+
- ``ecma-regex`` extra installed: uses ``regress`` for ECMAScript-oriented
256+
regex validation and matching
257+
258+
Install optional ECMAScript regex support with:
259+
260+
.. code-block:: console
261+
262+
pip install "openapi-schema-validator[ecma-regex]"
263+
248264
Example usage:
249265

250266
.. code-block:: python

openapi_schema_validator/_format.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
from jsonschema._format import FormatChecker
77

8+
from openapi_schema_validator._regex import is_valid_regex
9+
810

911
def is_int32(instance: object) -> bool:
1012
# bool inherits from int, so ensure bools aren't reported as ints
@@ -82,6 +84,12 @@ def is_password(instance: object) -> bool:
8284
return True
8385

8486

87+
def is_regex(instance: object) -> bool:
88+
if not isinstance(instance, str):
89+
return True
90+
return is_valid_regex(instance)
91+
92+
8593
oas30_format_checker = FormatChecker()
8694
oas30_format_checker.checks("int32")(is_int32)
8795
oas30_format_checker.checks("int64")(is_int64)
@@ -90,6 +98,7 @@ def is_password(instance: object) -> bool:
9098
oas30_format_checker.checks("binary")(is_binary_pragmatic)
9199
oas30_format_checker.checks("byte", (binascii.Error, TypeError))(is_byte)
92100
oas30_format_checker.checks("password")(is_password)
101+
oas30_format_checker.checks("regex")(is_regex)
93102

94103
oas30_strict_format_checker = FormatChecker()
95104
oas30_strict_format_checker.checks("int32")(is_int32)
@@ -101,13 +110,15 @@ def is_password(instance: object) -> bool:
101110
is_byte
102111
)
103112
oas30_strict_format_checker.checks("password")(is_password)
113+
oas30_strict_format_checker.checks("regex")(is_regex)
104114

105115
oas31_format_checker = FormatChecker()
106116
oas31_format_checker.checks("int32")(is_int32)
107117
oas31_format_checker.checks("int64")(is_int64)
108118
oas31_format_checker.checks("float")(is_float)
109119
oas31_format_checker.checks("double")(is_double)
110120
oas31_format_checker.checks("password")(is_password)
121+
oas31_format_checker.checks("regex")(is_regex)
111122

112123
# OAS 3.2 uses the same format checks as OAS 3.1
113124
oas32_format_checker = FormatChecker()
@@ -116,3 +127,4 @@ def is_password(instance: object) -> bool:
116127
oas32_format_checker.checks("float")(is_float)
117128
oas32_format_checker.checks("double")(is_double)
118129
oas32_format_checker.checks("password")(is_password)
130+
oas32_format_checker.checks("regex")(is_regex)

openapi_schema_validator/_keywords.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66
from jsonschema._keywords import allOf as _allOf
77
from jsonschema._keywords import anyOf as _anyOf
88
from jsonschema._keywords import oneOf as _oneOf
9+
from jsonschema._keywords import pattern as _pattern
910
from jsonschema._utils import extras_msg
1011
from jsonschema._utils import find_additional_properties
1112
from jsonschema.exceptions import FormatError
1213
from jsonschema.exceptions import ValidationError
1314
from jsonschema.exceptions import _WrappedReferencingError
1415

16+
from openapi_schema_validator._regex import ECMARegexSyntaxError
17+
from openapi_schema_validator._regex import has_ecma_regex
18+
from openapi_schema_validator._regex import search as regex_search
19+
1520

1621
def handle_discriminator(
1722
validator: Any, _: Any, instance: Any, schema: Mapping[str, Any]
@@ -159,6 +164,34 @@ def strict_type(
159164
yield ValidationError(f"{instance!r} is not of type {data_repr}")
160165

161166

167+
def pattern(
168+
validator: Any,
169+
patrn: str,
170+
instance: Any,
171+
schema: Mapping[str, Any],
172+
) -> Iterator[ValidationError]:
173+
if not has_ecma_regex():
174+
yield from cast(
175+
Iterator[ValidationError],
176+
_pattern(validator, patrn, instance, schema),
177+
)
178+
return
179+
180+
if not validator.is_type(instance, "string"):
181+
return
182+
183+
try:
184+
matches = regex_search(patrn, instance)
185+
except ECMARegexSyntaxError as exc:
186+
yield ValidationError(
187+
f"{patrn!r} is not a valid regular expression ({exc})"
188+
)
189+
return
190+
191+
if not matches:
192+
yield ValidationError(f"{instance!r} does not match {patrn!r}")
193+
194+
162195
def format(
163196
validator: Any,
164197
format: str,

openapi_schema_validator/_regex.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import re
2+
from typing import Any
3+
4+
_REGEX_CLASS: Any = None
5+
_REGRESS_ERROR: type[Exception] = Exception
6+
7+
try:
8+
from regress import Regex as _REGEX_CLASS
9+
from regress import RegressError as _REGRESS_ERROR
10+
except ImportError: # pragma: no cover - optional dependency
11+
pass
12+
13+
14+
class ECMARegexSyntaxError(ValueError):
15+
pass
16+
17+
18+
def has_ecma_regex() -> bool:
19+
return _REGEX_CLASS is not None
20+
21+
22+
def is_valid_regex(pattern: str) -> bool:
23+
if _REGEX_CLASS is None:
24+
try:
25+
re.compile(pattern)
26+
except re.error:
27+
return False
28+
return True
29+
30+
try:
31+
_REGEX_CLASS(pattern)
32+
except _REGRESS_ERROR:
33+
return False
34+
return True
35+
36+
37+
def search(pattern: str, instance: str) -> bool:
38+
if _REGEX_CLASS is None:
39+
return re.search(pattern, instance) is not None
40+
41+
try:
42+
return _REGEX_CLASS(pattern).find(instance) is not None
43+
except _REGRESS_ERROR as exc:
44+
raise ECMARegexSyntaxError(str(exc)) from exc

openapi_schema_validator/validators.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def _oas30_id_of(schema: Any) -> str:
6262
"minimum": _legacy_keywords.minimum_draft3_draft4,
6363
"maxLength": _keywords.maxLength,
6464
"minLength": _keywords.minLength,
65-
"pattern": _keywords.pattern,
65+
"pattern": oas_keywords.pattern,
6666
"maxItems": _keywords.maxItems,
6767
"minItems": _keywords.minItems,
6868
"uniqueItems": _keywords.uniqueItems,
@@ -118,6 +118,7 @@ def _build_oas31_validator() -> Any:
118118
"allOf": oas_keywords.allOf,
119119
"oneOf": oas_keywords.oneOf,
120120
"anyOf": oas_keywords.anyOf,
121+
"pattern": oas_keywords.pattern,
121122
"description": oas_keywords.not_implemented,
122123
# fixed OAS fields
123124
"discriminator": oas_keywords.not_implemented,
@@ -180,5 +181,6 @@ def _build_oas32_validator() -> Any:
180181
OAS31Validator = _build_oas31_validator()
181182
OAS32Validator = _build_oas32_validator()
182183

184+
OAS30Validator.check_schema = classmethod(check_openapi_schema)
183185
OAS31Validator.check_schema = classmethod(check_openapi_schema)
184186
OAS32Validator.check_schema = classmethod(check_openapi_schema)

0 commit comments

Comments
 (0)