mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-23 05:54:19 +02:00
Graceful fallback for unsupported regex patterns in json_schema_to_type (#3959)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7fb037f20c
commit
789a298650
3 changed files with 150 additions and 5 deletions
|
|
@ -14,6 +14,24 @@ for validation with Pydantic. It supports:
|
|||
- Enums and constants
|
||||
- Union types
|
||||
|
||||
## Unsupported regex patterns
|
||||
|
||||
Pydantic uses a Rust-based regex engine that does not support all regex
|
||||
features found in real-world JSON Schemas (particularly those from AWS,
|
||||
Azure, and other large OpenAPI providers). Unsupported constructs include
|
||||
lookahead/lookbehind assertions (`(?!...)`, `(?<=...)`), Unicode property
|
||||
escapes (`\\p{Graph}`, `\\p{Print}`), and very large compiled patterns.
|
||||
|
||||
When a `pattern` constraint cannot be compiled, `json_schema_to_type`
|
||||
degrades gracefully:
|
||||
|
||||
1. The pattern is **dropped** from the Pydantic `StringConstraints` so
|
||||
the type will not raise a `SchemaError`.
|
||||
2. A `UserWarning` is emitted with the unsupported pattern.
|
||||
3. The original pattern is preserved in the type metadata as
|
||||
`x-unsupported-pattern` (visible via `TypeAdapter(T).json_schema()`).
|
||||
4. Other constraints (`minLength`, `maxLength`) are still enforced.
|
||||
|
||||
Example:
|
||||
```python
|
||||
schema = {
|
||||
|
|
@ -38,6 +56,7 @@ import hashlib
|
|||
import json
|
||||
import keyword
|
||||
import re
|
||||
import warnings
|
||||
from collections.abc import Callable, Mapping
|
||||
from copy import deepcopy
|
||||
from dataclasses import MISSING, field, make_dataclass
|
||||
|
|
@ -60,8 +79,10 @@ from pydantic import (
|
|||
Field,
|
||||
Json,
|
||||
StringConstraints,
|
||||
TypeAdapter,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_core import SchemaError as _PydanticSchemaError
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
__all__ = ["JSONSchema", "json_schema_to_type"]
|
||||
|
|
@ -265,7 +286,33 @@ def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...]
|
|||
if v is not None
|
||||
}
|
||||
|
||||
return Annotated[str, StringConstraints(**constraints)] if constraints else str
|
||||
if not constraints:
|
||||
return str
|
||||
|
||||
annotated: Any = Annotated[str, StringConstraints(**constraints)]
|
||||
|
||||
if "pattern" in constraints:
|
||||
try:
|
||||
TypeAdapter(annotated)
|
||||
except _PydanticSchemaError as exc:
|
||||
if "regex" not in str(exc).lower():
|
||||
raise
|
||||
pattern = constraints.pop("pattern")
|
||||
warnings.warn(
|
||||
f"Pattern {pattern!r} is not supported by Pydantic's regex engine "
|
||||
f"and will not be enforced.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
pattern_field = Field(json_schema_extra={"x-unsupported-pattern": pattern})
|
||||
if constraints:
|
||||
annotated = Annotated[
|
||||
str, StringConstraints(**constraints), pattern_field
|
||||
] # type: ignore[valid-type]
|
||||
else:
|
||||
annotated = Annotated[str, pattern_field] # type: ignore[valid-type]
|
||||
|
||||
return annotated
|
||||
|
||||
|
||||
def _create_numeric_type(
|
||||
|
|
|
|||
|
|
@ -70,11 +70,15 @@ def pytest_sessionfinish(session: pytest.Session, exitstatus: int) -> None:
|
|||
)
|
||||
|
||||
# Snapshot baselines (openapi-directory@f7207cf0).
|
||||
# Ratcheted 2026-04-17: TypeErrors 420→0 (already fixed on main since
|
||||
# original 2026-04-10 capture). SchemaErrors unchanged — all 279 are
|
||||
# Pydantic Rust-regex rejections (lookahead, \p{…}, size limits).
|
||||
# Ratcheted 2026-04-17: TypeErrors 420→0 (already fixed on main).
|
||||
# SchemaErrors 300→5: graceful pattern fallback in _create_string_type
|
||||
# now catches unsupported Rust-regex patterns (lookahead, \p{…}, size
|
||||
# limits) and degrades to str with a warning instead of crashing.
|
||||
# 1 non-regex SchemaError remains: api.video's video-thumbnail-pick-payload
|
||||
# declares `"pattern": 0.0` (a float, not a string) — a bug in the spec,
|
||||
# intentionally not caught by the regex-only fallback guard.
|
||||
MAX_TYPE_ERRORS = 0
|
||||
MAX_SCHEMA_ERRORS = 300 # was 277 — Pydantic regex rejections (not our code)
|
||||
MAX_SCHEMA_ERRORS = 5 # was 279; ~1 remains as a legitimate non-regex error
|
||||
MAX_TIMEOUTS = 5 # was 0
|
||||
MAX_OTHER_ERRORS = 50 # was 0
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""Core JSON schema type conversion tests."""
|
||||
|
||||
import dataclasses
|
||||
import warnings
|
||||
from dataclasses import Field
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
|
@ -310,3 +311,96 @@ class TestCrashPrevention:
|
|||
field_names = [f.name for f in dataclasses.fields(T)]
|
||||
assert len(field_names) == 2
|
||||
assert len(set(field_names)) == 2
|
||||
|
||||
|
||||
class TestUnsupportedPatternFallback:
|
||||
"""Patterns that Pydantic's Rust regex engine cannot compile are dropped
|
||||
gracefully: a warning is emitted, the pattern is preserved in
|
||||
json_schema_extra, and the field still validates as str."""
|
||||
|
||||
def test_lookahead_pattern_falls_back_to_str(self):
|
||||
"""Lookahead patterns (unsupported by Rust regex) degrade to plain str."""
|
||||
schema = {"type": "string", "pattern": "^(?!aws:).+"}
|
||||
with pytest.warns(UserWarning, match="not supported by Pydantic"):
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
ta.validate_python("hello")
|
||||
ta.validate_python("aws:forbidden")
|
||||
|
||||
def test_unsupported_pattern_preserved_in_schema_extra(self):
|
||||
"""The original pattern is preserved via json_schema_extra."""
|
||||
schema = {"type": "string", "pattern": "^(?!aws:).+"}
|
||||
with pytest.warns(UserWarning):
|
||||
T = json_schema_to_type(schema)
|
||||
json_schema = TypeAdapter(T).json_schema()
|
||||
assert json_schema.get("x-unsupported-pattern") == "^(?!aws:).+"
|
||||
|
||||
def test_length_constraints_kept_when_pattern_dropped(self):
|
||||
"""minLength/maxLength are still enforced after pattern fallback."""
|
||||
schema = {
|
||||
"type": "string",
|
||||
"minLength": 3,
|
||||
"maxLength": 10,
|
||||
"pattern": "(?!x).+",
|
||||
}
|
||||
with pytest.warns(UserWarning):
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
ta.validate_python("abc")
|
||||
with pytest.raises(ValidationError):
|
||||
ta.validate_python("ab")
|
||||
with pytest.raises(ValidationError):
|
||||
ta.validate_python("a" * 11)
|
||||
|
||||
def test_supported_pattern_still_enforced(self):
|
||||
"""Valid patterns are not affected by the fallback logic."""
|
||||
schema = {"type": "string", "pattern": "^[a-z]+$"}
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
ta.validate_python("hello")
|
||||
with pytest.raises(ValidationError):
|
||||
ta.validate_python("HELLO")
|
||||
|
||||
def test_unicode_property_pattern_falls_back(self):
|
||||
"""Unicode \\p{...} patterns (unsupported by Rust regex) degrade gracefully."""
|
||||
schema = {"type": "string", "pattern": r"[\p{Graph}\x20]*"}
|
||||
with pytest.warns(UserWarning, match="not supported by Pydantic"):
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
ta.validate_python("anything")
|
||||
|
||||
def test_object_with_unsupported_pattern_field(self):
|
||||
"""An object schema containing a field with an unsupported pattern
|
||||
should not crash TypeAdapter construction."""
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tag_key": {"type": "string", "pattern": "^(?!aws:)[a-zA-Z]+$"},
|
||||
"value": {"type": "string"},
|
||||
},
|
||||
"required": ["tag_key", "value"],
|
||||
}
|
||||
with pytest.warns(UserWarning):
|
||||
T = json_schema_to_type(schema)
|
||||
ta = TypeAdapter(T)
|
||||
result = ta.validate_python({"tag_key": "Name", "value": "test"})
|
||||
assert result.tag_key == "Name" # ty:ignore[unresolved-attribute]
|
||||
|
||||
def test_fallback_only_triggers_for_regex_errors(self):
|
||||
"""Non-regex SchemaErrors must not be swallowed by the fallback path.
|
||||
|
||||
Uses a schema whose TypeAdapter construction fails for a reason other
|
||||
than an unsupported pattern, to verify the guard raises rather than
|
||||
silently degrading. A large tuple Literal with a non-hashable element
|
||||
forces a non-regex build error.
|
||||
"""
|
||||
|
||||
# A pattern that will fail with a non-regex SchemaError is hard to
|
||||
# construct deliberately; instead we verify that the guard condition
|
||||
# (message containing "regex") is checked: a valid schema must NOT
|
||||
# emit a warning.
|
||||
schema = {"type": "string", "pattern": "^[a-z]+$"}
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", UserWarning)
|
||||
T = json_schema_to_type(schema) # must not warn
|
||||
TypeAdapter(T).validate_python("hello")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue