Skip to content

Commit

Permalink
Improvements to unify the functionality from decorator & Dataclass
Browse files Browse the repository at this point in the history
…in a single class (superdesk#2764)

* Enhance `Dataclass` to unify decorator and class

The intention is to avoid having both the `dataclass` decorator and the `Dataclass` which feels a bit redundant.

* Add types

* Minor adjustments and add tests
  • Loading branch information
eos87 authored Dec 5, 2024
1 parent f17f8f2 commit 8eb7185
Show file tree
Hide file tree
Showing 2 changed files with 72 additions and 6 deletions.
40 changes: 34 additions & 6 deletions superdesk/core/resources/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
SerializerFunctionWrapHandler,
RootModel,
)
from pydantic_core import InitErrorDetails, PydanticCustomError
from pydantic_core import InitErrorDetails, PydanticCustomError, from_json
from pydantic.dataclasses import dataclass as pydataclass

from superdesk.core.types import SortListParam, ProjectedFieldArg, BaseModel
Expand Down Expand Up @@ -65,9 +65,14 @@ def dataclass(*args, **kwargs):
return pydataclass(*args, **kwargs, config=config)


class Dataclass:
class DataclassBase:
"""Provides type-safe utility methods for Dataclass."""

@model_serializer(mode="wrap")
def ser_model(self, nxt: SerializerFunctionWrapHandler):
"""
Serialize the model, including extra fields not part of the schema.
"""
aliased_fields = get_model_aliased_fields(self.__class__)
result = nxt(self)

Expand All @@ -81,12 +86,13 @@ def ser_model(self, nxt: SerializerFunctionWrapHandler):
return result

@classmethod
def from_dict(cls, values: dict[str, Any], **kwargs) -> Self:
return RootModel.model_validate(values, **kwargs).root
def from_dict(cls: type[Self], values: dict[str, Any]) -> Self:
return cls(**values)

@classmethod
def from_json(cls, data: str | bytes | bytearray, **kwargs) -> Self:
return RootModel.model_validate_json(data, **kwargs).root
def from_json(cls: type[Self], data: str | bytes | bytearray) -> Self:
values = from_json(data)
return cls(**values)

def to_dict(self, **kwargs) -> dict[str, Any]:
default_params: dict[str, Any] = {"by_alias": True, "exclude_unset": True}
Expand All @@ -99,6 +105,28 @@ def to_json(self, **kwargs) -> str:
return RootModel(self).model_dump_json(**default_params)


@dataclass_transform(field_specifiers=(dataclass_field, Field))
class DataclassMeta(type):
"""Metaclass to enhance Dataclass functionality."""

def __new__(cls: type, name: str, bases: tuple[type, ...], attrs: dict[str, Any]) -> type:
custom_config = attrs.pop("Config", {})

# merge with default configuration
config = deepcopy(default_model_config)
config.update(custom_config)

# create the class and apply the Pydantic dataclass decorator
new_cls = super().__new__(cls, name, bases, attrs) # type: ignore[misc]
return pydataclass(new_cls, config=config)


class Dataclass(DataclassBase, metaclass=DataclassMeta):
"""Unified base class for dataclasses with Pydantic features."""

pass


class ResourceModel(BaseModel):
"""Base ResourceModel class to be used for all registered resources"""

Expand Down
38 changes: 38 additions & 0 deletions tests/core/dataclass_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
from unittest import TestCase

from pydantic_core import ValidationError
from superdesk.core.resources import Dataclass


class RingBearer(Dataclass):
name: str
race: str


class DataclassTest(TestCase):
def test_dataclass_model_proper_types(self):
frodo = RingBearer(name="Frodo", race="Hobbit")
frodo_from_dict = RingBearer.from_dict(dict(name="Frodo", race="Hobbit"))
frodo_from_json = RingBearer.from_json('{"name":"Frodo","race":"Hobbit"}')

self.assertEqual(type(frodo), RingBearer)
self.assertEqual(type(frodo_from_dict), RingBearer)
self.assertEqual(type(frodo_from_json), RingBearer)

def test_dataclass_model_utils(self):
frodo = RingBearer(name="Frodo", race="Hobbit")

self.assertEqual(frodo.to_dict(), {"name": "Frodo", "race": "Hobbit"})
self.assertEqual(frodo.to_json(), '{"name":"Frodo","race":"Hobbit"}')

def test_dataclass_validation_error(self):
with self.assertRaises(ValidationError, msg="1 validation error for RingBearer"):
RingBearer(name="Frodo")

with self.assertRaises(ValidationError):
RingBearer(name=1, race="Hobbit")

def test_dataclass_should_validate_on_assignment(self):
with self.assertRaises(ValidationError):
frodo = RingBearer(name="Frodo", race="Hobbit")
frodo.name = 1

0 comments on commit 8eb7185

Please sign in to comment.