From a95a754496d3357b53488384533b877c25244324 Mon Sep 17 00:00:00 2001 From: Dmitry Dygalo Date: Fri, 29 Oct 2021 17:25:24 +0200 Subject: [PATCH] feat(python): A way to compile schemas from a string Signed-off-by: Dmitry Dygalo --- .github/workflows/build.yml | 2 +- .pre-commit-config.yaml | 2 +- bindings/python/CHANGELOG.md | 9 +++++ bindings/python/README.rst | 9 ++++- bindings/python/benches/bench.py | 27 ++++++++++++- bindings/python/src/lib.rs | 45 ++++++++++++++++++++- bindings/python/tests-py/test_jsonschema.py | 14 ++++++- 7 files changed, 100 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0e6b56f..c966d7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: - uses: actions/setup-python@v2 with: - python-version: 3.7 + python-version: 3.9 - run: pip install pre-commit - run: pre-commit run --all-files diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e099b63..6d836ef 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ default_language_version: - python: python3.7 + python: python3.9 repos: - repo: https://github.com/pre-commit/pre-commit-hooks diff --git a/bindings/python/CHANGELOG.md b/bindings/python/CHANGELOG.md index 1fc73c0..568d533 100644 --- a/bindings/python/CHANGELOG.md +++ b/bindings/python/CHANGELOG.md @@ -2,10 +2,19 @@ ## [Unreleased] +### Added + +- `JSONSchema.from_str` method that accepts a string to construct a compiled schema. + Useful if you have a schema as string, because you don't have to call `json.loads` on your side - parsing will happen on the Rust side. + ### Fixed - Set `jsonschema_rs.JSONSchema.__module__` to `jsonschema_rs`. +### Performance + +- Minor performance improvements. + ## [0.12.3] - 2021-10-22 ### Added diff --git a/bindings/python/README.rst b/bindings/python/README.rst index 3e668e9..7e39171 100644 --- a/bindings/python/README.rst +++ b/bindings/python/README.rst @@ -46,7 +46,14 @@ or: validator = jsonschema_rs.JSONSchema({"minimum": 42}) validator.validate(41) # raises ValidationError -**NOTE**. This library is in early development. +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: + +.. code:: python + + import jsonschema_rs + + validator = jsonschema_rs.JSONSchema.from_str('{"minimum": 42}') + ... Performance ----------- diff --git a/bindings/python/benches/bench.py b/bindings/python/benches/bench.py index 46c3f52..aff3a90 100644 --- a/bindings/python/benches/bench.py +++ b/bindings/python/benches/bench.py @@ -18,8 +18,13 @@ def load_json(filename): return json.load(fd) -def load_from_benches(filename): - return load_json(f"../../jsonschema/benches/data/{filename}") +def load_json_str(filename): + with open(filename) as fd: + return fd.read() + + +def load_from_benches(filename, loader=load_json): + return loader(f"../../jsonschema/benches/data/{filename}") OPENAPI = load_from_benches("openapi.json") @@ -79,6 +84,24 @@ def args(request, variant, is_compiled): return partial(fastjsonschema.validate, use_default=False), schema, instance +@pytest.mark.parametrize( + "name", ("openapi.json", "swagger.json", "geojson.json", "citm_catalog_schema.json", "fast_schema.json") +) +@pytest.mark.parametrize( + "func", + ( + lambda x: jsonschema_rs.JSONSchema(json.loads(x)), + jsonschema_rs.JSONSchema.from_str, + ), + ids=["py-parse", "rs-parse"], +) +@pytest.mark.benchmark(group="create schema") +def test_create_schema(benchmark, func, name): + benchmark.group = f"{name}: {benchmark.group}" + schema = load_from_benches(name, loader=load_json_str) + benchmark(func, schema) + + # Small schemas diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 8efd489..e7d1803 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -16,10 +16,10 @@ use jsonschema::{paths::JSONPointer, Draft}; use pyo3::{ - exceptions, + exceptions::{self, PyValueError}, prelude::*, types::{PyAny, PyList, PyType}, - wrap_pyfunction, PyIterProtocol, PyObjectProtocol, + wrap_pyfunction, AsPyPointer, PyIterProtocol, PyObjectProtocol, }; mod ser; @@ -348,6 +348,47 @@ impl JSONSchema { Err(error) => Err(into_py_err(py, error)?), } } + /// from_str(string, draft=None, with_meta_schemas=False) + /// + /// Create `JSONSchema` from a serialized JSON string. + /// + /// >>> compiled = JSONSchema.from_str('{"minimum": 5}') + /// + /// Use it if you have your schema as a string and want to utilize Rust JSON parsing. + #[classmethod] + #[pyo3(text_signature = "(string, draft=None, with_meta_schemas=False)")] + fn from_str( + _: &PyType, + py: Python, + pyschema: &PyAny, + draft: Option, + with_meta_schemas: Option, + ) -> PyResult { + let obj_ptr = pyschema.as_ptr(); + let object_type = unsafe { pyo3::ffi::Py_TYPE(obj_ptr) }; + if unsafe { object_type != types::STR_TYPE } { + let type_name = + unsafe { std::ffi::CStr::from_ptr((*object_type).tp_name).to_string_lossy() }; + Err(PyValueError::new_err(format!( + "Expected string, got {}", + type_name + ))) + } else { + let mut str_size: pyo3::ffi::Py_ssize_t = 0; + let uni = unsafe { string::read_utf8_from_str(obj_ptr, &mut str_size) }; + let slice = unsafe { std::slice::from_raw_parts(uni, str_size as usize) }; + let raw_schema = serde_json::from_slice(slice) + .map_err(|error| PyValueError::new_err(format!("Invalid string: {}", error)))?; + let options = make_options(draft, with_meta_schemas)?; + match options.compile(&raw_schema) { + Ok(schema) => Ok(JSONSchema { + schema, + repr: get_schema_repr(&raw_schema), + }), + Err(error) => Err(into_py_err(py, error)?), + } + } + } /// is_valid(instance) /// diff --git a/bindings/python/tests-py/test_jsonschema.py b/bindings/python/tests-py/test_jsonschema.py index aa1b766..54f1bd4 100644 --- a/bindings/python/tests-py/test_jsonschema.py +++ b/bindings/python/tests-py/test_jsonschema.py @@ -47,12 +47,24 @@ def test_repr(): assert repr(JSONSchema({"minimum": 5})) == '' -@pytest.mark.parametrize("func", (JSONSchema({"minimum": 5}).validate, partial(validate, {"minimum": 5}))) +@pytest.mark.parametrize( + "func", + ( + JSONSchema({"minimum": 5}).validate, + JSONSchema.from_str('{"minimum": 5}').validate, + partial(validate, {"minimum": 5}), + ), +) def test_validate(func): with pytest.raises(ValidationError, match="2 is less than the minimum of 5"): func(2) +def test_from_str_error(): + with pytest.raises(ValueError, match="Expected string, got int"): + JSONSchema.from_str(42) + + def test_recursive_dict(): instance = {} instance["foo"] = instance