mirror of
https://github.com/ComposioHQ/composio.git
synced 2026-09-22 11:46:35 +08:00
ab289d6224
## Summary - preserve boolean, empty, null, type-array, enum, const, and scalar-constraint semantics across every Python conversion entry point - intersect Zod enum and const values with declared types and constraints, including compound JSON values - default unversioned exact validation to Draft 7 and apply inclusive and numeric exclusive bounds independently - run one byte-identical corpus through Python, Zod, and Effect so accepted and rejected inputs stay aligned - keep exact JSON Schema acceptance separate from Pydantic default materialization ## Review follow-up (second push) - Python: exact Draft 7 acceptance now wraps all three entry points (`json_schema_to_pydantic_type`, `json_schema_to_model`, `pydantic_model_from_param_schema`), so they can no longer disagree - Python: draft-4 boolean `exclusiveMinimum`/`exclusiveMaximum` (OpenAPI 3.0 style) no longer crash conversion — exact validation falls back to Draft 4, and the library input is translated to the numeric spelling - Python: ECMA-only regex patterns (look-around) no longer crash pydantic model builds — Rust-incompatible patterns fall back to Python `re` - Python: type arrays with sibling constraints no longer raise `TypeError` on valid input — constraints are scoped per member before the library sees them - Python: integral floats satisfy `integer`, `const` intersects `enum`, annotation-only schemas accept anything, and an optional property with an empty `enum` tolerates absence - Zod: typeless scalar constraints apply per instance type, and string lengths count Unicode code points instead of UTF-16 code units - Effect: draft-4 boolean exclusive bounds are enforced instead of silently ignored - `multipleOf` uses decimal scaling in all three converters (declared `divergesFromJsonSchema` on the corpus case) - shared corpus grows by 13 primitive cases; new property-based tests check acceptance against real Draft 7 oracles (hypothesis + `jsonschema` in Python, fast-check + Ajv in TypeScript) ## Verification - Python `make chk` (ruff + mypy) - Python pytest: 1,572 passed (5 langchain-extra tests need an env this sandbox lacks; unchanged from base) - `@composio/json-schema-to-zod`: 187 passed incl. 300-run fast-check property test; typecheck + build - `@composio/json-schema-to-effect-schema`: 133 passed; typecheck - `@composio/core` corpus ingress tests: 61 passed - shared Python/TypeScript corpus files are byte-identical (shasum-verified) - `git diff --check` ## Contributor context This replaces four narrow proposals after independent local reproduction: - [#4301](https://github.com/ComposioHQ/composio/pull/4301) · [Glen](https://app.tryglen.com/ComposioHQ/composio/pull/4301) - [#4302](https://github.com/ComposioHQ/composio/pull/4302) · [Glen](https://app.tryglen.com/ComposioHQ/composio/pull/4302) - [#4303](https://github.com/ComposioHQ/composio/pull/4303) · [Glen](https://app.tryglen.com/ComposioHQ/composio/pull/4303) - [#4307](https://github.com/ComposioHQ/composio/pull/4307) · [Glen](https://app.tryglen.com/ComposioHQ/composio/pull/4307) --------- Co-authored-by: simpleqt <89645338+simpleqt@users.noreply.github.com>
216 lines
7.0 KiB
Python
216 lines
7.0 KiB
Python
"""Property-based acceptance parity for scalar JSON Schema conversion.
|
|
|
|
The oracle is the `jsonschema` Draft 7 validator: for every generated scalar
|
|
schema and instance, the converted Pydantic artifacts must agree with it.
|
|
|
|
The generator deliberately stays inside the semantics the converters claim to
|
|
support today; shrinking an exclusion is the way to turn a fixed behavior into
|
|
a permanent regression guard. The remaining exclusions are deliberate
|
|
divergences or oracle limits, not open bugs:
|
|
|
|
- ``multipleOf`` is always an integer: the converters check decimal multiples
|
|
through decimal scaling (see the ``primitive-float-multiple-of`` corpus
|
|
case), while the oracle uses raw float modulo
|
|
- draft-4 boolean ``exclusiveMinimum``/``exclusiveMaximum`` are never
|
|
generated: they are not Draft 7, so the oracle cannot express them (the
|
|
``primitive-draft4-boolean-exclusive-bounds`` corpus case covers them)
|
|
"""
|
|
|
|
import typing as t
|
|
|
|
import pytest
|
|
from hypothesis import HealthCheck, given, settings
|
|
from hypothesis import strategies as st
|
|
from jsonschema import Draft7Validator
|
|
from pydantic import TypeAdapter, ValidationError
|
|
|
|
from composio.utils.schema_converter import json_schema_to_pydantic_type
|
|
from composio.utils.shared import pydantic_model_from_param_schema
|
|
|
|
SCALAR_TYPES = ("string", "integer", "number", "boolean", "null")
|
|
|
|
PATTERN_POOL = ("^[a-z]+$", "^[A-Z]{2}$", "[0-9]", "^a.*z$", "^(?=.*[a-y])a")
|
|
|
|
scalar_values = st.one_of(
|
|
st.none(),
|
|
st.booleans(),
|
|
st.integers(min_value=-100, max_value=100),
|
|
st.floats(
|
|
min_value=-100,
|
|
max_value=100,
|
|
allow_nan=False,
|
|
allow_infinity=False,
|
|
),
|
|
st.text(
|
|
alphabet=st.characters(min_codepoint=97, max_codepoint=122),
|
|
max_size=6,
|
|
),
|
|
)
|
|
|
|
|
|
@st.composite
|
|
def scalar_schemas(draw: st.DrawFn) -> dict[str, t.Any]:
|
|
schema: dict[str, t.Any] = {}
|
|
declared: tuple[str, ...] = ()
|
|
|
|
if draw(st.booleans()):
|
|
members = draw(
|
|
st.lists(st.sampled_from(SCALAR_TYPES), min_size=1, max_size=3, unique=True)
|
|
)
|
|
if len(members) == 1 and draw(st.booleans()):
|
|
schema["type"] = members[0]
|
|
declared = (members[0],)
|
|
else:
|
|
schema["type"] = members
|
|
declared = tuple(members)
|
|
|
|
literal_kind = draw(st.sampled_from(("none", "enum", "const", "both")))
|
|
if literal_kind in ("enum", "both"):
|
|
schema["enum"] = draw(
|
|
st.lists(scalar_values, min_size=1, max_size=4, unique_by=repr)
|
|
)
|
|
if literal_kind in ("const", "both"):
|
|
schema["const"] = draw(scalar_values)
|
|
|
|
# Constraints attach independently of the declared type (or its absence):
|
|
# Draft 7 scopes every scalar keyword to matching instance types anyway.
|
|
del declared
|
|
if draw(st.booleans()):
|
|
schema["minLength"] = draw(st.integers(min_value=0, max_value=4))
|
|
if draw(st.booleans()):
|
|
schema["maxLength"] = draw(st.integers(min_value=0, max_value=6))
|
|
if draw(st.booleans()):
|
|
schema["pattern"] = draw(st.sampled_from(PATTERN_POOL))
|
|
if draw(st.booleans()):
|
|
schema["minimum"] = draw(st.integers(min_value=-20, max_value=20))
|
|
if draw(st.booleans()):
|
|
schema["maximum"] = draw(st.integers(min_value=-20, max_value=20))
|
|
if draw(st.booleans()):
|
|
schema["exclusiveMinimum"] = draw(st.integers(min_value=-20, max_value=20))
|
|
if draw(st.booleans()):
|
|
schema["exclusiveMaximum"] = draw(st.integers(min_value=-20, max_value=20))
|
|
if draw(st.booleans()):
|
|
schema["multipleOf"] = draw(st.integers(min_value=1, max_value=5))
|
|
|
|
return schema
|
|
|
|
|
|
@st.composite
|
|
def schema_and_instance(draw: st.DrawFn) -> tuple[dict[str, t.Any], t.Any]:
|
|
schema = draw(scalar_schemas())
|
|
|
|
pools: list[st.SearchStrategy[t.Any]] = [scalar_values]
|
|
interesting: list[t.Any] = []
|
|
if "enum" in schema:
|
|
interesting.extend(schema["enum"])
|
|
if "const" in schema:
|
|
interesting.append(schema["const"])
|
|
for keyword in ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum"):
|
|
if keyword in schema:
|
|
bound = schema[keyword]
|
|
interesting.extend((bound, bound + 1, bound - 1))
|
|
if interesting:
|
|
pools.append(st.sampled_from(interesting))
|
|
|
|
return schema, draw(st.one_of(pools))
|
|
|
|
|
|
def _oracle_accepts(object_schema: dict[str, t.Any], instance: t.Any) -> bool:
|
|
return Draft7Validator(object_schema).is_valid(instance)
|
|
|
|
|
|
def _pydantic_accepts(annotation: t.Any, instance: t.Any) -> bool:
|
|
try:
|
|
TypeAdapter(annotation).validate_python(instance)
|
|
return True
|
|
except ValidationError:
|
|
return False
|
|
|
|
|
|
def _wrap(schema: dict[str, t.Any]) -> dict[str, t.Any]:
|
|
return {
|
|
"type": "object",
|
|
"properties": {"value": schema},
|
|
"required": ["value"],
|
|
"title": "PropertyCase",
|
|
}
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
@settings(
|
|
max_examples=200,
|
|
deadline=None,
|
|
suppress_health_check=[HealthCheck.too_slow],
|
|
)
|
|
@given(schema_and_instance())
|
|
def test_object_conversion_matches_draft7_oracle(
|
|
case: tuple[dict[str, t.Any], t.Any],
|
|
) -> None:
|
|
"""`json_schema_to_pydantic_type` on an object schema must agree with Draft 7."""
|
|
schema, value = case
|
|
object_schema = _wrap(schema)
|
|
instance = {"value": value}
|
|
|
|
expected = _oracle_accepts(object_schema, instance)
|
|
actual = _pydantic_accepts(json_schema_to_pydantic_type(object_schema), instance)
|
|
|
|
assert actual == expected, (
|
|
f"object path disagreed with Draft 7 oracle for schema={schema!r} "
|
|
f"value={value!r}: oracle={expected} converted={actual}"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
@settings(
|
|
max_examples=200,
|
|
deadline=None,
|
|
suppress_health_check=[HealthCheck.too_slow],
|
|
)
|
|
@given(schema_and_instance())
|
|
def test_legacy_model_matches_draft7_oracle(
|
|
case: tuple[dict[str, t.Any], t.Any],
|
|
) -> None:
|
|
"""`pydantic_model_from_param_schema` enforces exact Draft 7 acceptance."""
|
|
schema, value = case
|
|
object_schema = _wrap(schema)
|
|
instance = {"value": value}
|
|
|
|
expected = _oracle_accepts(object_schema, instance)
|
|
model = pydantic_model_from_param_schema(object_schema)
|
|
try:
|
|
model.model_validate(instance)
|
|
actual = True
|
|
except ValidationError:
|
|
actual = False
|
|
|
|
assert actual == expected, (
|
|
f"legacy path disagreed with Draft 7 oracle for schema={schema!r} "
|
|
f"value={value!r}: oracle={expected} converted={actual}"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
@pytest.mark.schema
|
|
@settings(
|
|
max_examples=200,
|
|
deadline=None,
|
|
suppress_health_check=[HealthCheck.too_slow],
|
|
)
|
|
@given(schema_and_instance())
|
|
def test_toplevel_conversion_never_rejects_draft7_valid_input(
|
|
case: tuple[dict[str, t.Any], t.Any],
|
|
) -> None:
|
|
"""The top-level scalar path may coerce, but must not over-reject."""
|
|
schema, value = case
|
|
|
|
if not Draft7Validator(schema).is_valid(value):
|
|
return
|
|
|
|
annotation = json_schema_to_pydantic_type(schema)
|
|
assert _pydantic_accepts(annotation, value), (
|
|
f"top-level path rejected a Draft 7-valid instance for schema={schema!r} "
|
|
f"value={value!r}"
|
|
)
|