Skip to content

Commit cdf4315

Browse files
committed
Support swappable regular expression engines
Closes #1142
1 parent eda9779 commit cdf4315

9 files changed

Lines changed: 374 additions & 12 deletions

File tree

‎docs/spelling-wordlist.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ ValidationError
77
# 0th, sigh...
88
th
99
amongst
10+
backreference
11+
backreferences
1012
callables
1113
# non-codeblocked cls from autoapi
1214
cls
@@ -26,6 +28,7 @@ iterable
2628
iteratively
2729
Javascript
2830
jsonschema
31+
lookahead
2932
majorly
3033
metaschema
3134
online

‎docs/validate.rst‎

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,15 @@ The JSON Schema specification `recommends (but does not require) <https://json-s
299299

300300
Given that there is no current library in Python capable of supporting the ECMA 262 dialect, the ``regex`` format will instead validate *Python* regular expressions, which are the ones used by this implementation for other keywords like :kw:`pattern` or :kw:`patternProperties`.
301301

302+
.. note::
303+
304+
The ``regex`` format checker currently uses Python's :mod:`re` module
305+
regardless of any custom `regex provider <regex-providers>` that may be
306+
in use. A pattern accepted by Python's :mod:`re` but rejected by a
307+
custom engine — such as a backreference when using RE2 — will pass the
308+
format check but fail at match time. See :ref:`regex-providers` for
309+
details.
310+
302311
email
303312
^^^^^
304313

@@ -307,3 +316,140 @@ Since in most cases "validating" an email address is an attempt instead to confi
307316
The same applies to the ``idn-email`` format.
308317

309318
If you indeed want a particular well-specified set of emails to be considered valid, you can use `FormatChecker.checks` to provide your specific definition.
319+
320+
321+
.. _regex-providers:
322+
323+
Regex Providers
324+
---------------
325+
326+
``jsonschema`` uses regular expressions when validating schemas.
327+
By default, these operations use Python's built-in :mod:`re` module.
328+
329+
Python's :mod:`re` engine is vulnerable to catastrophic (or "pathological") backtracking,
330+
which occurs when a regular expression and an input
331+
cause the :mod:`re` module to run for exponentially long periods of time.
332+
If you are validating user-submitted input against user-supplied schemas,
333+
you may want to use a regular expression engine that isn't vulnerable.
334+
335+
336+
The ``RegexProvider`` Protocol
337+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
338+
339+
A regex provider is any class that satisfies the
340+
`jsonschema.protocols.RegexProvider` protocol.
341+
342+
Two methods are required, as well as a class attribute named ``raises``:
343+
344+
``raises: tuple[type[Exception], ...]``
345+
A tuple of exception classes that might be raised
346+
when interacting with the regex provider.
347+
348+
``compile(pattern: str)``
349+
Compile ``pattern`` and return the result.
350+
351+
This is expected to be equivalent to the behavior of
352+
the builtin ``re.compile()`` function.
353+
354+
``search(pattern, text: str)``
355+
Search for ``text`` in the ``pattern``.
356+
357+
``pattern`` may be a string, or the result returned by ``compile()``, above.
358+
359+
This is expected to be equivalent to the behavior of
360+
the builtin ``re.search()`` function.
361+
362+
Depending on your use case and the features or drawbacks of the regex engine you're using,
363+
it may be beneficial to use caching.
364+
365+
366+
Installing a Provider at the Class Level
367+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
368+
369+
Pass a regex provider instance to `jsonschema.validators.extend`
370+
to derive a validator class that uses your regex provider:
371+
372+
.. code-block:: python
373+
374+
from jsonschema import validators
375+
376+
SafeValidator = validators.extend(
377+
validators.Draft202012Validator,
378+
regex_provider=MyProvider(),
379+
)
380+
381+
All instances of ``SafeValidator`` will use ``MyProvider`` by default.
382+
The same argument is accepted by `jsonschema.validators.create`
383+
when building a validator class from scratch.
384+
385+
386+
Per-Instance Override
387+
~~~~~~~~~~~~~~~~~~~~~
388+
389+
Pass a provider instance to the validator constructor
390+
to override the default for a single validator instance:
391+
392+
.. code-block:: python
393+
394+
validator = Draft202012Validator(schema, regex_provider=MyProvider())
395+
396+
397+
Critical pitfalls
398+
~~~~~~~~~~~~~~~~~
399+
400+
The ``{"format": "regex"}`` format checker in ``jsonschema``
401+
is hard-coded to use Python's :mod:`re` module
402+
to determine whether a string is a valid regular expression.
403+
It currently cannot access the regex provider you designate,
404+
so its validity check may not align with what your regex engine supports.
405+
406+
At the current time, the only solution is to override
407+
the default ``"regex"`` format checker with a custom function.
408+
409+
You cannot use the ``extend`` function's ``format_checker`` argument
410+
because when ``Validator.check_schema`` is called
411+
``jsonschema`` creates a new validator class
412+
that will not honor that argument ``format_checker``.
413+
Therefore, always override the class attribute ``FORMAT_CHECKER`` directly:
414+
415+
.. code-block:: python
416+
417+
def is_regex(instance: str) -> bool:
418+
if not isinstance(instance, str):
419+
return True
420+
return bool(re2.compile(_instance))
421+
422+
MyValidator.FORMAT_CHECKER.checkers["regex"] = (is_regex, (MyException,))
423+
424+
425+
Example: ``google-re2``
426+
~~~~~~~~~~~~~~~~~~~~~~~
427+
428+
`Google RE2 <https://github.com/google/re2>`_ is a regex engine
429+
that is not vulnerable to catastrophic backtracking
430+
(it simply doesn't support features that require backtracking,
431+
like backreferences or lookahead assertions).
432+
It is available as the ``google-re2`` package on PyPI
433+
and exposes a :mod:`re`-compatible Python API.
434+
435+
.. code-block:: python
436+
437+
import jsonschema
438+
import re2
439+
440+
class Re2Provider(jsonschema.protocols.RegexProvider):
441+
raises = (re2.error,)
442+
compile = staticmethod(re2.compile)
443+
search = staticmethod(re2.search)
444+
445+
SafeValidator = jsonschema.validators.extend(
446+
jsonschema.validators.Draft202012Validator,
447+
regex_provider=Re2Provider(),
448+
)
449+
450+
def is_regex(instance: str) -> bool:
451+
if not isinstance(instance, str):
452+
return True
453+
return bool(re2.compile(instance))
454+
455+
SafeValidator.FORMAT_CHECKER.checkers["regex"] = (is_regex, Re2Provider.raises)

‎jsonschema/_keywords.py‎

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
from fractions import Fraction
2-
import re
32

43
from jsonschema._utils import (
54
ensure_list,
@@ -19,7 +18,7 @@ def patternProperties(validator, patternProperties, instance, schema):
1918

2019
for pattern, subschema in patternProperties.items():
2120
for k, v in instance.items():
22-
if re.search(pattern, k):
21+
if validator.regex_provider.search(pattern, k):
2322
yield from validator.descend(
2423
v, subschema, path=k, schema_path=pattern,
2524
)
@@ -37,7 +36,7 @@ def additionalProperties(validator, aP, instance, schema):
3736
if not validator.is_type(instance, "object"):
3837
return
3938

40-
extras = set(find_additional_properties(instance, schema))
39+
extras = set(find_additional_properties(validator, instance, schema))
4140

4241
if validator.is_type(aP, "object"):
4342
for extra in extras:
@@ -215,7 +214,7 @@ def uniqueItems(validator, uI, instance, schema):
215214
def pattern(validator, patrn, instance, schema):
216215
if (
217216
validator.is_type(instance, "string")
218-
and not re.search(patrn, instance)
217+
and not validator.regex_provider.search(patrn, instance)
219218
):
220219
yield ValidationError(f"{instance!r} does not match {patrn!r}")
221220

‎jsonschema/_legacy_keywords.py‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import re
2-
31
from referencing.jsonschema import lookup_recursive_ref
42

53
from jsonschema import _utils
@@ -380,7 +378,7 @@ def find_evaluated_property_keys_by_schema(validator, instance, schema):
380378
if "patternProperties" in schema:
381379
for property in instance:
382380
for pattern in schema["patternProperties"]:
383-
if re.search(pattern, property):
381+
if validator.regex_provider.search(pattern, property):
384382
evaluated_keys.append(property)
385383

386384
if "dependentSchemas" in schema:

‎jsonschema/_regex_provider.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import re
2+
3+
from jsonschema.protocols import RegexProvider
4+
5+
6+
class PythonRegexProvider(RegexProvider):
7+
raises = (re.error,)
8+
compile = staticmethod(re.compile) # type: ignore[assignment]
9+
search = staticmethod(re.search) # type: ignore[assignment]

‎jsonschema/_utils.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from collections.abc import Mapping, MutableMapping, Sequence
22
from urllib.parse import urlsplit
3-
import re
43

54
# Module-level sentinels so recursive `_uniq_key` calls produce comparable
65
# keys for nested True/False (function-default sentinels would also work, but
@@ -72,7 +71,7 @@ def format_as_index(container, indices):
7271
return f"{container}[{']['.join(repr(index) for index in indices)}]"
7372

7473

75-
def find_additional_properties(instance, schema):
74+
def find_additional_properties(validator, instance, schema):
7675
"""
7776
Return the set of additional properties for the given ``instance``.
7877
@@ -83,9 +82,10 @@ def find_additional_properties(instance, schema):
8382
"""
8483
properties = schema.get("properties", {})
8584
patterns = "|".join(schema.get("patternProperties", {}))
85+
_search = validator.regex_provider.search
8686
for property in instance:
8787
if property not in properties:
88-
if patterns and re.search(patterns, property):
88+
if patterns and _search(patterns, property):
8989
continue
9090
yield property
9191

@@ -370,7 +370,7 @@ def find_evaluated_property_keys_by_schema(validator, instance, schema):
370370
if "patternProperties" in schema:
371371
for property in instance:
372372
for pattern in schema["patternProperties"]:
373-
if re.search(pattern, property):
373+
if validator.regex_provider.search(pattern, property):
374374
evaluated_keys.append(property)
375375

376376
if "dependentSchemas" in schema:

‎jsonschema/protocols.py‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ class Validator(Protocol):
7575
its `extra (optional) dependencies <index:extras>` when
7676
invoking ``pip``.
7777
78+
regex_provider:
79+
80+
if provided, this is the `RegexProvider` that will be used
81+
when validating regex-based constructs, like ``patternProperties``.
82+
7883
.. deprecated:: v4.12.0
7984
8085
Subclassing validator classes now explicitly warns this is not part of
@@ -99,6 +104,10 @@ class Validator(Protocol):
99104
#: :kw:`format` keywords in JSON schemas.
100105
FORMAT_CHECKER: ClassVar[jsonschema.FormatChecker]
101106

107+
#: A `jsonschema.protocols.RegexProvider` that will be used
108+
#: when handling
109+
REGEX_PROVIDER: ClassVar[jsonschema.protocols.RegexProvider]
110+
102111
#: A function which given a schema returns its ID.
103112
ID_OF: _typing.id_of
104113

@@ -112,6 +121,7 @@ def __init__(
112121
format_checker: jsonschema.FormatChecker | None = None,
113122
*,
114123
registry: referencing.jsonschema.SchemaRegistry = ...,
124+
regex_provider: jsonschema.protocols.RegexProvider = ...,
115125
) -> None: ...
116126

117127
@classmethod
@@ -228,3 +238,35 @@ def evolve(self, **kwargs) -> Validator:
228238
... )
229239
Draft7Validator(schema=..., format_checker=None)
230240
"""
241+
242+
243+
class Match(Protocol):
244+
"""Protocol for a regex match result."""
245+
246+
def __bool__(self) -> bool: ...
247+
248+
249+
class Pattern(Protocol):
250+
"""Protocol for a compiled regex pattern."""
251+
252+
def __bool__(self) -> bool: ...
253+
254+
255+
class RegexProvider(Protocol):
256+
"""Protocol for a regular expression engine provider."""
257+
258+
raises: ClassVar[tuple[type[Exception], ...]]
259+
260+
def compile(self, pattern: str, /) -> Pattern:
261+
"""
262+
Compile the given pattern into a Pattern object.
263+
264+
Any exception raised must be listed in the *raises* attribute.
265+
"""
266+
267+
def search(self, pattern: str | Pattern, text: str, /) -> Match | None:
268+
"""
269+
Find text in the given pattern, or return None.
270+
271+
Any exception raised must be listed in the *raises* attribute.
272+
"""

0 commit comments

Comments
 (0)