From 74f72061791144e0302f1955d603fd77c8eba488 Mon Sep 17 00:00:00 2001 From: Berislav Lopac Date: Fri, 3 Feb 2023 20:42:38 +0000 Subject: [PATCH] add custom model factory --- CONTRIBUTE.md | 2 +- momoa/__init__.py | 14 ++++++++------ momoa/model.py | 27 +++++++++++++++------------ pyproject.toml | 1 + tests/conftest.py | 9 ++++----- tests/test_deserialization.py | 20 ++++++++++++++++++++ tests/test_model.py | 6 +++--- tests/test_schema.py | 29 ++++++++++++++++++----------- tests/test_serialization.py | 5 +++-- 9 files changed, 73 insertions(+), 40 deletions(-) diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index 6aa5777..22d275c 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -36,7 +36,7 @@ This is not needed for the CI, which runs one Python version (image) at a time. During development, each code check can be executed independently: ```shell -$ ruff # code linting +$ ruff . # code linting $ mypy --install-types --non-interactive momoa/ # Python typing analysis $ black --check . # Python code formatting $ isort --check . # Import statement optimisation diff --git a/momoa/__init__.py b/momoa/__init__.py index e49f032..4afac86 100644 --- a/momoa/__init__.py +++ b/momoa/__init__.py @@ -13,18 +13,20 @@ from statham.titles import title_labeller from .exceptions import SchemaParseError -from .model import make_model, Model +from .model import Model, ModelFactory class Schema: """Basic class to parse the schema and prepare the model class.""" - def __init__(self, schema: Dict[str, Any]): + def __init__(self, schema: Dict[str, Any], model_factory: ModelFactory = Model.make_model): """ Constructs the Schema class instance. Arguments: schema: A Python dict representation of the JSONSchema specification. + model_factory: A callable that creates a model subclass from a JSON Schema. + Can be used to customize Model creation. """ self.schema_dict = schema self.title: str = self.schema_dict["title"] @@ -33,16 +35,14 @@ def __init__(self, schema: Dict[str, Any]): except KeyError as ex: raise SchemaParseError(f"Error parsing schema `{self.title}`: {ex}") from ex else: - self.models: Sequence[Type[Model]] = tuple( - make_model(cls) for cls in orderer(*parsed) - ) + self.models: Sequence[Type[Model]] = tuple(map(model_factory, orderer(*parsed))) @classmethod def from_uri(cls, input_uri: str) -> Schema: """ Instantiates the Schema from a URI to the schema document. - For local files use the `file://` scheme. The method also dereferences + For local files use the `file://` scheme. This method also dereferences the internal `$ref` links. Arguments: @@ -58,6 +58,8 @@ def from_file(cls, file_path: Union[Path, str]) -> Schema: """ Helper to instantiate the Schema from a local file path. + Note: This method will _not_ dereference any internal `$ref` links. + Arguments: file_path: Either a simple string path or a `pathlib.Path` object. diff --git a/momoa/model.py b/momoa/model.py index ef182e3..e35f458 100644 --- a/momoa/model.py +++ b/momoa/model.py @@ -1,6 +1,7 @@ """Base wrapper class for building JSONSchema based models.""" +from __future__ import annotations -from typing import Any, cast, Type +from typing import Any, Callable, cast, Type from statham.schema.constants import NotPassed from statham.schema.elements import meta, String @@ -77,20 +78,22 @@ def serialize(self): """Validates data and serializes it into JSON-ready format.""" return _serialize_schema_value(self._instance) + @staticmethod + def make_model(schema_class: meta.ObjectMeta) -> Type[Model]: + """ + Constructs a Model subclass based on the class derived from JSONSchema. -def make_model(schema_class: meta.ObjectMeta) -> Type[Model]: - """ - Constructs a Model subclass based on the class derived from JSONSchema. + Attributes: + schema_class: Class derived from the JSONSchema. - Attributes: - schema_class: Class derived from the JSONSchema. + Returns: + Subclass of the Model class. + """ + name = pascalcase(schema_class.__name__) + "Model" + return cast(Type[Model], type(name, (Model,), {"_schema_class": schema_class})) - Returns: - Subclass of the Model class. - """ - name = pascalcase(schema_class.__name__) + "Model" - bases = (Model,) - return cast(Type[Model], type(name, bases, {"_schema_class": schema_class})) + +ModelFactory = Callable[[meta.ObjectMeta], Type[Model]] def _serialize_schema_value(value: Any) -> Any: diff --git a/pyproject.toml b/pyproject.toml index bd449ef..118bc98 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ test = [ "mutmut>=2.4.3", "tox>=3.28.0", "tox-pdm>=0.6.1", + "hypothesis-jsonschema>=0.22.0", ] checks = [ "black>=22.12.0", diff --git a/tests/conftest.py b/tests/conftest.py index 95c6c1e..8a87b62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,11 +10,10 @@ def test_data_dir(): @pytest.fixture -def schema_text(test_data_dir): - schema_path = test_data_dir / "schema.json" - return schema_path.read_text() +def schema_file_path(test_data_dir): + return test_data_dir / "schema.json" @pytest.fixture -def schema_dict(schema_text): - return json.loads(schema_text) +def schema_dict(schema_file_path): + return json.loads(schema_file_path.read_text()) diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py index 5022e4a..8f84da4 100644 --- a/tests/test_deserialization.py +++ b/tests/test_deserialization.py @@ -120,3 +120,23 @@ def test_deserialization_is_inverse_of_serialization(schema_dict): deserialized = schema.deserialize(serialized) assert deserialized == instance + + +def test_deserialization_creates_default_values(schema_dict): + schema = Schema(schema_dict) + + test_data = { + "firstName": "Boris", + "lastName": "Harrison", + "age": 53, + "dogs": ["Fluffy", "Crumpet"], + "deceased": False, + "address": { + "street": "adipisicing do proident laborum", + "city": "veniam nulla ipsum adipisicing eu", + "state": "Excepteur esse elit", + }, + } + deserialized = schema.deserialize(test_data) + + assert deserialized.gender == "male" diff --git a/tests/test_model.py b/tests/test_model.py index a7a7c6e..5f62955 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -5,18 +5,18 @@ from statham.schema.parser import parse from momoa.exceptions import DataValidationError -from momoa.model import make_model, Model, UNDEFINED +from momoa.model import Model, UNDEFINED @pytest.fixture def PersonModel(schema_dict): schema_class = parse(schema_dict).pop() - return make_model(schema_class) + return Model.make_model(schema_class) def test_make_model_creates_model_class(schema_dict): schema_class = parse(schema_dict).pop() - model = make_model(schema_class) + model = Model.make_model(schema_class) assert inspect.isclass(model) assert issubclass(model, Model) diff --git a/tests/test_schema.py b/tests/test_schema.py index 106de06..8a91d4c 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,5 +1,4 @@ import logging -from inspect import isclass import pytest @@ -14,12 +13,15 @@ def test_valid_schema_loads_from_dict(schema_dict, caplog): loaded_schema = Schema(schema_dict) assert loaded_schema.schema_dict == schema_dict + assert loaded_schema.title == "Person" - for model in loaded_schema.models: - assert model.__name__ in ("AddressModel", "ShoePreferencesModel", "PersonModel") - assert isclass(model) + model_names = ("AddressModel", "ShoePreferencesModel", "PersonModel") + assert loaded_schema.model == loaded_schema.models[-1] + assert loaded_schema.model.__name__ == model_names[-1] + + for index, model in enumerate(loaded_schema.models): assert issubclass(model, Model) - assert loaded_schema.title == "Person" + assert model.__name__ == model_names[index] def test_invalid_schema_fails_loading(schema_dict): @@ -29,19 +31,24 @@ def test_invalid_schema_fails_loading(schema_dict): Schema(schema_dict) -def test_valid_schema_loads_from_uri(test_data_dir): - schema_file_path = test_data_dir / "schema.json" +def test_valid_schema_loads_from_uri(schema_file_path): loaded_schema = Schema.from_uri(f"file://{schema_file_path}") for model in loaded_schema.models: - assert isclass(model) assert issubclass(model, Model) -def test_valid_schema_loads_from_file_path(test_data_dir): - schema_file_path = test_data_dir / "schema.json" +def test_valid_schema_loads_from_file_path(schema_file_path): loaded_schema = Schema.from_file(schema_file_path) for model in loaded_schema.models: - assert isclass(model) assert issubclass(model, Model) + + +def test_custom_model_factory_creates_model(schema_dict): + def custom_model_factory(schema_class): + name = schema_class.__name__.lower() + "blabla" + return type(name, (Model,), {"_schema_class": schema_class}) + + loaded_schema = Schema(schema_dict, model_factory=custom_model_factory) + assert loaded_schema.model.__name__ == "personblabla" diff --git a/tests/test_serialization.py b/tests/test_serialization.py index eb34c38..3060c12 100644 --- a/tests/test_serialization.py +++ b/tests/test_serialization.py @@ -107,7 +107,8 @@ def test_generic_subschemas_are_serialized_correctly(test_data_dir): } -def test_serialization_is_inverse_of_deserialization(schema_dict): +def test_serialization_is_inverse_of_deserialization_if_no_undefined_defaults(schema_dict): + """Note: Works only if there are no undefined values for properties with a default.""" schema = Schema(schema_dict) test_data = { @@ -115,7 +116,7 @@ def test_serialization_is_inverse_of_deserialization(schema_dict): "lastName": "Harrison", "age": 53, "dogs": ["Fluffy", "Crumpet"], - "gender": "male", + "gender": "other", "deceased": False, "address": { "street": "adipisicing do proident laborum",