Skip to content

yggdrasil.data.cast.registry

registry

Type conversion registry and default converters.

This module is the engine room for yggdrasil casting.

Design goals

Small, predictable conversion engine:

  • Fast lookup for exact (from_hint, to_hint) registrations.
  • MRO-aware fallback (so subclasses work without extra registrations).
  • Optional[T] / T | None handling (None passes through when optional, otherwise defaults).
  • Container support (list / set / tuple / dict / Mapping) via recursive element casting.
  • Enum + dataclass helpers (ergonomic for schemas / config objects).
  • Namespace-triggered lazy imports to register ecosystem-specific converters (polars / pandas / pyspark) only when needed.

Key ideas

  • Converters are registered for type hints, not just raw Python types.
  • We keep two registries:
  • _registry[(from_hint, to_hint)] for concrete registrations
  • _any_registry[to_hint] for wildcard "Any -> to_hint" handlers
  • Dispatch prefers: 1) exact match 2) cheap identity 3) Any-wildcard target handler 4) MRO cross-product lookup 5) scan-based fallback (issubclass checks for "odd" keys)

Note: an earlier version of the registry auto-composed a from -> mid -> to converter by chaining two registered hops through any intermediate type. That path produced silent surprises — picked an unintended intermediate when multiple candidates matched, masked missing direct converters, and made every cast's correctness depend on the order of unrelated registrations. The composition step is gone; callers wanting a two-hop cast register the direct from -> to converter explicitly.

The public API is register_converter() + convert().

Notes on options

options is intentionally optional and can be: - None - CastOptions - pyarrow Field / DataType / Schema

CastOptions.check_arg(...) normalizes it into a CastOptions instance, and kwargs can override fields.

This module does not define CastOptions to avoid import cycles.

identity

identity(x: Any, *args, **kwargs) -> Any

Return value as-is.

register_converter

register_converter(from_hint: Any, to_hint: Any) -> Callable[[F], F]

Decorator to register a converter from from_hint -> to_hint.

This preserves the original function object (and its type signature), while registering it as a runtime Converter.

Wildcard registrations

If from_hint is typing.Any or object, the converter is stored in _any_registry[to_hint] and is eligible for any source value type.

Expected converter behavior

func(value, options) -> converted_value

where options may be None.

unwrap_optional

unwrap_optional(hint: Any) -> tuple[bool, Any]

Forward to :meth:DataType.unwrap_optional.

Kept as a module-level alias for back-compat. New code reaches for DataType.unwrap_optional directly — the canonical resolver lives there as part of the centralized Python type-hint API.

iter_mro

iter_mro(tp: Any) -> Iterable[Any]

Yield (tp, ...) including MRO if tp is class-like; else yield (tp,).

This keeps lookups deterministic and cheap.

type_matches

type_matches(actual: Any, registered: Any) -> bool

True if actual can use converter registered for registered.

This is slightly more permissive than plain == because it supports issubclass checks for class-like keys.

find_converter

find_converter(
    from_type: Any, to_hint: Any, check_namespace: bool = True
) -> Optional[Converter]

Find the best converter for (from_type -> to_hint).

Dispatch order

1) exact (_registry[(from_type, to_hint)]) 2) identity-ish (same type, or target Any/object) 3) wildcard Any->to_hint 4) namespace-triggered late imports (polars/pandas/pyspark) once 5) MRO cross-product lookup 6) scan-based fallback with issubclass checks for odd keys

Composition (auto-chaining from -> mid -> to through any registered intermediate) used to live as a step 7; it was removed because the intermediate picked depended on the order of unrelated registrations, masked missing direct converters, and produced silent surprises. Callers needing a two-hop cast register the direct converter explicitly.

Results (including None for "no path") are cached in _find_cache on the check_namespace=True path so repeated calls for the same type pair pay only a single dict lookup on subsequent invocations.

is_runtime_value

is_runtime_value(x: Any) -> bool

Forward to :meth:DataType.is_runtime_value.

convert

convert(
    value: Any,
    target_hint: type[T],
    options: Optional[Union["CastOptions", Field, DataType, Schema]] = None,
    **kwargs: Any
) -> T

Convert value to target_hint using registered converters + built-ins.

Dispatch order (cheapest first): 1) Any / object target → identity passthrough. 2) Plain-type identity: isinstance(value, target_hint) → identity. Most calls (convert(42, int), convert('x', str)) bail out here, before any function call. 3) Optional[T] unwrap for generic-alias targets. 4) NoneNone if optional, else default_scalar(target). 5) Registry lookup (exact / wildcard / namespace / MRO / scan-fallback). 6) Enum member resolution and dataclass from-mapping coercion. 7) Container generics — list / set / tuple / dict / Mapping. 8) TypeError — no path found.

Options are normalized through CastOptions.check only when the caller actually supplied one (the no-options call site, which is the overwhelmingly common one, skips the allocation and the import).

convert_tuple

convert_tuple(
    value: Any, args: tuple[Any, ...], options: "CastOptions"
) -> tuple[Any, ...]

Convert an iterable into a tuple type.

Supports
  • tuple[T, ...]
  • tuple[T1, T2, ...] (fixed-length)

Fast path: a tuple with an Any / object element hint skips per-element recursion.

convert_mapping

convert_mapping(
    value: Any, origin: Any, args: tuple[Any, ...], options: "CastOptions"
) -> Mapping[Any, Any]

Convert a mapping into dict/Mapping[K, V] with recursive casting.

Fast path: if both key and value hints are Any/object the mapping is constructed directly without per-element recursion.

convert_to_python_enum

convert_to_python_enum(
    value: Any, target: type[Enum], options: Optional["CastOptions"] = None
) -> enum.Enum

Convert to an Enum member.

Strategies (all O(1) lookups via a cached per-class map): 1) Already an instance -> return as-is. 2) str -> case-insensitive lookup against member names and stringified values. 3) Otherwise -> coerce to the underlying value type of the first member, then equality-match.

convert_to_python_dataclass

convert_to_python_dataclass(
    value: Any, target: type[T], options: Optional["CastOptions"] = None
) -> T

Convert a mapping-like object into a dataclass instance.

Rules: - If value is already an instance of target, return it. - Input must be a Mapping; keys are dataclass field names. - Fields with init=False or name starting with "_" are ignored. - For each init field: * if present in input -> cast using resolved type hints (get_type_hints) * else if dataclass default/default_factory exists -> use it * else -> default_scalar(resolved_field_type)

Why resolved_field_type matters: - With from __future__ import annotations, dataclasses.Field.type can be a string (e.g. "int"), which would break default_scalar(...) because it expects real hints/types. So for defaults we must use get_type_hints(target) (already computed as hints).

convert_to_python_iterable

convert_to_python_iterable(
    value: Any,
    origin: type,
    args: tuple[Any, ...],
    options: Optional["CastOptions"] = None,
) -> Any

Convert value into a list / set / frozenset with recursive casting.

Fast path: when the element hint is Any / object we skip the per-element convert() call and materialize the container directly from the iterator, which avoids a pure-Python loop with a function call per element.

str_to_int

str_to_int(value: str, opts: Any) -> int

Parse int from string via IntegerType. Empty string → 0.

str_to_float

str_to_float(value: str, opts: Any) -> float

Parse float from string via FloatingPointType.

Honors opts.default_value when input is the empty string — convenient for CSV/Excel ingest where missing cells round-trip as "".

str_to_bool

str_to_bool(value: str, opts: Any) -> bool

Parse bool from string via BooleanType.

Empty string is rejected (use opts.default_value to opt in to a fallback). The accepted truthy/falsy tokens are owned by BooleanType.

int_to_str

int_to_str(value: int, _: Any) -> str

Stringify int via StringType.