A settings resolver that always falls back to what already worked
Shipped
v0.9.0 pulled five grading and projection knobs out of the code and turned them into user settings: whether a recovery walk counts toward an easy day, whether walking counts toward weekly mileage, the done/partial grade-band fractions, and how many days back to look for a best effort when projecting a race finish. Every default matches the value that used to be hardcoded, so a fresh clone behaves exactly like it did before, and each knob can now be set three ways: a per-user settings table, an environment variable, or the built-in default if neither is set. A design review before the build caught three separate ways the naive version of this would have quietly returned the wrong answer instead of an obviously wrong one.
That’s the part worth building yourself: a config resolver where bad input degrades toward the last-known-good value instead of crashing or, worse, silently doing something different than what you asked for.
Setup: three places a value can live
The pattern only needs three things: a small persistent store for per-user overrides, the environment for deployment-time config, and a hardcoded default as the floor everything falls back to. Python’s standard library covers all of it. sqlite3 ships with the interpreter, so the settings table needs no dependency beyond the language itself (Python docs: sqlite3).
Make a scratch directory with three files: store.py, config.py, grading.py. Start with the store.
# store.py
"""A tiny SQLite-backed settings store: the DB layer of the precedence chain."""
import sqlite3
from pathlib import Path
def connect(db_path: Path):
con = sqlite3.connect(db_path)
con.row_factory = sqlite3.Row
con.execute(
"CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)"
)
return con
def get_setting(db_path: Path, key: str) -> str | None:
with connect(db_path) as con:
row = con.execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
return row["value"] if row else None
def set_setting(db_path: Path, key: str, value: str) -> None:
with connect(db_path) as con:
con.execute(
"INSERT INTO settings (key, value) VALUES (?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
(key, value),
)
def all_settings(db_path: Path) -> dict[str, str]:
with connect(db_path) as con:
rows = con.execute("SELECT key, value FROM settings").fetchall()
return {r["key"]: r["value"] for r in rows}
This is the whole DB layer: get one setting, set one setting, or read all of them for a batched resolve. Nothing here decides precedence yet; it just gives the resolver a place to read from and a place to write to.
Build: coerce without ever raising on bad input
The resolver’s job is to turn three possible raw values (a DB row, an env var, a typed default) into one typed value, and to never let a malformed input propagate past this layer. Two things make that hard in practice. First, an empty string is not the same as an unset value: a cleared settings-table row comes back as "", not None, and if you only check is None you’ll try to cast that empty string instead of treating it as “nothing here, check the next layer.” Second, casting a boolean with the generic type(default)(raw) pattern is a trap, because bool("false") evaluates to True (every non-empty string is truthy), so a value meant to turn a setting off would silently turn it on instead.
# config.py
"""Blank-normalization and safe casting shared by every knob."""
_BOOL_TRUE = {"1", "true", "yes", "on"}
_BOOL_FALSE = {"0", "false", "no", "off"}
def _blank(raw) -> bool:
"""An empty- or whitespace-only value (from the DB or the environment)
means UNSET, not "set to empty"."""
return raw is not None and str(raw).strip() == ""
def as_bool(raw) -> bool:
"""Strict bool parse: only known tokens; anything else raises so the
caller falls back to the default instead of silently flipping polarity."""
tok = str(raw).strip().lower()
if tok in _BOOL_TRUE:
return True
if tok in _BOOL_FALSE:
return False
raise ValueError(f"not a recognized bool: {raw!r}")
def coerce(db_raw, env_raw, default, cast):
"""settings table > environment variable > built-in default, with blank
values at either layer falling through instead of being cast."""
raw = None if _blank(db_raw) else db_raw
if raw is None:
raw = None if _blank(env_raw) else env_raw
if raw is None:
return default
try:
return cast(raw)
except (ValueError, TypeError):
return default
coerce is the whole precedence order in five lines: blank-normalize the DB value, fall through to the env value if the DB value was blank or absent, fall through to the default if both were blank or absent, then cast and catch. Every failure mode in this function returns the default; nothing here ever raises out to the caller.
Build: batch the read, validate the pair, fail toward the default
Per-knob resolution handles most of it, but some knobs depend on each other. A “done” threshold below a “partial” threshold inverts the grade bands, so a run that should read as “done” reads as “partial” instead, or worse, “partial” becomes unreachable entirely. That kind of rule can’t live inside a single knob’s resolver; it has to run after every knob in the group has resolved, and if it fails, both fields need to revert together, not just one, or the pair stays broken in a different way.
# grading.py
"""The pure grading function plus the resolver that feeds it a validated
GradingConfig. Field defaults equal the old hardcoded constants, so calling
classify() with no cfg at all reproduces the historical behavior exactly."""
import os
from dataclasses import dataclass
import config
import store
DONE_FRACTION = 0.80
PARTIAL_FRACTION = 0.40
LOOKBACK_DAYS = 120
_LOOKBACK_MAX_DAYS = 3650
@dataclass(frozen=True)
class GradingConfig:
done_fraction: float = DONE_FRACTION
partial_fraction: float = PARTIAL_FRACTION
lookback_days: int = LOOKBACK_DAYS
count_bonus_activity: bool = True
def resolve_grading_config(db_path) -> GradingConfig:
"""One batched settings read, then per-knob resolve, then the cross-field
check. Callers resolve this once per request and pass the result down;
the grading function itself never touches the DB or the environment."""
settings = store.all_settings(db_path)
done = config.coerce(
settings.get("done_fraction"), os.environ.get("DONE_FRACTION"),
DONE_FRACTION, float,
)
partial = config.coerce(
settings.get("partial_fraction"), os.environ.get("PARTIAL_FRACTION"),
PARTIAL_FRACTION, float,
)
# Cross-field invariant: partial must stay reachable. Any violation
# reverts BOTH fields, never just one, so the pair stays coherent.
if not (0 <= partial <= done <= 1):
done, partial = DONE_FRACTION, PARTIAL_FRACTION
lookback = config.coerce(
settings.get("lookback_days"), os.environ.get("LOOKBACK_DAYS"),
LOOKBACK_DAYS, int,
)
if not (1 <= lookback <= _LOOKBACK_MAX_DAYS):
lookback = LOOKBACK_DAYS
count_bonus = config.coerce(
settings.get("count_bonus_activity"), os.environ.get("COUNT_BONUS_ACTIVITY"),
True, config.as_bool,
)
return GradingConfig(
done_fraction=done, partial_fraction=partial,
lookback_days=lookback, count_bonus_activity=count_bonus,
)
def classify(actual: float, target: float, cfg: GradingConfig = GradingConfig()) -> str:
"""done / partial / missed, against a resolved (or default) config."""
if not target:
return "done" if actual > 0 else "missed"
frac = actual / target
if frac >= cfg.done_fraction:
return "done"
if frac >= cfg.partial_fraction:
return "partial"
return "missed"
GradingConfig is a frozen dataclass whose field defaults are the same constants the code used to hardcode, so GradingConfig() with no arguments reproduces the old behavior exactly (Python docs: dataclasses). classify never reads the settings table or the environment itself; it takes a resolved config as a parameter, which is what keeps it a pure, easily tested function even though the values behind it now come from three different places. Only resolve_grading_config does I/O, and it does it once, batched, rather than once per knob.
Use it, then verify it
Set a value at either layer, then resolve and see which one won.
# verify.py
import os
from pathlib import Path
import grading
import store
db_path = Path("settings.db")
db_path.unlink(missing_ok=True)
# 1. Nothing set anywhere: the resolved config matches the hardcoded defaults.
cfg = grading.resolve_grading_config(db_path)
print("1. no overrides: ", cfg)
assert cfg == grading.GradingConfig()
# 2. An environment variable overrides the default.
os.environ["DONE_FRACTION"] = "0.9"
cfg = grading.resolve_grading_config(db_path)
print("2. env override: ", cfg)
assert cfg.done_fraction == 0.9
# 3. A value in the settings table wins over the environment variable.
store.set_setting(db_path, "done_fraction", "0.95")
cfg = grading.resolve_grading_config(db_path)
print("3. db beats env: ", cfg)
assert cfg.done_fraction == 0.95
# 4. A blank settings-table value falls through to the env var, not the default.
store.set_setting(db_path, "done_fraction", " ")
cfg = grading.resolve_grading_config(db_path)
print("4. blank db -> env: ", cfg)
assert cfg.done_fraction == 0.9
# 5. An inverted pair reverts BOTH fields to their defaults.
store.set_setting(db_path, "done_fraction", "0.3")
store.set_setting(db_path, "partial_fraction", "0.7")
cfg = grading.resolve_grading_config(db_path)
print("5. inverted pair: ", cfg)
assert cfg.done_fraction == grading.DONE_FRACTION
assert cfg.partial_fraction == grading.PARTIAL_FRACTION
# 6. A nonsense lookback clamps to the default instead of propagating.
store.set_setting(db_path, "lookback_days", "-5")
cfg = grading.resolve_grading_config(db_path)
print("6. bad lookback: ", cfg)
assert cfg.lookback_days == grading.LOOKBACK_DAYS
# 7. An unrecognized bool token falls back to the default, not to False.
store.set_setting(db_path, "count_bonus_activity", "maybe")
cfg = grading.resolve_grading_config(db_path)
print("7. bad bool token: ", cfg)
assert cfg.count_bonus_activity is True
# 8. classify() with no cfg at all reproduces the old hardcoded behavior.
verdict = grading.classify(actual=6.0, target=10.0)
print("8. classify(6.0/10): ", verdict)
assert verdict == "partial"
print("\nall checks passed")
Running it against the three files above produces:
1. no overrides: GradingConfig(done_fraction=0.8, partial_fraction=0.4, lookback_days=120, count_bonus_activity=True)
2. env override: GradingConfig(done_fraction=0.9, partial_fraction=0.4, lookback_days=120, count_bonus_activity=True)
3. db beats env: GradingConfig(done_fraction=0.95, partial_fraction=0.4, lookback_days=120, count_bonus_activity=True)
4. blank db -> env: GradingConfig(done_fraction=0.9, partial_fraction=0.4, lookback_days=120, count_bonus_activity=True)
5. inverted pair: GradingConfig(done_fraction=0.8, partial_fraction=0.4, lookback_days=120, count_bonus_activity=True)
6. bad lookback: GradingConfig(done_fraction=0.8, partial_fraction=0.4, lookback_days=120, count_bonus_activity=True)
7. bad bool token: GradingConfig(done_fraction=0.8, partial_fraction=0.4, lookback_days=120, count_bonus_activity=True)
8. classify(6.0/10): partial
all checks passed
Every failure case in that run, including the typo’d bool token in check 7, resolved to a known-good value instead of an exception. That’s the property the whole resolver is built around: config as a first-class, separately-managed concern with a clear precedence order, exactly the discipline the twelve-factor config guidance argues for, applied here with the settings table standing in for a per-deployment override on top of the environment (The Twelve-Factor App: Config).
Gotchas
Every trap below shares the same shape: none of them raise an exception, they just quietly change behavior, which is exactly the failure mode fail-safe defaults is meant to catch, generalized here from access control to configuration: on bad or missing input, fall back to the state that’s known to work, rather than the one that’s merely unrejected.
Casting a bool with type(default)(raw) instead of an explicit parser. The generic cast line works for float and int because float("0.9") and int("120") do what you’d expect. It breaks silently for bool, because bool("false") is True; any non-empty string is truthy in Python. The fix isn’t a special case bolted onto the generic cast, it’s a dedicated as_bool that only recognizes an explicit token set and raises on anything else, so an unrecognized value falls back to the default through the same except branch every other knob uses. Check 7 in the verification run above sets count_bonus_activity to the typo "maybe" and confirms it resolves to the default True, not to False.
Treating an empty string as a value instead of as unset. A cleared row in a settings table is "", not None. If the resolver only checks raw is None before casting, a deliberately-cleared override gets cast as if the user had typed something, instead of falling through to the environment variable underneath it the way clearing it was supposed to work. Normalizing blank-or-whitespace to “unset” at every layer, before the fallthrough logic runs, is what makes clearing a setting behave the same as never having set it.
Threading a config value through one call site and missing a sibling. The distance-based grading path and the duration-based grading path both used to reference the same hardcoded PARTIAL_FRACTION constant. Threading the resolved config through the distance path but leaving the duration path on the old module constant would make the setting appear to work, change one grade, and leave the other silently wrong. When a value moves from a constant to a parameter, grep every call site that referenced the old constant, not just the one you were looking at when you made the change.
Sources
- The Twelve-Factor App: Config — keep configuration in the environment, strictly separated from code, as granular values rather than named bundles.
- Python docs: sqlite3 — the standard-library module used for the settings table, no extra dependency required.
- Python docs: dataclasses —
@dataclass(frozen=True)and per-field defaults, which is howGradingConfig()reproduces the old hardcoded behavior with zero arguments. - Saltzer & Schroeder: The Protection of Information in Computer Systems — fail-safe defaults, generalized here from access control to configuration: on bad or missing input, fall back to the state that’s known to work.