Merging
When ConfigLoader.load() receives multiple sources, it loads them in
order and merges each new result into the running total using a
merge strategy. This is what makes hiera-style layered configuration
(base config → environment overrides → local overrides) work.
from yaconfiglib import ConfigLoader, ConfigLoaderMergeMethod
loader = ConfigLoader(merge=ConfigLoaderMergeMethod.Deep)
config = loader.load("base.yaml", "production.yaml", "local.yaml")
Later sources take precedence. With base.yaml containing
{"server": {"host": "0.0.0.0", "port": 8080}} and production.yaml
containing {"server": {"port": 443}}, a Deep merge produces
{"server": {"host": "0.0.0.0", "port": 443}}.
Strategies
| Strategy | Dicts | Lists | Scalars |
|---|---|---|---|
Simple (default) |
shallow update (top-level keys only) | replaced wholesale | replaced |
Substitute |
merged recursively | replaced wholesale | replaced |
Deep |
merged recursively | extended (see below) | replaced |
Last |
replaced wholesale | replaced wholesale | replaced |
List |
collected into a list, one entry per source | — | — |
Hash |
collected into a dict keyed by each source's merge key | — | — |
Simple— the default. Good when configs override entire sub-sections rather than individual nested keys.Substitute— likeSimple, but nested dicts are merged recursively instead of only at the top level; lists still replace.Deep— fully recursive dict merging. Lists are extended with unique scalar/array items from the new source; passmergelists=Trueviamerge_optionsto also merge dict elements positionally within a list.Last— each new source simply replaces the previous result outright, ignoring its structure.List— instead of merging, collect every source's result into a list, one entry per source, in load order.Hash— collect every source's result into a dict, keyed by each source's merge key (seekey_factorybelow). Useful for "load a directory of files, keyed by filename" patterns.
Controlling list merges
loader = ConfigLoader(
merge=ConfigLoaderMergeMethod.Deep,
merge_options={"mergelists": True},
)
With mergelists=True, dict elements at the same position in two lists
are merged into each other (when they share at least one key) instead of
both being kept as separate entries.
Per-source merge keys
Hash merging (and any custom key_factory use) needs a key per source.
By default it's the source's filename stem, but you can override it:
loader = ConfigLoader(
merge=ConfigLoaderMergeMethod.Hash,
key_factory="%pathname.name", # a Jinja2 expression, evaluated per source
)
key_factory accepts a callable (path, value) -> str, a string
attribute name looked up on the path object, or a "%<jinja-expr>"
string evaluated with pathname and value in scope.
Overriding merge strategy per call
Every option accepted by ConfigLoader.__init__ can also be passed to an
individual load() call, overriding the instance default for just that
call:
loader = ConfigLoader(merge=ConfigLoaderMergeMethod.Simple)
# Uses Deep merging just this once.
result = loader.load("a.yaml", "b.yaml", merge=ConfigLoaderMergeMethod.Deep)
Typed merge and custom merge behavior
typed_merge merges several objects into one instance of a target type,
guided by its type hints: mappings and dataclasses/namespaces merge field by
field, sequences element by element, and scalars last-object-wins.
from yaconfiglib import typed_merge
merged = typed_merge(MyConfig, base_cfg, override_cfg)
Two per-type hooks let a class customize how it is merged.
__merge__ — take over merging for a type
Define a __merge__(cls, *objects, init=True) classmethod to fully override
how instances of that type are merged. The most common need — "this is a
fully-built object, just take the last one" — is packaged as OpaqueMerge
(a mixin) and opaque (a class decorator):
from yaconfiglib import OpaqueMerge, opaque
class Zone(OpaqueMerge, argparse.Namespace):
network: IPNetwork # a factory function, not a class
# ...or, without changing the base classes:
@opaque
class Zone(argparse.Namespace):
...
Use it when a config object's __init__ already normalized its fields, or
when a field is annotated by a factory function rather than a class:
typed_merge then returns the last object unchanged instead of introspecting
its fields (which would otherwise fail on a non-class annotation).
_parse_<field> — coerce a field as it is merged
If a source object defines a _parse_<name>(value) method, typed_merge
applies it to that field's value while collecting it. TypedNamespace
applies the same convention at construction time, so a built object is
already normalized:
from yaconfiglib import TypedNamespace
class ServerConfig(TypedNamespace):
def _parse_port(self, value):
return int(value)
ServerConfig(port="8080").port # -> 8080 (int)
Compose TypedNamespace with OpaqueMerge for a config object that both
coerces its fields at build time and is opaque to re-merging.