Source code for loom.core.config.binder
"""Strategy for constructor injection from a resolved config mapping."""
from __future__ import annotations
import inspect
import sys
from collections.abc import Mapping
from typing import TypeVar, get_type_hints
import msgspec
from loom.core.config.errors import ConfigError
T = TypeVar("T")
_SKIP_KINDS = frozenset({inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD})
[docs]
class StructBinder:
"""Strategy: constructor injection from a resolved config mapping.
Converts every annotated ``__init__`` parameter present in *raw* via
``msgspec.convert``. Supports primitives (``int``, ``str``, ``bool``),
``Literal`` constraints, and ``LoomFrozenStruct`` subclasses uniformly.
All reflection runs once per ``bind`` call at compile time — never on
the message-processing hot path.
Args:
strict: When ``True``, ``msgspec.convert`` uses strict mode
(no implicit coercion from string to int, etc.).
Example::
binder = StructBinder()
step = binder.bind(
ReadOrdersStep,
{"db": {"host": "localhost", "port": 5432}, "mode": "batch"},
)
"""
def __init__(self, strict: bool = False) -> None:
self._strict = strict
[docs]
def bind(self, target: type[T], raw: Mapping[str, object]) -> T:
"""Instantiate *target* injecting and converting values from *raw*.
For each annotated ``__init__`` parameter that appears in *raw*,
the value is converted to the declared type via ``msgspec.convert``.
Parameters absent from *raw* use their default; required parameters
absent from *raw* raise ``ConfigError``.
Args:
target: Class to instantiate.
raw: Flat mapping of parameter names to raw values.
Returns:
A fully constructed instance of *target*.
Raises:
ConfigError: If a required parameter is absent from *raw* or
a value fails type conversion.
"""
kwargs: dict[str, object] = dict(raw)
hints = _resolve_hints(target)
sig = inspect.signature(target.__init__)
for name, param in sig.parameters.items():
if name == "self" or param.kind in _SKIP_KINDS:
continue
annotation = hints.get(name)
if annotation is None:
continue
if name not in kwargs:
_check_required(target, name, param)
continue
kwargs[name] = _convert(kwargs[name], annotation, name, target, self._strict)
return target(**kwargs)
def _check_required(target: type, name: str, param: inspect.Parameter) -> None:
if param.default is inspect.Parameter.empty:
raise ConfigError(f"{target.__name__} requires config field {name!r}")
def _convert(
value: object,
annotation: object,
name: str,
target: type,
strict: bool,
) -> object:
try:
return msgspec.convert(value, annotation, strict=strict)
except msgspec.ValidationError as exc:
raise ConfigError(f"{target.__name__}.{name}: {exc}") from exc
def _resolve_hints(target: type) -> dict[str, object]:
module = sys.modules.get(target.__module__)
globalns = vars(module) if module is not None else {}
return get_type_hints(target.__init__, globalns=globalns) # type: ignore[misc]