Serializing Dataclasses

As I am working with dataclass and wanting to return the values in a simplified serialized format that AWS Lambda and other brittle response wrappers can handle. Lambda is able to handle the base class of dict[str, str] as serializable, I wrote this helper method to perform recursive serialization.

It works reasonable well as I don't have any complicated types on purpose with deep nesting. The painful cases are dates, times, enums, and exceptions and this handles those without issue.

import dataclasses
from datetime import datetime, timedelta
from enum import Enum
from typing import Any
from uuid import UUID
import json
from pydantic import BaseModel, ConfigDict


class _Duration(BaseModel):
    input_duration: timedelta
    model_config = ConfigDict(extra="forbid", validate_assignment=True, frozen=True, validate_default=True)

    @property
    def iso8601(self) -> str | None:
        as_text = self.model_dump_json()
        to_dict = json.loads(as_text)
        as_value = to_dict.get("input_duration")
        if as_value is not None and isinstance(as_value, str):
            return as_value
        return None


def serialize_dataclass(obj: Any) -> dict[str, Any]:
    if dataclasses.is_dataclass(obj):
        return serialize_dataclass(dataclasses.asdict(obj))  # type: ignore[arg-type]
    if isinstance(obj, dict):
        for key, value in obj.items():
            if isinstance(value, dict):
                obj[key] = serialize_dataclass(value)
                continue
            if isinstance(value, datetime):
                obj[key] = value.isoformat()
                continue
            if isinstance(value, timedelta):
                obj[key] = _Duration(input_duration=value).iso8601
                continue
            if isinstance(value, Exception):
                obj[key] = value.__repr__()
                continue
            # expecting that enum base class off of str like so class TheEnum(str, Enum): order is important
            # or use the StrEnum enum base class after 3.11
            if isinstance(value, Enum):
                obj[key] = value.value
                continue
            if isinstance(value, UUID):
                obj[key] = str(value)
                continue
            if isinstance(value, list | set):
                obj[key] = [serialize_dataclass(z) for z in value]
    return obj

Note

Do not make a case of Iterator over list, set in my experience this will give you unpredictable representations as str properties will be decoded into arrays of characters.

Additional Note

Use the pydantic v1 library to represent durations in iso8601 format i.e. 1 day = P1D and parse them back out against dataclasses with __post_init__ to assign the proper value from the string representation.

Example

@dataclasses.dataclass(frozen=True, kw_only=True)
class TestTimeDelta:
    elapsed: timedelta

    def __post_init__(self) -> None:
        if self.elapsed is not None and isinstance(self.elapsed, str):
            inputs = self.__dict__
            inputs['elapsed'] = parse_duration(self.elapsed)
            self.__init__(**inputs)


class TestPydanticDelta(BaseModel):
    elapsed: timedelta
    model_config = ConfigDict(extra="forbid", validate_assignment=True, frozen=True, validate_default=True)


def test_serialization() -> None:
    test_duration = timedelta(hours=1, minutes=30, seconds=45)
    test_pydantic_delta = TestPydanticDelta(elapsed=test_duration)
    serialized_pydantic = test_pydantic_delta.model_dump_json()
    ic(serialized_pydantic)
    serialized_pydantic_dict = json.loads(serialized_pydantic)
    from_serialized = TestTimeDelta(**serialized_pydantic_dict)
    ic(from_serialized.elapsed == test_pydantic_delta.elapsed == test_duration)

if __name__ == '__main__':
    test_serialization()

Output

ic| serialized_pydantic: '{"elapsed":"PT1H30M45S"}'
ic| from_serialized.elapsed == test_pydantic_delta.elapsed == test_duration: True