From 119cadd341bc5acc475947ecef0474b92e75c626 Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Wed, 18 Sep 2024 21:41:20 +0200 Subject: [PATCH] refactor(python): Rework public API Signed-off-by: Dmitry Dygalo --- Justfile | 3 + crates/jsonschema-py/CHANGELOG.md | 15 + crates/jsonschema-py/MIGRATION.md | 25 ++ crates/jsonschema-py/README.md | 69 +++-- crates/jsonschema-py/benches/bench.py | 8 +- crates/jsonschema-py/pyproject.toml | 1 - .../python/jsonschema_rs/__init__.pyi | 91 ++++++ crates/jsonschema-py/src/lib.rs | 277 +++++++++++++++++- .../jsonschema-py/tests-py/test_jsonschema.py | 40 +-- crates/jsonschema-py/tests-py/test_readme.py | 20 ++ crates/jsonschema-py/tests-py/test_suite.py | 6 +- crates/jsonschema-py/uv.lock | 40 ++- crates/jsonschema/src/resolver.rs | 2 +- 13 files changed, 529 insertions(+), 68 deletions(-) create mode 100644 crates/jsonschema-py/MIGRATION.md create mode 100644 crates/jsonschema-py/tests-py/test_readme.py diff --git a/Justfile b/Justfile index 7aea809d..20eae3f1 100644 --- a/Justfile +++ b/Justfile @@ -22,6 +22,9 @@ test-rs *FLAGS: cargo llvm-cov --html test {{FLAGS}} test-py *FLAGS: + uvx --with="crates/jsonschema-py[tests]" --refresh pytest crates/jsonschema-py/tests-py -rs {{FLAGS}} + +test-py-no-rebuild *FLAGS: uvx --with="crates/jsonschema-py[tests]" pytest crates/jsonschema-py/tests-py -rs {{FLAGS}} bench-py *FLAGS: diff --git a/crates/jsonschema-py/CHANGELOG.md b/crates/jsonschema-py/CHANGELOG.md index 509a91b6..1530d3ef 100644 --- a/crates/jsonschema-py/CHANGELOG.md +++ b/crates/jsonschema-py/CHANGELOG.md @@ -2,6 +2,21 @@ ## [Unreleased] +### Added + +- New draft-specific validator classes: `Draft4Validator`, `Draft6Validator`, `Draft7Validator`, `Draft201909Validator`, and `Draft202012Validator`. +- `validator_for` function for automatic draft detection. + +### Changed + +- The `JSONSchema` class has been renamed to `Validator`. The old name is retained for backward compatibility but will be removed in a future release. + +### Deprecated + +- The `JSONSchema` class is deprecated. Use the `validator_for` function or draft-specific validators instead. + You can use `validator_for` instead of `JSONSchema.from_str`. +- Constants `jsonschema_rs.DRAFT4`, `jsonschema_rs.DRAFT6`, `jsonschema_rs.DRAFT7`, `jsonschema_rs.DRAFT201909`, and `jsonschema_rs.DRAFT202012` are deprecated in favor of draft-specific validator classes. + ### Fixed - Location-independent references in remote schemas on drafts 4, 6, and 7. diff --git a/crates/jsonschema-py/MIGRATION.md b/crates/jsonschema-py/MIGRATION.md new file mode 100644 index 00000000..daa5385c --- /dev/null +++ b/crates/jsonschema-py/MIGRATION.md @@ -0,0 +1,25 @@ +# Migration Guide + +## Upgrading from 0.19.x to 0.20.0 + + +Draft-specific validators are now available: + +```python +# Old (0.19.x) +validator = jsonschema_rs.JSONSchema(schema, draft=jsonschema_rs.Draft202012) + +# New (0.20.0) +validator = jsonschema_rs.Draft202012Validator(schema) +``` + +Automatic draft detection: + +```python +# Old (0.19.x) +validator = jsonschema_rs.JSONSchema(schema) + +# New (0.20.0) +validator = jsonschema_rs.validator_for(schema) +``` + diff --git a/crates/jsonschema-py/README.md b/crates/jsonschema-py/README.md index b83e8048..fb575399 100644 --- a/crates/jsonschema-py/README.md +++ b/crates/jsonschema-py/README.md @@ -11,25 +11,34 @@ A high-performance JSON Schema validator for Python. ```python import jsonschema_rs -validator = jsonschema_rs.JSONSchema({"minimum": 42}) +schema = {"maxLength": 5} +instance = "foo" -# Boolean result -validator.is_valid(45) - -# Raise a ValidationError -validator.validate(41) -# ValidationError: 41 is less than the minimum of 42 -# -# Failed validating "minimum" in schema -# -# On instance: -# 41 - -# Iterate over all validation errors -for error in validator.iter_errors(40): +# One-off validation +try: + jsonschema_rs.validate(schema, "incorrect") +except jsonschema_rs.ValidationError as exc: + assert str(exc) == '''"incorrect" is longer than 5 characters + +Failed validating "maxLength" in schema + +On instance: + "incorrect"''' + +# Build & reuse (faster) +validator = jsonschema_rs.validator_for(schema) + +# Iterate over errors +for error in validator.iter_errors(instance): print(f"Error: {error}") + print(f"Location: {error.instance_path}") + +# Boolean result +assert validator.is_valid(instance) ``` +> ⚠️ **Upgrading from pre-0.20.0?** Check our [Migration Guide](MIGRATION.md) for key changes. + ## Highlights - 📚 Support for popular JSON Schema drafts @@ -62,20 +71,34 @@ pip install jsonschema-rs ## Usage -If you have a schema as a JSON string, then you could use -`jsonschema_rs.JSONSchema.from_str` to avoid parsing on the -Python side: +If you have a schema as a JSON string, then you could pass it to `validator_for` +to avoid parsing on the Python side: ```python -validator = jsonschema_rs.JSONSchema.from_str('{"minimum": 42}') +validator = jsonschema_rs.validator_for('{"minimum": 42}') ... ``` -You can specify a custom JSON Schema draft using the `draft` argument: +You can use draft-specific validators for different JSON Schema versions: + +```python +import jsonschema_rs + +# Automatic draft detection +validator = jsonschema_rs.validator_for({"minimum": 42}) + +# Draft-specific validators +validator = jsonschema_rs.Draft7Validator({"minimum": 42}) +validator = jsonschema_rs.Draft201909Validator({"minimum": 42}) +validator = jsonschema_rs.Draft202012Validator({"minimum": 42}) +``` + +For backwards compatibility, you can still use the `JSONSchema` class with the `draft` argument, but this is deprecated: ```python import jsonschema_rs +# Deprecated: Use draft-specific validators instead validator = jsonschema_rs.JSONSchema( {"minimum": 42}, draft=jsonschema_rs.Draft7 @@ -99,7 +122,7 @@ def is_currency(value): return len(value) == 3 and value.isascii() -validator = jsonschema_rs.JSONSchema( +validator = jsonschema_rs.validator_for( {"type": "string", "format": "currency"}, formats={"currency": is_currency} ) @@ -121,6 +144,10 @@ For detailed benchmarks, see our [full performance comparison](BENCHMARKS.md). `jsonschema-rs` supports CPython 3.8, 3.9, 3.10, 3.11, and 3.12. +## Acknowledgements + +This library draws API design inspiration from the Python [`jsonschema`](https://github.com/python-jsonschema/jsonschema) package. We're grateful to the Python `jsonschema` maintainers and contributors for their pioneering work in JSON Schema validation. + ## Support If you have questions, need help, or want to suggest improvements, please use [GitHub Discussions](https://github.com/Stranger6667/jsonschema-rs/discussions). diff --git a/crates/jsonschema-py/benches/bench.py b/crates/jsonschema-py/benches/bench.py index f06eb0b2..61f2d6bf 100644 --- a/crates/jsonschema-py/benches/bench.py +++ b/crates/jsonschema-py/benches/bench.py @@ -81,9 +81,9 @@ def args(request, variant): if (schema is OPENAPI or schema is SWAGGER) and variant == "fastjsonschema": pytest.skip("fastjsonschema does not support the uri-reference format and errors") if variant == "jsonschema-rs-is-valid": - return jsonschema_rs.JSONSchema(schema).is_valid, instance + return jsonschema_rs.validator_for(schema).is_valid, instance if variant == "jsonschema-rs-validate": - return jsonschema_rs.JSONSchema(schema).validate, instance + return jsonschema_rs.validator_for(schema).validate, instance if variant == "jsonschema": return jsonschema.validators.validator_for(schema)(schema).is_valid, instance if variant == "fastjsonschema": @@ -105,8 +105,8 @@ def args(request, variant): @pytest.mark.parametrize( "func", ( - lambda x: jsonschema_rs.JSONSchema(json.loads(x)), - jsonschema_rs.JSONSchema.from_str, + lambda x: jsonschema_rs.validator_for(json.loads(x)), + jsonschema_rs.validator_for, ), ids=["py-parse", "rs-parse"], ) diff --git a/crates/jsonschema-py/pyproject.toml b/crates/jsonschema-py/pyproject.toml index ac6b7507..f39e8bec 100644 --- a/crates/jsonschema-py/pyproject.toml +++ b/crates/jsonschema-py/pyproject.toml @@ -31,7 +31,6 @@ classifiers = [ "Topic :: File Formats :: JSON :: JSON Schema", ] requires-python = ">=3.8" -dependencies = [] [project.optional-dependencies] tests = [ diff --git a/crates/jsonschema-py/python/jsonschema_rs/__init__.pyi b/crates/jsonschema-py/python/jsonschema_rs/__init__.pyi index a57954ac..7d087523 100644 --- a/crates/jsonschema-py/python/jsonschema_rs/__init__.pyi +++ b/crates/jsonschema-py/python/jsonschema_rs/__init__.pyi @@ -70,3 +70,94 @@ Draft6: int Draft7: int Draft201909: int Draft202012: int + +class Draft4Validator: + def __init__( + self, + schema: _SchemaT | str, + formats: dict[str, _FormatFunc] | None = None, + ) -> None: + pass + + def is_valid(self, instance: Any) -> bool: + pass + + def validate(self, instance: Any) -> None: + pass + + def iter_errors(self, instance: Any) -> Iterator[ValidationError]: + pass + +class Draft6Validator: + def __init__( + self, + schema: _SchemaT | str, + formats: dict[str, _FormatFunc] | None = None, + ) -> None: + pass + + def is_valid(self, instance: Any) -> bool: + pass + + def validate(self, instance: Any) -> None: + pass + + def iter_errors(self, instance: Any) -> Iterator[ValidationError]: + pass + +class Draft7Validator: + def __init__( + self, + schema: _SchemaT | str, + formats: dict[str, _FormatFunc] | None = None, + ) -> None: + pass + + def is_valid(self, instance: Any) -> bool: + pass + + def validate(self, instance: Any) -> None: + pass + + def iter_errors(self, instance: Any) -> Iterator[ValidationError]: + pass + +class Draft201909Validator: + def __init__( + self, + schema: _SchemaT | str, + formats: dict[str, _FormatFunc] | None = None, + ) -> None: + pass + + def is_valid(self, instance: Any) -> bool: + pass + + def validate(self, instance: Any) -> None: + pass + + def iter_errors(self, instance: Any) -> Iterator[ValidationError]: + pass + +class Draft202012Validator: + def __init__( + self, + schema: _SchemaT | str, + formats: dict[str, _FormatFunc] | None = None, + ) -> None: + pass + + def is_valid(self, instance: Any) -> bool: + pass + + def validate(self, instance: Any) -> None: + pass + + def iter_errors(self, instance: Any) -> Iterator[ValidationError]: + pass + +def validator_for( + schema: _SchemaT, + formats: dict[str, _FormatFunc] | None = None, +) -> Draft4Validator | Draft6Validator | Draft7Validator | Draft201909Validator | Draft202012Validator: + pass diff --git a/crates/jsonschema-py/src/lib.rs b/crates/jsonschema-py/src/lib.rs index bee23c84..815b15ce 100644 --- a/crates/jsonschema-py/src/lib.rs +++ b/crates/jsonschema-py/src/lib.rs @@ -106,11 +106,11 @@ fn into_path(py: Python<'_>, pointer: JsonPointer) -> PyResult> { fn get_draft(draft: u8) -> PyResult { match draft { - DRAFT4 => Ok(jsonschema::Draft::Draft4), - DRAFT6 => Ok(jsonschema::Draft::Draft6), - DRAFT7 => Ok(jsonschema::Draft::Draft7), - DRAFT201909 => Ok(jsonschema::Draft::Draft201909), - DRAFT202012 => Ok(jsonschema::Draft::Draft202012), + DRAFT4 => Ok(Draft::Draft4), + DRAFT6 => Ok(Draft::Draft6), + DRAFT7 => Ok(Draft::Draft7), + DRAFT201909 => Ok(Draft::Draft201909), + DRAFT202012 => Ok(Draft::Draft202012), _ => Err(exceptions::PyValueError::new_err(format!( "Unknown draft: {}", draft @@ -261,7 +261,7 @@ fn to_error_message(error: &jsonschema::ValidationError<'_>) -> String { /// >>> is_valid({"minimum": 5}, 3) /// False /// -/// If your workflow implies validating against the same schema, consider using `JSONSchema.is_valid` +/// If your workflow implies validating against the same schema, consider using `validator_for(...).is_valid` /// instead. #[pyfunction] #[allow(unused_variables)] @@ -295,7 +295,7 @@ fn is_valid( /// ValidationError: 3 is less than the minimum of 5 /// /// If the input instance is invalid, only the first occurred error is raised. -/// If your workflow implies validating against the same schema, consider using `JSONSchema.validate` +/// If your workflow implies validating against the same schema, consider using `validator_for(...).validate` /// instead. #[pyfunction] #[allow(unused_variables)] @@ -324,7 +324,7 @@ fn validate( /// ... /// ValidationError: 3 is less than the minimum of 5 /// -/// If your workflow implies validating against the same schema, consider using `JSONSchema.iter_errors` +/// If your workflow implies validating against the same schema, consider using `validator_for().iter_errors` /// instead. #[pyfunction] #[allow(unused_variables)] @@ -383,6 +383,126 @@ fn handle_format_checked_panic(err: Box) -> PyErr { }) } +#[pyclass(module = "jsonschema_rs", subclass)] +struct Validator { + validator: jsonschema::Validator, + repr: String, +} + +/// validator_for(schema, formats=None) +/// +/// Create a validator for the input schema with automatic draft detection and default options. +/// +/// >>> validator = validator_for({"minimum": 5}) +/// >>> validator.is_valid(3) +/// False +/// +#[pyfunction] +#[pyo3(signature = (schema, formats=None))] +fn validator_for( + py: Python<'_>, + schema: &Bound<'_, PyAny>, + formats: Option<&Bound<'_, PyDict>>, +) -> PyResult { + validator_for_impl(py, schema, None, formats) +} + +fn validator_for_impl( + py: Python<'_>, + schema: &Bound<'_, PyAny>, + draft: Option, + formats: Option<&Bound<'_, PyDict>>, +) -> PyResult { + let obj_ptr = schema.as_ptr(); + let object_type = unsafe { pyo3::ffi::Py_TYPE(obj_ptr) }; + let schema = if unsafe { object_type == types::STR_TYPE } { + let mut str_size: pyo3::ffi::Py_ssize_t = 0; + let ptr = unsafe { PyUnicode_AsUTF8AndSize(obj_ptr, &mut str_size) }; + let slice = unsafe { std::slice::from_raw_parts(ptr.cast::(), str_size as usize) }; + serde_json::from_slice(slice) + .map_err(|error| PyValueError::new_err(format!("Invalid string: {}", error)))? + } else { + ser::to_value(schema)? + }; + let options = make_options(draft, formats)?; + match options.build(&schema) { + Ok(validator) => Ok(Validator { + validator, + repr: get_schema_repr(&schema), + }), + Err(error) => Err(into_py_err(py, error)?), + } +} + +#[pymethods] +impl Validator { + #[new] + #[pyo3(signature = (schema, formats=None))] + fn new( + py: Python<'_>, + schema: &Bound<'_, PyAny>, + formats: Option<&Bound<'_, PyDict>>, + ) -> PyResult { + validator_for(py, schema, formats) + } + /// is_valid(instance) + /// + /// Perform fast validation against the schema. + /// + /// >>> validator = validator_for({"minimum": 5}) + /// >>> validator.is_valid(3) + /// False + /// + /// The output is a boolean value, that indicates whether the instance is valid or not. + #[pyo3(text_signature = "(instance)")] + fn is_valid(&self, instance: &Bound<'_, PyAny>) -> PyResult { + let instance = ser::to_value(instance)?; + panic::catch_unwind(AssertUnwindSafe(|| Ok(self.validator.is_valid(&instance)))) + .map_err(handle_format_checked_panic)? + } + /// validate(instance) + /// + /// Validate the input instance and raise `ValidationError` in the error case + /// + /// >>> validator = validator_for({"minimum": 5}) + /// >>> validator.validate(3) + /// ... + /// ValidationError: 3 is less than the minimum of 5 + /// + /// If the input instance is invalid, only the first occurred error is raised. + #[pyo3(text_signature = "(instance)")] + fn validate(&self, py: Python<'_>, instance: &Bound<'_, PyAny>) -> PyResult<()> { + raise_on_error(py, &self.validator, instance) + } + /// iter_errors(instance) + /// + /// Iterate the validation errors of the input instance + /// + /// >>> validator = validator_for({"minimum": 5}) + /// >>> next(validator.iter_errors(3)) + /// ... + /// ValidationError: 3 is less than the minimum of 5 + #[pyo3(text_signature = "(instance)")] + fn iter_errors( + &self, + py: Python<'_>, + instance: &Bound<'_, PyAny>, + ) -> PyResult { + iter_on_error(py, &self.validator, instance) + } + fn __repr__(&self) -> String { + let draft = match self.validator.draft() { + Draft::Draft4 => "Draft4", + Draft::Draft6 => "Draft6", + Draft::Draft7 => "Draft7", + Draft::Draft201909 => "Draft201909", + Draft::Draft202012 => "Draft202012", + _ => "Unknown", + }; + format!("<{draft}Validator: {}>", self.repr) + } +} + #[pymethods] impl JSONSchema { #[new] @@ -501,6 +621,141 @@ impl JSONSchema { } } +/// Draft4Validator(schema, formats=None) +/// +/// A JSON Schema Draft 4 validator. +/// +/// >>> validator = Draft4Validator({"minimum": 5}) +/// >>> validator.is_valid(3) +/// False +/// +#[pyclass(module = "jsonschema_rs", extends=Validator, subclass)] +struct Draft4Validator {} + +#[pymethods] +impl Draft4Validator { + #[new] + #[pyo3(signature = (schema, formats=None))] + fn new( + py: Python<'_>, + schema: &Bound<'_, PyAny>, + formats: Option<&Bound<'_, PyDict>>, + ) -> PyResult<(Self, Validator)> { + Ok(( + Draft4Validator {}, + validator_for_impl(py, schema, Some(DRAFT4), formats)?, + )) + } +} + +/// Draft6Validator(schema, formats=None) +/// +/// A JSON Schema Draft 6 validator. +/// +/// >>> validator = Draft6Validator({"minimum": 5}) +/// >>> validator.is_valid(3) +/// False +/// +#[pyclass(module = "jsonschema_rs", extends=Validator, subclass)] +struct Draft6Validator {} + +#[pymethods] +impl Draft6Validator { + #[new] + #[pyo3(signature = (schema, formats=None))] + fn new( + py: Python<'_>, + schema: &Bound<'_, PyAny>, + formats: Option<&Bound<'_, PyDict>>, + ) -> PyResult<(Self, Validator)> { + Ok(( + Draft6Validator {}, + validator_for_impl(py, schema, Some(DRAFT6), formats)?, + )) + } +} + +/// Draft7Validator(schema, formats=None) +/// +/// A JSON Schema Draft 7 validator. +/// +/// >>> validator = Draft7Validator({"minimum": 5}) +/// >>> validator.is_valid(3) +/// False +/// +#[pyclass(module = "jsonschema_rs", extends=Validator, subclass)] +struct Draft7Validator {} + +#[pymethods] +impl Draft7Validator { + #[new] + #[pyo3(signature = (schema, formats=None))] + fn new( + py: Python<'_>, + schema: &Bound<'_, PyAny>, + formats: Option<&Bound<'_, PyDict>>, + ) -> PyResult<(Self, Validator)> { + Ok(( + Draft7Validator {}, + validator_for_impl(py, schema, Some(DRAFT7), formats)?, + )) + } +} + +/// Draft201909Validator(schema, formats=None) +/// +/// A JSON Schema Draft 2019-09 validator. +/// +/// >>> validator = Draft201909Validator({"minimum": 5}) +/// >>> validator.is_valid(3) +/// False +/// +#[pyclass(module = "jsonschema_rs", extends=Validator, subclass)] +struct Draft201909Validator {} + +#[pymethods] +impl Draft201909Validator { + #[new] + #[pyo3(signature = (schema, formats=None))] + fn new( + py: Python<'_>, + schema: &Bound<'_, PyAny>, + formats: Option<&Bound<'_, PyDict>>, + ) -> PyResult<(Self, Validator)> { + Ok(( + Draft201909Validator {}, + validator_for_impl(py, schema, Some(DRAFT201909), formats)?, + )) + } +} + +/// Draft202012Validator(schema, formats=None) +/// +/// A JSON Schema Draft 2020-12 validator. +/// +/// >>> validator = Draft202012Validator({"minimum": 5}) +/// >>> validator.is_valid(3) +/// False +/// +#[pyclass(module = "jsonschema_rs", extends=Validator, subclass)] +struct Draft202012Validator {} + +#[pymethods] +impl Draft202012Validator { + #[new] + #[pyo3(signature = (schema, formats=None))] + fn new( + py: Python<'_>, + schema: &Bound<'_, PyAny>, + formats: Option<&Bound<'_, PyDict>>, + ) -> PyResult<(Self, Validator)> { + Ok(( + Draft202012Validator {}, + validator_for_impl(py, schema, Some(DRAFT202012), formats)?, + )) + } +} + #[allow(dead_code)] mod build { include!(concat!(env!("OUT_DIR"), "/built.rs")); @@ -515,7 +770,13 @@ fn jsonschema_rs(py: Python<'_>, module: &Bound<'_, PyModule>) -> PyResult<()> { module.add_wrapped(wrap_pyfunction!(is_valid))?; module.add_wrapped(wrap_pyfunction!(validate))?; module.add_wrapped(wrap_pyfunction!(iter_errors))?; + module.add_wrapped(wrap_pyfunction!(validator_for))?; module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; module.add("ValidationError", py.get_type_bound::())?; module.add("Draft4", DRAFT4)?; module.add("Draft6", DRAFT6)?; diff --git a/crates/jsonschema-py/tests-py/test_jsonschema.py b/crates/jsonschema-py/tests-py/test_jsonschema.py index 6932da41..2adb3318 100644 --- a/crates/jsonschema-py/tests-py/test_jsonschema.py +++ b/crates/jsonschema-py/tests-py/test_jsonschema.py @@ -9,7 +9,7 @@ from hypothesis import given from hypothesis import strategies as st -from jsonschema_rs import JSONSchema, ValidationError, is_valid, iter_errors, validate +from jsonschema_rs import ValidationError, is_valid, iter_errors, validate, validator_for json = st.recursive( st.none() @@ -49,19 +49,15 @@ def test_invalid_type(func): func(set(), True) -def test_module(): - assert JSONSchema.__module__ == "jsonschema_rs" - - def test_repr(): - assert repr(JSONSchema({"minimum": 5})) == '' + assert repr(validator_for({"minimum": 5})) == '' @pytest.mark.parametrize( "func", ( - JSONSchema({"minimum": 5}).validate, - JSONSchema.from_str('{"minimum": 5}').validate, + validator_for({"minimum": 5}).validate, + validator_for('{"minimum": 5}').validate, partial(validate, {"minimum": 5}), ), ) @@ -71,8 +67,8 @@ def test_validate(func): def test_from_str_error(): - with pytest.raises(ValueError, match="Expected string, got int"): - JSONSchema.from_str(42) # type: ignore + with pytest.raises(ValidationError, match='42 is not of types "boolean", "object"'): + validator_for(42) # type: ignore @pytest.mark.parametrize( @@ -127,18 +123,6 @@ def test_paths(): assert exc.value.message == '1 is not of type "string"' -@pytest.mark.parametrize( - "schema, draft, error", - ( - ([], None, r'\[\] is not of types "boolean", "object"'), - ({}, 5, "Unknown draft: 5"), - ), -) -def test_initialization_errors(schema, draft, error): - with pytest.raises(ValueError, match=error): - JSONSchema(schema, draft) - - @given(minimum=st.integers().map(abs)) def test_minimum(minimum): with suppress(SystemError, ValueError): @@ -155,7 +139,7 @@ def test_maximum(maximum): @pytest.mark.parametrize("method", ("is_valid", "validate")) def test_invalid_value(method): - schema = JSONSchema({"minimum": 42}) + schema = validator_for({"minimum": 42}) with pytest.raises(ValueError, match="Unsupported type: 'object'"): getattr(schema, method)(object()) @@ -184,7 +168,7 @@ def test_error_message(): @pytest.mark.parametrize( "func", ( - JSONSchema(SCHEMA).iter_errors, + validator_for(SCHEMA).iter_errors, partial(iter_errors, SCHEMA), ), ) @@ -205,7 +189,7 @@ def test_iter_err_message(func): @pytest.mark.parametrize( "func", ( - JSONSchema({"properties": {"foo": {"type": "integer"}}}).iter_errors, + validator_for({"properties": {"foo": {"type": "integer"}}}).iter_errors, partial(iter_errors, {"properties": {"foo": {"type": "integer"}}}), ), ) @@ -281,7 +265,7 @@ def test_custom_format(): def is_currency(value): return len(value) == 3 and value.isascii() - validator = JSONSchema({"type": "string", "format": "currency"}, formats={"currency": is_currency}) + validator = validator_for({"type": "string", "format": "currency"}, formats={"currency": is_currency}) assert validator.is_valid("USD") assert not validator.is_valid(42) assert not validator.is_valid("invalid") @@ -289,7 +273,7 @@ def is_currency(value): def test_custom_format_invalid_callback(): with pytest.raises(ValueError, match="Format checker for 'currency' must be a callable"): - JSONSchema({"type": "string", "format": "currency"}, formats={"currency": 42}) + validator_for({"type": "string", "format": "currency"}, formats={"currency": 42}) def test_custom_format_with_exception(): @@ -298,7 +282,7 @@ def is_currency(_): schema = {"type": "string", "format": "currency"} formats = {"currency": is_currency} - validator = JSONSchema(schema, formats=formats) + validator = validator_for(schema, formats=formats) with pytest.raises(ValueError, match="Invalid currency"): validator.is_valid("USD") with pytest.raises(ValueError, match="Invalid currency"): diff --git a/crates/jsonschema-py/tests-py/test_readme.py b/crates/jsonschema-py/tests-py/test_readme.py new file mode 100644 index 00000000..e3299d1f --- /dev/null +++ b/crates/jsonschema-py/tests-py/test_readme.py @@ -0,0 +1,20 @@ +import re +from pathlib import Path + +import pytest + +HERE = Path(__file__).absolute() + + +def test_readme(): + with (HERE.parent.parent / "README.md").open() as f: + readme = f.read() + + code_blocks = re.findall(r"```python\n(.*?)```", readme, re.DOTALL) + + # Execute each code block + for i, code_block in enumerate(code_blocks): + try: + exec(code_block) + except Exception as e: + pytest.fail(f"Code block {i + 1} failed: {str(e)}\n\nCode:\n{code_block}") diff --git a/crates/jsonschema-py/tests-py/test_suite.py b/crates/jsonschema-py/tests-py/test_suite.py index 858ccf5c..b137cd54 100644 --- a/crates/jsonschema-py/tests-py/test_suite.py +++ b/crates/jsonschema-py/tests-py/test_suite.py @@ -52,16 +52,14 @@ def mock_server(): SUPPORTED_DRAFTS = (4, 6, 7) NOT_SUPPORTED_CASES = { - 4: ("bignum.json", "email.json", "ecmascript-regex.json", "refRemote.json"), - 6: ("bignum.json", "email.json", "ecmascript-regex.json", "refRemote.json"), + 4: ("bignum.json", "email.json", "ecmascript-regex.json"), + 6: ("bignum.json", "email.json", "ecmascript-regex.json"), 7: ( "bignum.json", "email.json", "idn-hostname.json", "time.json", "ecmascript-regex.json", - "refRemote.json", - "cross-draft.json", ), } diff --git a/crates/jsonschema-py/uv.lock b/crates/jsonschema-py/uv.lock index f7920cb7..d4127836 100644 --- a/crates/jsonschema-py/uv.lock +++ b/crates/jsonschema-py/uv.lock @@ -152,8 +152,12 @@ wheels = [ [[package]] name = "jsonschema-rs" -version = "0.18.3" +version = "0.19.1" source = { editable = "." } +dependencies = [ + { name = "maturin" }, + { name = "pip" }, +] [package.optional-dependencies] bench = [ @@ -173,6 +177,8 @@ requires-dist = [ { name = "flask", marker = "extra == 'tests'", specifier = ">=2.2.5" }, { name = "hypothesis", marker = "extra == 'tests'", specifier = ">=6.79.4" }, { name = "jsonschema", marker = "extra == 'bench'", specifier = ">=4.23.0" }, + { name = "maturin", specifier = ">=1.7.1" }, + { name = "pip", specifier = ">=24.2" }, { name = "pytest", marker = "extra == 'tests'", specifier = ">=7.4.4" }, { name = "pytest-benchmark", marker = "extra == 'bench'", specifier = ">=4.0.0" }, ] @@ -248,6 +254,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f6/f8/4da07de16f10551ca1f640c92b5f316f9394088b183c6a57183df6de5ae4/MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5", size = 17211 }, ] +[[package]] +name = "maturin" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/ec/1f688d6ad82a568fd7c239f1c7a130d3fc2634977df4ef662ee0ac58a153/maturin-1.7.1.tar.gz", hash = "sha256:147754cb3d81177ee12d9baf575d93549e76121dacd3544ad6a50ab718de2b9c", size = 190286 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d5/71/2da6a923a8c65749c614f95046ea0190ff00d6923edc20b0c5ecff2119f1/maturin-1.7.1-py3-none-linux_armv6l.whl", hash = "sha256:372a141b31ae7396728d2dedc6061fe4522c1803ae1c05700d37008e1d1a2cc9", size = 8198799 }, + { url = "https://files.pythonhosted.org/packages/21/7c/70e4f4e634777652101277eb1449777310f960f831c831bf7956ea81ef82/maturin-1.7.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:49939608095d9bcdf19d081dfd6ac1e8f915c645115090514c7b86e1e382f241", size = 15603724 }, + { url = "https://files.pythonhosted.org/packages/0d/2c/06702f20e9f8f019bc036084292c9fe3ae04b4f6a163929ee10627dd0258/maturin-1.7.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:973126a36cfb9861b3207df579678c1bcd7c348578a41ccfbe80d811a84f1740", size = 7982580 }, + { url = "https://files.pythonhosted.org/packages/6c/a3/a4841dddb81e1855b57acf393ba72c405f097a3c6d7d5078e4d7105a4735/maturin-1.7.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:6eec984d26f707b18765478f4892e58ac72e777287cd2ba721d6e2ef6da1f66e", size = 8548076 }, + { url = "https://files.pythonhosted.org/packages/ad/1c/1d0fd54bb2d068d0f9d513b0fdfb089a0fe8d20f020e673de0a0cda4f485/maturin-1.7.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:0df0a6aaf7e9ab92cce2490b03d80b8f5ecbfa0689747a2ea4dfb9e63877b79c", size = 8705393 }, + { url = "https://files.pythonhosted.org/packages/61/f4/6f4023c9653256fbcf2ef1ab6926f9fd4260390d25c258108ddfd45978d3/maturin-1.7.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:09cca3491c756d1bce6ffff13f004e8a10e67c72a1cba9579058f58220505881", size = 8422778 }, + { url = "https://files.pythonhosted.org/packages/15/d9/d927f225959e95c89fc6999130426d90d3f8285815dc2503a473049cb232/maturin-1.7.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:00f0f8f5051f4c0d0f69bdd0c6297ea87e979f70fb78a377eb4277c932804e2d", size = 8090248 }, + { url = "https://files.pythonhosted.org/packages/e3/d7/577d081996b901e02c2f3f9881fa1c1b4097bf3a0a46b7f7d8481a37ce1e/maturin-1.7.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:7bb184cfbac4e3c55ca21d322e4801e0f75e7932287e156c280c279eae60b69e", size = 8731356 }, + { url = "https://files.pythonhosted.org/packages/53/3e/725176fac7ce884bc577603f58026fd56b4faed067e81fe6a0839f5a4464/maturin-1.7.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5e8e61468d7d79790f0b54f2ed24f2fefbce3518548bc4e1a1f0c7be5bad710", size = 9901905 }, + { url = "https://files.pythonhosted.org/packages/61/69/3960d152d0a3e527212b4fe991ada3618fd2f5ec64edffdd38875adb1b9c/maturin-1.7.1-py3-none-win32.whl", hash = "sha256:07c8800603e551a45e16fe7ad1742977097ea43c18b28e491df74d4ca15c5857", size = 6479042 }, + { url = "https://files.pythonhosted.org/packages/a1/5b/512efa939f747f1a1277f981ca1de332f01bb187d193cb8d67f816c38735/maturin-1.7.1-py3-none-win_amd64.whl", hash = "sha256:c5e7e6d130072ca76956106daa276f24a66c3407cfe6cf64c196d4299fd4175c", size = 7268270 }, + { url = "https://files.pythonhosted.org/packages/c6/ce/eda05e623102dfb75b60f8b222ab3d6bc98a6e7182cc44602b422bd0f07a/maturin-1.7.1-py3-none-win_arm64.whl", hash = "sha256:acf9f539f53a7ad64d406a40b27b768f67d75e6e4e93cb04b29025144a74ef45", size = 6277021 }, +] + [[package]] name = "packaging" version = "24.0" @@ -257,6 +286,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/df/1fceb2f8900f8639e278b056416d49134fb8d84c5942ffaa01ad34782422/packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5", size = 53488 }, ] +[[package]] +name = "pip" +version = "24.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/87/fb90046e096a03aeab235e139436b3fe804cdd447ed2093b0d70eba3f7f8/pip-24.2.tar.gz", hash = "sha256:5b5e490b5e9cb275c879595064adce9ebd31b854e3e803740b72f9ccf34a45b8", size = 1922041 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/55/90db48d85f7689ec6f81c0db0622d704306c5284850383c090e6c7195a5c/pip-24.2-py3-none-any.whl", hash = "sha256:2cd581cf58ab7fcfca4ce8efa6dcacd0de5bf8d0a3eb9ec927e07405f4d9e2a2", size = 1815170 }, +] + [[package]] name = "pkgutil-resolve-name" version = "1.3.10" diff --git a/crates/jsonschema/src/resolver.rs b/crates/jsonschema/src/resolver.rs index 057a179a..ee5e57bf 100644 --- a/crates/jsonschema/src/resolver.rs +++ b/crates/jsonschema/src/resolver.rs @@ -182,7 +182,7 @@ impl Resolver { let draft = draft_from_schema(&resolved).unwrap_or(self.draft); // traverse the schema and store all named ones under their canonical ids let mut schemas = self.schemas.write(); - find_schemas(draft, &resolved, &url, &mut |id, schema| { + find_schemas(draft, &resolved, url, &mut |id, schema| { schemas.insert(id, Arc::new(schema.clone())); None })?;