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))
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:
format_name— class-level string shown in__repr__and error messages (e.g."xml","consul")- A load method —
_load()forSource/FlatKeySource, or_load_file(path)forFileSource. Must returnJSONValue(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)
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 returnstring_value_loaders()fromdature.sources.retortif 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.