Skip to content

Custom Types & Loaders#

Custom Types#

Use type_loaders to teach dature how to parse custom types from strings.

Pass type_loaders as a dict[type, Callable] mapping types to conversion functions:

"""Custom type loader — parse 'r,g,b' strings into an Rgb dataclass."""

from dataclasses import dataclass
from pathlib import Path

import dature

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass(frozen=True, slots=True)
class Rgb:
    r: int
    g: int
    b: int


def rgb_from_string(value: str) -> Rgb:
    parts = value.split(",")
    return Rgb(r=int(parts[0]), g=int(parts[1]), b=int(parts[2]))


@dataclass
class AppConfig:
    name: str
    color: Rgb


config = dature.load(
    dature.Yaml12Source(
        file=SOURCES_DIR / "custom_type_common.yaml",
        type_loaders={Rgb: rgb_from_string},
    ),
    schema=AppConfig,
)

assert config == AppConfig(name="my-app", color=Rgb(r=255, g=128, b=0))
name: my-app
color: "255,128,0"

Per-source vs Global#

type_loaders can be set per-source in Source, in dature.load() for merge mode, or globally via configure():

"""Custom type loader — parse 'r,g,b' strings into an Rgb dataclass."""

from dataclasses import dataclass
from pathlib import Path

import dature

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass(frozen=True, slots=True)
class Rgb:
    r: int
    g: int
    b: int


def rgb_from_string(value: str) -> Rgb:
    parts = value.split(",")
    return Rgb(r=int(parts[0]), g=int(parts[1]), b=int(parts[2]))


@dataclass
class AppConfig:
    name: str
    color: Rgb


config = dature.load(
    dature.Yaml12Source(
        file=SOURCES_DIR / "custom_type_common.yaml",
        type_loaders={Rgb: rgb_from_string},
    ),
    schema=AppConfig,
)

assert config == AppConfig(name="my-app", color=Rgb(r=255, g=128, b=0))
"""Per-merge type_loaders — set on load() for multi-source loads."""

from dataclasses import dataclass
from pathlib import Path

import dature

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass(frozen=True, slots=True)
class Rgb:
    r: int
    g: int
    b: int


def rgb_from_string(value: str) -> Rgb:
    parts = value.split(",")
    return Rgb(r=int(parts[0]), g=int(parts[1]), b=int(parts[2]))


@dataclass
class AppConfig:
    name: str
    color: Rgb


config = dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "custom_type_common.yaml"),
    dature.Yaml12Source(file=SOURCES_DIR / "custom_type_merge_override.yaml"),
    schema=AppConfig,
    type_loaders={Rgb: rgb_from_string},
)

assert config == AppConfig(name="my-app", color=Rgb(r=100, g=200, b=50))
"""Global type_loaders via dature.configure().

Register custom type parsers once for all load() calls.
"""

from dataclasses import dataclass
from pathlib import Path

import dature

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass(frozen=True, slots=True)
class Rgb:
    r: int
    g: int
    b: int


def rgb_from_string(value: str) -> Rgb:
    parts = value.split(",")
    return Rgb(r=int(parts[0]), g=int(parts[1]), b=int(parts[2]))


@dataclass
class AppConfig:
    name: str
    color: Rgb


# Register Rgb parser globally — no need to pass type_loaders
# to every load() call
dature.configure(type_loaders={Rgb: rgb_from_string})

config = dature.load(
    dature.Yaml12Source(file=SOURCES_DIR / "custom_type_common.yaml"),
    schema=AppConfig,
)
assert config == AppConfig(name="my-app", color=Rgb(r=255, g=128, b=0))

When both per-source and global type_loaders are set, they merge — per-source loaders take priority.

Custom Source Classes#

For formats that dature doesn't support out of the box, you can create your own source by subclassing one of the base classes from dature.sources.base:

Choosing a base class#

Base class Use when You implement You get for free
Source Non-file data (API, database, custom protocol) format_name, _load() -> JSONValue Prefix filtering, env var expansion, type coercion, validation, merge support
FileSource File-based format (XML, CSV, HCL, …) format_name, _load_file(path: FileOrStream) -> JSONValue Everything from Source + file parameter, stream support, file_display(), file_path_for_errors(), __repr__
FlatKeySource Flat key=value data (custom env store, Consul KV, …) format_name, _load() -> JSONValue (flat dict[str, str]) Everything from Source + nested_sep nesting, nested_resolve, automatic string→type parsing (int, bool, date, …)

All base classes are in dature.sources.base:

from dature.sources.base import FileSource, FlatKeySource, Source

__all__ = ["FileSource", "FlatKeySource", "Source"]

Minimal interface#

Every custom source needs:

  1. format_name — class-level string shown in __repr__ and error messages (e.g. "xml", "consul")
  2. A load method_load() for Source/FlatKeySource, or _load_file(path) for FileSource. Must return JSONValue (a nested dict).

Optional overrides#

Method Default Override when
additional_loaders() [] (FileSource) or string-value loaders (FlatKeySource) Your format stores all values as strings and needs extra type parsers (e.g. bool, float).
_build_line_index(content) None (no diagnostics) You want errors to show exact line numbers from your source. Return a dict[tuple[str, ...], LineRange] mapping dotted key paths to line ranges. See sources/yaml_.py as reference.
file_display() None Your source has a meaningful display path (shown in logs and errors).
file_path_for_errors() None Your source points to a file on disk (used in error messages).
resolve_location(...) Uses _build_line_index + caret computation Low-level escape hatch — override only when _build_line_index is not enough (e.g. env var name in error messages).
location_label inherited Change the label in error messages (e.g. "FILE", "ENV", "API").

Example: FileSource subclass#

The most common case — reading a file format:

"""Custom source — subclass Source to read XML files."""

import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path

import dature
from dature.loaders import Provider, bool_loader, float_from_string, loader
from dature.sources.base import FileSource
from dature.types import FileOrStream, JSONValue

SOURCES_DIR = Path(__file__).parent / "sources"


@dataclass(kw_only=True, repr=False)
class XmlSource(FileSource):
    format_name = "xml"

    def _load_file(self, path: FileOrStream) -> JSONValue:
        if not isinstance(path, Path):
            msg = "XmlSource only supports file paths"
            raise TypeError(msg)
        tree = ET.parse(path)  # noqa: S314
        root = tree.getroot()
        return {child.tag: child.text or "" for child in root}

    def additional_loaders(self) -> list[Provider]:
        return [
            loader(bool, bool_loader),
            loader(float, float_from_string),
        ]

    # Override _build_line_index(content) to add line-number diagnostics.
    # Return dict[tuple[str, ...], LineRange] mapping paths to line ranges,
    # or None to disable. See sources/yaml_.py for a reference.


@dataclass
class Config:
    host: str
    port: int
    debug: bool


config = dature.load(
    XmlSource(
        file=SOURCES_DIR / "custom_loader.xml",
    ),
    schema=Config,
)

assert config == Config(host="localhost", port=9090, debug=True)
custom_loader.xml
<config>
    <host>localhost</host>
    <port>9090</port>
    <debug>true</debug>
</config>

FileSource handles the file parameter, path expansion, and stream detection. Your _load_file() receives a Path or file-like object and returns a dict.

Example: Source subclass (non-file)#

For sources that don't read files — e.g. an API, a database, or an in-memory dict:

"""Custom source — subclass Source to load from a plain dict."""

from dataclasses import dataclass
from typing import Any, cast

import dature
from dature.sources.base import Source
from dature.types import JSONValue


@dataclass(kw_only=True, repr=False)
class DictSource(Source):
    format_name = "dict"
    data: dict[str, Any]

    def _load(self) -> JSONValue:
        return cast("JSONValue", self.data)


@dataclass
class Config:
    host: str
    port: int


config = dature.load(
    DictSource(data={"host": "localhost", "port": 8080}),
    schema=Config,
)

assert config == Config(host="localhost", port=8080)

Tips#

  • All built-in features (type coercion, validation, prefix extraction, ENV expansion, merge support) work automatically with any custom source.
  • Override additional_loaders() to return string_value_loaders() from dature.sources.retort if your format stores everything as strings (like INI or ENV).
  • Pass your custom source to dature.load() the same way as any built-in source.