Skip to content

Commit fb8889b

Browse files
committed
fix config backfill merge
1 parent 2d32234 commit fb8889b

2 files changed

Lines changed: 160 additions & 3 deletions

File tree

src/core/runtime_config.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ def _load(self) -> None:
419419
if not isinstance(loaded, dict):
420420
loaded = {}
421421

422+
merged_defaults = self._needs_default_backfill(loaded, DEFAULT_CONFIG)
422423
config = self._merge_defaults(loaded, DEFAULT_CONFIG)
423424
config, migrated = self._migrate_runtime_overrides(config)
424425
config, normalized = self._normalize_legacy_actions(config)
@@ -427,13 +428,22 @@ def _load(self) -> None:
427428

428429
self._config = config
429430

430-
if migrated or normalized or "$schema" not in loaded:
431+
if migrated or normalized or "$schema" not in loaded or merged_defaults:
431432
self._save()
432433

433434
except Exception as e:
434435
print(f"[CONFIG] Error loading config: {e}")
435-
self._config = self._ensure_schema_key(deepcopy(DEFAULT_CONFIG))
436-
self._save()
436+
fallback = (
437+
deepcopy(self._config)
438+
if isinstance(self._config, dict) and self._config
439+
else deepcopy(DEFAULT_CONFIG)
440+
)
441+
fallback = self._ensure_schema_key(fallback)
442+
try:
443+
self._assert_valid_config(fallback)
444+
self._config = fallback
445+
except Exception:
446+
self._config = self._ensure_schema_key(deepcopy(DEFAULT_CONFIG))
437447

438448
def _merge_defaults(self, config: dict, defaults: dict) -> dict:
439449
"""Recursively merge defaults into config for missing keys."""
@@ -445,6 +455,17 @@ def _merge_defaults(self, config: dict, defaults: dict) -> dict:
445455
result[key] = value
446456
return result
447457

458+
def _needs_default_backfill(self, config: dict[str, Any], defaults: dict[str, Any]) -> bool:
459+
"""Check whether config is missing any keys present in defaults."""
460+
for key, default_value in defaults.items():
461+
if key not in config:
462+
return True
463+
current_value = config.get(key)
464+
if isinstance(default_value, dict) and isinstance(current_value, dict):
465+
if self._needs_default_backfill(current_value, default_value):
466+
return True
467+
return False
468+
448469
def _deep_merge(self, base: dict, overrides: dict) -> dict:
449470
"""Deep merge overrides into base config."""
450471
result = deepcopy(base)

tests/test_runtime_config_validation.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,139 @@ def test_valid_schema_update_is_persisted(isolated_runtime_config):
4444
cfg.set_nested("rate_limit", "burst_limit", 9)
4545

4646
assert cfg.get_nested("rate_limit", "burst_limit") == 9
47+
48+
49+
def test_missing_schema_is_merged_and_preserved(tmp_path, monkeypatch):
50+
schema_path = Path(__file__).resolve().parents[1] / "config.schema.json"
51+
config_path = tmp_path / "config.json"
52+
config_path.write_text(
53+
"""
54+
{
55+
"bot": {
56+
"name": "Custom Bot",
57+
"owner_jid": "12345@s.whatsapp.net"
58+
},
59+
"features": {
60+
"notes": false
61+
}
62+
}
63+
""".strip(),
64+
encoding="utf-8",
65+
)
66+
67+
monkeypatch.setattr(runtime_config_module, "CONFIG_FILE", config_path)
68+
monkeypatch.setattr(runtime_config_module, "SCHEMA_FILE", schema_path)
69+
monkeypatch.setattr(
70+
runtime_config_module, "OVERRIDES_FILE", tmp_path / "runtime_overrides.json"
71+
)
72+
monkeypatch.setattr(
73+
runtime_config_module,
74+
"OVERRIDES_MIGRATION_MARKER",
75+
tmp_path / ".runtime_overrides_migrated",
76+
)
77+
runtime_config_module.RuntimeConfig._instance = None
78+
79+
cfg = runtime_config_module.RuntimeConfig()
80+
81+
assert cfg.get_nested("bot", "name") == "Custom Bot"
82+
assert cfg.get_nested("bot", "owner_jid") == "12345@s.whatsapp.net"
83+
assert cfg.get_nested("features", "notes") is False
84+
assert cfg.get_nested("features", "anti_delete") is True
85+
86+
persisted = runtime_config_module.jsonc.load(config_path)
87+
assert persisted.get("$schema") == runtime_config_module.DEFAULT_SCHEMA_PATH
88+
assert persisted.get("bot", {}).get("name") == "Custom Bot"
89+
assert persisted.get("features", {}).get("notes") is False
90+
91+
runtime_config_module.RuntimeConfig._instance = None
92+
93+
94+
def test_invalid_config_does_not_overwrite_file(tmp_path, monkeypatch):
95+
schema_path = Path(__file__).resolve().parents[1] / "config.schema.json"
96+
config_path = tmp_path / "config.json"
97+
invalid_content = """
98+
{
99+
"bot": "not-an-object"
100+
}
101+
""".strip()
102+
config_path.write_text(invalid_content, encoding="utf-8")
103+
104+
monkeypatch.setattr(runtime_config_module, "CONFIG_FILE", config_path)
105+
monkeypatch.setattr(runtime_config_module, "SCHEMA_FILE", schema_path)
106+
monkeypatch.setattr(
107+
runtime_config_module, "OVERRIDES_FILE", tmp_path / "runtime_overrides.json"
108+
)
109+
monkeypatch.setattr(
110+
runtime_config_module,
111+
"OVERRIDES_MIGRATION_MARKER",
112+
tmp_path / ".runtime_overrides_migrated",
113+
)
114+
runtime_config_module.RuntimeConfig._instance = None
115+
116+
cfg = runtime_config_module.RuntimeConfig()
117+
118+
assert cfg.get_nested("bot", "name") == "Zero Ichi"
119+
assert config_path.read_text(encoding="utf-8").strip() == invalid_content
120+
121+
runtime_config_module.RuntimeConfig._instance = None
122+
123+
124+
def test_missing_default_keys_are_persisted_with_existing_schema(tmp_path, monkeypatch):
125+
schema_path = Path(__file__).resolve().parents[1] / "config.schema.json"
126+
config_path = tmp_path / "config.json"
127+
config_path.write_text(
128+
"""
129+
{
130+
"$schema": "./config.schema.json",
131+
"bot": {
132+
"name": "Custom Bot",
133+
"prefix": "/",
134+
"login_method": "QR",
135+
"phone_number": "",
136+
"owner_jid": "owner@s.whatsapp.net",
137+
"auto_read": false,
138+
"auto_reload": true,
139+
"auto_react": false,
140+
"auto_react_emoji": "",
141+
"ignore_self_messages": true,
142+
"self_mode": false
143+
},
144+
"features": {
145+
"anti_delete": true,
146+
"anti_link": true,
147+
"welcome": true,
148+
"notes": false,
149+
"filters": true,
150+
"blacklist": true,
151+
"warnings": true,
152+
"automation_rules": true
153+
}
154+
}
155+
""".strip(),
156+
encoding="utf-8",
157+
)
158+
159+
monkeypatch.setattr(runtime_config_module, "CONFIG_FILE", config_path)
160+
monkeypatch.setattr(runtime_config_module, "SCHEMA_FILE", schema_path)
161+
monkeypatch.setattr(
162+
runtime_config_module, "OVERRIDES_FILE", tmp_path / "runtime_overrides.json"
163+
)
164+
monkeypatch.setattr(
165+
runtime_config_module,
166+
"OVERRIDES_MIGRATION_MARKER",
167+
tmp_path / ".runtime_overrides_migrated",
168+
)
169+
runtime_config_module.RuntimeConfig._instance = None
170+
171+
cfg = runtime_config_module.RuntimeConfig()
172+
173+
# Existing user values are preserved
174+
assert cfg.get_nested("bot", "name") == "Custom Bot"
175+
assert cfg.get_nested("features", "notes") is False
176+
177+
# Newly added keys are merged and persisted
178+
assert cfg.get_nested("features", "anti_spam") is False
179+
persisted = runtime_config_module.jsonc.load(config_path)
180+
assert persisted.get("features", {}).get("anti_spam") is False
181+
182+
runtime_config_module.RuntimeConfig._instance = None

0 commit comments

Comments
 (0)