Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,46 @@ jobs:
- name: Upload coverage
uses: codecov/codecov-action@v5

tests_no_extras:
name: "py3.14 no extras"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Set up Python 3.14
uses: actions/setup-python@v6
with:
python-version: "3.14"

- name: Get full Python version
id: full-python-version
run: echo ::set-output name=version::$(python -c "import sys; print('-'.join(str(v) for v in sys.version_info))")

- name: Set up poetry
uses: Gr1N/setup-poetry@v9

- name: Configure poetry
run: poetry config virtualenvs.in-project true

- name: Set up cache
uses: actions/cache@v5
id: cache
with:
path: .venv
key: venv-no-extras-${{ steps.full-python-version.outputs.version }}-${{ hashFiles('**/poetry.lock') }}

- name: Ensure cache is healthy
if: steps.cache.outputs.cache-hit == 'true'
run: timeout 10s poetry run pip --version || rm -rf .venv

- name: Install dependencies
run: poetry install

- name: Test fallback regex behavior
env:
PYTEST_ADDOPTS: "--color=yes"
run: poetry run pytest tests/integration/test_validators.py -k pattern

static_checks:
name: "Static checks"
runs-on: ubuntu-latest
Expand Down
14 changes: 13 additions & 1 deletion README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,6 @@ unresolved-metaschema fallback warnings.
assert validator_for(schema) is OAS31Validator
assert validator_for(schema32) is OAS32Validator
Binary Data Semantics
=====================

Expand Down Expand Up @@ -189,6 +188,19 @@ Quick Reference
- Fail
- Same semantics as OAS 3.1


Regex Behavior
==============

By default, ``pattern`` handling follows host Python regex behavior.
For ECMAScript-oriented regex validation and matching (via ``regress``),
install the optional extra:

.. code-block:: console
pip install "openapi-schema-validator[ecma-regex]"
For more details read about `Validation <https://openapi-schema-validator.readthedocs.io/en/latest/validation.html>`__.


Expand Down
18 changes: 17 additions & 1 deletion docs/validation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,8 @@ Malformed schema values (for example an invalid regex in ``pattern``) raise
If you instantiate a validator class directly and call ``.validate(...)``,
schema checking is not performed automatically, matching
``jsonschema`` validator-class behavior.
For malformed regex patterns this may raise a lower-level regex error.
For malformed regex patterns this may raise a lower-level regex error
(default mode) or ``ValidationError`` from the validator (ECMAScript mode).

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

Regex Behavior
--------------

Pattern validation follows one of two modes:

- default installation: follows host Python regex behavior
- ``ecma-regex`` extra installed: uses ``regress`` for ECMAScript-oriented
regex validation and matching

Install optional ECMAScript regex support with:

.. code-block:: console

pip install "openapi-schema-validator[ecma-regex]"

Example usage:

.. code-block:: python
Expand Down
12 changes: 12 additions & 0 deletions openapi_schema_validator/_format.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@

from jsonschema._format import FormatChecker

from openapi_schema_validator._regex import is_valid_regex


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


def is_regex(instance: object) -> bool:
if not isinstance(instance, str):
return True
return is_valid_regex(instance)


oas30_format_checker = FormatChecker()
oas30_format_checker.checks("int32")(is_int32)
oas30_format_checker.checks("int64")(is_int64)
Expand All @@ -90,6 +98,7 @@ def is_password(instance: object) -> bool:
oas30_format_checker.checks("binary")(is_binary_pragmatic)
oas30_format_checker.checks("byte", (binascii.Error, TypeError))(is_byte)
oas30_format_checker.checks("password")(is_password)
oas30_format_checker.checks("regex")(is_regex)

oas30_strict_format_checker = FormatChecker()
oas30_strict_format_checker.checks("int32")(is_int32)
Expand All @@ -101,13 +110,15 @@ def is_password(instance: object) -> bool:
is_byte
)
oas30_strict_format_checker.checks("password")(is_password)
oas30_strict_format_checker.checks("regex")(is_regex)

oas31_format_checker = FormatChecker()
oas31_format_checker.checks("int32")(is_int32)
oas31_format_checker.checks("int64")(is_int64)
oas31_format_checker.checks("float")(is_float)
oas31_format_checker.checks("double")(is_double)
oas31_format_checker.checks("password")(is_password)
oas31_format_checker.checks("regex")(is_regex)

# OAS 3.2 uses the same format checks as OAS 3.1
oas32_format_checker = FormatChecker()
Expand All @@ -116,3 +127,4 @@ def is_password(instance: object) -> bool:
oas32_format_checker.checks("float")(is_float)
oas32_format_checker.checks("double")(is_double)
oas32_format_checker.checks("password")(is_password)
oas32_format_checker.checks("regex")(is_regex)
33 changes: 33 additions & 0 deletions openapi_schema_validator/_keywords.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@
from jsonschema._keywords import allOf as _allOf
from jsonschema._keywords import anyOf as _anyOf
from jsonschema._keywords import oneOf as _oneOf
from jsonschema._keywords import pattern as _pattern
from jsonschema._utils import extras_msg
from jsonschema._utils import find_additional_properties
from jsonschema.exceptions import FormatError
from jsonschema.exceptions import ValidationError
from jsonschema.exceptions import _WrappedReferencingError

from openapi_schema_validator._regex import ECMARegexSyntaxError
from openapi_schema_validator._regex import has_ecma_regex
from openapi_schema_validator._regex import search as regex_search


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


def pattern(
validator: Any,
patrn: str,
instance: Any,
schema: Mapping[str, Any],
) -> Iterator[ValidationError]:
if not has_ecma_regex():
yield from cast(
Iterator[ValidationError],
_pattern(validator, patrn, instance, schema),
)
return

if not validator.is_type(instance, "string"):
return

try:
matches = regex_search(patrn, instance)
except ECMARegexSyntaxError as exc:
yield ValidationError(
f"{patrn!r} is not a valid regular expression ({exc})"
)
return

if not matches:
yield ValidationError(f"{instance!r} does not match {patrn!r}")


def format(
validator: Any,
format: str,
Expand Down
44 changes: 44 additions & 0 deletions openapi_schema_validator/_regex.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import re
from typing import Any

_REGEX_CLASS: Any = None
_REGRESS_ERROR: type[Exception] = Exception

try:
from regress import Regex as _REGEX_CLASS
from regress import RegressError as _REGRESS_ERROR
except ImportError: # pragma: no cover - optional dependency
pass


class ECMARegexSyntaxError(ValueError):
pass


def has_ecma_regex() -> bool:
return _REGEX_CLASS is not None


def is_valid_regex(pattern: str) -> bool:
if _REGEX_CLASS is None:
try:
re.compile(pattern)
except re.error:
return False
return True

try:
_REGEX_CLASS(pattern)
except _REGRESS_ERROR:
return False
return True


def search(pattern: str, instance: str) -> bool:
if _REGEX_CLASS is None:
return re.search(pattern, instance) is not None

try:
return _REGEX_CLASS(pattern).find(instance) is not None
except _REGRESS_ERROR as exc:
raise ECMARegexSyntaxError(str(exc)) from exc
9 changes: 8 additions & 1 deletion openapi_schema_validator/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ def _oas30_id_of(schema: Any) -> str:
"minimum": _legacy_keywords.minimum_draft3_draft4,
"maxLength": _keywords.maxLength,
"minLength": _keywords.minLength,
"pattern": _keywords.pattern,
"pattern": oas_keywords.pattern,
"maxItems": _keywords.maxItems,
"minItems": _keywords.minItems,
"uniqueItems": _keywords.uniqueItems,
Expand Down Expand Up @@ -118,6 +118,7 @@ def _build_oas31_validator() -> Any:
"allOf": oas_keywords.allOf,
"oneOf": oas_keywords.oneOf,
"anyOf": oas_keywords.anyOf,
"pattern": oas_keywords.pattern,
"description": oas_keywords.not_implemented,
# fixed OAS fields
"discriminator": oas_keywords.not_implemented,
Expand Down Expand Up @@ -180,5 +181,11 @@ def _build_oas32_validator() -> Any:
OAS31Validator = _build_oas31_validator()
OAS32Validator = _build_oas32_validator()

# These validator classes are generated via jsonschema create/extend, so there
# is no simpler hook to inject registry-aware schema checking while preserving
# each class's FORMAT_CHECKER. Override check_schema on each class to keep
# OpenAPI metaschema resolution local and to apply optional ecma-regex
# behavior consistently across OAS 3.0/3.1/3.2.
OAS30Validator.check_schema = classmethod(check_openapi_schema)
OAS31Validator.check_schema = classmethod(check_openapi_schema)
OAS32Validator.check_schema = classmethod(check_openapi_schema)
Loading
Loading