Coverage for core / platform / config.py: 95.2%
126 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""
2Platform Config — Unified 3-layer configuration resolution.
4Resolves settings with precedence: environment variables > DB row > defaults.
5Generalizes the compute_config.py pattern for use across all OS subsystems.
7Design decisions:
8- Namespace-based: each subsystem defines its own config class
9- TTL cache (30s default) — same pattern as compute_config.py
10- Change notifications: on_change(key, callback)
11- Typed: each key has a converter (int, float, str, bool, json)
12- Falls back gracefully when DB unavailable
13- Thread-safe via threading.Lock
15Usage:
16 class DisplayConfig(PlatformConfig):
17 _namespace = 'display'
18 _defaults = {'scale': 1.0, 'brightness': 1.0}
19 _env_map = {'scale': ('HART_DISPLAY_SCALE', float)}
21 display = DisplayConfig()
22 scale = display.get('scale') # env > DB > default
23 display.set('scale', 1.5) # persists + notifies
24 display.on_change('scale', fn) # subscribe to changes
25"""
27import json
28import logging
29import os
30import threading
31import time
32from typing import Any, Callable, Dict, List, Optional, Tuple
34logger = logging.getLogger('hevolve.platform')
37# ═══════════════════════════════════════════════════════════════
38# Type Converters
39# ═══════════════════════════════════════════════════════════════
41def _convert_bool(val: Any) -> bool:
42 """Convert string/int/bool to bool."""
43 if isinstance(val, bool):
44 return val
45 if isinstance(val, str):
46 return val.lower() in ('true', '1', 'yes', 'on')
47 return bool(val)
50def _convert_json(val: Any) -> Any:
51 """Convert JSON string to Python object."""
52 if isinstance(val, str):
53 return json.loads(val)
54 return val
57CONVERTERS = {
58 int: int,
59 float: float,
60 str: str,
61 bool: _convert_bool,
62 'json': _convert_json,
63}
66# ═══════════════════════════════════════════════════════════════
67# Platform Config
68# ═══════════════════════════════════════════════════════════════
70class PlatformConfig:
71 """3-layer config resolution: env vars > DB > defaults.
73 Subclass and set _namespace, _defaults, _env_map to create
74 a config namespace for any subsystem.
76 Class attributes (set by subclasses):
77 _namespace: str - Config namespace (e.g., 'display', 'audio')
78 _defaults: dict - Default values for all keys
79 _env_map: dict - Maps key → (ENV_VAR_NAME, converter)
80 _cache_ttl: int - Cache TTL in seconds (default 30)
81 """
83 _namespace: str = ''
84 _defaults: Dict[str, Any] = {}
85 _env_map: Dict[str, Tuple[str, type]] = {}
86 _cache_ttl: int = 30
88 def __init__(self, db_loader: Optional[Callable] = None):
89 """Initialize config.
91 Args:
92 db_loader: Optional callable(namespace, key) -> value or None.
93 Used for layer 2 (DB) resolution. If None, DB layer
94 is skipped (env + defaults only).
95 """
96 self._db_loader = db_loader
97 self._db_saver: Optional[Callable] = None
98 self._cache: Dict[str, Any] = {}
99 self._cache_ts: float = 0.0
100 self._overrides: Dict[str, Any] = {} # in-memory set() values
101 self._listeners: Dict[str, List[Callable]] = {}
102 self._lock = threading.Lock()
104 def set_db_saver(self, saver: Callable) -> None:
105 """Set a callable(namespace, key, value) for persisting to DB."""
106 self._db_saver = saver
108 def get(self, key: str, default: Any = None) -> Any:
109 """Resolve a config value. Precedence: env > override > DB > defaults.
111 Args:
112 key: Config key name.
113 default: Fallback if key not found anywhere (overrides _defaults).
115 Returns:
116 Resolved value, type-converted if env_map specifies a converter.
117 """
118 # Layer 1: Environment variable (always highest priority)
119 if key in self._env_map:
120 env_name, converter = self._env_map[key]
121 env_val = os.environ.get(env_name)
122 if env_val is not None:
123 try:
124 conv = CONVERTERS.get(converter, converter)
125 return conv(env_val)
126 except (ValueError, TypeError, json.JSONDecodeError):
127 logger.warning("Bad env value for %s=%s", env_name, env_val)
129 # Layer 2: In-memory override (from set())
130 with self._lock:
131 if key in self._overrides:
132 return self._overrides[key]
134 # Layer 3: DB (cached with TTL)
135 db_val = self._get_from_db(key)
136 if db_val is not None:
137 return db_val
139 # Layer 4: Defaults
140 if key in self._defaults:
141 return self._defaults[key]
143 return default
145 def get_all(self) -> Dict[str, Any]:
146 """Return all config values (resolved)."""
147 result = {}
148 for key in self._defaults:
149 result[key] = self.get(key)
150 return result
152 def set(self, key: str, value: Any) -> None:
153 """Set a config value. Persists to DB if saver is configured.
155 Also triggers change notifications for registered listeners.
156 """
157 old_val = self.get(key)
159 with self._lock:
160 self._overrides[key] = value
162 # Persist to DB
163 if self._db_saver:
164 try:
165 self._db_saver(self._namespace, key, value)
166 except Exception as e:
167 logger.warning("DB save failed for %s.%s: %s",
168 self._namespace, key, e)
170 # Invalidate cache
171 self._invalidate_cache()
173 # Notify listeners
174 if old_val != value:
175 self._notify(key, old_val, value)
177 def reset(self, key: str) -> None:
178 """Reset a key to its default value."""
179 with self._lock:
180 self._overrides.pop(key, None)
181 self._invalidate_cache()
182 default_val = self._defaults.get(key)
183 self._notify(key, None, default_val)
185 def reset_all(self) -> None:
186 """Reset all overrides."""
187 with self._lock:
188 self._overrides.clear()
189 self._invalidate_cache()
191 # ── Change Notifications ──────────────────────────────────
193 def on_change(self, key: str, callback: Callable) -> None:
194 """Subscribe to changes for a specific key.
196 Callback receives (key, old_value, new_value).
197 """
198 with self._lock:
199 if key not in self._listeners:
200 self._listeners[key] = []
201 self._listeners[key].append(callback)
203 def off_change(self, key: str, callback: Callable) -> None:
204 """Unsubscribe from changes."""
205 with self._lock:
206 if key in self._listeners:
207 try:
208 self._listeners[key].remove(callback)
209 except ValueError:
210 pass
212 # ── Internal ──────────────────────────────────────────────
214 def _get_from_db(self, key: str) -> Optional[Any]:
215 """Load from DB with TTL cache."""
216 if not self._db_loader:
217 return None
219 now = time.time()
220 with self._lock:
221 if now - self._cache_ts < self._cache_ttl and key in self._cache:
222 return self._cache[key]
224 try:
225 val = self._db_loader(self._namespace, key)
226 if val is not None:
227 with self._lock:
228 self._cache[key] = val
229 self._cache_ts = now
230 return val
231 except Exception as e:
232 logger.debug("DB load failed for %s.%s: %s",
233 self._namespace, key, e)
234 return None
236 def _invalidate_cache(self) -> None:
237 """Clear the TTL cache."""
238 with self._lock:
239 self._cache.clear()
240 self._cache_ts = 0.0
242 def _notify(self, key: str, old_val: Any, new_val: Any) -> None:
243 """Dispatch change notifications to listeners."""
244 with self._lock:
245 listeners = list(self._listeners.get(key, []))
246 for cb in listeners:
247 try:
248 cb(key, old_val, new_val)
249 except Exception as e:
250 logger.warning("Config listener error for %s.%s: %s",
251 self._namespace, key, e)
253 # ── Settings Export / Import (for sync) ─────────────────
255 def export_settings(self) -> Dict[str, Any]:
256 """Export all config values as a JSON-serializable dict.
258 Used for cross-device settings sync via federation.
259 """
260 return {
261 'namespace': self._namespace,
262 'values': self.get_all(),
263 'exported_at': time.time(),
264 }
266 def import_settings(self, data: Dict[str, Any]) -> int:
267 """Import settings from an exported dict.
269 Returns the number of keys updated.
270 """
271 values = data.get('values', {})
272 count = 0
273 for key, val in values.items():
274 if key in self._defaults:
275 self.set(key, val)
276 count += 1
277 return count
279 @property
280 def namespace(self) -> str:
281 """Return the config namespace."""
282 return self._namespace
284 def __repr__(self) -> str:
285 return f"PlatformConfig(namespace={self._namespace!r})"