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, …) |
RemoteSource | Network/remote data (AWS Secrets Manager, Consul, Kubernetes, …) | format_name, remote_address() -> str, _fetch() -> JSONValue | Everything from Source + display_name(), file_display(), location_label = "REMOTE" |
All base classes are in dature.sources.base:
```python
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:
```python
import xml.etree.ElementTree as ET
from dataclasses import dataclass
import dature
from adaptix import Provider, loader
from dature.coercion import bool_loader, float_from_string
from dature.sources.base import FileSource
from dature.type_aliases import FileOrStream, JSONValue
@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)
```
```xml title="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:
```python
from dataclasses import dataclass
from typing import Any, cast
import dature
from dature.sources.base import Source
from dature.type_aliases import JSONValue
@dataclass(kw_only=True, repr=False)
class DictSource(Source):
format_name = "dict"
location_label = "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)
```
Protocol-based sources#
Subclassing Source is the recommended path — you get all the shared behaviour for free. But dature's loader uses structural typing: it checks isinstance(source, SourceProtocol), not issubclass(MySource, Source). Any @dataclass that implements the full SourceProtocol interface is accepted, without inheriting from Source.
SourceProtocol (in dature.sources.protocol) is the complete interface the loader requires — class-level attributes (format_name, location_label, config_group), instance fields (prefix, tag, when, …), and methods (load_raw(), display_name(), resolve_location(), …).
FileSourceProtocol is the narrower interface for sources that point to a file on disk. If your source implements it (exposes skip_if_broken, skip_if_missing, file_path_for_errors(), encoding_for_errors()), the loading machinery will use these for per-source skip behaviour and error enrichment automatically.
Tip
Implementing SourceProtocol from scratch is verbose — you'd replicate most of what Source already provides. Consider this path only when you have an existing class hierarchy that can't inherit from Source. For every other case, subclass Source, FileSource, or FlatKeySource as shown above.
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.baseif 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.