Skip to content

Commit

Permalink
add custom model factory
Browse files Browse the repository at this point in the history
  • Loading branch information
berislavlopac committed Feb 3, 2023
1 parent b348417 commit 74f7206
Show file tree
Hide file tree
Showing 9 changed files with 73 additions and 40 deletions.
2 changes: 1 addition & 1 deletion CONTRIBUTE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions momoa/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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:
Expand All @@ -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.
Expand Down
27 changes: 15 additions & 12 deletions momoa/model.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 4 additions & 5 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
20 changes: 20 additions & 0 deletions tests/test_deserialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 3 additions & 3 deletions tests/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
29 changes: 18 additions & 11 deletions tests/test_schema.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import logging
from inspect import isclass

import pytest

Expand All @@ -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):
Expand All @@ -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"
5 changes: 3 additions & 2 deletions tests/test_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,16 @@ 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 = {
"firstName": "Boris",
"lastName": "Harrison",
"age": 53,
"dogs": ["Fluffy", "Crumpet"],
"gender": "male",
"gender": "other",
"deceased": False,
"address": {
"street": "adipisicing do proident laborum",
Expand Down

0 comments on commit 74f7206

Please sign in to comment.