attrs classes + cattrs JSON dict round-trip: working versions (tested 2026-10-15)
Nested attrs classes with enums, datetimes and defaults, structured and unstructured to JSON dicts with cattrs' preconfigured json converter.
passed5 passed · 0 failed2026-10-15 15:57 UTC- Status
- passed
- Last passed
- 2026-10-15 15:57 UTC
- Tests
- 5 / 5
- Language
- Python
- Runtime
- Python 3.13.16
pinned versions3
attrs@26.1.0cattrs@26.2.1pytest@9.1.1
run it
uv venv -q .venv && uv pip install -q --python .venv/bin/python -r requirements.txt .venv/bin/python -m pytest -q
test environment
Python 3.13.16 linux 6.17.0-1022-azure x64 runner: github-actions 38764699834
The code
Exactly the files that passed. Your agent can pull them with get_recipe("attrs-cattrs-json-roundtrip") (install), or see them on GitHub.
requirements.txt
attrs==26.1.0 cattrs==26.2.1 pytest==9.1.1
models.py
from datetime import datetime
from enum import Enum
import attrs
from cattrs.preconf.json import make_converter
class Role(Enum):
ADMIN = "admin"
USER = "user"
@attrs.define(frozen=True, slots=True)
class Address:
city: str
zip: str = "00000"
@attrs.define(slots=True)
class User:
id: int
name: str
role: Role = Role.USER
created_at: datetime | None = None
address: Address | None = None
tags: list[str] = attrs.field(factory=list)
# preconf json adds ISO datetime hooks on top of the stdlib json support
converter = make_converter()
def to_json(user: User) -> str:
return converter.dumps(user)
def from_json(data: str) -> User:
return converter.loads(data, User)
tests/test_roundtrip.py
import json
from datetime import datetime, timezone
import cattrs
import pytest
from cattrs.gen import make_dict_unstructure_fn
from cattrs.preconf.json import make_converter
from models import Address, Role, User, converter, from_json, to_json
def make_user() -> User:
return User(
id=1,
name="Ada",
role=Role.ADMIN,
created_at=datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc),
address=Address("London", "N1"),
tags=["math", "code"],
)
def test_full_roundtrip_preserves_values():
user = make_user()
assert from_json(to_json(user)) == user
def test_bare_converter_output_is_not_json_serializable():
# a plain Converter keeps datetime objects, so json.dumps fails
plain = cattrs.Converter()
with pytest.raises(TypeError):
json.dumps(plain.unstructure(make_user()))
def test_enum_uses_its_value_by_default():
assert converter.unstructure(Role.ADMIN) == "admin"
assert converter.structure("admin", Role) is Role.ADMIN
def test_unknown_keys_are_ignored_unless_forbidden():
data = {"id": 1, "name": "Ada", "typo": "oops"}
assert not hasattr(converter.structure(data, User), "typo")
strict = make_converter(forbid_extra_keys=True)
with pytest.raises(cattrs.errors.ClassValidationError):
strict.structure(data, User)
def test_defaults_can_be_omitted_on_the_way_out():
lean = make_dict_unstructure_fn(User, converter, _cattrs_omit_if_default=True)
user = User(id=1, name="Ada")
assert lean(user) == {"id": 1, "name": "Ada"}
# defaults are restored when the lean payload is structured back
assert converter.structure(lean(user), User) == user
pytest.ini
[pytest] pythonpath = .
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
terminal
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
~/.cursor/mcp.json
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }.vscode/mcp.json
{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }~/.codeium/windsurf/mcp_config.json
{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }MCP settings
{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.