cattrs structures and unstructures Python objects (attrs, dataclasses, plain dicts). pip install cattrs. Structure: import cattrs; user = cattrs.structure({"name":"Alice","age":30}, User). Unstructure: cattrs.unstructure(user) → dict. Converter: c = cattrs.Converter(). c.structure(data, T). c.unstructure(obj). GenConverter: c = cattrs.GenConverter() — auto-generates hooks for attrs and dataclasses at first use. Preconf JSON: from cattrs.preconf.json import make_converter; c = make_converter() — handles datetime→ISO, UUID→str, Enum→value automatically. Structure list: cattrs.structure([{"name":"A"},{"name":"B"}], list[User]). Optional: handled automatically — None passes through. Nested: cattrs.structure({"user":{"name":"A"},"qty":2}, Order) recurses into User. Register hook: c.register_structure_hook(Decimal, lambda v, t: Decimal(str(v))). c.register_unstructure_hook(Decimal, str). Hook factory: c.register_structure_hook_func(lambda t: issubclass(t,Enum), lambda v,t: t(v)). Override (GenConverter): from cattrs.gen import make_dict_structure_fn, override; c.register_structure_hook(User, make_dict_structure_fn(User, c, email=override(rename="emailAddress"))). override(omit=True) — skip field. override(omit_if_default=True). Unstructure override: make_dict_unstructure_fn(User, c, _cattrs_omit_if_default=True). Union structuring: from cattrs.disambiguators import disambiguate_union_via_type_tag. Tagged union: add type field to each Struct, register disambiguator → c.register_structure_hook(Union[A,B], make_converter_for_tagged_union(...)). ClassValidationError: from cattrs import ClassValidationError. c.structure(bad_data, User) raises ClassValidationError with exceptions attribute listing per-field errors. Iterate: for exc in e.exceptions: print(exc). iterable errors. cattrs.transform_error(exc) → list of "field: message" strings. Claude Code generates cattrs converters, structure/unstructure hooks, and tagged union pipelines.
CLAUDE.md for cattrs
## cattrs Stack
- Version: cattrs >= 23.2 | pip install cattrs
- Converter: cattrs.GenConverter() — auto-hooks for attrs/dataclasses
- Preconf: from cattrs.preconf.json import make_converter — datetime/UUID/Enum ready
- Structure: c.structure(dict_or_list, TargetType) — validates and constructs
- Unstructure: c.unstructure(obj) — converts to plain dict/list/primitives
- Hooks: c.register_structure_hook(T, fn) | register_unstructure_hook(T, fn)
- Override: make_dict_structure_fn(Cls, c, field=override(rename="jsonKey"))
cattrs Structuring Pipeline
# app/converters.py — cattrs converter setup and usage
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from decimal import Decimal
from enum import Enum
from typing import Optional, Union
from uuid import UUID
import cattrs
from cattrs import ClassValidationError
from cattrs.gen import make_dict_structure_fn, make_dict_unstructure_fn, override
from cattrs.preconf.json import make_converter
# ─────────────────────────────────────────────────────────────────────────────
# Domain models (dataclasses — cattrs works with attrs too)
# ─────────────────────────────────────────────────────────────────────────────
class UserRole(str, Enum):
USER = "user"
MODERATOR = "moderator"
ADMIN = "admin"
class OrderStatus(str, Enum):
PENDING = "pending"
PAID = "paid"
SHIPPED = "shipped"
DELIVERED = "delivered"
CANCELLED = "cancelled"
@dataclass
class Address:
street: str
city: str
state: str
postal_code: str
country: str = "US"
@dataclass
class User:
id: UUID
email: str
first_name: str
last_name: str
role: UserRole = UserRole.USER
is_active: bool = True
address: Optional[Address] = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@dataclass
class OrderLine:
product_id: UUID
sku: str
quantity: int
unit_price: Decimal
@dataclass
class Order:
id: UUID
user_id: UUID
lines: list[OrderLine]
status: OrderStatus = OrderStatus.PENDING
notes: Optional[str] = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
@property
def total(self) -> Decimal:
return sum(line.unit_price * line.quantity for line in self.lines)
# ─────────────────────────────────────────────────────────────────────────────
# 1. Preconf JSON converter — handles datetime, UUID, Enum out of the box
# ─────────────────────────────────────────────────────────────────────────────
def build_converter() -> cattrs.Converter:
"""
make_converter() returns a Converter pre-configured for JSON round-trips:
- datetime ↔ ISO 8601 string
- UUID ↔ string
- Enum ↔ its .value
- bytes ↔ base64 string
"""
c = make_converter() # from cattrs.preconf.json
# Add Decimal support (not in preconf by default)
c.register_structure_hook(Decimal, lambda v, _: Decimal(str(v)))
c.register_unstructure_hook(Decimal, str)
# Rename camelCase JSON keys → snake_case Python fields for User
c.register_structure_hook(
User,
make_dict_structure_fn(
User,
c,
first_name=override(rename="firstName"),
last_name=override(rename="lastName"),
),
)
c.register_unstructure_hook(
User,
make_dict_unstructure_fn(
User,
c,
first_name=override(rename="firstName"),
last_name=override(rename="lastName"),
),
)
return c
CONVERTER = build_converter()
# ─────────────────────────────────────────────────────────────────────────────
# 2. Structure — dict → typed object
# ─────────────────────────────────────────────────────────────────────────────
def user_from_dict(data: dict) -> User:
"""
CONVERTER.structure recurses into nested types:
- "role": "admin" → UserRole.ADMIN
- "id": "uuid-string" → UUID instance
- "created_at": "..." → datetime
- "address": {...} → Address dataclass
"""
return CONVERTER.structure(data, User)
def order_from_dict(data: dict) -> Order:
return CONVERTER.structure(data, Order)
def users_from_list(data: list[dict]) -> list[User]:
return CONVERTER.structure(data, list[User])
# ─────────────────────────────────────────────────────────────────────────────
# 3. Unstructure — typed object → plain dict
# ─────────────────────────────────────────────────────────────────────────────
def user_to_dict(user: User) -> dict:
"""
CONVERTER.unstructure converts:
- UUID → str
- datetime → ISO 8601 string
- Enum → .value
- None → None (omitted or included depending on override)
"""
return CONVERTER.unstructure(user)
def order_to_dict(order: Order) -> dict:
return CONVERTER.unstructure(order)
# ─────────────────────────────────────────────────────────────────────────────
# 4. Validation errors — ClassValidationError
# ─────────────────────────────────────────────────────────────────────────────
def safe_structure_user(data: dict) -> tuple[Optional[User], list[str]]:
"""
Returns (user, []) on success or (None, [error_strings]) on failure.
ClassValidationError.exceptions is a list of per-field errors.
cattrs.transform_error(exc) converts the tree to flat "path: message" strings.
"""
try:
return CONVERTER.structure(data, User), []
except ClassValidationError as exc:
errors = cattrs.transform_error(exc)
return None, errors
except Exception as exc:
return None, [str(exc)]
# ─────────────────────────────────────────────────────────────────────────────
# 5. Tagged union — event discriminated by "type" field
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class UserCreatedEvent:
type: str
event_id: str
user_id: str
email: str
@dataclass
class OrderPlacedEvent:
type: str
event_id: str
order_id: str
user_id: str
total: Decimal
@dataclass
class PaymentReceivedEvent:
type: str
event_id: str
order_id: str
amount: Decimal
currency: str
DomainEvent = Union[UserCreatedEvent, OrderPlacedEvent, PaymentReceivedEvent]
_EVENT_TYPE_MAP: dict[str, type] = {
"user_created": UserCreatedEvent,
"order_placed": OrderPlacedEvent,
"payment_received": PaymentReceivedEvent,
}
def _structure_event(data: dict, _: type) -> DomainEvent:
"""Manual discriminator using the 'type' field."""
event_type = data.get("type")
cls = _EVENT_TYPE_MAP.get(event_type)
if cls is None:
raise ValueError(f"Unknown event type: {event_type!r}")
return CONVERTER.structure(data, cls)
CONVERTER.register_structure_hook(DomainEvent, _structure_event) # type: ignore[arg-type]
def decode_event(data: dict) -> DomainEvent:
return CONVERTER.structure(data, DomainEvent)
# ─────────────────────────────────────────────────────────────────────────────
# 6. omit_if_default — clean API responses without None noise
# ─────────────────────────────────────────────────────────────────────────────
def build_sparse_converter() -> cattrs.Converter:
"""
Converter that omits fields equal to their defaults when unstructuring.
Useful for PATCH responses where absent = unchanged.
"""
c = make_converter()
c.register_unstructure_hook(
User,
make_dict_unstructure_fn(
User,
c,
_cattrs_omit_if_default=True,
first_name=override(rename="firstName"),
last_name=override(rename="lastName"),
),
)
return c
SPARSE_CONVERTER = build_sparse_converter()
# ─────────────────────────────────────────────────────────────────────────────
# Demo
# ─────────────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
# Structure a user from a camelCase JSON payload
raw = {
"id": str(uuid.uuid4()),
"email": "[email protected]",
"firstName": "Alice",
"lastName": "Smith",
"role": "admin",
"is_active": True,
"created_at": "2024-01-15T10:30:00+00:00",
"address": {
"street": "123 Main St",
"city": "Springfield",
"state": "IL",
"postal_code": "62701",
},
}
user = user_from_dict(raw)
print(f"Structured: {user.first_name} {user.last_name} role={user.role.value}")
print(f" UUID type: {type(user.id).__name__}")
print(f" datetime: {user.created_at.isoformat()}")
print(f" address: {user.address.city}, {user.address.state}")
# Unstructure back to dict
d = user_to_dict(user)
print(f"\nUnstructured keys: {sorted(d.keys())}")
print(f" firstName: {d['firstName']}") # renamed back to camelCase
print(f" role: {d['role']}") # enum → value string
# Validation errors
bad_raw = {"id": "not-a-uuid", "email": "alice", "firstName": 123, "lastName": None}
result, errors = safe_structure_user(bad_raw)
print(f"\nValidation failed with {len(errors)} error(s):")
for e in errors:
print(f" {e}")
# Event union
event_payload = {
"type": "order_placed",
"event_id": "evt-001",
"order_id": "ord-456",
"user_id": str(uuid.uuid4()),
"total": "99.99",
}
event = decode_event(event_payload)
print(f"\nEvent type: {type(event).__name__}")
print(f" order_id: {event.order_id}")
print(f" total: {event.total} ({type(event.total).__name__})")
# Order with nested lines
order_raw = {
"id": str(uuid.uuid4()),
"user_id": str(uuid.uuid4()),
"status": "paid",
"lines": [
{"product_id": str(uuid.uuid4()), "sku": "PROD-1001",
"quantity": 2, "unit_price": "19.99"},
{"product_id": str(uuid.uuid4()), "sku": "BOOK-2005",
"quantity": 1, "unit_price": "39.99"},
],
}
order = order_from_dict(order_raw)
print(f"\nOrder total: {order.total}")
print(f" lines: {len(order.lines)}")
print(f" status: {order.status.value}")
For the attrs.asdict alternative — attrs.asdict(user) recursively converts an attrs object to a plain dict but does not handle Enum→value, UUID→str, or datetime→ISO conversion: the result still contains raw UUID and datetime objects that fail json.dumps(), while CONVERTER.unstructure(user) applies all registered hooks in one pass so the result is immediately JSON-serialisable. For the dacite.from_dict alternative — dacite’s from_dict(User, data, Config(type_hooks={datetime: datetime.fromisoformat})) handles basic type construction but has no unstructure step so you still need manual serialization, and it calls validators but does not collect all field errors before stopping, while CONVERTER.structure(data, User) raises ClassValidationError whose .exceptions attribute reports every failing field in the tree — one call reports all problems at once. The Claude Skills 360 bundle includes cattrs skill sets covering GenConverter and preconf JSON converters, structure and unstructure round-trips, register_structure_hook and register_unstructure_hook for custom types, make_dict_structure_fn with override for field renaming and omission, _cattrs_omit_if_default for sparse responses, ClassValidationError and transform_error for structured validation feedback, union disambiguation, and attrs/dataclass interoperability. Start with the free tier to try type-safe data conversion code generation.