Metadata-Version: 2.4
Name: serdechko
Version: 0.1.0
Summary: A lightweight, type-safe JSON (de)serializer for NamedTuple-based models.
Author-email: Danil Shein <dshein@altlinux.org>
License-Expression: MIT
Project-URL: Homepage, https://github.com/dshein-alt/serde
Project-URL: Repository, https://github.com/dshein-alt/serde
Project-URL: Issues, https://github.com/dshein-alt/serde/issues
Project-URL: Changelog, https://github.com/dshein-alt/serde/releases
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: rustypie>=0.1.1
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-cov; extra == "dev"
Dynamic: license-file

# serdechko

[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
![Python 3.12+](https://img.shields.io/badge/python-3.12%2B-blue)
![Coverage 100%](https://img.shields.io/badge/coverage-100%25-brightgreen)

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`](https://github.com/dshein-alt/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

```bash
pip install serdechko
```

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

```python
from serdechko import deserialize, serialize
```

Its only runtime dependency is [`rustypie`](https://pypi.org/p/rustypie),
imported as `rusty`, which provides the `Result` type.

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

## Table of Contents

- [Quick Start](#quick-start)
- [Supported Types](#supported-types)
- [Serialization](#serialization)
- [Deserialization](#deserialization)
- [Enums](#enums)
- [Nested Models and Lists](#nested-models-and-lists)
- [Optional Fields and Defaults](#optional-fields-and-defaults)
- [Error Handling](#error-handling)
- [Quick Reference](#quick-reference)
- [Testing](#testing)
- [License](#license)

## Quick Start

```python
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`.

```python
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:

```python
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`:

```python
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:

```python
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:

```python
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:

```python
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:

```python
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](#error-handling).

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

```python
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:

```python
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:

```python
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`:

```python
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:

```python
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:

```python
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:

```python
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](#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:

```python
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:

```python
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`:

```python
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`:

```python
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`:

```python
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:

```python
import pytest

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

## Quick Reference

```python
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](#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:

```bash
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:

```bash
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](LICENSE).
