Coverage for src / lilbee / core / settings.py: 100%
65 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-05-15 20:55 +0000
1"""Persistent settings stored in config.toml alongside the data directory."""
3import logging
4import sys
5import threading
6import tomllib
7from pathlib import Path
9from lilbee.config_meta import MODEL_ROLE_FIELDS, WRITABLE_CONFIG_FIELDS
10from lilbee.core.config import cfg
12_settings_lock = threading.Lock()
15def _config_path(data_root: Path) -> Path:
16 return data_root / "config.toml"
19def _escape_toml_string(s: str) -> str:
20 """Escape a string for embedding in a TOML double-quoted value."""
21 return (
22 s.replace("\\", "\\\\")
23 .replace('"', '\\"')
24 .replace("\n", "\\n")
25 .replace("\r", "\\r")
26 .replace("\t", "\\t")
27 .replace("\b", "\\b")
28 .replace("\f", "\\f")
29 )
32def load(data_root: Path) -> dict[str, str]:
33 """Read all settings from config.toml. Returns {} if file is missing."""
34 path = _config_path(data_root)
35 if not path.exists():
36 return {}
37 with path.open("rb") as f:
38 return {k: str(v) for k, v in tomllib.load(f).items()}
41def save(data_root: Path, settings: dict[str, str]) -> None:
42 """Write settings dict as simple TOML key-value pairs."""
43 path = _config_path(data_root)
44 path.parent.mkdir(parents=True, exist_ok=True)
45 lines = [f'{k} = "{_escape_toml_string(v)}"\n' for k, v in sorted(settings.items())]
46 path.write_text("".join(lines))
47 if sys.platform != "win32":
48 path.chmod(0o600)
51def get(data_root: Path, key: str) -> str | None:
52 """Look up a single key from config.toml."""
53 return load(data_root).get(key)
56def set_value(data_root: Path, key: str, value: str) -> None:
57 """Read-modify-write a single key in config.toml (thread-safe)."""
58 with _settings_lock:
59 current = load(data_root)
60 current[key] = value
61 save(data_root, current)
64def delete_value(data_root: Path, key: str) -> None:
65 """Remove a key from config.toml. No-op if key doesn't exist."""
66 with _settings_lock:
67 current = load(data_root)
68 current.pop(key, None)
69 save(data_root, current)
72def update_values(data_root: Path, updates: dict[str, str]) -> None:
73 """Batch update multiple keys in config.toml (single write)."""
74 with _settings_lock:
75 current = load(data_root)
76 current.update(updates)
77 save(data_root, current)
80def delete_values(data_root: Path, keys: list[str]) -> None:
81 """Batch delete multiple keys from config.toml (single write)."""
82 with _settings_lock:
83 current = load(data_root)
84 for key in keys:
85 current.pop(key, None)
86 save(data_root, current)
89def overlay_persisted_settings(root: Path) -> None:
90 """Overlay persisted scalars from ``<root>/config.toml`` onto cfg, skipping bad values."""
91 log = logging.getLogger(__name__)
92 try:
93 persisted = load(root)
94 except (OSError, ValueError):
95 log.warning("Failed to read %s/config.toml; using in-memory defaults", root)
96 return
97 if not persisted:
98 return
99 overlayable = set(WRITABLE_CONFIG_FIELDS) | set(MODEL_ROLE_FIELDS)
100 for key, raw in persisted.items():
101 if key not in overlayable:
102 continue
103 # Legacy: set_setting used to persist None as "". Skip rather than
104 # warn so a stale config doesn't spam logs on every CLI invocation.
105 if raw == "":
106 continue
107 try:
108 setattr(cfg, key, raw)
109 except (ValueError, TypeError) as exc:
110 log.warning(
111 "Ignoring invalid persisted value for %s in %s: %s",
112 key,
113 root,
114 exc,
115 )