Coverage for src/lilbee/core/settings.py: 100%
89 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-08 09:20 +0000
1"""Persistent settings stored in config.toml alongside the data directory."""
3import logging
4import os
5import threading
6import tomllib
7from collections.abc import Callable, Generator
8from contextlib import contextmanager
9from pathlib import Path, PurePath
10from typing import Any, TypeVar
12import tomli_w
14from lilbee.config_meta import MODEL_ROLE_FIELDS, WRITABLE_CONFIG_FIELDS
15from lilbee.core.config import CONFIG_FILE_NAME, cfg
16from lilbee.core.security import file_lock_or_warn, harden_private_file, write_private_text
18_settings_lock = threading.Lock()
20T = TypeVar("T")
22# A server, a CLI invocation, and an MCP process routinely run against the same
23# data root, so the in-process mutex alone lets two of them interleave a
24# read-modify-write and silently drop each other's keys.
25_CONFIG_LOCK_TIMEOUT_S = 10.0
28def _config_path(data_root: Path) -> Path:
29 return data_root / CONFIG_FILE_NAME
32@contextmanager
33def _config_write_lock(data_root: Path) -> Generator[None, None, None]:
34 """Serialize a config read-modify-write across threads and processes.
36 A lock timeout falls through to the write rather than failing the caller:
37 losing a settings update to a stale lock file is worse than the interleave
38 the lock exists to prevent, which is already rare.
39 """
40 path = _config_path(data_root)
41 path.parent.mkdir(parents=True, exist_ok=True)
42 with _settings_lock, file_lock_or_warn(path, _CONFIG_LOCK_TIMEOUT_S):
43 yield
46def load(data_root: Path) -> dict[str, Any]:
47 """Read all settings from config.toml. Returns {} if file is missing.
49 Values keep the types TOML gave them. Stringifying here used to turn a
50 ``true`` into ``"True"`` in memory, which the next save then wrote back
51 quoted, so the file drifted away from valid types for its own fields.
52 """
53 path = _config_path(data_root)
54 if not path.exists():
55 return {}
56 harden_private_file(path)
57 with path.open("rb") as f:
58 return dict(tomllib.load(f))
61def save(data_root: Path, settings: dict[str, Any]) -> None:
62 """Write *settings* to config.toml.
64 ``tomli_w`` is the write half of the stdlib ``tomllib`` used by ``load``.
65 The emitter this replaced escaped strings by hand and stringified anything
66 that was not a bool or a number, so a list value was persisted as its
67 quoted repr and read back as text. A control character it escaped wrongly
68 was worse still: the reader discards the whole file on a parse error, so
69 one bad value silently wiped every other setting.
71 A ``None`` is dropped rather than written. TOML has no null, and the old
72 emitter wrote the literal string "None", which then read back as a set
73 value instead of an absent one. A path is written as its string: tomli_w
74 refuses ``Path`` objects, and the config's path fields hold them after
75 validation.
76 """
77 path = _config_path(data_root)
78 present = {
79 k: str(v) if isinstance(v, PurePath) else v
80 for k, v in sorted(settings.items())
81 if v is not None
82 }
83 # config.toml can hold provider API keys, so it gets the same owner-only
84 # treatment as the session token rather than a post-hoc chmod.
85 write_private_text(path, tomli_w.dumps(present))
88def get(data_root: Path, key: str) -> str | None:
89 """Look up a single key from config.toml, as text for callers that want text."""
90 value = load(data_root).get(key)
91 return None if value is None else str(value)
94def set_value(data_root: Path, key: str, value: Any) -> None:
95 """Read-modify-write a single key in config.toml."""
96 with _config_write_lock(data_root):
97 current = load(data_root)
98 current[key] = value
99 save(data_root, current)
102def delete_value(data_root: Path, key: str) -> None:
103 """Remove a key from config.toml. No-op if key doesn't exist."""
104 with _config_write_lock(data_root):
105 current = load(data_root)
106 current.pop(key, None)
107 save(data_root, current)
110def update_values(data_root: Path, updates: dict[str, Any]) -> None:
111 """Batch update multiple keys in config.toml (single write)."""
112 with _config_write_lock(data_root):
113 current = load(data_root)
114 current.update(updates)
115 save(data_root, current)
118def delete_values(data_root: Path, keys: list[str]) -> None:
119 """Batch delete multiple keys from config.toml (single write)."""
120 with _config_write_lock(data_root):
121 current = load(data_root)
122 for key in keys:
123 current.pop(key, None)
124 save(data_root, current)
127def mutate_value(data_root: Path, key: str, fn: Callable[[Any], tuple[Any, T]]) -> T:
128 """Read-modify-write a single key under the config lock, atomically.
130 ``fn`` receives the key's persisted value (or None if absent) read *inside*
131 the lock and returns ``(new_value, result)``; the new value is written and
132 the result is returned. Unlike a read-then-:func:`set_value`, the whole
133 compound update is serialized across threads and processes, so two callers
134 updating a dict-valued key cannot lose each other's change.
135 """
136 with _config_write_lock(data_root):
137 current = load(data_root)
138 new_value, result = fn(current.get(key))
139 current[key] = new_value
140 save(data_root, current)
141 return result
144def overlay_persisted_settings(root: Path) -> None:
145 """Overlay persisted scalars from ``<root>/config.toml`` onto cfg, skipping bad values.
147 An explicit ``LILBEE_<FIELD>`` env var wins over config.toml (the documented
148 precedence): cfg already holds the env-loaded value, so a key whose env var is
149 set is left untouched rather than overwritten by the persisted file.
151 ``LILBEE_SKIP_TOML_CONFIG=1`` disables this overlay entirely, matching the
152 pydantic-settings source in ``config/model.py`` so the escape hatch is honored
153 on every config-read path (import-time load, CLI callback, MCP server).
154 """
155 if os.environ.get("LILBEE_SKIP_TOML_CONFIG") == "1":
156 return
157 log = logging.getLogger(__name__)
158 try:
159 persisted = load(root)
160 except (OSError, ValueError):
161 log.warning("Failed to read %s/config.toml; using in-memory defaults", root)
162 return
163 if not persisted:
164 return
165 overlayable = set(WRITABLE_CONFIG_FIELDS) | set(MODEL_ROLE_FIELDS)
166 env_prefix = cfg.model_config.get("env_prefix", "")
167 for key, raw in persisted.items():
168 if key not in overlayable:
169 continue
170 # Non-empty env var wins over config.toml (matches pydantic env_ignore_empty=True).
171 if os.environ.get(f"{env_prefix}{key.upper()}", "") != "":
172 continue
173 # Legacy: set_setting used to persist None as "". Skip rather than
174 # warn so a stale config doesn't spam logs on every CLI invocation.
175 if raw == "":
176 continue
177 try:
178 setattr(cfg, key, raw)
179 except (ValueError, TypeError) as exc:
180 log.warning(
181 "Ignoring invalid persisted value for %s in %s: %s",
182 key,
183 root,
184 exc,
185 )