diff --git a/calamus/schema.py b/calamus/schema.py index e024c30..e4d2142 100644 --- a/calamus/schema.py +++ b/calamus/schema.py @@ -18,6 +18,7 @@ """Marshmallow schema implementation that supports JSON-LD.""" import inspect +import types import typing from collections.abc import Mapping from functools import lru_cache @@ -55,6 +56,8 @@ def __init__(self, meta, *args, **kwargs): super().__init__(meta, *args, **kwargs) self.rdf_type = getattr(meta, "rdf_type", None) + self.inherit_parent_types = getattr(meta, "inherit_parent_types", True) + if not isinstance(self.rdf_type, list): self.rdf_type = [self.rdf_type] if self.rdf_type else [] self.rdf_type = sorted(self.rdf_type) @@ -71,12 +74,15 @@ class JsonLDSchemaMeta(SchemaMeta): def __new__(mcs, name, bases, attrs): klass = super().__new__(mcs, name, bases, attrs) - # Include rdf_type of all parent schemas - for base in bases: - if hasattr(base, "opts"): - rdf_type = getattr(base.opts, "rdf_type", []) - if rdf_type: - klass.opts.rdf_type.extend(rdf_type) + if klass.opts.inherit_parent_types: + # Include rdf_type of all parent schemas + for base in bases: + if hasattr(base, "opts"): + rdf_type = getattr(base.opts, "rdf_type", []) + if rdf_type: + klass.opts.rdf_type.extend(rdf_type) + if not getattr(base.opts, "inherit_parent_types", True): + break klass.opts.rdf_type = sorted(set(klass.opts.rdf_type)) @@ -561,6 +567,7 @@ def __new__(mcs, name, bases, namespace, **kwargs): if potential_base_schemas: base_schemas = tuple(potential_base_schemas) + # Copy fields to schema attribute_dict = {} for attr_name, value in namespace.copy().items(): if isinstance(value, fields._JsonLDField): @@ -577,7 +584,16 @@ def __new__(mcs, name, bases, namespace, **kwargs): if "Meta" not in namespace or not hasattr(namespace["Meta"], "rdf_type"): raise ValueError("Setting 'rdf_type' on the `class Meta` is required for calamus annotations") - attribute_dict["Meta"] = type("Meta", (), {"rdf_type": namespace["Meta"].rdf_type}) + # Copy `Meta` fields to schema + hook_dict = {} + meta_attr_dict = {} + for attr_name, value in namespace["Meta"].__dict__.items(): + if hasattr(value, "__marshmallow_hook__"): + hook_dict[attr_name] = value + elif not attr_name.startswith("_"): + meta_attr_dict[attr_name] = value + + attribute_dict["Meta"] = type("Meta", (), meta_attr_dict) namespace["__calamus_schema__"] = type(f"{name}Schema", base_schemas, attribute_dict) @lru_cache(maxsize=5) @@ -587,6 +603,16 @@ def schema(*args, **kwargs): namespace[schema.__name__] = schema + # copy over and patch marshmallow hooks + for name, hook in hook_dict.items(): + if getattr(hook, "__closure__", None) is None: + setattr(namespace["__calamus_schema__"], name, hook) + else: + hook_with_closure = _patch_function_closure_with_class( + hook, namespace["Meta"], namespace["__calamus_schema__"] + ) + setattr(namespace["__calamus_schema__"], name, hook_with_closure) + def dump(self, *args, **kwargs): """Convenience method to dump object directly.""" return type(self).schema(*args, **kwargs).dump(self) @@ -599,3 +625,50 @@ def dump(self, *args, **kwargs): namespace["__calamus_schema__"].opts.model = cls return cls + + +def _patch_function_closure_with_class(func, old_cls, cls): + """Patches a functions closure over to a new class. + + Needed to fix `super()` being a closure and copying hooks. + `super()` creates a closure over the parent class of a method when instantiating we need to replace that closure + to point to the new type see https://bugs.python.org/issue29944 . + """ + + def make_class_closure(__class__): + """Get `cell` for `super`.""" + return (lambda: super).__closure__[0] + + def make_cell(value): + """Wrap `value` into a `cell`.""" + return (lambda: value).__closure__[0] + + func_with_closure = func + + if getattr(func, "__closure__", None) is not None: + # patch class in __closure__ recursively + new_closure = [] + for cell in func.__closure__: + if cell.cell_contents == old_cls: + new_closure.append(make_class_closure(cls)) + elif isinstance(cell.cell_contents, types.FunctionType): + new_closure.append(make_cell(_patch_function_closure_with_class(cell.cell_contents, old_cls, cls))) + else: + new_closure.append(cell) + + new_closure = tuple(new_closure) + func_with_closure = types.FunctionType( + func.__code__, + func.__globals__, + func.__name__, + func.__defaults__, + closure=new_closure, + ) + + # copy over additional attributes that might be on the function + for attr_name, value in func.__dict__.items(): + if isinstance(value, types.FunctionType): + value = _patch_function_closure_with_class(value, old_cls, cls) + setattr(func_with_closure, attr_name, value) + + return func_with_closure diff --git a/tests/test_annotation.py b/tests/test_annotation.py index 50723fd..ad9874f 100644 --- a/tests/test_annotation.py +++ b/tests/test_annotation.py @@ -17,6 +17,10 @@ # limitations under the License. """Tests for deserialization from python dicts.""" +from functools import lru_cache +import functools +from marshmallow import pre_load + import calamus.fields as fields from calamus.schema import JsonLDAnnotation @@ -54,6 +58,38 @@ class Meta: assert data == dumped +def test_annotation_meta_option(): + """Test annotation support with marshmallow meta option.""" + schema = fields.Namespace("http://schema.org/") + + class Book(metaclass=JsonLDAnnotation): + _id = fields.Id() + name = fields.String(schema.name) + + def __init__(self, _id, name=""): + self._id = _id + self.name = name + + class Meta: + rdf_type = schema.Book + exclude = ("name",) + + data = {"@id": "http://example.com/books/1", "@type": ["http://schema.org/Book"]} + + book = Book.schema().load(data) + + assert book._id == "http://example.com/books/1" + assert book.name == "" + + dumped = Book.schema().dump(book) + assert data == dumped + assert "http://schema.org/name" not in data + + dumped = book.dump() + assert data == dumped + assert "http://schema.org/name" not in data + + def test_annotation_with_default(): """Test annotation with default values.""" schema = fields.Namespace("http://schema.org/") @@ -170,6 +206,111 @@ class Meta(Book.Meta): assert data == dumped +def test_annotation_without_inheritance(): + """Test that inheritance works for annotated classes with type inheritance disabled.""" + schema = fields.Namespace("http://schema.org/") + + class Book(metaclass=JsonLDAnnotation): + _id = fields.Id() + name = fields.String(schema.name) + + def __init__(self, _id, name): + self._id = _id + self.name = name + + class Meta: + rdf_type = schema.Book + + class Schoolbook(Book): + course = fields.String(schema.course) + + def __init__(self, _id, name, course): + self.course = course + super().__init__(_id, name) + + class Meta(Book.Meta): + rdf_type = schema.SchoolBook + inherit_parent_types = False + + data = { + "@id": "http://example.com/books/1", + "@type": ["http://schema.org/SchoolBook"], + "http://schema.org/name": "Hitchhikers Guide to the Galaxy", + "http://schema.org/course": "Literature", + } + + book = Schoolbook.schema().load(data) + + assert book._id == "http://example.com/books/1" + assert book.name == "Hitchhikers Guide to the Galaxy" + assert book.course == "Literature" + + dumped = Schoolbook.schema().dump(book) + assert data == dumped + + dumped = book.dump() + assert data == dumped + + +def test_annotation_without_inheritance_multiple(): + """Test that inheritance works for annotated classes with type inheritance enabled selectively.""" + schema = fields.Namespace("http://schema.org/") + + class Book(metaclass=JsonLDAnnotation): + _id = fields.Id() + name = fields.String(schema.name) + + def __init__(self, _id, name): + self._id = _id + self.name = name + + class Meta: + rdf_type = schema.Book + + class Schoolbook(Book): + course = fields.String(schema.course) + + def __init__(self, _id, name, course): + self.course = course + super().__init__(_id, name) + + class Meta: + rdf_type = schema.SchoolBook + inherit_parent_types = False + + class Biologybook(Schoolbook): + topic = fields.String(schema.topic) + + def __init__(self, _id, name, course, topic): + self.topic = topic + super().__init__(_id, name, course) + + class Meta: + rdf_type = schema.BiologyBook + inherit_parent_types = True + + data = { + "@id": "http://example.com/books/1", + "@type": ["http://schema.org/BiologyBook", "http://schema.org/SchoolBook"], + "http://schema.org/name": "Hitchhikers Guide to the Galaxy", + "http://schema.org/course": "Literature", + "http://schema.org/topic": "Genetics", + } + + book = Biologybook.schema().load(data) + + assert book._id == "http://example.com/books/1" + assert book.name == "Hitchhikers Guide to the Galaxy" + assert book.course == "Literature" + assert book.topic == "Genetics" + + dumped = Biologybook.schema().dump(book) + assert data == dumped + + dumped = book.dump() + assert data == dumped + + def test_annotation_multiple_inheritance(): """Test that inheritance works for annotated classes.""" schema = fields.Namespace("http://schema.org/") @@ -226,3 +367,264 @@ class Meta: dumped = book.dump() assert data == dumped + + +def test_annotation_hook_inheritance(): + """Test that inheritance works for hooks declared on annotated classes.""" + schema = fields.Namespace("http://schema.org/") + + class Book(metaclass=JsonLDAnnotation): + _id = fields.Id() + name = fields.String(schema.name) + + def __init__(self, _id, name): + self._id = _id + self.name = name + + class Meta: + rdf_type = schema.Book + + @pre_load + def _preload(self, in_data, **kwargs): + in_data["@id"] += "hook1" + return in_data + + class Schoolbook(Book): + course = fields.String(schema.course) + + def __init__(self, _id, name, course): + self.course = course + super().__init__(_id, name) + + class Meta(Book.Meta): + rdf_type = Book.Meta.rdf_type + + @pre_load + def _preload(self, in_data, **kwargs): + super()._preload(in_data, **kwargs) + in_data["@id"] += "hook2" + return in_data + + data = { + "@id": "http://example.com/books/1", + "@type": ["http://schema.org/Book"], + "http://schema.org/name": "Hitchhikers Guide to the Galaxy", + "http://schema.org/course": "Literature", + } + + book = Schoolbook.schema().load(data) + + assert book._id.endswith("hook1hook2") + + +def test_annotation_hook_only_on_parent(): + """Test that inheritance works for hooks declared on parent annotated class.""" + schema = fields.Namespace("http://schema.org/") + + class Book(metaclass=JsonLDAnnotation): + _id = fields.Id() + name = fields.String(schema.name) + + def __init__(self, _id, name): + self._id = _id + self.name = name + + class Meta: + rdf_type = schema.Book + + @pre_load + def _preload(self, in_data, **kwargs): + in_data["@id"] += "hook1" + return in_data + + class Schoolbook(Book): + course = fields.String(schema.course) + + def __init__(self, _id, name, course): + self.course = course + super().__init__(_id, name) + + class Meta: + rdf_type = Book.Meta.rdf_type + + data = { + "@id": "http://example.com/books/1", + "@type": ["http://schema.org/Book"], + "http://schema.org/name": "Hitchhikers Guide to the Galaxy", + "http://schema.org/course": "Literature", + } + + book = Schoolbook.schema().load(data) + + assert book._id.endswith("hook1") + + +def test_annotation_hook_interrupted_inheritance(): + """Test that inheritance works for occasional hooks declared on annotated classes.""" + schema = fields.Namespace("http://schema.org/") + + class Book(metaclass=JsonLDAnnotation): + _id = fields.Id() + name = fields.String(schema.name) + + def __init__(self, _id, name): + self._id = _id + self.name = name + + class Meta: + rdf_type = schema.Book + + @pre_load + def _preload(self, in_data, **kwargs): + in_data["@id"] += "hook1" + return in_data + + class Schoolbook(Book): + course = fields.String(schema.course) + + def __init__(self, _id, name, course): + self.course = course + super().__init__(_id, name) + + class Meta: + rdf_type = Book.Meta.rdf_type + + class Biologybook(Schoolbook): + topic = fields.String(schema.topic) + + def __init__(self, _id, name, course, topic): + self.topic = topic + super().__init__(_id, name, course) + + class Meta: + rdf_type = schema.BiologyBook + + @pre_load + def _preload(self, in_data, **kwargs): + super()._preload(in_data, **kwargs) + in_data["@id"] += "hook2" + return in_data + + data = { + "@id": "http://example.com/books/1", + "@type": ["http://schema.org/BiologyBook"], + "http://schema.org/name": "Hitchhikers Guide to the Galaxy", + "http://schema.org/course": "Literature", + "http://schema.org/topic": "Genetics", + } + + book = Biologybook.schema().load(data) + + assert book._id.endswith("hook1hook2") + + +def test_annotation_hook_inheritance_with_extra_closure(): + """Test that inheritance works for hooks declared on annotated classes with extra closures in the hook.""" + schema = fields.Namespace("http://schema.org/") + + x = 3 + y = 4 + + class Book(metaclass=JsonLDAnnotation): + _id = fields.Id() + name = fields.String(schema.name) + + def __init__(self, _id, name): + self._id = _id + self.name = name + + class Meta: + rdf_type = schema.Book + + @pre_load + def _preload(self, in_data, **kwargs): + in_data["@id"] += f"hook{x}" + return in_data + + class Schoolbook(Book): + course = fields.String(schema.course) + + def __init__(self, _id, name, course): + self.course = course + super().__init__(_id, name) + + class Meta(Book.Meta): + rdf_type = Book.Meta.rdf_type + + @pre_load + def _preload(self, in_data, **kwargs): + super()._preload(in_data, **kwargs) + in_data["@id"] += f"hook{y}" + return in_data + + data = { + "@id": "http://example.com/books/1", + "@type": ["http://schema.org/Book"], + "http://schema.org/name": "Hitchhikers Guide to the Galaxy", + "http://schema.org/course": "Literature", + } + + book = Schoolbook.schema().load(data) + + assert book._id.endswith(f"hook{x}hook{y}") + + +def test_annotation_hook_inheritance_with_additional_decorator(): + """Test that inheritance works for hooks declared on annotated classes with decorators other than the hook.""" + schema = fields.Namespace("http://schema.org/") + + def my_decorator(value): + def actual_decorator(func): + @functools.wraps(func) + def wrapper_my_decorator(self, in_data, *args, **kwargs): + in_data["@id"] += f"dec{value}" + return func(self, in_data, *args, **kwargs) + + return wrapper_my_decorator + + return actual_decorator + + class Book(metaclass=JsonLDAnnotation): + _id = fields.Id() + name = fields.String(schema.name) + + def __init__(self, _id, name): + self._id = _id + self.name = name + + class Meta: + rdf_type = schema.Book + + @pre_load + @my_decorator(1) + def _preload(self, in_data, **kwargs): + in_data["@id"] += "hook1" + return in_data + + class Schoolbook(Book): + course = fields.String(schema.course) + + def __init__(self, _id, name, course): + self.course = course + super().__init__(_id, name) + + class Meta(Book.Meta): + rdf_type = Book.Meta.rdf_type + + @my_decorator(2) + @pre_load + def _preload(self, in_data, **kwargs): + super()._preload(in_data, **kwargs) + in_data["@id"] += "hook2" + return in_data + + data = { + "@id": "http://example.com/books/1", + "@type": ["http://schema.org/Book"], + "http://schema.org/name": "Hitchhikers Guide to the Galaxy", + "http://schema.org/course": "Literature", + } + + book = Schoolbook.schema().load(data) + + assert book._id.endswith("dec2dec1hook1hook2")