diff --git a/superdesk/core/resources/model.py b/superdesk/core/resources/model.py index fa3adda15..a73677b4e 100644 --- a/superdesk/core/resources/model.py +++ b/superdesk/core/resources/model.py @@ -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 @@ -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) @@ -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} @@ -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""" diff --git a/tests/core/dataclass_test.py b/tests/core/dataclass_test.py new file mode 100644 index 000000000..505c34df6 --- /dev/null +++ b/tests/core/dataclass_test.py @@ -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