Most config systems I've written have the same shape. A function that looks at the environment, looks at the command line, looks at a YAML file, and decides which one wins. Something like this:
def load_config():
if cli_args.timeout:
return cli_args.timeout
if os.getenv("TIMEOUT"):
return os.getenv("TIMEOUT")
return yaml_config.get("timeout", 30000)
Multiply that by every setting and you get a function that's 200 lines long, impossible to test in isolation, and impossible to change precedence on without editing every branch.
The version I've been working on separates two things the if/else version fuses together. What order sources apply in. And how they merge. Once those are separate, precedence stops being code and starts being structure.
The resolver doesn't know what "CLI" means. Here's the merge function in full:
def resolve_config(sources: Iterable[SourceSpec]) -> dict:
merged_config = {}
for spec in sources:
try:
config = spec.source.get_config()
except Exception as error:
if spec.failure_policy == FailurePolicy.REQUIRED:
raise
logger.warning(
"Optional configuration source %s failed: %s",
spec.source.__class__.__name__,
error,
)
continue
merged_config = {**merged_config, **config}
return merged_config
Read that and notice what it doesn't do. It doesn't check whether a source is a CLI source, an env source, or a YAML source. It doesn't sort anything. It doesn't decide that one kind of source should win over another kind. It takes a list of sources, merges them left to right, and returns the result.
The last source in the list wins. That's the entire precedence rule.
That's not a limitation. It's the design. The moment the resolver knows what "CLI" means, it can start making decisions it shouldn't. It can start reordering. It can start special-casing. And once it does that, you can't test precedence without also testing the resolver's opinions, and you can't change precedence without reading the resolver's code.
Precedence is a list. That list comes from a builder:
def build_standard_pipeline(file_path: str, env: Environment, cli_args=None) -> list[SourceSpec]:
yaml_source = YAMLSource(file_path, env)
env_source = EnvSource()
cli_source = CLISource(args=cli_args)
return [
SourceSpec(source=yaml_source, failure_policy=FailurePolicy.REQUIRED),
SourceSpec(source=env_source, failure_policy=FailurePolicy.OPTIONAL),
SourceSpec(source=cli_source, failure_policy=FailurePolicy.OPTIONAL),
]
YAML first. Env second. CLI last. Since the resolver merges left-to-right and later sources override earlier ones, CLI wins over env wins over YAML. That's the whole precedence system, and it's expressed as three lines of a list.
If you want to change precedence, say you decide env should beat CLI, you change the order of that list. Nothing else changes. No if branches to hunt down. No settings that read precedence from a config file. Just the order of the list.
The tests are where the pattern earns its keep, because they lock in behaviors, not implementation details.
First test. Later source wins.
def test_1_later_source_wins():
source_a = FakeConfigSource({"browser": "chromium"})
source_b = FakeConfigSource({"browser": "firefox"})
source_c = FakeConfigSource({"browser": "webkit"})
specs = [
SourceSpec(source=source_a, failure_policy=FailurePolicy.REQUIRED),
SourceSpec(source=source_b, failure_policy=FailurePolicy.REQUIRED),
SourceSpec(source=source_c, failure_policy=FailurePolicy.REQUIRED)
]
result = resolve_config(specs)
assert result["browser"] == "webkit"
That's the base contract. Merge is left-to-right, last write wins. It's obvious in isolation and it's the thing every other behavior depends on.
Second test. Reordering changes result.
def test_2_reordering_changes_result():
source_a = FakeConfigSource({"browser": "chromium"})
source_b = FakeConfigSource({"browser": "firefox"})
source_c = FakeConfigSource({"browser": "webkit"})
specs = [
SourceSpec(source=source_c, failure_policy=FailurePolicy.REQUIRED),
SourceSpec(source=source_a, failure_policy=FailurePolicy.REQUIRED),
SourceSpec(source=source_b, failure_policy=FailurePolicy.REQUIRED)
]
result = resolve_config(specs)
assert result["browser"] == "firefox"
This one looks trivial and isn't. It proves the resolver has no hidden opinion about which source should win. If someone ever helpfully adds logic to the resolver that reorders sources, say putting CLI sources last regardless of where they appear, this test fails. That's the category of mistake it prevents. Someone trying to make precedence smarter inside the merge.
Third test. The resolver doesn't sort.
def test_3_resolver_does_not_sort():
# Simulate a logically reversed order: CLI-like -> YAML-like -> ENV-like
# The resolver should NOT know these names mean anything.
cli_like = FakeConfigSource({"browser": "webkit"})
yaml_like = FakeConfigSource({"browser": "chromium"})
env_like = FakeConfigSource({"browser": "firefox"})
specs = [
SourceSpec(source=cli_like, failure_policy=FailurePolicy.REQUIRED),
SourceSpec(source=yaml_like, failure_policy=FailurePolicy.REQUIRED),
SourceSpec(source=env_like, failure_policy=FailurePolicy.REQUIRED)
]
result = resolve_config(specs)
assert result["browser"] == "firefox"
This is the one I'd defend hardest. It uses three sources named after the standard precedence chain, CLI, YAML, ENV, but passes them in a deliberately wrong order. The resolver's job is to merge them in the order given, not to fix it.
If you ever find yourself wanting to add precedence knowledge to the resolver, this test is the one that will stop you. That's the point. Tests aren't just assertions about current behavior. They're arguments against future versions of yourself who will have worse ideas.
There's a subtle thing in the resolver that's easy to miss.
try:
config = spec.source.get_config()
except Exception as error:
if spec.failure_policy == FailurePolicy.REQUIRED:
raise
logger.warning(...)
continue
Each source carries its own failure policy. YAML is required. If the file is missing or malformed, the pipeline fails. Env and CLI are optional. If they fail to load, the pipeline continues with what it has.
This is deliberately a separate concern from precedence. A source can be last in precedence, highest priority, and optional, failing silently. A source can be first in precedence, lowest priority, and required. The two axes are independent.
def test_5_failure_policy_remains_independent(caplog):
valid_yaml = FakeConfigSource({"browser": "chromium", "timeout": 30000})
failing_env = FailingSource()
valid_cli = FakeConfigSource({"browser": "webkit"})
specs = [
SourceSpec(source=valid_yaml, failure_policy=FailurePolicy.REQUIRED),
SourceSpec(source=failing_env, failure_policy=FailurePolicy.OPTIONAL),
SourceSpec(source=valid_cli, failure_policy=FailurePolicy.REQUIRED)
]
with caplog.at_level("WARNING"):
result = resolve_config(specs)
assert result["browser"] == "webkit"
assert result["timeout"] == 30000
assert "Optional configuration source" in caplog.text
Env fails. The pipeline continues. CLI still overrides YAML. Timeout from YAML still applies because nothing else set it. Precedence and failure handling do their jobs without interacting.
Fusing these two concerns is the most common mistake I see in config systems. It looks natural. If the CLI arg is missing, fall back to env, which falls back to YAML. But it collapses two orthogonal decisions into one tangled function. Keeping them separate makes each one testable in isolation and changeable without touching the other.
One more test, because it's the one that guards the whole design:
def test_7_resolver_remains_unchanged():
sig = inspect.signature(resolve_config)
params = list(sig.parameters.values())
assert len(params) == 1
assert params[0].name == "sources"
This asserts that resolve_config takes exactly one parameter. Nothing else. No pipeline name, no environment, no flags. Just the list of sources.
It sounds pedantic, and it is, deliberately. Six months from now, someone (probably me) will want to pass an Environment into the resolver so it can adjust precedence based on the environment. This test makes that change fail loudly, which forces a conversation about whether the resolver should know anything about environments. The answer is almost always no. But without the test, the answer quietly becomes yes, one small change at a time.
What this pattern buys you.
Precedence is a list. Changing it means reordering a list, not editing logic.
The resolver does one thing. It merges dictionaries left to right. That's it. It's 20 lines and it doesn't know what a CLI is.
Failure handling is orthogonal. Each source declares whether it's required or optional. The resolver respects that declaration without conflating it with priority.
Tests lock behaviors, not implementations. Each test asserts something category-level about what the system should never do, not just what it currently does.
None of this is novel. It's the same pattern behind a dozen config libraries. But the version where precedence is structure, rather than a decision buried in a conditional, is the version I've found easiest to live with. Because the day you need to change precedence is the day you least want to be reading the resolver's code.
Top comments (0)