Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

logo

serdechko

License: MIT Python 3.12+ Coverage 100%

A lightweight, type-safe JSON (de)serializer for NamedTuple-based models.

serdechko is a single module that maps JSON documents onto plain typing.NamedTuple classes and back. There is no schema DSL, no metaclass, and no base class to inherit — your models are ordinary immutable named tuples, and their type annotations are the schema. Deserialization failures come back as rusty Result values instead of exceptions, so a malformed payload is a value you handle, not a surprise you catch.

Why serdechko?

  • Your models stay plain. A NamedTuple with type hints is a complete model definition. Nothing to subclass, nothing to decorate, nothing to register.
  • Errors are values. deserialize returns Result[T, Exception], so failure paths are visible in the signature and compose with map, and_then, and friends.
  • Batteries for real payloads. datetime, UUID, Enum, nested models, lists of models, and Optional fields are handled out of the box.
  • Escape hatches where you need them. A value can override its own encoding with a serialize() method, and any Enum can override decoding with a deserialize() classmethod.
  • One file, one dependency. Drop serdechko.py into any project running Python 3.12+ alongside rusty.py.
  • Fully tested. The test suite covers all library features.

Installation

pip install serdechko

The distribution and the module share a name, so it is imported as it is installed:

from serdechko import deserialize, serialize

Its only runtime dependency is rustypie, imported as rusty, which provides the Result type.

Alternatively, since the entire library is a single file, you can simply copy serdechko.py into your project.

Table of Contents

Quick Start

from datetime import datetime
from typing import NamedTuple, Optional
from uuid import UUID

from serdechko import deserialize, serialize


class User(NamedTuple):
    id: UUID
    name: str
    created: datetime
    email: Optional[str] = None


payload = {
    "id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
    "name": "Ferris",
    "created": "2026-01-01T12:00:00+00:00",
}

user = deserialize(User, payload).unwrap()
assert user.id == UUID("3f2504e0-4f89-11d3-9a0c-0305e82c3301")
assert user.created == datetime.fromisoformat("2026-01-01T12:00:00+00:00")
assert user.email is None

assert serialize(user) == {
    "id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
    "name": "Ferris",
    "created": "2026-01-01T12:00:00+00:00",
    "email": None,
}

Supported Types

Annotation Serialized as Deserialized from
int / float native number anything int() / float() accepts
str native string passed through unchanged
bool native boolean native JSON boolean only — anything else is an Err
datetime value.isoformat() datetime.fromisoformat(value)
UUID str(value) UUID(value)
Enum value.serialize() member name, then member value, or value.deserialize()
nested NamedTuple nested JSON object nested JSON object
list[NamedTuple] list of JSON objects list of JSON objects
Optional[T] / T | None null or the encoded T null or a T payload
Union[A, B] whatever the value is each arm is tried until one succeeds
anything else passed through unchanged passed through unchanged

Types not listed above are passed through untouched in both directions. That keeps the library out of your way for values that are already JSON-native, but it also means a str field is not validated to be a string — only fields with a conversion listed above are checked.

Models must be typing.NamedTuple, because the annotations are the schema. An unannotated collections.namedtuple still serializes — that reads runtime values — but it cannot be deserialized, since there are no annotations to drive the conversion, and deserialize returns an Err saying so. Annotate the model to make it work in both directions.

bool is deliberately strict: only a native JSON boolean is accepted. Truthy strings and numbers are rejected rather than coerced, so "true", "yes", and 1 each produce an Err instead of silently becoming True.

Both union spellings are equivalent — Optional[T], Union[T, None], and the PEP 604 T | None are handled identically on every supported Python version.

Serialization

serialize(model, skip_nones=False) -> JSONObject walks a NamedTuple instance and returns a plain dict ready for json.dumps.

import json
from datetime import datetime
from enum import Enum
from typing import NamedTuple, Optional
from uuid import UUID

from serdechko import serialize, serialize_enum


class Status(Enum):
    OPEN = "open"
    CLOSED = "closed"

    def serialize(self) -> str:
        return serialize_enum(self)


class Tag(NamedTuple):
    name: str


class Post(NamedTuple):
    id: UUID
    title: str
    created: datetime
    status: Status
    author: User
    tags: list[Tag]
    summary: Optional[str] = None


post = Post(
    id=UUID("7c9e6679-7425-40de-944b-e07fc1f90ae7"),
    title="Hello",
    created=datetime.fromisoformat("2026-01-01T12:00:00+00:00"),
    status=Status.OPEN,
    author=user,
    tags=[Tag("python"), Tag("serde")],
)

assert serialize(post) == {
    "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
    "title": "Hello",
    "created": "2026-01-01T12:00:00+00:00",
    "status": "open",
    "author": {
        "id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
        "name": "Ferris",
        "created": "2026-01-01T12:00:00+00:00",
        "email": None,
    },
    "tags": [{"name": "python"}, {"name": "serde"}],
    "summary": None,
}

# the result is a plain dict, so it goes straight into json.dumps
body = json.dumps(serialize(post))

Nested models, lists of models, enums, datetime, and UUID are all encoded in one pass — there is nothing to register and no encoder to pass to json.dumps.

A field value that implements a serialize() method controls its own encoding. This is the extension point for enums and for your own types:

class Money:
    def __init__(self, amount: int, currency: str) -> None:
        self.amount = amount
        self.currency = currency

    def serialize(self) -> str:
        return f"{self.amount} {self.currency}"


class Invoice(NamedTuple):
    total: Money


assert serialize(Invoice(total=Money(10, "EUR"))) == {"total": "10 EUR"}

Field values are dispatched in this order: lists are mapped element-wise, NamedTuple values are recursed into, and everything else goes through serialize() / datetime / UUID handling. A NamedTuple is therefore always expanded into a nested object — defining serialize() on one will not override that.

Skipping None values

Pass skip_nones=True to omit fields whose serialized value is None:

assert serialize(user, skip_nones=True) == {
    "id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
    "name": "Ferris",
    "created": "2026-01-01T12:00:00+00:00",
}

To make that the default for a model, set the SKIP_SERIALIZING_IF_NONE class attribute. It applies to the model and everything nested inside it:

class Account(NamedTuple):
    SKIP_SERIALIZING_IF_NONE = True

    id: UUID
    name: str
    email: Optional[str] = None


assert serialize(Account(id=user.id, name="Ferris")) == {
    "id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
    "name": "Ferris",
}

The explicit argument wins: skip_nones=True skips None fields even when the class attribute is absent or False. Note that the reverse is not available — once a model sets the attribute to True, passing skip_nones=False will not force None fields back into the output.

Deserialization

deserialize(cls, data) -> Result[T, Exception] builds a model from a JSON object, converting each value according to the field's annotation:

import json

from serdechko import deserialize


body = """
{
  "id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
  "title": "Hello",
  "created": "2026-01-01T12:00:00+00:00",
  "status": "open",
  "author": {
    "id": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
    "name": "Ferris",
    "created": "2026-01-01T12:00:00+00:00"
  },
  "tags": [{"name": "python"}, {"name": "serde"}]
}
"""

post = deserialize(Post, json.loads(body)).unwrap()

assert post.id == UUID("7c9e6679-7425-40de-944b-e07fc1f90ae7")
assert post.created == datetime.fromisoformat("2026-01-01T12:00:00+00:00")
assert post.status is Status.OPEN          # decoded by member value
assert post.author.name == "Ferris"        # nested model
assert post.tags == [Tag("python"), Tag("serde")]  # list of models
assert post.summary is None                # key absent, default applied

It never raises for modeled failures — inspect the Result instead:

from rusty import Err, Ok

from serdechko import deserialize


match deserialize(Post, json.loads(body)):
    case Ok(post):
        print(post.title)
    case Err(error):
        print(f"bad payload: {error}")

Because it returns a Result, decoding composes with the rest of the rusty API:

name = (
    deserialize(User, payload)
    .map(lambda user: user.name.upper())
    .unwrap_or("UNKNOWN")
)

Use .unwrap() at a boundary where you would rather have an exception — it re-raises whichever exception the Err carries, which is a SerdeError for structural problems and the conversion's own exception otherwise. See Error Handling.

Serializing and deserializing are inverses, so a model survives a full round trip:

assert deserialize(Post, serialize(post)).unwrap() == post

Enums

Give an Enum a serialize() method so it knows how to encode itself. serialize_enum provides the common lowercase-value behaviour:

from enum import Enum

from serdechko import serialize_enum


class Status(Enum):
    OPEN = "open"
    CLOSED = "closed"

    def serialize(self) -> str:
        return serialize_enum(self)


assert Status.OPEN.serialize() == "open"

Decoding needs no setup. deserialize_enum matches a string against the member name first and the member value second, both case-insensitively:

from rusty import Ok

from serdechko import deserialize_enum


assert deserialize_enum(Status, "OPEN") == Ok(Status.OPEN)   # by name
assert deserialize_enum(Status, "closed") == Ok(Status.CLOSED)  # by value
assert deserialize_enum(Status, "unknown").is_err()

An enum that needs different rules can define its own deserialize() as a classmethod or staticmethod; serdechko calls it instead, converting any exception it raises into an Err:

class LenientStatus(Enum):
    OPEN = "open"
    CLOSED = "closed"

    @classmethod
    def deserialize(cls, value: str) -> "LenientStatus":
        if value.lower() in ("open", "reopened", "new"):
            return cls.OPEN
        return cls.CLOSED


class Ticket(NamedTuple):
    status: LenientStatus


assert deserialize(Ticket, {"status": "reopened"}).unwrap().status is (
    LenientStatus.OPEN
)

Nested Models and Lists

Nested NamedTuple fields and list[NamedTuple] fields recurse automatically, in both directions and to any depth — as Post.author and Post.tags above already showed. Nesting composes without limit:

class Leaf(NamedTuple):
    value: int


class Branch(NamedTuple):
    leaves: list[Leaf]


class Tree(NamedTuple):
    branches: list[Branch]


tree = deserialize(
    Tree, {"branches": [{"leaves": [{"value": 1}, {"value": 2}]}]}
).unwrap()

assert tree.branches[0].leaves[1].value == 2
assert serialize(tree) == {"branches": [{"leaves": [{"value": 1}, {"value": 2}]}]}

An error anywhere in the tree short-circuits the whole decode and is returned as-is, so a bad value five levels down surfaces at the top-level call.

A field annotated as a list must receive a JSON array of objects — anything else is an Err. Optional[list[NamedTuple]] additionally accepts null, exactly like any other Optional[T].

Optional Fields and Defaults

A field is optional to the payload when it declares a default value, and optional in value when its annotation includes None. The two are independent:

class Config(NamedTuple):
    host: str                      # key required, value must be present
    tls: Optional[bool]            # key required, but value may be null
    port: int = 8080               # key may be omitted, falls back to 8080
    proxy: Optional[str] = None     # key may be omitted, value may be null

Note that Python itself requires every field without a default to come before the ones that have them, so tls sits above port here.

Omitting a key with no default is an error:

assert deserialize(Config, {"port": 80}).is_err()  # missing 'host' and 'tls'

Passing null for a field whose annotation does not admit None is rejected by the types that have a real conversion — int, float, datetime, UUID, and Enum. Pass-through types such as str accept it, per the caveat in Supported Types.

Unions of several types

A union is resolved by trying each arm in the order written and keeping the first that succeeds, so put the most specific type first:

from typing import Union


class Row(NamedTuple):
    value: Union[int, str]


assert deserialize(Row, {"value": "42"}).unwrap().value == 42      # int wins
assert deserialize(Row, {"value": "abc"}).unwrap().value == "abc"  # falls back

If every arm fails, the last arm's error is returned. Keep in mind that pass-through types such as str accept any value, so an arm of that kind placed first will always match.

Error Handling

Structural problems — a missing field, a value of the wrong shape, a payload that isn't an object — are reported as Err(SerdeError(...)) with a message naming the offending field:

from serdechko import SerdeError, deserialize


result = deserialize(User, {"name": "Ferris"})
assert result.is_err()

try:
    result.unwrap()
except SerdeError as error:
    print(error)  # Missing required field: 'id'

Value conversions keep the exception the conversion itself raised, so a bad int or datetime comes back as Err(ValueError(...)) rather than being flattened into a SerdeError:

class Port(NamedTuple):
    number: int


error = deserialize(Port, {"number": "http"}).err().unwrap()
assert isinstance(error, ValueError)

The same applies to a custom deserialize() on an enum: whatever it raises is wrapped in Err as-is, keeping its original type. Catch Exception if you want to handle every failure uniformly.

deserialize reports the first problem it encounters and stops.

deserialize never raises. A non-object payload, a target that isn't a NamedTuple, and a model whose annotations cannot be resolved all come back as Err:

assert deserialize(User, ["not", "an", "object"]).is_err()
assert deserialize(int, {}).is_err()

That last case is easy to hit by accident: deserialize resolves annotations with typing.get_type_hints, which looks names up in the module where the model was defined. A model declared inside a function therefore cannot resolve a reference to itself, and you get an Err naming the class rather than a NameError:

def build_model():
    class Node(NamedTuple):
        value: int
        nxt: Optional["Node"] = None   # 'Node' is not visible at module level

    return Node


error = deserialize(build_model(), {"value": 1}).err().unwrap()
assert "Cannot resolve type hints for 'Node'" in str(error)

Define recursive models at module level to avoid it.

serialize has no Result to return, so it raises SerdeError when handed something that isn't a NamedTuple instance:

import pytest

with pytest.raises(SerdeError):
    serialize({"already": "a dict"})

Quick Reference

from serdechko import (
    JSONObject,
    JSONResponse,
    JSONValue,
    SKIP_SERIALIZING_IF_NONE,
    SerdeError,
    deserialize,
    deserialize_enum,
    serialize,
    serialize_enum,
)
Name Description
serialize(model, skip_nones=False) Encode a NamedTuple into a JSON object; raises SerdeError on anything else.
deserialize(cls, data) Decode a JSON object into Result[cls, Exception]; never raises.
serialize_enum(member) Encode an Enum member as its lowercased value.
deserialize_enum(cls, value) Decode a string into Result[Enum, Exception].
SerdeError Error raised or carried for structural failures; see Error Handling.
SKIP_SERIALIZING_IF_NONE Name of the opt-in class attribute for skipping None.
JSONValue / JSONObject / JSONResponse Type aliases for JSON-shaped data.

Testing

The project uses pytest. Install the development dependencies and run the test suite from the repository root:

python -m pip install -e ".[dev]"
python -m pytest

To measure coverage, add --cov. Branch coverage and the failure threshold are configured in pyproject.toml, so no extra flags are needed:

python -m pytest --cov

CI measures coverage on every supported Python version and fails the build if it falls below 90%.

License

MIT License. See LICENSE.

About

A lightweight, type-safe JSON (de)serializer for NamedTuple-based models.

Resources

Stars

9 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages