DEV Community

Dakota Huang
Dakota Huang

Posted on

Config Merge Lies Until You Freeze Precedence and Coercion

Do not extract a settings helper from messy loaders.
Freeze merge order and type coercion before any extract.
A cleaner API will still lie about precedence.

The failure this article targets

Many services share one mutable settings dictionary.
File lines, environment variables, and CLI flags collide.
Refactors then hide which layer actually won.

Teams extract load_settings from the global dict too early.
The function still mutates the same global dict.
Call-shape tests miss the merged values that matter.

Signals the loader is unsafe to touch

Boolean strings often flip after a cleanup extract.
false becomes True under naive bool casts.
Nested keys replace whole maps instead of merging.

Missing files and empty files also diverge quietly.
One path keeps defaults while the other clears keys.
Unknown keys may drop during a typed rewrite.

Freeze these four observables first

Capture winners before you move any function.

  1. Record the precedence winner for each colliding key.
  2. Record the coerced Python type of every stored value.
  3. Record missing-file behavior versus empty-file behavior.
  4. Record unknown-key passthrough versus silent drop.

Use the decision table below as the contract.

Collision Layers present Expected winner Type rule
port file, env, flag flag coerce flag text
port file, env env coerce env text
port file only file coerce file text
port none default keep default type
missing file no file defaults unchanged defaults
empty file empty file defaults unchanged defaults
debug=false any overlay overlay False, not True
debug=0 any overlay overlay False under this policy
unknown key overlay only keep key overlay type

Read each row as a frozen contract, not a wishlist.
The extract must not promote env over flags.

Pick one bool policy and one int policy.
Do not change policy during the first extract.

Example: a messy stdlib loader

The module below is a local runnable example.
It uses only the Python standard library.
Treat this as example code, not production design.

# settings_messy.py
from __future__ import annotations

import argparse
import os
from pathlib import Path
from typing import Any

SETTINGS: dict[str, Any] = {
    "port": 8080,
    "debug": False,
    "name": "app",
    "features": {"cache": True},
}

TRUE_SET = {"1", "true", "yes", "on"}
FALSE_SET = {"0", "false", "no", "off"}


def _coerce(raw: str) -> Any:
    text = raw.strip()
    low = text.lower()
    if low in TRUE_SET:
        return True
    if low in FALSE_SET:
        return False
    if text.isdigit():
        return int(text)
    return text


def _read_file(path: Path) -> dict[str, Any]:
    if not path.exists():
        return {}
    parsed: dict[str, Any] = {}
    for line in path.read_text(encoding="utf-8").splitlines():
        if not line.strip() or line.lstrip().startswith("#"):
            continue
        if "=" not in line:
            continue
        key, value = line.split("=", 1)
        parsed[key.strip()] = _coerce(value)
    return parsed


def _read_env(prefix: str = "APP_") -> dict[str, Any]:
    overlay: dict[str, Any] = {}
    for key, value in os.environ.items():
        if not key.startswith(prefix):
            continue
        overlay[key[len(prefix):].lower()] = _coerce(value)
    return overlay


def _read_flags(argv: list[str] | None = None) -> dict[str, Any]:
    parser = argparse.ArgumentParser(add_help=False)
    parser.add_argument("--set", action="append", default=[])
    args, _ = parser.parse_known_args(argv)
    overlay: dict[str, Any] = {}
    for item in args.set:
        if "=" not in item:
            continue
        key, value = item.split("=", 1)
        overlay[key.strip()] = _coerce(value)
    return overlay


def load_settings(
    path: str = "settings.cfg",
    argv: list[str] | None = None,
) -> dict[str, Any]:
    # Current behavior: nested dicts are replaced, not merged.
    # Current behavior: the module global is mutated in place.
    SETTINGS.update(_read_file(Path(path)))
    SETTINGS.update(_read_env())
    SETTINGS.update(_read_flags(argv))
    return SETTINGS
Enter fullscreen mode Exit fullscreen mode

Note the two bugs the extract must not fix yet.
In-place update still mutates the module global object.
Nested feature maps get replaced wholesale during overlay.

A third bug still hides inside the coerce helper.
isdigit rejects values like 08 and negatives.
Empty strings fall through as empty strings, not defaults.

Characterization tests that freeze the contract

Place characterization tests next to the messy module.
Do not import production pytest plugins yet.
Stdlib unittest keeps this characterization harness portable.

# test_settings_char.py
from __future__ import annotations

import os
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory

import settings_messy as sm


def _reset() -> None:
    sm.SETTINGS.clear()
    sm.SETTINGS.update(
        {
            "port": 8080,
            "debug": False,
            "name": "app",
            "features": {"cache": True},
        }
    )


class SettingsCharacterization(unittest.TestCase):
    def setUp(self) -> None:
        _reset()
        self._env = os.environ.copy()

    def tearDown(self) -> None:
        os.environ.clear()
        os.environ.update(self._env)
        _reset()

    def test_missing_file_keeps_defaults(self) -> None:
        missing = Path("no-such-settings.cfg")
        result = sm.load_settings(str(missing), argv=[])
        self.assertEqual(result["port"], 8080)
        self.assertIs(result["debug"], False)

    def test_file_beats_defaults(self) -> None:
        with TemporaryDirectory() as tmp:
            cfg = Path(tmp) / "settings.cfg"
            cfg.write_text("port=9090\ndebug=true\n", encoding="utf-8")
            result = sm.load_settings(str(cfg), argv=[])
        self.assertEqual(result["port"], 9090)
        self.assertIs(result["debug"], True)

    def test_env_beats_file(self) -> None:
        with TemporaryDirectory() as tmp:
            cfg = Path(tmp) / "settings.cfg"
            cfg.write_text("port=9090\n", encoding="utf-8")
            os.environ["APP_PORT"] = "7070"
            result = sm.load_settings(str(cfg), argv=[])
        self.assertEqual(result["port"], 7070)

    def test_flag_beats_env_and_file(self) -> None:
        with TemporaryDirectory() as tmp:
            cfg = Path(tmp) / "settings.cfg"
            cfg.write_text("port=9090\n", encoding="utf-8")
            os.environ["APP_PORT"] = "7070"
            result = sm.load_settings(
                str(cfg), argv=["--set", "port=6060"]
            )
        self.assertEqual(result["port"], 6060)

    def test_false_string_is_false(self) -> None:
        with TemporaryDirectory() as tmp:
            cfg = Path(tmp) / "settings.cfg"
            cfg.write_text("debug=false\n", encoding="utf-8")
            result = sm.load_settings(str(cfg), argv=[])
        self.assertIs(result["debug"], False)

    def test_empty_file_keeps_defaults(self) -> None:
        with TemporaryDirectory() as tmp:
            cfg = Path(tmp) / "settings.cfg"
            cfg.write_text("", encoding="utf-8")
            result = sm.load_settings(str(cfg), argv=[])
        self.assertEqual(result["port"], 8080)

    def test_unknown_key_is_kept(self) -> None:
        with TemporaryDirectory() as tmp:
            cfg = Path(tmp) / "settings.cfg"
            cfg.write_text("region=us-east\n", encoding="utf-8")
            result = sm.load_settings(str(cfg), argv=[])
        self.assertEqual(result["region"], "us-east")

    def test_nested_features_replaced_not_merged(self) -> None:
        # Pins current behavior. Do not improve it in this change.
        os.environ["APP_FEATURES"] = "cache"
        result = sm.load_settings("no-such.cfg", argv=[])
        self.assertEqual(result["features"], "cache")

    def test_global_is_same_object(self) -> None:
        result = sm.load_settings("no-such.cfg", argv=[])
        self.assertIs(result, sm.SETTINGS)


if __name__ == "__main__":
    unittest.main()
Enter fullscreen mode Exit fullscreen mode

Run the suite before any rename or extract.

python -m unittest test_settings_char.py -v
Enter fullscreen mode Exit fullscreen mode

Record the nine results as the baseline.
A later extract must match this baseline exactly.
That baseline includes the nested-map replacement bug.

Workflow: characterization first, then one change

Follow these six steps in strict order.
Do not skip a gate to move faster.

Step 1 — Inventory every writer

List defaults, file reads, env reads, and flag parses.
Note each SETTINGS.update and each in-place write.
Ignore style nits and count writes only.

Step 2 — Freeze argv and env in tests

Never leak the host environment into assertions.
Copy os.environ in setUp and restore it in tearDown.
Pass an explicit argv list into the loader.

Step 3 — Add the four observable tests

Cover precedence, types, missing files, and unknown keys.
Add one test that pins a known bug on purpose.
Do not fix that bug in the first change.

Step 4 — Run the suite twice

Run once to confirm the baseline is green.
Run again after a no-op save to catch flakes.
If a test talks to the real filesystem root, rewrite it.

Step 5 — Extract only the merge, not I/O

Move dict overlay into a merge_layers helper only.
Keep file, env, and flag readers in place.
Keep the global mutation on the original name.

Step 6 — Re-run, then stop

If all nine tests pass, ship that extract.
Do not also rename keys or fix nested merge.
One behavior-preserving change is the whole task.

The smallest safe extract

This extract changes structure, not observable meaning.
File, env, and flags still overlay in the same order.

def merge_layers(*layers: dict[str, Any]) -> dict[str, Any]:
    merged: dict[str, Any] = {}
    for layer in layers:
        merged.update(layer)
    return merged


def load_settings(
    path: str = "settings.cfg",
    argv: list[str] | None = None,
) -> dict[str, Any]:
    merged = merge_layers(
        _read_file(Path(path)),
        _read_env(),
        _read_flags(argv),
    )
    SETTINGS.update(merged)
    return SETTINGS
Enter fullscreen mode Exit fullscreen mode

The merge_layers helper is a pure dict fold.
The global object identity test must still pass.
Nested replacement remains pinned, not silently repaired.

Check return identity after the extract, not only values.
assertIs(result, SETTINGS) catches an accidental new dict.
Value equality alone would hide that object change.

Using a free model to draft the tests

Drafting characterization tests is slow work by hand.
A free coding model can propose the first cases.

Disclosure: This article was prepared as part of MonkeyCode's product outreach.

MonkeyCode offers free model access and a free server option.
Those two options can host the draft-and-run loop.
Paste the messy module and ask only for characterization tests.

Do not ask the model to clean the API yet.
Generated tests are proposals until they run locally.
Reject any test that needs the public internet.

Keep the human in the loop for bug pins.
The nested features case is easy to fix by accident.
The model should snapshot behavior, not improve it.

Limitations

This workflow does not prove the loader is correct.
It only proves the extract did not change current behavior.
Wrong precedence stays wrong if tests freeze it.

It also ignores concurrent mutation of SETTINGS.
Threads can still race after a clean extract.
Secret values in env files need a different design.

isdigit still rejects signed and underscored ints.
Empty strings still do not restore defaults.
Those gaps stay until a later, explicit change.

Who should not use this approach

Skip this method on a greenfield settings module.
Write an explicit schema first in that case.
Do not freeze bugs you can still delete.

Skip this method when config carries credentials.
Characterization dumps can leak secrets into logs.
Use a secret manager, not a global dict.

Skip it when layers include remote HTTP sources.
This harness never pins network timeouts or retries.
Those belong in a separate client characterization.

Close

Precedence and coercion are the real settings API.
Extract a merge fold only after those stay frozen.
Leave nested-map bugs for a second, named change.

Run the suite twice after the extract lands.
Ship only when the frozen baseline still matches.

Top comments (0)