`__serialize__`: Arguing with myself

A serde-style streaming serialization protocol. I’ve spent a couple days on this now because I got motivated to come up with something after seeing the __json__ proposal but it didn’t actually go my way. If you’ve used Rust’s serde you probably already know what I’m talking about. Anyway, here’s the proposal:

The Protocol

class Serializer(Protocol):
    def serialize_bool(self, v: bool) -> None: ...
    def serialize_int(self, v: int) -> None: ...
    def serialize_float(self, v: float) -> None: ...
    def serialize_str(self, v: str) -> None: ...
    def serialize_bytes(self, v: bytes) -> None: ...
    def serialize_none(self) -> None: ...

    # Implementors should provide an accurate length when known, but it defaults to None.
    # The use is left to the format. A serializer may use this as an optimization
    # (maybe pre-alloc) or discard it. Some formats may require a length and error if it's not provided.
    def serialize_seq(self, length: int | None = None) -> SerializeSeq: ...
    def serialize_map(self, length: int | None = None) -> SerializeMap: ...

    # Honestly, thought about calling this serialize_class but
    # for now I'm sticking with struct. msgspec gets by with Struct so it's not entirely unfamiliar to Python devs.
    # name should typically be type(obj).__name__ but it could be whatever makes sense.
    # Again, the use is left to the format. length is the number of fields.
    def serialize_struct(self, name: str, length: int) -> SerializeStruct: ...
    def is_human_readable(self) -> bool: ...

class SerializeSeq(Protocol):
    def serialize_element(self, value: Serialize) -> None: ...
    def __enter__(self) -> SerializeSeq: ...
    def __exit__(self, exc_type, exc, tb) -> None: ...

class SerializeMap(Protocol):
    def serialize_key(self, key: Serialize) -> None: ...
    def serialize_value(self, value: Serialize) -> None: ...
    def __enter__(self) -> SerializeMap: ...
    def __exit__(self, exc_type, exc, tb) -> None: ...

class SerializeStruct(Protocol):
    def serialize_field(self, key: str, value: Serialize) -> None: ...
    def __enter__(self) -> SerializeStruct: ...
    def __exit__(self, exc_type, exc, tb) -> None: ...

# The star of the show
# This is what users would need to implement
class Serialize(Protocol):
    def __serialize__(self, serializer: Serializer) -> None: ...

class SerializeError(Exception): ...

Recipes

A few assumptions here:

  • to_json and to_toml are __serialize__-aware functions for the rest of this.
  • The built-in primitives (bool, int, float, str, bytes, etc) each implement __serialize__ and forward to the matching primitive method (int => serializer.serialize_int(self), etc.)
Same type, two formats
class Serializable:
    def __serialize__(self, serializer: Serializer) -> None:
        fs = dataclasses.fields(self)
        with serializer.serialize_struct(type(self).__name__, len(fs)) as s:
            for f in fs:
                s.serialize_field(f.name, getattr(self, f.name))

@dataclass
class Server(Serializable):
    host: str
    port: int

@dataclass
class Config(Serializable):
    name: str
    server: Server

config = Config("prod", Server("0.0.0.0", 443))

to_json(config)   # {"name": "prod", "server": {"host": "0.0.0.0", "port": 443}}
to_toml(config)
# name = "prod"
#
# [server]
# host = "0.0.0.0"
# port = 443
Type with unowned field types
from dataclasses import dataclass
from datetime import datetime, timezone
from decimal import Decimal

@dataclass
class Transaction:
    amount: Decimal
    when: datetime

    def __serialize__(self, serializer: Serializer) -> None:
        with serializer.serialize_struct("Transaction", 2) as s:
            s.serialize_field("amount", str(self.amount))
            if serializer.is_human_readable():
                s.serialize_field("when", self.when.isoformat())
            else:
                s.serialize_field("when", int(self.when.timestamp()))
>>> tx = Transaction(Decimal("99.95"), datetime(2024, 1, 1, tzinfo=timezone.utc))
>>> to_json(tx)
'{"amount": "99.95", "when": "2024-01-01T00:00:00+00:00"}'
>>> to_toml(tx)
'amount = "99.95"\nwhen = "2024-01-01T00:00:00+00:00"\n'
Subclassing an unowned type
from datetime import datetime, timezone

class UnixTimestamp(datetime):
    def __serialize__(self, serializer: Serializer) -> None:
        serializer.serialize_int(int(self.timestamp()))
>>> ts = UnixTimestamp(2024, 1, 1, tzinfo=timezone.utc)
>>> to_json(ts)
'1704067200'
>>> to_toml({"t": ts})
't = 1704067200\n'
Enum
from enum import Enum

class Color(Enum):
    RED = "red"
    GREEN = "green"
    BLUE = "blue"

    def __serialize__(self, serializer: Serializer) -> None:
        serializer.serialize_str(self.value)

>>> to_json(Color.RED)
'"red"'
`None` across formats
from dataclasses import dataclass

@dataclass
class User:
    name: str
    nickname: str | None

u = User("Ada", None)
>>> to_json(u)
'{"name": "Ada", "nickname": null}'
>>> to_toml(u)
# SerializeError: TOML has no representation for None

rtoml provides a knob to decide what to do with None.
A future __serialize__-aware rtoml can continue doing that by implementing something like:

class RTOMLSerializer(Serializer):
    def __init__(self, none_value: Literal["null", "raise", None] = "null"):
        self.none_value = none_value
        self.buf = ... # internal buffer

    def serialize_none(self):
        match self.none_value:
            case "null":
                self.buf.write("null")
            case None:
                # omit, do nothing
            case "raise":
                raise SerializeError
    # rest of the methods go here
Lazy serialization of a custom collection type
class Range:
    def __init__(self, start, stop, step=1):
        self.start, self.stop, self.step = start, stop, step

    def __serialize__(self, serializer):
        with serializer.serialize_seq() as s:
            v = self.start
            while v < self.stop:
                s.serialize_element(v)
                v += self.step
>>> to_json(Range(0, 5))
'[0, 1, 2, 3, 4]'
>>> to_toml({"nums": Range(0, 3)})
'nums = [0, 1, 2]\n'
Field rename within a dataclass
@dataclass
class User(Serializable):
    user_id: int
    display_name: str

    def __serialize__(self, serializer: Serializer) -> None:
        with serializer.serialize_struct("User", 2) as s:
            s.serialize_field("id", self.user_id)               # user_id -> id
            s.serialize_field("display_name", self.display_name)
>>> to_json(User(42, "ada"))
'{"id": 42, "display_name": "ada"}'
>>> to_toml(User(42, "ada"))
'id = 42\ndisplay_name = "ada"\n'
Transparent wrapper

A newtype-style wrapper that serializes as its inner value with no struct overhead. Transparent(Transparent(42)) should produce 42, not {"inner": 42}:

from dataclasses import dataclass

@dataclass
class Transparent[T: Serialize]:
    inner: T

    def __serialize__(self, serializer: Serializer) -> None:
        self.inner.__serialize__(serializer)
>>> to_json(Transparent(Transparent(42)))
'42'
>>> to_toml({"n": Transparent(42)})
'n = 42\n'

Open issues

  1. The Transaction example (“Type with unowned field types”) above shoves datetime into a string even for TOML, which has a real datetime type. Rust’s serde suffers from the same issue. In fact, writing this proposal out, I realized serde doesn’t really provide a format agnostic representation. It only really supports JSON types with a couple extra (bytes) thrown in. Maybe that’s good enough to cover most use cases considering how prolific Serde has become in the Rust ecosystem despite the same limitations and most format serializers are built on top of it.

  2. We cannot extend this in future. The first iteration will be the last because extending this will be a massively breaking change. The data model is fixed at bool/int/float/str/bytes/none/seq/map/struct. A type outside it (datetime, Decimal, uuid, whatever the stdlib grows next) has to encode itself as one of those primitives and lose whatever the format would’ve done natively. Once again, Rust’s serde suffers from the same issue.

After all this, __json__ might actually be better

As you can see, if this proposal was truly format agnostic, it wouldn’t suffer from the “datetime turns to string in TOML” issue.

So this might just be a glorified __json__ after all. There’s also the fact that A TOML/YAML/etc serializer can always just call __json__ and then serialize that. It also gives us an actual spec for what primitives are supported instead of coming up with our own set.

I may have talked myself out of __serialize__ while writing this, but I might as well post this now.

1 Like

This is not true. We can add optional members to Serializer, e.g. serialize_datetime(self, v: datetime.datetime) -> None.

Implementation code would then check if that function exists and fall back otherwise.

class DateTime:
    def __serialize__(self, serializer: Serializer) -> None:
        if hasattr(serializer, "serialize_datetime"):
            serializer.serilaize_datetime(self.as_std_datetime())
        else:
            serializer.serialize_str(self.as_iso_string())

Python isn’t statically typed, so some library having “incomplete” definitions of Serializer isn’t a problem as long as the consumers are aware of that.

Though, if you’re going to do it that way, I would define ALL of them that way, and so they should never return None. Instead, if the method exists, it is used.

Protocols don’t require inheritance, so the base protocol cannot be extended without breaking every existing implementation in the eyes of the type checker. I would honestly prefer an alternative: versioned protocol. Maybe call it Serializer2026 or SerializerV1 and then it’ll play nice with type checkers too. A future extension can then be SerializerV2(SerializerV1) and guarantee the presence of new methods

Oh. Then it’s a fundamental flaw in the typing system that Protocols can’t have optional members. I am just going to ignore that and assume that this works, since it’s what we need for this feature.

And no, versioned protocols do not solve this issue. We want JsonSerializer to not support datetime and TomlSerailizerto support datetime, at the same time.

(I think you overlooked that “return None” just is there for complete typing - the methods are expected to always work and return None, an error state should be signaled by raising an exception)

I still think the issue is trying to put this on the class at all.

There’s nothing wrong with having a registry of conversion behavior in copyreg. Having dunders for this though creates most of the problems people have had both with __serialize__ and with __json__ here.

If they are going to be dunder methods, I would expect the methods always be available, but use NotImplemented to signal lack of support for a specific serialization method due to type not having a cannonical representation. (and for that behavior to exist on the base object as to not create forward compatability problems)

1 Like

To what extent are you simply reinventing cattrs here? Disclaimer: I only briefly skimmed your proposal, but my impression is that it’s solving basically the same problem that cattrs is designed to solve.

Oh gotcha. Doesn’t make a lot of difference though - I just mean that they can ALL be optional if there’s any possibility that SOME of them should be optional.

That’s fine. if serialize_datetime exists, then JSON can simply not use it. But tomorrow if someone makes TOML2 with say a UUID type and there’s demand for it, we need a way to add serialize_uuid without deeming every existing implementation non-compliant.

What does it mean for json to not use it?

Yes, and I proposed a strictly better solution that just doesn’t quite play nicely with python’s current typing system.

You could say it’s reinventing part of what cattrs, msgspec, or Pydantic already do. They all support taking an object and serializing it to JSON (or TOML, YAML, and other formats).

>>> from attrs import define
>>> from cattrs import structure, unstructure
>>> from pydantic import TypeAdapter
>>> import msgspec.json
>>> @define
... class C:
...     a: int
...     b: list[str]
...
>>> inst = C(1, ["hello"])
>>> unstructure(inst)
{'a': 1, 'b': ['hello']}
>>> TypeAdapter(C).dump_json(inst)
Traceback (most recent call last):
[...]
pydantic.errors.PydanticSchemaGenerationError
[...]

The real issue is ecosystem lock-in. More than once I’ve ended up choosing Pydantic as the serialization layer simply because that’s what the rest of the ecosystem uses. If I instead build my library around msgspec or attrs/cattrs, it becomes awkward to use in an application that’s already standardized on Pydantic for its validation and other features.

2 Likes
class PragmaticJSONSerializer(Serializer):
    def __init__(
        self,
        *,
        datetime_format: Literal["iso8601", "unix", "raise"] = "iso8601",
        ensure_ascii: bool = True,
        allow_nan: bool = True,
        sort_keys: bool = False,
        indent: int |None = None,
    ):
        self.datetime_format = datetime_format
        self.ensure_ascii = ensure_ascii
        self.allow_nan = allow_nan
        self.sort_keys = sort_keys
        self.indent = indent
        self.buf = ...  # internal buffer

    def serialize_datetime(self, value: datetime):
        match self.datetime_format:
            case "iso8601":
                self.serialize_str(value.isoformat())
            case "unix":
                self.serialize_int(int(value.timestamp()))
            case "raise":
                raise SerializeError("datetime is not supported by this serializer")

    # rest of the methods...
class StrictJSONSerializer(Serializer):
    def __init__(
        self,
        *,
        ensure_ascii: bool = True,
        allow_nan: bool = True,
        sort_keys: bool = False,
        indent: int |None = None,
    ):
        self.datetime_format = datetime_format
        self.ensure_ascii = ensure_ascii
        self.allow_nan = allow_nan
        self.sort_keys = sort_keys
        self.indent = indent
        self.buf = ...  # internal buffer

    def serialize_datetime(self, value: datetime):
        raise SerializeError("datetime is not supported by this serializer")

    # rest of the methods...

It’s perfect fine for any serializer to say “We know what you’re asking for but we do not support it”.

1 Like

I’m inclined to respond with xkcd: Standards

It’s certainly an issue with any situation where multiple competing frameworks exist. And I guess in theory, if we have something in the stdlib, it has a chance of becoming the unifying approach. But in reality, I think it’ll just end up being yet another option.

I guess I see nothing wrong with the proposal, I just doubt it stands much chance of getting into the stdlib, precisely because there are well-tested 3rd party solutions already.

4 Likes