Skip to content
Open
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- `jsonschema::value_format` helper lets `ValidationOptions::with_format` register value-aware checkers that inspect non-string instances.

## [0.37.1] - 2025-11-19

### Fixed
Expand Down
4 changes: 4 additions & 0 deletions crates/jsonschema-py/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

### Added

- `jsonschema_rs.value_format()` decorator allows for registering value-aware checkers that inspect non-string instances.

## [0.37.1] - 2025-11-19

### Fixed
Expand Down
1 change: 0 additions & 1 deletion crates/jsonschema-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ crate-type = ["cdylib"]
jsonschema = { path = "../jsonschema/", features = ["arbitrary-precision"] }
pyo3 = { version = "0.27", features = ["extension-module", "abi3-py310"] }
pyo3-built = "0.6"
pythonize = "0.27"
serde.workspace = true
serde_json.workspace = true

Expand Down
21 changes: 21 additions & 0 deletions crates/jsonschema-py/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,27 @@ validator.is_valid("USD") # True
validator.is_valid("invalid") # False
```

If you need to inspect instances that are not strings (for example integers or entire objects),
wrap your callable with `jsonschema_rs.value_format` or use it as a decorator:

```python
import jsonschema_rs

@jsonschema_rs.value_format
def is_answer(instance):
return not isinstance(instance, int) or instance == 42


validator = jsonschema_rs.validator_for(
{"format": "knows-answer"},
formats={"knows-answer": is_answer},
validate_formats=True,
)
validator.is_valid(42) # True
validator.is_valid(41) # False
validator.is_valid("still a string") # True
```

Additional configuration options are available for fine-tuning the validation process:

- `validate_formats`: Override the draft-specific default behavior for format validation.
Expand Down
4 changes: 4 additions & 0 deletions crates/jsonschema-py/python/jsonschema_rs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@
is_valid,
iter_errors,
meta,
value_format,
validate,
validator_for,
ValueFormat,
)

Validator: TypeAlias = (
Expand Down Expand Up @@ -109,6 +111,7 @@ def __hash__(self) -> int:
"iter_errors",
"evaluate",
"validator_for",
"value_format",
"Draft4",
"Draft6",
"Draft7",
Expand All @@ -124,4 +127,5 @@ def __hash__(self) -> int:
"FancyRegexOptions",
"RegexOptions",
"meta",
"ValueFormat",
]
28 changes: 17 additions & 11 deletions crates/jsonschema-py/python/jsonschema_rs/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,14 @@ from decimal import Decimal
from typing import Any, Callable, List, Protocol, TypeAlias, TypeVar, TypedDict, Union

_SchemaT = TypeVar("_SchemaT", bool, dict[str, Any])
_FormatFunc = TypeVar("_FormatFunc", bound=Callable[[str], bool])
JSONType: TypeAlias = dict[str, Any] | list | str | int | float | Decimal | bool | None
JSONPrimitive: TypeAlias = str | int | float | Decimal | bool | None

class ValueFormat:
def __call__(self, instance: JSONType) -> bool: ...

FormatChecker: TypeAlias = Callable[[str], bool] | ValueFormat

class EvaluationAnnotation(TypedDict):
schemaLocation: str
absoluteKeywordLocation: str | None
Expand Down Expand Up @@ -66,12 +70,14 @@ PatternOptionsType = Union[FancyRegexOptions, RegexOptions]
class RetrieverProtocol(Protocol):
def __call__(self, uri: str) -> JSONType: ...

def value_format(func: Callable[[JSONType], bool]) -> ValueFormat: ...

def is_valid(
schema: _SchemaT,
instance: Any,
draft: int | None = None,
with_meta_schemas: bool | None = None,
formats: dict[str, _FormatFunc] | None = None,
formats: dict[str, FormatChecker] | None = None,
validate_formats: bool | None = None,
ignore_unknown_formats: bool = True,
retriever: RetrieverProtocol | None = None,
Expand All @@ -91,7 +97,7 @@ def validate(
instance: Any,
draft: int | None = None,
with_meta_schemas: bool | None = None,
formats: dict[str, _FormatFunc] | None = None,
formats: dict[str, FormatChecker] | None = None,
validate_formats: bool | None = None,
ignore_unknown_formats: bool = True,
retriever: RetrieverProtocol | None = None,
Expand All @@ -111,7 +117,7 @@ def iter_errors(
instance: Any,
draft: int | None = None,
with_meta_schemas: bool | None = None,
formats: dict[str, _FormatFunc] | None = None,
formats: dict[str, FormatChecker] | None = None,
validate_formats: bool | None = None,
ignore_unknown_formats: bool = True,
retriever: RetrieverProtocol | None = None,
Expand All @@ -130,7 +136,7 @@ def evaluate(
schema: _SchemaT,
instance: Any,
draft: int | None = None,
formats: dict[str, _FormatFunc] | None = None,
formats: dict[str, FormatChecker] | None = None,
validate_formats: bool | None = None,
ignore_unknown_formats: bool = True,
retriever: RetrieverProtocol | None = None,
Expand Down Expand Up @@ -268,7 +274,7 @@ class Draft4Validator:
def __init__(
self,
schema: _SchemaT | str,
formats: dict[str, _FormatFunc] | None = None,
formats: dict[str, FormatChecker] | None = None,
validate_formats: bool | None = None,
ignore_unknown_formats: bool = True,
retriever: RetrieverProtocol | None = None,
Expand All @@ -287,7 +293,7 @@ class Draft6Validator:
def __init__(
self,
schema: _SchemaT | str,
formats: dict[str, _FormatFunc] | None = None,
formats: dict[str, FormatChecker] | None = None,
validate_formats: bool | None = None,
ignore_unknown_formats: bool = True,
retriever: RetrieverProtocol | None = None,
Expand All @@ -306,7 +312,7 @@ class Draft7Validator:
def __init__(
self,
schema: _SchemaT | str,
formats: dict[str, _FormatFunc] | None = None,
formats: dict[str, FormatChecker] | None = None,
validate_formats: bool | None = None,
ignore_unknown_formats: bool = True,
retriever: RetrieverProtocol | None = None,
Expand All @@ -325,7 +331,7 @@ class Draft201909Validator:
def __init__(
self,
schema: _SchemaT | str,
formats: dict[str, _FormatFunc] | None = None,
formats: dict[str, FormatChecker] | None = None,
validate_formats: bool | None = None,
ignore_unknown_formats: bool = True,
retriever: RetrieverProtocol | None = None,
Expand All @@ -344,7 +350,7 @@ class Draft202012Validator:
def __init__(
self,
schema: _SchemaT | str,
formats: dict[str, _FormatFunc] | None = None,
formats: dict[str, FormatChecker] | None = None,
validate_formats: bool | None = None,
ignore_unknown_formats: bool = True,
retriever: RetrieverProtocol | None = None,
Expand All @@ -363,7 +369,7 @@ Validator: TypeAlias = Draft4Validator | Draft6Validator | Draft7Validator | Dra

def validator_for(
schema: _SchemaT,
formats: dict[str, _FormatFunc] | None = None,
formats: dict[str, FormatChecker] | None = None,
validate_formats: bool | None = None,
ignore_unknown_formats: bool = True,
retriever: RetrieverProtocol | None = None,
Expand Down
78 changes: 76 additions & 2 deletions crates/jsonschema-py/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ use pyo3::{
exceptions::{self, PyValueError},
ffi::PyUnicode_AsUTF8AndSize,
prelude::*,
types::{PyAny, PyDict, PyList, PyString, PyType},
wrap_pyfunction,
types::{PyAny, PyDict, PyList, PyString, PyTuple, PyType},
wrap_pyfunction, PyRef,
};
use regex::{FancyRegexOptions, RegexOptions};
use retriever::{into_retriever, Retriever};
Expand Down Expand Up @@ -104,6 +104,51 @@ fn value_to_python(py: Python<'_>, value: &serde_json::Value) -> PyResult<Py<PyA
}
}

#[pyclass(module = "jsonschema_rs", name = "ValueFormat")]
struct PyValueFormat {
callback: Py<PyAny>,
}

impl PyValueFormat {
fn new(callback: Py<PyAny>) -> Self {
PyValueFormat { callback }
}
}

#[pymethods]
impl PyValueFormat {
#[pyo3(name = "__call__")]
fn call(
&self,
py: Python<'_>,
args: &Bound<'_, PyTuple>,
kwargs: Option<&Bound<'_, PyDict>>,
) -> PyResult<Py<PyAny>> {
self.callback
.bind(py)
.call(args, kwargs)
.map(pyo3::Bound::unbind)
}
}

#[pyfunction]
fn value_format(py: Python<'_>, callback: &Bound<'_, PyAny>) -> PyResult<Py<PyValueFormat>> {
if !callback.is_callable() {
return Err(exceptions::PyValueError::new_err(
"value_format decorator requires a callable",
));
}
let wrapper = Py::new(py, PyValueFormat::new(callback.clone().unbind()))?;
wrapper.bind(py).setattr("__wrapped__", callback)?;
if let Ok(update_wrapper) = py
.import("functools")
.and_then(|module| module.getattr("update_wrapper"))
{
let _ = update_wrapper.call1((wrapper.bind(py), callback));
}
Ok(wrapper)
}

fn evaluation_output_to_python<T>(py: Python<'_>, output: &T) -> PyResult<Py<PyAny>>
where
T: Serialize + ?Sized,
Expand Down Expand Up @@ -619,6 +664,33 @@ fn make_options(
}
if let Some(formats) = formats {
for (name, callback) in formats.iter() {
if let Ok(wrapper) = callback.extract::<PyRef<PyValueFormat>>() {
let py = wrapper.py();
let callback = wrapper.callback.clone_ref(py);
let call_py_callback = move |value: &serde_json::Value| {
Python::attach(|py| {
let value = value_to_python(py, value)?;
callback.bind(py).call((value.bind(py),), None)?.is_truthy()
})
};
options = options.with_format(
name.to_string(),
jsonschema::value_format(
move |value: &serde_json::Value| match call_py_callback(value) {
Ok(r) => r,
Err(e) => {
LAST_FORMAT_ERROR.with(|last| {
*last.borrow_mut() = Some(e);
});
std::panic::set_hook(Box::new(|_| {}));
panic!("Format checker failed")
}
},
),
);
continue;
}

if !callback.is_callable() {
return Err(exceptions::PyValueError::new_err(format!(
"Format checker for '{name}' must be a callable",
Expand Down Expand Up @@ -1617,6 +1689,7 @@ fn jsonschema_rs(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_wrapped(wrap_pyfunction!(iter_errors))?;
module.add_wrapped(wrap_pyfunction!(evaluate))?;
module.add_wrapped(wrap_pyfunction!(validator_for))?;
module.add_wrapped(wrap_pyfunction!(value_format))?;
module.add_class::<Draft4Validator>()?;
module.add_class::<Draft6Validator>()?;
module.add_class::<Draft7Validator>()?;
Expand All @@ -1626,6 +1699,7 @@ fn jsonschema_rs(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_class::<registry::Registry>()?;
module.add_class::<FancyRegexOptions>()?;
module.add_class::<RegexOptions>()?;
module.add_class::<PyValueFormat>()?;
module.add("ValidationErrorKind", py.get_type::<ValidationErrorKind>())?;
module.add("Draft4", DRAFT4)?;
module.add("Draft6", DRAFT6)?;
Expand Down
25 changes: 25 additions & 0 deletions crates/jsonschema-py/tests-py/test_jsonschema.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
iter_errors,
validate,
validator_for,
value_format,
)

json = st.recursive(
Expand Down Expand Up @@ -484,6 +485,30 @@ def is_currency(_):
pass


def test_value_format_allows_non_strings():
formats = {
"magic-number": value_format(lambda instance: not isinstance(instance, int) or instance == 42)
}
validator = validator_for({"format": "magic-number"}, formats=formats, validate_formats=True)

assert validator.is_valid(42)
assert not validator.is_valid(41)
assert validator.is_valid("still a string")


def test_value_format_decorator_keeps_callable():
@value_format
def is_answer(instance):
return isinstance(instance, dict) and instance.get("answer") == 42

schema = {"format": "knows-answer"}
validator = validator_for(schema, formats={"knows-answer": is_answer}, validate_formats=True)

assert validator.is_valid({"answer": 42})
assert not validator.is_valid({"answer": 41})
assert is_answer({"answer": 42}) is True


@pytest.mark.parametrize(
"cls,validate_formats,input,expected",
[
Expand Down
Loading
Loading