ProvenPin Install

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

Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.