Skip to content

Commit 47fddfa

Browse files
committed
[codex] Add Python value validator API
1 parent 45e987d commit 47fddfa

4 files changed

Lines changed: 179 additions & 3 deletions

File tree

examples/python/basic/demo.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,43 @@ def main() -> None:
4747
example = jsc.generate_value(old_schema, 3)
4848
print("example value:", example)
4949

50+
print("\n=== Reusable validation ===")
51+
validator = jsc.validator_for(old_schema)
52+
assert validator.is_valid_json(example)
53+
assert validator.is_valid_value({"name": "Robbie", "age": 37})
54+
assert not validator.is_valid_value({"name": True})
55+
56+
try:
57+
validator.is_valid_value({"age": float("nan")})
58+
except ValueError:
59+
pass
60+
else:
61+
raise AssertionError("non-finite JSON numbers must be rejected")
62+
63+
try:
64+
validator.is_valid_value({1: "invalid"})
65+
except TypeError:
66+
pass
67+
else:
68+
raise AssertionError("JSON object keys must be strings")
69+
70+
integer_validator = jsc.validator_for('{"type": "integer"}')
71+
assert integer_validator.is_valid_value(1)
72+
assert not integer_validator.is_valid_value(True)
73+
74+
try:
75+
integer_validator.is_valid_value(2**2000)
76+
except ValueError:
77+
pass
78+
else:
79+
raise AssertionError("oversized Python integers must be rejected")
80+
81+
print("generated JSON is valid:", validator.is_valid_json(example))
82+
print(
83+
"Python value is valid:",
84+
validator.is_valid_value({"name": "Robbie", "age": 37}),
85+
)
86+
5087

5188
if __name__ == "__main__":
5289
main()

python/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ print(is_compatible)
2323

2424
example = jsc.generate_value(old_schema, depth=5)
2525
print(example)
26+
27+
validator = jsc.validator_for(old_schema)
28+
print(validator.is_valid_json(example))
29+
print(validator.is_valid_value("hello"))
2630
```
2731

2832
## API
@@ -33,6 +37,11 @@ print(example)
3337
- `generate_value(schema_json: str, depth: int = 5) -> str`
3438
- Returns a JSON string for one generated value accepted by the schema.
3539
- Raises `ValueError` when the schema is invalid, known to be unsatisfiable, or generation exhausts its retry budget.
40+
- `validator_for(schema_json: str) -> Validator`
41+
- Parses the schema once and returns a reusable validator.
42+
- `Validator.is_valid_json(instance_json: str) -> bool` validates JSON strings against the parsed schema.
43+
- `Validator.is_valid_value(instance: JsonValue) -> bool` validates Python JSON-compatible values: `None`, `bool`, finite `int`/`float`, `str`, `list`, `tuple`, and `dict[str, ...]`.
44+
- `Validator.is_valid(instance_json: str) -> bool` remains a short compatibility alias for JSON-string validation.
3645
- `Role.SERIALIZER`, `Role.DESERIALIZER`, and `Role.BOTH` are string constants accepted by `check_compat`.
3746

3847
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.

python/jsoncompat.pyi

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,18 @@
11
"""Typing stubs for jsoncompat Python package"""
22

3-
from typing import Final, Literal
3+
from typing import Final, Literal, TypeAlias
44

55
RoleLiteral = Literal["serializer", "deserializer", "both"]
6+
JsonValue: TypeAlias = (
7+
None
8+
| bool
9+
| int
10+
| float
11+
| str
12+
| list["JsonValue"]
13+
| tuple["JsonValue", ...]
14+
| dict[str, "JsonValue"]
15+
)
616

717
class _Role:
818
SERIALIZER: Final[Literal["serializer"]]
@@ -11,9 +21,15 @@ class _Role:
1121

1222
Role: _Role
1323

24+
class Validator:
25+
def is_valid(self, instance_json: str) -> bool: ...
26+
def is_valid_json(self, instance_json: str) -> bool: ...
27+
def is_valid_value(self, instance: JsonValue) -> bool: ...
28+
1429
def check_compat(
1530
old_schema_json: str, new_schema_json: str, role: RoleLiteral = "both"
1631
) -> bool: ...
1732
def generate_value(schema_json: str, depth: int = 5) -> str: ...
33+
def validator_for(schema_json: str) -> Validator: ...
1834

1935
__all__: list[str]

python/src/lib.rs

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,39 @@
44
//! constants module. Both functions accept JSON schemas as strings and report
55
//! invalid inputs or hard unsupported core-library cases as `ValueError`.
66
7-
use pyo3::exceptions::PyValueError;
7+
use pyo3::exceptions::{PyTypeError, PyValueError};
88
use pyo3::prelude::*;
9+
use pyo3::types::{PyAny, PyBool, PyDict, PyFloat, PyInt, PyList, PyString, PyTuple};
910

1011
use ::jsoncompat::{Role, SchemaDocument, check_compat, validate_compatibility_input};
1112
use json_schema_fuzz::{GenerateError, GenerationConfig, ValueGenerator};
1213

13-
use serde_json::Value as JsonValue;
14+
use serde_json::{Map as JsonMap, Number as JsonNumber, Value as JsonValue};
15+
16+
#[pyclass(name = "Validator", module = "jsoncompat", unsendable)]
17+
struct ValidatorPy {
18+
schema: SchemaDocument,
19+
}
20+
21+
#[pymethods]
22+
impl ValidatorPy {
23+
/// Check whether a JSON value encoded as a string satisfies this validator's schema.
24+
fn is_valid(&self, instance_json: &str) -> PyResult<bool> {
25+
self.is_valid_json(instance_json)
26+
}
27+
28+
/// Check whether a JSON value encoded as a string satisfies this validator's schema.
29+
fn is_valid_json(&self, instance_json: &str) -> PyResult<bool> {
30+
let instance = parse_json(instance_json)?;
31+
validate_value_for_schema(&self.schema, &instance)
32+
}
33+
34+
/// Check whether a Python JSON-compatible value satisfies this validator's schema.
35+
fn is_valid_value(&self, instance: &Bound<'_, PyAny>) -> PyResult<bool> {
36+
let instance = py_to_json_value(instance)?;
37+
validate_value_for_schema(&self.schema, &instance)
38+
}
39+
}
1440

1541
fn validated_schema(raw: &JsonValue) -> Result<SchemaDocument, String> {
1642
let schema = SchemaDocument::from_json(raw).map_err(|error| error.to_string())?;
@@ -27,6 +53,82 @@ fn compatibility_schema(raw: &JsonValue) -> Result<SchemaDocument, String> {
2753
Ok(schema)
2854
}
2955

56+
fn validate_value_for_schema(schema: &SchemaDocument, instance: &JsonValue) -> PyResult<bool> {
57+
schema
58+
.is_valid(instance)
59+
.map_err(|e| PyErr::new::<PyValueError, _>(format!("Validation failed: {e}")))
60+
}
61+
62+
fn py_to_json_value(value: &Bound<'_, PyAny>) -> PyResult<JsonValue> {
63+
if value.is_none() {
64+
return Ok(JsonValue::Null);
65+
}
66+
if value.is_instance_of::<PyBool>() {
67+
return Ok(JsonValue::Bool(value.extract::<bool>()?));
68+
}
69+
if value.is_instance_of::<PyInt>() {
70+
return py_int_to_json_value(value);
71+
}
72+
if value.is_instance_of::<PyFloat>() {
73+
let number = value.extract::<f64>()?;
74+
if !number.is_finite() {
75+
return Err(PyErr::new::<PyValueError, _>("JSON numbers must be finite"));
76+
}
77+
let Some(number) = JsonNumber::from_f64(number) else {
78+
return Err(PyErr::new::<PyValueError, _>(
79+
"failed to convert Python float to JSON number",
80+
));
81+
};
82+
return Ok(JsonValue::Number(number));
83+
}
84+
if value.is_instance_of::<PyString>() {
85+
return Ok(JsonValue::String(value.extract::<String>()?));
86+
}
87+
if let Ok(list) = value.cast::<PyList>() {
88+
return list
89+
.iter()
90+
.map(|item| py_to_json_value(&item))
91+
.collect::<PyResult<Vec<_>>>()
92+
.map(JsonValue::Array);
93+
}
94+
if let Ok(tuple) = value.cast::<PyTuple>() {
95+
return tuple
96+
.iter()
97+
.map(|item| py_to_json_value(&item))
98+
.collect::<PyResult<Vec<_>>>()
99+
.map(JsonValue::Array);
100+
}
101+
if let Ok(dict) = value.cast::<PyDict>() {
102+
let mut object = JsonMap::with_capacity(dict.len());
103+
for (key, item) in dict {
104+
if !key.is_instance_of::<PyString>() {
105+
return Err(PyErr::new::<PyTypeError, _>(
106+
"JSON object keys must be strings",
107+
));
108+
}
109+
object.insert(key.extract::<String>()?, py_to_json_value(&item)?);
110+
}
111+
return Ok(JsonValue::Object(object));
112+
}
113+
114+
Err(PyErr::new::<PyTypeError, _>(format!(
115+
"expected a JSON-compatible value, got {}",
116+
value.get_type().name()?
117+
)))
118+
}
119+
120+
fn py_int_to_json_value(value: &Bound<'_, PyAny>) -> PyResult<JsonValue> {
121+
if let Ok(number) = value.extract::<i64>() {
122+
return Ok(JsonValue::Number(JsonNumber::from(number)));
123+
}
124+
if let Ok(number) = value.extract::<u64>() {
125+
return Ok(JsonValue::Number(JsonNumber::from(number)));
126+
}
127+
Err(PyErr::new::<PyValueError, _>(
128+
"JSON integer is outside the supported range",
129+
))
130+
}
131+
30132
/// Parse a JSON string into a serde_json::Value, converting any error into a Python ValueError.
31133
fn parse_json(s: &str) -> PyResult<JsonValue> {
32134
serde_json::from_str(s).map_err(|e| PyErr::new::<PyValueError, _>(format!("Invalid JSON: {e}")))
@@ -115,12 +217,24 @@ fn generate_value_py(schema_json: &str, depth: u8) -> PyResult<String> {
115217
})
116218
}
117219

220+
/// Build a reusable validator for one JSON Schema document.
221+
#[pyfunction]
222+
#[pyo3(signature = (schema_json), name = "validator_for")]
223+
fn validator_for_py(schema_json: &str) -> PyResult<ValidatorPy> {
224+
let raw = parse_json(schema_json)?;
225+
let schema = validated_schema(&raw)
226+
.map_err(|e| PyErr::new::<PyValueError, _>(format!("Invalid schema: {e}")))?;
227+
Ok(ValidatorPy { schema })
228+
}
229+
118230
/// Python module definition
119231
#[pymodule]
120232
#[pyo3(name = "jsoncompat")]
121233
fn jsoncompat(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> {
122234
m.add_function(wrap_pyfunction!(check_compat_py, m)?)?;
123235
m.add_function(wrap_pyfunction!(generate_value_py, m)?)?;
236+
m.add_function(wrap_pyfunction!(validator_for_py, m)?)?;
237+
m.add_class::<ValidatorPy>()?;
124238

125239
let role_constants = PyModule::new(py, "Role")?;
126240
role_constants.add("SERIALIZER", "serializer")?;

0 commit comments

Comments
 (0)