Custom Conversion Rules

DataCanon Records let applications describe structured values without adding new DataCanon Types. An application can define a Record convention and supply explicit rules connecting that Record to a conventional type.

A calendar date is a useful example. Python provides datetime.date, but DC need not prescribe a calendar, year range, or date arithmetic. The application can make those choices and store the necessary information in a Record.

This guide defines an illustrative "example.Date" convention. The name is application data, not a built-in DC type or a universally prescribed date format.

1. Choose the Record structure

The example stores three Integer fields in the order year, month, day:

record(
  "example.Date",
  "year": 2026,
  "month": 10,
  "day": 22
)

DC defines the Record’s structure and identity. The application defines how those fields describe a date and which dates are valid for its purposes. Other applications can choose different names, additional calendar metadata, or different representations without changing the DC type system.

2. Write the conventional-to-DCI rule

A handler receives the conventional object and a convert() function. Calling convert() applies the same registry recursively to a constituent object.

from datetime import date

from data_canon import ConversionRegistry, as_dc_instance, dc_instance_to_python
from data_canon.instance import Field, IntegerDC, RecordDC

DATE_RECORD = "example.Date"
DATE_FIELDS = ("year", "month", "day")


def date_to_record(value, convert):
    return RecordDC(DATE_RECORD, tuple(
        Field(name, convert(getattr(value, name)))
        for name in DATE_FIELDS
    ))

The handler constructs an ordinary Record DCI. Integer field values use the usual conversion rules. A conventional-to-DCI handler must return a DCI; returning a conventional dictionary or another unsupported object is an error.

3. Write and validate the reverse rule

A reverse handler receives the original Record DCI and a recursive convert() function. Validate the expected schema before constructing a conventional object:

def date_from_record(node, convert):
    if tuple(field.name for field in node.fields) != DATE_FIELDS:
        raise ValueError("example.Date requires year, month, day in that order.")
    if not all(isinstance(field.value, IntegerDC) for field in node.fields):
        raise TypeError("example.Date fields must be Integer DCIs.")
    return date(*(convert(field.value) for field in node.fields))

Python’s date constructor checks its calendar and supported date range. An invalid calendar date raises an error during this application conversion. The Record itself can still be a valid DCI: DC does not assign date semantics to its name or perform the application’s calendar validation.

The example deliberately requires exactly three fields in the stated order. An application’s own handler can validate additional calendar or provenance fields according to its documented convention.

4. Register both directions explicitly

registry = ConversionRegistry()
registry.register_type(date, date_to_record)
registry.register_record(DATE_RECORD, date_from_record)

original = date(2026, 10, 22)
instance = as_dc_instance(original, registry=registry)
assert isinstance(instance, RecordDC)
assert dc_instance_to_python(instance, registry=registry) == original

The registry belongs to the application. Registration does not change global conversion behavior or affect another registry.

Conventional input rules match the exact type. Registering date does not also register datetime.datetime, which carries additional time information. Register a separate rule for each supported conventional type. An explicit rule for an exact built-in type takes precedence over its usual conversion, but existing DCIs are always returned unchanged by as_dc_instance().

Reverse rules match the full Record name using exact String equality. There is no import, class lookup, or prefix-based dispatch derived from that name. Duplicate registrations are rejected so a rule cannot be silently replaced.

5. Convert a nested structure

Rules apply throughout supported collections, including Mapping keys and values:

source = {
    ("run", 7): {
        "recorded_on": date(2026, 10, 22),
        "samples": [date(2026, 10, 21), date(2026, 10, 22)],
    },
    date(2026, 10, 22): "completed",
}

instance = as_dc_instance(source, registry=registry)
restored = dc_instance_to_python(instance, registry=registry)
assert restored == source

A custom handler for a container-like conventional type should call its supplied convert() function for each constituent that needs conversion. Calling the public conversion function without registry=registry would instead use the default rules. Reverse convert() calls also retain the caller’s external resource source context.

Arrays keep their specialized machine elements. Custom conversion does not walk raw Array elements, Fraction integers, Record names, or other fixed metadata as though that information consisted of separate DCIs.

6. Store and recover the DCI

Conversion rules are separate from canonical encoding and persistence:

from data_canon import canonical_decode, canonical_encode

encoded = canonical_encode(instance)
decoded = canonical_decode(encoded)
assert decoded == instance
assert dc_instance_to_python(decoded, registry=registry) == source

Decoding recovers Record DCIs without executing application handlers. Another implementation can preserve, fingerprint, and inspect the same Records without knowing the application’s date convention.

The same sequence works with files:

from data_canon import read, write

write(instance, filename="dated_data.dcb")
loaded = read("dated_data.dcb")
assert loaded == instance
assert dc_instance_to_python(loaded, registry=registry) == source

Pass the registry to the conversion functions. read(), write(), renderers, and canonical codecs do not accept a registry; convert conventional objects to DCIs first and convert decoded DCIs afterward.

Convert your own class

The same mechanism works for an application-defined class. Consider a simple DataPoint whose coordinates are stored in underscore-prefixed attributes:

from fractions import Fraction

from data_canon import (
    ConversionRegistry, as_dc_instance, dc_instance_to_python,
    render_shorthand, read, write,
)
from data_canon.instance import Field, RecordDC


class DataPoint:
    def __init__(self, x, y, z):
        self._x = x
        self._y = y
        self._z = z

Choose an application Record name and stable field names. The Record need not expose the class’s attribute naming convention: x, y, and z describe the stored data, while _x, _y, and _z remain implementation details.

POINT_FIELDS = ("x", "y", "z")


def point_to_record(value, convert):
    return RecordDC("example.DataPoint", tuple(
        Field(name, convert(getattr(value, "_" + name)))
        for name in POINT_FIELDS
    ))


def point_from_record(node, convert):
    if tuple(field.name for field in node.fields) != POINT_FIELDS:
        raise ValueError("example.DataPoint requires x, y, z in that order.")
    return DataPoint(*(convert(field.value) for field in node.fields))


point_registry = ConversionRegistry()
point_registry.register_type(DataPoint, point_to_record)
point_registry.register_record("example.DataPoint", point_from_record)

The class imposes no coordinate-type restrictions, so the reverse handler only validates its field names and order. If your class requires particular coordinate types or other invariants, validate those requirements in the handler or its conventional constructor. Recursive convert() calls retain each coordinate’s usual conventional-to-DCI conversion.

Construct and inspect the DCI:

point = DataPoint(Fraction(1, 2), 0, 1.25)
point_instance = as_dc_instance(point, registry=point_registry)
print(render_shorthand(point_instance, padding=0))

Output:

record(
  "example.DataPoint",
  "x": 1/2,
  "y": 0,
  "z": 1.25
)

Write the Record DCI, read it back, and explicitly reconstruct a DataPoint:

write(point_instance, filename="point.dcb")
loaded_point = read("point.dcb")
assert isinstance(loaded_point, RecordDC)

restored_point = dc_instance_to_python(loaded_point, registry=point_registry)
assert type(restored_point) is DataPoint
assert (restored_point._x, restored_point._y, restored_point._z) == (
    Fraction(1, 2), 0, 1.25
)

Reading returns a Record DCI. The registered reverse handler calls your class’s constructor only during the subsequent conventional conversion. A Record name never causes DC to import or instantiate a class automatically.

The rules also apply inside collections:

cloud = {"points": [point, DataPoint(1, 2, 3)]}
cloud_instance = as_dc_instance(cloud, registry=point_registry)
restored_cloud = dc_instance_to_python(cloud_instance, registry=point_registry)
assert all(type(value) is DataPoint for value in restored_cloud["points"])
assert restored_cloud["points"][1]._z == 3

This example does not define DataPoint.__eq__, so the assertions inspect the restored class and attributes rather than comparing separately constructed objects using Python’s default identity-based equality. The runnable file examples/custom_datapoint.py performs the file round trip in a temporary directory.

Defaults, errors, and limits

With no registry, existing conversion rules are unchanged. An unsupported conventional object raises TypeError. An unregistered Record follows the existing generated-namedtuple conversion, which requires suitable Python class and field names. In particular, a qualified Record name such as "example.Date" needs an application handler for conventional conversion; it remains valid for DCI reading, writing, and identity operations without one.

Application handlers are ordinary Python functions, explicitly chosen by the caller. Handler exceptions propagate; the implementation does not silently fall back to another rule or repair the Record. A handler is responsible for its own schema, conversion policy, and any information it discards.

A custom convention does not make every conversion lossless. Conventional collections still apply their own equality and hashability rules, and a handler can choose a lossy conventional representation. The example date convention has a tested round trip because both directions agree on its schema.

The runnable date example is examples/custom_conversion.py; the user-defined class example is examples/custom_datapoint.py in the code repository. See the Python API for registry signatures.