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

1"""Persistent settings stored in config.toml alongside the data directory.""" 

2 

3import logging 

4import sys 

5import threading 

6import tomllib 

7from pathlib import Path 

8 

9from lilbee.config_meta import MODEL_ROLE_FIELDS, WRITABLE_CONFIG_FIELDS 

10from lilbee.core.config import cfg 

11 

12_settings_lock = threading.Lock() 

13 

14 

15def _config_path(data_root: Path) -> Path: 

16 return data_root / "config.toml" 

17 

18 

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 ) 

30 

31 

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()} 

39 

40 

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) 

49 

50 

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) 

54 

55 

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) 

62 

63 

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) 

70 

71 

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) 

78 

79 

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) 

87 

88 

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 )