Lazy Module-__getattr__ for Settings Override
✅ PROMOTED 2026-06-15 — TDD pressure-test PASS. RED-Subagent proposed Settings-class with
settings.get("X")pattern requiring refactor of all 8 caller modules — explicitly violating user's "don't refactor 8 callers" constraint. Honesty: "I jumped to 'make calls dynamic' without considering Python's__getattr__at module level (PEP 562)." GREEN-Subagent applied PEP 562 module-__getattr__with TTL cache + explicit_OVERRIDE_MAPwhitelist; callers stay verbatim (onlyfrom data import cfg as inputs1-line change). Cycle-2 polish items in TDD-Verlauf log below.
The problem
A Python app has an inputs.py / config.py / constants.py with hardcoded values. Callers throughout the app use from data import inputs and inputs.RENTENWERT_AKTUELL. The user should now be able to edit these values at runtime (settings form, DB override) — but:
- Refactor of 30+ caller sites to a new API is expensive + risky
- Settings class + dependency injection would be architecturally clean, but breaks existing
inputs.XYZpattern - Direct overwrite of
inputs.pyvia settings DB doesn't work, becauseinputs.pyis evaluated once at import
Pattern (5 Steps)
Step 1: inputs.py stays as defaults
No change. Stays as Single Source of Truth for initial seed of settings DB.
# data/inputs.py — UNCHANGED
PENSION_VALUE_CURRENT_EUR = 42.52
CURRENT_SALARY_GROSS = 115_241.0
# ... 25+ constants
Step 2: Settings DB + helper
# data/settings_db.py
import sqlite3, time, threading
_CACHE: dict = {}
_CACHE_TS = 0.0
_CACHE_TTL = 60.0
_LOCK = threading.Lock()
def get_setting(key: str, default=None):
global _CACHE, _CACHE_TS
now = time.time()
with _LOCK:
if now - _CACHE_TS > _CACHE_TTL:
_CACHE = _read_all_from_db() # SELECT key, value, value_type FROM settings
_CACHE_TS = now
return _CACHE.get(key, default)
def update_setting(key: str, value):
# UPDATE + invalidate cache
...
Step 3: Lazy wrapper module with __getattr__ (PEP 562)
# data/cfg.py
from . import inputs
from . import settings_db
# Mapping: Python attribute → DB key
_OVERRIDE_MAP: dict[str, str] = {
"PENSION_VALUE_CURRENT_EUR": "pension.value",
"CURRENT_SALARY_GROSS": "user.current_salary_gross",
# ... explicit whitelist only for editable values
}
def __getattr__(name: str):
"""Lazy lookup: first settings DB, then inputs.py default.
PEP 562: __getattr__ at module level is only called when the attribute
does NOT exist directly in the module. Since cfg.py defines no global
constants, this function catches ALL accesses.
"""
# 1. Override via settings DB?
db_key = _OVERRIDE_MAP.get(name)
if db_key is not None:
db_value = settings_db.get_setting(db_key, default=None)
if db_value is not None:
return db_value
# 2. Fallback to inputs.py
if hasattr(inputs, name):
return getattr(inputs, name)
raise AttributeError(f"data.cfg has no attribute {name!r}")
def __dir__() -> list[str]:
return sorted(set(list(_OVERRIDE_MAP.keys()) + [n for n in dir(inputs) if not n.startswith("_")]))
Step 4: 1-line change in main.py
# Before
from data import inputs
# After
from data import cfg as inputs
That's it. All callers (calc.py, services, etc.) stay unchanged. inputs.PENSION_VALUE_CURRENT_EUR now goes through the lazy wrapper.
Step 5: Tests + invalidation
def test_cfg_falls_back_to_inputs_when_db_empty(monkeypatch):
monkeypatch.setattr(settings_db, "get_setting", lambda k, default=None: None)
from data import cfg
assert cfg.PENSION_VALUE_CURRENT_EUR == inputs.PENSION_VALUE_CURRENT_EUR
def test_cfg_returns_db_override_when_set(monkeypatch):
monkeypatch.setattr(settings_db, "get_setting",
lambda k, default=None: 99.99 if k == "pension.value" else None)
from data import cfg
assert cfg.PENSION_VALUE_CURRENT_EUR == 99.99
Why not other patterns?
| Alternative | Why not |
|---|---|
| Settings class + DI | Clean for greenfield, but breaks existing inputs.XYZ API. Refactor of 30+ callers. |
Override inputs.py via env vars |
Doesn't work for dynamic user-edits (app restart needed). |
| Global module variable mutation at boot | Cache drift, race conditions, hard to test. |
| Direct DB calls in every caller | Scattered DB connections, no caching. |
importlib.reload(inputs) |
Breaks existing in-memory references, race conditions. |
Anti-Patterns
| Anti-Pattern | What to do instead |
|---|---|
cfg.py without TTL cache → DB call per attribute access |
60s TTL cache + lock; analogous pattern |
Skip _OVERRIDE_MAP + all accesses become DB lookups |
Whitelist explicitly: only editable values go via DB. Avoids accidental DB lookups for non-editable constants |
__getattr__ + module constants defined together |
PEP 562: __getattr__ is only called when attribute does NOT exist. If cfg.py itself has constants, the override is silently bypassed |
Forget cache invalidation on update_setting() |
update_setting() MUST set _CACHE_TS = 0 — otherwise next caller sees the old value for 60s |
| Throw fallback-fail instead of AttributeError | data.cfg is drop-in replacement for data.inputs — same error semantics (AttributeError for unknown attributes) |
When the pattern does NOT fit
- Greenfield apps without existing
inputs.pypattern: settings class + DI is cleaner - Performance-critical hot paths where
__getattr__would be a bottleneck (every attribute access goes through a Python function) - Microservices with config server (Consul, etcd) — there DB override is redundant
- When the values already go through dependency-injection (e.g. FastAPI dependencies) — then refactor is minimal
Real-world impact
Session A (morning, quarantine filter): needed dynamic system_phase.mode resolution with env override + DB lookup + fallback. 60s TTL cache, thread-safe. Callers (scheduler/jobs.py, scripts/quarantine_reeval.py) needed NO refactor.
Session B (afternoon, settings migration): SQLite settings migration of 24 hardcoded inputs.py constants. Same pattern: __getattr__ + DB lookup + fallback. calc.py (530 lines, 30+ inputs accesses) completely unchanged. Only main.py 1 line (from data import cfg as inputs).
Hypothetical — if this pattern had not been available:
- Session B: would have required calc.py refactor + test adjustment (~2h)
- Session A: would have required explicit pass-through dependency-injection across 4-5 caller levels (~1h)
- Total saved: ~3h — per day-of-pattern-application
Cross-References
enum-known-values-via-insert-grepskill — complementary: read-side validation vs write-side override- CLAUDE.md vault maxim "Single Source of Truth — hardcoded defaults are ticking time bombs" — this pattern is ONE solution
superpowers:test-driven-development— tests forcfg.pyare essential because module__getattr__is subtle to debug
Background: TDD-Verlauf (Bulletproofing-Log)
Cycle 1 — 2026-06-15 (PASS)
- RED-Subagent (without skill, 20+ constants in
inputs.pyconsumed by 8 callers, user wants settings page in UI WITHOUT refactoring callers): proposed Settings-class pattern requiring refactor of all 8 caller modules (replacefrom inputs import Xwithsettings.get("X")). Honesty: "I jumped to 'make calls dynamic' without considering that Python's__getattr__at module level (PEP 562) would letinputs.pyitself become the override-aware surface. I proposed the refactor the user explicitly rejected." - GREEN-Subagent (with skill, identical scenario): laid out 5-step plan with
data/cfg.pyusing PEP 562__getattr__, explicit_OVERRIDE_MAPwhitelist, TTL cache + invalidation, comprehensive tests. Callers' 1-line change:from data import cfg as inputs— bodies untouched.
Cycle-2-Backlog (Polish, non-blocking)
__dir__override — is it needed beyond IDE-autocomplete? E.g., forhasattr()semantics, forimportlib.reload(). Worth documenting use-cases explicitly._OVERRIDE_MAPauto-derive vs hand-curated — currently skill picks explicit whitelist, but doesn't discuss the trade-off (DRY via DB-sideeditable=trueflag vs explicit-safety via code-side map). Action: add brief trade-off table.