Custom Types

Crous provides a registry system for custom type serialization. You can register serializers for any Python class and decoders to reconstruct them. Tags are auto-assigned starting from 100.

Registering a Serializer

Use register_serializer(type, serializer_fn, tag=None) to register a serializer function for a Python type. The serializer receives the object and must return a serializable value (dict, list, string, etc.).

register_serializer.py
import crous
from datetime import datetime

# Define serializer function
def serialize_datetime(dt):
    return {
        "year": dt.year,
        "month": dt.month,
        "day": dt.day,
        "hour": dt.hour,
        "minute": dt.minute,
        "second": dt.second,
    }

# Register it
crous.register_serializer(datetime, serialize_datetime)

# Now datetime objects serialize automatically!
data = {"created": datetime(2024, 1, 15, 10, 30, 0)}
binary = crous.dumps(data)

Registering a Decoder

Use register_decoder(tag, decoder_fn) to register a decoder that reconstructs the custom type from the serialized data.

register_decoder.py
import crous
from datetime import datetime

# Define decoder function
def decode_datetime(data):
    return datetime(
        data["year"], data["month"], data["day"],
        data["hour"], data["minute"], data["second"]
    )

# Register decoder for the same tag
# Tags are auto-assigned starting at 100
crous.register_decoder(100, decode_datetime)

# Full round-trip!
data = {"created": datetime(2024, 1, 15, 10, 30, 0)}
binary = crous.dumps(data)
result = crous.loads(binary)

assert isinstance(result["created"], datetime)  # ✓
assert result["created"] == data["created"]      # ✓

Tag Auto-Assignment

Tags are auto-assigned starting from 100. The first call to register_serializergets tag 100, the second gets 101, and so on. You can also specify a tag explicitly.

Complete Example: Multiple Custom Types

multiple_types.py
import crous
from datetime import datetime, date
from decimal import Decimal
from uuid import UUID
from pathlib import Path

# --- Serializers ---
crous.register_serializer(datetime, lambda dt: dt.isoformat())
crous.register_serializer(date, lambda d: d.isoformat())
crous.register_serializer(Decimal, lambda d: str(d))
crous.register_serializer(UUID, lambda u: str(u))
crous.register_serializer(Path, lambda p: str(p))

# --- Decoders ---
crous.register_decoder(100, lambda s: datetime.fromisoformat(s))
crous.register_decoder(101, lambda s: date.fromisoformat(s))
crous.register_decoder(102, lambda s: Decimal(s))
crous.register_decoder(103, lambda s: UUID(s))
crous.register_decoder(104, lambda s: Path(s))

# Use it!
data = {
    "timestamp": datetime.now(),
    "birthday": date(1990, 5, 15),
    "price": Decimal("29.99"),
    "id": UUID("12345678-1234-5678-1234-567812345678"),
    "config": Path("/etc/config.json"),
}

binary = crous.dumps(data)
result = crous.loads(binary)

# All types preserved!
assert isinstance(result["timestamp"], datetime)
assert isinstance(result["birthday"], date)
assert isinstance(result["price"], Decimal)
assert isinstance(result["id"], UUID)
assert isinstance(result["config"], Path)

MRO-Aware Lookup

The serializer registry uses Python's Method Resolution Order (MRO) for type lookup. If no exact serializer is registered for a type, Crous walks the MRO chain to find the closest registered parent.

mro_example.py
import crous

class Animal:
    def __init__(self, name):
        self.name = name

class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)
        self.breed = breed

# Register serializer for the base class
crous.register_serializer(Animal, lambda a: {"name": a.name})

# Dog inherits the Animal serializer via MRO!
dog = Dog("Rex", "Labrador")
binary = crous.dumps(dog)  # Uses Animal's serializer

Unregistering

Remove serializers and decoders when they're no longer needed:

# Remove serializer
crous.unregister_serializer(datetime)

# Remove decoder
crous.unregister_decoder(100)

Thread Safety

The custom serializer/decoder registry is protected by a thread lock. Multiple threads can safely:

  • Register/unregister serializers concurrently
  • Call dumps/loads concurrently
  • Mix registration and serialization across threads

Thread Safe

All registry operations acquire a PyThread_type_lock before accessing the global serializer/decoder dictionaries. The core encode/decode functions are stateless and can run fully in parallel.

How It Works Internally

When encoding, Crous follows this flow for each object:

  1. Check if the type is a built-in type (None, bool, int, float, str, bytes, list, tuple, dict, set, frozenset)
  2. If not, check the custom serializer registry (with MRO fallback)
  3. If found, call the serializer, convert the result to a crous_value, wrap it in a TAGGED value with the assigned tag
  4. If not found, call the default function (if provided)
  5. If no default, raise CrousEncodeError

When decoding, for each TAGGED value:

  1. Tag 90 → reconstruct as set
  2. Tag 91 → reconstruct as frozenset
  3. Other tags → look up in custom decoder registry
  4. If decoder found, call it with the inner value
  5. If not found, return the inner value as-is