From 47fddfa3d5c93513c70b95fc3759dc87a2cf5892 Mon Sep 17 00:00:00 2001 From: Robbie Ostrow Date: Mon, 18 May 2026 23:10:32 -0700 Subject: [PATCH 1/2] [codex] Add Python value validator API --- examples/python/basic/demo.py | 37 +++++++++++ python/README.md | 9 +++ python/jsoncompat.pyi | 18 +++++- python/src/lib.rs | 118 +++++++++++++++++++++++++++++++++- 4 files changed, 179 insertions(+), 3 deletions(-) diff --git a/examples/python/basic/demo.py b/examples/python/basic/demo.py index 49d635e0..3f4600ca 100644 --- a/examples/python/basic/demo.py +++ b/examples/python/basic/demo.py @@ -47,6 +47,43 @@ def main() -> None: example = jsc.generate_value(old_schema, 3) print("example value:", example) + print("\n=== Reusable validation ===") + validator = jsc.validator_for(old_schema) + assert validator.is_valid_json(example) + assert validator.is_valid_value({"name": "Robbie", "age": 37}) + assert not validator.is_valid_value({"name": True}) + + try: + validator.is_valid_value({"age": float("nan")}) + except ValueError: + pass + else: + raise AssertionError("non-finite JSON numbers must be rejected") + + try: + validator.is_valid_value({1: "invalid"}) + except TypeError: + pass + else: + raise AssertionError("JSON object keys must be strings") + + integer_validator = jsc.validator_for('{"type": "integer"}') + assert integer_validator.is_valid_value(1) + assert not integer_validator.is_valid_value(True) + + try: + integer_validator.is_valid_value(2**2000) + except ValueError: + pass + else: + raise AssertionError("oversized Python integers must be rejected") + + print("generated JSON is valid:", validator.is_valid_json(example)) + print( + "Python value is valid:", + validator.is_valid_value({"name": "Robbie", "age": 37}), + ) + if __name__ == "__main__": main() diff --git a/python/README.md b/python/README.md index ec268d39..ced2d67b 100644 --- a/python/README.md +++ b/python/README.md @@ -23,6 +23,10 @@ print(is_compatible) example = jsc.generate_value(old_schema, depth=5) print(example) + +validator = jsc.validator_for(old_schema) +print(validator.is_valid_json(example)) +print(validator.is_valid_value("hello")) ``` ## API @@ -33,6 +37,11 @@ print(example) - `generate_value(schema_json: str, depth: int = 5) -> str` - Returns a JSON string for one generated value accepted by the schema. - Raises `ValueError` when the schema is invalid, known to be unsatisfiable, or generation exhausts its retry budget. +- `validator_for(schema_json: str) -> Validator` + - Parses the schema once and returns a reusable validator. + - `Validator.is_valid_json(instance_json: str) -> bool` validates JSON strings against the parsed schema. + - `Validator.is_valid_value(instance: JsonValue) -> bool` validates Python JSON-compatible values: `None`, `bool`, finite `int`/`float`, `str`, `list`, `tuple`, and `dict[str, ...]`. + - `Validator.is_valid(instance_json: str) -> bool` remains a short compatibility alias for JSON-string validation. - `Role.SERIALIZER`, `Role.DESERIALIZER`, and `Role.BOTH` are string constants accepted by `check_compat`. Schemas are passed as JSON strings. `check_compat` returns a boolean verdict and raises `ValueError` for invalid JSON, invalid schemas, or hard unsupported compatibility cases. diff --git a/python/jsoncompat.pyi b/python/jsoncompat.pyi index 24d1692e..1e49ae65 100644 --- a/python/jsoncompat.pyi +++ b/python/jsoncompat.pyi @@ -1,8 +1,18 @@ """Typing stubs for jsoncompat Python package""" -from typing import Final, Literal +from typing import Final, Literal, TypeAlias RoleLiteral = Literal["serializer", "deserializer", "both"] +JsonValue: TypeAlias = ( + None + | bool + | int + | float + | str + | list["JsonValue"] + | tuple["JsonValue", ...] + | dict[str, "JsonValue"] +) class _Role: SERIALIZER: Final[Literal["serializer"]] @@ -11,9 +21,15 @@ class _Role: Role: _Role +class Validator: + def is_valid(self, instance_json: str) -> bool: ... + def is_valid_json(self, instance_json: str) -> bool: ... + def is_valid_value(self, instance: JsonValue) -> bool: ... + def check_compat( old_schema_json: str, new_schema_json: str, role: RoleLiteral = "both" ) -> bool: ... def generate_value(schema_json: str, depth: int = 5) -> str: ... +def validator_for(schema_json: str) -> Validator: ... __all__: list[str] diff --git a/python/src/lib.rs b/python/src/lib.rs index 384acbf2..74c97b34 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -4,13 +4,39 @@ //! constants module. Both functions accept JSON schemas as strings and report //! invalid inputs or hard unsupported core-library cases as `ValueError`. -use pyo3::exceptions::PyValueError; +use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple}; use ::jsoncompat::{Role, SchemaDocument, check_compat, validate_compatibility_input}; use json_schema_fuzz::{GenerateError, GenerationConfig, ValueGenerator}; -use serde_json::Value as JsonValue; +use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue}; + +#[pyclass(name = "Validator", module = "jsoncompat", unsendable)] +struct ValidatorPy { + schema: SchemaDocument, +} + +#[pymethods] +impl ValidatorPy { + /// Check whether a JSON value encoded as a string satisfies this validator's schema. + fn is_valid(&self, instance_json: &str) -> PyResult { + self.is_valid_json(instance_json) + } + + /// Check whether a JSON value encoded as a string satisfies this validator's schema. + fn is_valid_json(&self, instance_json: &str) -> PyResult { + let instance = parse_json(instance_json)?; + validate_value_for_schema(&self.schema, &instance) + } + + /// Check whether a Python JSON-compatible value satisfies this validator's schema. + fn is_valid_value(&self, instance: &Bound<'_, PyAny>) -> PyResult { + let instance = py_to_json_value(instance)?; + validate_value_for_schema(&self.schema, &instance) + } +} fn validated_schema(raw: &JsonValue) -> Result { let schema = SchemaDocument::from_json(raw).map_err(|error| error.to_string())?; @@ -27,6 +53,82 @@ fn compatibility_schema(raw: &JsonValue) -> Result { Ok(schema) } +fn validate_value_for_schema(schema: &SchemaDocument, instance: &JsonValue) -> PyResult { + schema + .is_valid(instance) + .map_err(|e| PyErr::new::(format!("Validation failed: {e}"))) +} + +fn py_to_json_value(value: &Bound<'_, PyAny>) -> PyResult { + if value.is_none() { + return Ok(JsonValue::Null); + } + if value.is_instance_of::() { + return Ok(JsonValue::Bool(value.extract::()?)); + } + if value.is_instance_of::() { + return py_int_to_json_value(value); + } + if value.is_instance_of::() { + let number = value.extract::()?; + if !number.is_finite() { + return Err(PyErr::new::("JSON numbers must be finite")); + } + let Some(number) = JsonNumber::from_f64(number) else { + return Err(PyErr::new::( + "failed to convert Python float to JSON number", + )); + }; + return Ok(JsonValue::Number(number)); + } + if value.is_instance_of::() { + return Ok(JsonValue::String(value.extract::()?)); + } + if let Ok(list) = value.cast::() { + return list + .iter() + .map(|item| py_to_json_value(&item)) + .collect::>>() + .map(JsonValue::Array); + } + if let Ok(tuple) = value.cast::() { + return tuple + .iter() + .map(|item| py_to_json_value(&item)) + .collect::>>() + .map(JsonValue::Array); + } + if let Ok(dict) = value.cast::() { + let mut object = JsonMap::with_capacity(dict.len()); + for (key, item) in dict { + if !key.is_instance_of::() { + return Err(PyErr::new::( + "JSON object keys must be strings", + )); + } + object.insert(key.extract::()?, py_to_json_value(&item)?); + } + return Ok(JsonValue::Object(object)); + } + + Err(PyErr::new::(format!( + "expected a JSON-compatible value, got {}", + value.get_type().name()? + ))) +} + +fn py_int_to_json_value(value: &Bound<'_, PyAny>) -> PyResult { + if let Ok(number) = value.extract::() { + return Ok(JsonValue::Number(JsonNumber::from(number))); + } + if let Ok(number) = value.extract::() { + return Ok(JsonValue::Number(JsonNumber::from(number))); + } + Err(PyErr::new::( + "JSON integer is outside the supported range", + )) +} + /// Parse a JSON string into a serde_json::Value, converting any error into a Python ValueError. fn parse_json(s: &str) -> PyResult { serde_json::from_str(s).map_err(|e| PyErr::new::(format!("Invalid JSON: {e}"))) @@ -115,12 +217,24 @@ fn generate_value_py(schema_json: &str, depth: u8) -> PyResult { }) } +/// Build a reusable validator for one JSON Schema document. +#[pyfunction] +#[pyo3(signature = (schema_json), name = "validator_for")] +fn validator_for_py(schema_json: &str) -> PyResult { + let raw = parse_json(schema_json)?; + let schema = validated_schema(&raw) + .map_err(|e| PyErr::new::(format!("Invalid schema: {e}")))?; + Ok(ValidatorPy { schema }) +} + /// Python module definition #[pymodule] #[pyo3(name = "jsoncompat")] fn jsoncompat(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(check_compat_py, m)?)?; m.add_function(wrap_pyfunction!(generate_value_py, m)?)?; + m.add_function(wrap_pyfunction!(validator_for_py, m)?)?; + m.add_class::()?; let role_constants = PyModule::new(py, "Role")?; role_constants.add("SERIALIZER", "serializer")?; From 272b737f5851208e3acc4e7b75eb07fa29fdfd34 Mon Sep 17 00:00:00 2001 From: Robbie Ostrow Date: Mon, 18 May 2026 23:15:54 -0700 Subject: [PATCH 2/2] [codex] Add WASM validator API --- examples/wasm/demo.html | 11 ++++++++++- wasm/README.md | 8 +++++++- wasm/src/lib.rs | 40 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/examples/wasm/demo.html b/examples/wasm/demo.html index 25e1b835..8f9f286c 100644 --- a/examples/wasm/demo.html +++ b/examples/wasm/demo.html @@ -15,7 +15,7 @@

json_schema_wasm - browser demo