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.
- Record the precedence winner for each colliding key.
- Record the coerced Python type of every stored value.
- Record missing-file behavior versus empty-file behavior.
- 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
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()
Run the suite before any rename or extract.
python -m unittest test_settings_char.py -v
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
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)