attrs models with cattrs structuring, custom datetime hooks and renaming: working versions (tested 2026-10-15)
Define attrs classes with validators and converters, then use a cattrs Converter with custom datetime hooks and wire-name renaming, roundtrip through unstructure/structure, and inspect ClassValidationError details. Tested offline.
passed5 passed · 0 failed2026-10-15 13:27 UTC- Status
- passed
- Last passed
- 2026-10-15 13:27 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 38742087302
The code
Exactly the files that passed. Your agent can pull them with get_recipe("attrs-cattrs-structuring") (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 attrs import define, field, validators
def _non_negative(instance, attribute, value):
if value < 0:
raise ValueError(f"{attribute.name} must be >= 0, got {value}")
@define
class Job:
job_id: int = field(validator=validators.instance_of(int))
created_at: datetime
# converter runs first, then validator sees the converted value
attempts: int = field(default=0, converter=int, validator=_non_negative)
tags: list[str] = field(factory=list)
converter.py
from datetime import datetime
from cattrs import Converter
from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override
from models import Job
def make_converter() -> Converter:
c = Converter()
c.register_unstructure_hook(datetime, datetime.isoformat)
c.register_structure_hook(datetime, lambda s, _: datetime.fromisoformat(s))
# wire name "jobId" <-> attribute "job_id", applied in both directions
renames = {"job_id": override(rename="jobId")}
c.register_structure_hook(Job, make_dict_structure_fn(Job, c, **renames))
c.register_unstructure_hook(Job, make_dict_unstructure_fn(Job, c, **renames))
return c
tests/test_converter.py
import json
from datetime import UTC, datetime
import pytest
from cattrs.errors import ClassValidationError
from converter import make_converter
from models import Job
NOW = datetime(2024, 1, 2, 3, 4, 5, tzinfo=UTC)
@pytest.fixture
def conv():
return make_converter()
def test_roundtrip_renames_fields_and_datetimes(conv):
job = Job(job_id=1, created_at=NOW, attempts=2, tags=["a"])
data = conv.unstructure(job)
assert data["jobId"] == 1 and "job_id" not in data
assert data["created_at"] == NOW.isoformat()
assert conv.structure(data, Job) == job
def test_unstructured_is_json_safe(conv):
job = Job(job_id=1, created_at=NOW)
assert json.loads(json.dumps(conv.unstructure(job)))["created_at"] == NOW.isoformat()
def test_converter_runs_before_validator():
assert Job(job_id=1, created_at=NOW, attempts="3").attempts == 3
with pytest.raises(ValueError, match="attempts"):
Job(job_id=1, created_at=NOW, attempts="-1")
def test_assignment_also_converts_and_validates():
job = Job(job_id=1, created_at=NOW)
job.attempts = "7"
assert job.attempts == 7
with pytest.raises(ValueError):
job.attempts = -1
def test_class_validation_error_collects_all_bad_fields(conv):
bad = {"jobId": "x", "attempts": 0, "created_at": "nope", "tags": []}
with pytest.raises(ClassValidationError) as info:
conv.structure(bad, Job)
assert len(info.value.exceptions) == 2
assert all(isinstance(e, ValueError) for e in info.value.exceptions)
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.