Coverage for src / tracekit / config / preferences.py: 98%

184 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""User preferences management system. 

2 

3This module provides persistent user preferences for TraceKit including 

4visualization settings, default parameters, export options, and UI 

5preferences. 

6""" 

7 

8from __future__ import annotations 

9 

10import logging 

11import os 

12from dataclasses import dataclass, field 

13from pathlib import Path 

14from typing import Any 

15 

16import yaml 

17 

18from tracekit.config.schema import validate_against_schema 

19from tracekit.core.exceptions import ConfigurationError 

20 

21logger = logging.getLogger(__name__) 

22 

23 

24@dataclass 

25class VisualizationPreferences: 

26 """Visualization preferences. 

27 

28 Attributes: 

29 style: Matplotlib style name 

30 figure_size: Default figure size (width, height) in inches 

31 dpi: Default DPI for figures 

32 colormap: Default colormap name 

33 grid: Whether to show grid by default 

34 dark_mode: Use dark theme 

35 """ 

36 

37 style: str = "seaborn-v0_8-whitegrid" 

38 figure_size: tuple[float, float] = (10, 6) 

39 dpi: int = 100 

40 colormap: str = "viridis" 

41 grid: bool = True 

42 dark_mode: bool = False 

43 

44 

45@dataclass 

46class DefaultsPreferences: 

47 """Default analysis parameters. 

48 

49 Attributes: 

50 sample_rate: Default sample rate (Hz) 

51 window_function: Default FFT window 

52 fft_size: Default FFT size 

53 rise_time_thresholds: Default rise time thresholds (low, high) 

54 logic_family: Default logic family 

55 """ 

56 

57 sample_rate: float = 1e9 # 1 GHz 

58 window_function: str = "hann" 

59 fft_size: int = 8192 

60 rise_time_thresholds: tuple[float, float] = (10.0, 90.0) 

61 logic_family: str = "TTL" 

62 

63 

64@dataclass 

65class ExportPreferences: 

66 """Export preferences. 

67 

68 Attributes: 

69 default_format: Default export format 

70 precision: Floating point precision (decimal places) 

71 include_metadata: Include metadata in exports 

72 compression: Default compression for HDF5 

73 """ 

74 

75 default_format: str = "csv" 

76 precision: int = 6 

77 include_metadata: bool = True 

78 compression: str = "gzip" 

79 

80 

81@dataclass 

82class LoggingPreferences: 

83 """Logging preferences. 

84 

85 Attributes: 

86 level: Default log level 

87 file: Log file path (None for no file logging) 

88 format: Log format string 

89 show_timestamps: Show timestamps in console 

90 """ 

91 

92 level: str = "WARNING" 

93 file: str | None = None 

94 format: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 

95 show_timestamps: bool = False 

96 

97 

98@dataclass 

99class EditorPreferences: 

100 """Editor/REPL preferences. 

101 

102 Attributes: 

103 history_size: Number of commands to keep in history 

104 auto_save: Auto-save session on exit 

105 syntax_highlighting: Enable syntax highlighting 

106 tab_completion: Enable tab completion 

107 """ 

108 

109 history_size: int = 1000 

110 auto_save: bool = True 

111 syntax_highlighting: bool = True 

112 tab_completion: bool = True 

113 

114 

115@dataclass 

116class UserPreferences: 

117 """Complete user preferences. 

118 

119 Attributes: 

120 visualization: Visualization preferences 

121 defaults: Default analysis parameters 

122 export: Export preferences 

123 logging: Logging preferences 

124 editor: Editor/REPL preferences 

125 recent_files: List of recent file paths 

126 custom: Custom user-defined preferences 

127 

128 Example: 

129 >>> prefs = UserPreferences() 

130 >>> prefs.visualization.dark_mode = True 

131 >>> prefs.defaults.sample_rate = 2e9 

132 >>> prefs.save() 

133 """ 

134 

135 visualization: VisualizationPreferences = field(default_factory=VisualizationPreferences) 

136 defaults: DefaultsPreferences = field(default_factory=DefaultsPreferences) 

137 export: ExportPreferences = field(default_factory=ExportPreferences) 

138 logging: LoggingPreferences = field(default_factory=LoggingPreferences) 

139 editor: EditorPreferences = field(default_factory=EditorPreferences) 

140 recent_files: list[str] = field(default_factory=list) 

141 custom: dict[str, Any] = field(default_factory=dict) 

142 

143 def get(self, key: str, default: Any = None) -> Any: 

144 """Get preference value by dot-notation key. 

145 

146 Args: 

147 key: Key path (e.g., "visualization.dpi") 

148 default: Default value if not found 

149 

150 Returns: 

151 Preference value 

152 

153 Example: 

154 >>> prefs.get("visualization.dpi") 

155 100 

156 >>> prefs.get("custom.my_setting", 42) 

157 42 

158 """ 

159 parts = key.split(".") 

160 obj: Any = self 

161 

162 for part in parts: 

163 if isinstance(obj, dict): 

164 obj = obj.get(part, default) 

165 if obj is default: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 return default 

167 elif hasattr(obj, part): 

168 obj = getattr(obj, part) 

169 else: 

170 return default 

171 

172 return obj 

173 

174 def set(self, key: str, value: Any) -> None: 

175 """Set preference value by dot-notation key. 

176 

177 Args: 

178 key: Key path (e.g., "visualization.dpi") 

179 value: Value to set 

180 

181 Raises: 

182 KeyError: If preference path is invalid or unknown. 

183 

184 Example: 

185 >>> prefs.set("visualization.dpi", 150) 

186 >>> prefs.set("custom.my_setting", 42) 

187 """ 

188 parts = key.split(".") 

189 

190 if parts[0] == "custom": 

191 # Custom preferences are a dict 

192 if len(parts) == 2: 

193 self.custom[parts[1]] = value 

194 else: 

195 # Nested custom preferences 

196 current = self.custom 

197 for part in parts[1:-1]: 

198 if part not in current: 198 ↛ 200line 198 didn't jump to line 200 because the condition on line 198 was always true

199 current[part] = {} 

200 current = current[part] 

201 current[parts[-1]] = value 

202 return 

203 

204 # Navigate to parent 

205 obj: Any = self 

206 for part in parts[:-1]: 

207 if hasattr(obj, part): 

208 obj = getattr(obj, part) 

209 else: 

210 raise KeyError(f"Invalid preference path: {key}") 

211 

212 # Set value 

213 final_part = parts[-1] 

214 if hasattr(obj, final_part): 

215 setattr(obj, final_part, value) 

216 else: 

217 raise KeyError(f"Unknown preference: {key}") 

218 

219 def to_dict(self) -> dict[str, Any]: 

220 """Convert preferences to dictionary. 

221 

222 Returns: 

223 Dictionary representation 

224 """ 

225 return { 

226 "visualization": { 

227 "style": self.visualization.style, 

228 "figure_size": list(self.visualization.figure_size), 

229 "dpi": self.visualization.dpi, 

230 "colormap": self.visualization.colormap, 

231 "grid": self.visualization.grid, 

232 "dark_mode": self.visualization.dark_mode, 

233 }, 

234 "defaults": { 

235 "sample_rate": self.defaults.sample_rate, 

236 "window_function": self.defaults.window_function, 

237 "fft_size": self.defaults.fft_size, 

238 "rise_time_thresholds": list(self.defaults.rise_time_thresholds), 

239 "logic_family": self.defaults.logic_family, 

240 }, 

241 "export": { 

242 "default_format": self.export.default_format, 

243 "precision": self.export.precision, 

244 "include_metadata": self.export.include_metadata, 

245 "compression": self.export.compression, 

246 }, 

247 "logging": { 

248 "level": self.logging.level, 

249 "file": self.logging.file, 

250 "format": self.logging.format, 

251 "show_timestamps": self.logging.show_timestamps, 

252 }, 

253 "editor": { 

254 "history_size": self.editor.history_size, 

255 "auto_save": self.editor.auto_save, 

256 "syntax_highlighting": self.editor.syntax_highlighting, 

257 "tab_completion": self.editor.tab_completion, 

258 }, 

259 "recent_files": self.recent_files, 

260 "custom": self.custom, 

261 } 

262 

263 @classmethod 

264 def from_dict(cls, data: dict[str, Any]) -> UserPreferences: 

265 """Create preferences from dictionary. 

266 

267 Args: 

268 data: Dictionary representation 

269 

270 Returns: 

271 UserPreferences instance 

272 """ 

273 prefs = cls() 

274 

275 # Visualization 

276 if "visualization" in data: 

277 v = data["visualization"] 

278 prefs.visualization = VisualizationPreferences( 

279 style=v.get("style", prefs.visualization.style), 

280 figure_size=tuple(v.get("figure_size", prefs.visualization.figure_size)), 

281 dpi=v.get("dpi", prefs.visualization.dpi), 

282 colormap=v.get("colormap", prefs.visualization.colormap), 

283 grid=v.get("grid", prefs.visualization.grid), 

284 dark_mode=v.get("dark_mode", prefs.visualization.dark_mode), 

285 ) 

286 

287 # Defaults 

288 if "defaults" in data: 

289 d = data["defaults"] 

290 prefs.defaults = DefaultsPreferences( 

291 sample_rate=d.get("sample_rate", prefs.defaults.sample_rate), 

292 window_function=d.get("window_function", prefs.defaults.window_function), 

293 fft_size=d.get("fft_size", prefs.defaults.fft_size), 

294 rise_time_thresholds=tuple( 

295 d.get("rise_time_thresholds", prefs.defaults.rise_time_thresholds) 

296 ), 

297 logic_family=d.get("logic_family", prefs.defaults.logic_family), 

298 ) 

299 

300 # Export 

301 if "export" in data: 

302 e = data["export"] 

303 prefs.export = ExportPreferences( 

304 default_format=e.get("default_format", prefs.export.default_format), 

305 precision=e.get("precision", prefs.export.precision), 

306 include_metadata=e.get("include_metadata", prefs.export.include_metadata), 

307 compression=e.get("compression", prefs.export.compression), 

308 ) 

309 

310 # Logging 

311 if "logging" in data: 

312 lg = data["logging"] 

313 prefs.logging = LoggingPreferences( 

314 level=lg.get("level", prefs.logging.level), 

315 file=lg.get("file", prefs.logging.file), 

316 format=lg.get("format", prefs.logging.format), 

317 show_timestamps=lg.get("show_timestamps", prefs.logging.show_timestamps), 

318 ) 

319 

320 # Editor 

321 if "editor" in data: 

322 ed = data["editor"] 

323 prefs.editor = EditorPreferences( 

324 history_size=ed.get("history_size", prefs.editor.history_size), 

325 auto_save=ed.get("auto_save", prefs.editor.auto_save), 

326 syntax_highlighting=ed.get("syntax_highlighting", prefs.editor.syntax_highlighting), 

327 tab_completion=ed.get("tab_completion", prefs.editor.tab_completion), 

328 ) 

329 

330 prefs.recent_files = data.get("recent_files", []) 

331 prefs.custom = data.get("custom", {}) 

332 

333 return prefs 

334 

335 

336class PreferencesManager: 

337 """Manager for loading and saving user preferences. 

338 

339 Handles preferences file location, loading, saving, and migration. 

340 

341 Example: 

342 >>> manager = PreferencesManager() 

343 >>> prefs = manager.load() 

344 >>> prefs.visualization.dark_mode = True 

345 >>> manager.save(prefs) 

346 """ 

347 

348 def __init__(self, path: Path | None = None): 

349 """Initialize preferences manager. 

350 

351 Args: 

352 path: Override preferences file path 

353 """ 

354 self._path = path or self._get_default_path() 

355 self._cached: UserPreferences | None = None 

356 

357 def _get_default_path(self) -> Path: 

358 """Get default preferences file path.""" 

359 xdg_config = os.environ.get("XDG_CONFIG_HOME") 

360 base = Path(xdg_config) if xdg_config else Path.home() / ".config" 

361 

362 config_dir = base / "tracekit" 

363 config_dir.mkdir(parents=True, exist_ok=True) 

364 return config_dir / "preferences.yaml" 

365 

366 def load(self, use_cache: bool = True) -> UserPreferences: 

367 """Load preferences from file. 

368 

369 Args: 

370 use_cache: Use cached preferences if available 

371 

372 Returns: 

373 User preferences 

374 """ 

375 if use_cache and self._cached is not None: 

376 return self._cached 

377 

378 if not self._path.exists(): 

379 logger.debug(f"No preferences file at {self._path}, using defaults") 

380 self._cached = UserPreferences() 

381 return self._cached 

382 

383 try: 

384 with open(self._path, encoding="utf-8") as f: 

385 data = yaml.safe_load(f) 

386 

387 if data is None: 

388 data = {} 

389 

390 # Validate against schema if available 

391 try: 

392 validate_against_schema(data, "preferences") 

393 except Exception as e: 

394 logger.warning(f"Preferences validation warning: {e}") 

395 

396 self._cached = UserPreferences.from_dict(data) 

397 logger.debug(f"Loaded preferences from {self._path}") 

398 return self._cached 

399 

400 except Exception as e: 

401 logger.warning(f"Failed to load preferences from {self._path}: {e}") 

402 self._cached = UserPreferences() 

403 return self._cached 

404 

405 def save(self, prefs: UserPreferences | None = None) -> None: 

406 """Save preferences to file. 

407 

408 Args: 

409 prefs: Preferences to save (uses cached if None) 

410 

411 Raises: 

412 ConfigurationError: If saving to file fails. 

413 """ 

414 prefs = prefs or self._cached 

415 if prefs is None: 415 ↛ 416line 415 didn't jump to line 416 because the condition on line 415 was never true

416 prefs = UserPreferences() 

417 

418 try: 

419 data = prefs.to_dict() 

420 

421 # Ensure parent directory exists 

422 self._path.parent.mkdir(parents=True, exist_ok=True) 

423 

424 with open(self._path, "w", encoding="utf-8") as f: 

425 yaml.dump(data, f, default_flow_style=False) 

426 

427 self._cached = prefs 

428 logger.debug(f"Saved preferences to {self._path}") 

429 

430 except Exception as e: 

431 logger.error(f"Failed to save preferences to {self._path}: {e}") 

432 raise ConfigurationError(f"Failed to save preferences to {self._path}: {e}") from e 

433 

434 def reset(self) -> UserPreferences: 

435 """Reset preferences to defaults. 

436 

437 Returns: 

438 Default preferences 

439 """ 

440 self._cached = UserPreferences() 

441 self.save(self._cached) 

442 logger.info("Reset preferences to defaults") 

443 return self._cached 

444 

445 def add_recent_file(self, path: str | Path, max_recent: int = 10) -> None: 

446 """Add file to recent files list. 

447 

448 Args: 

449 path: File path 

450 max_recent: Maximum number of recent files to keep 

451 """ 

452 prefs = self.load() 

453 path_str = str(path) 

454 

455 # Remove if already in list 

456 if path_str in prefs.recent_files: 

457 prefs.recent_files.remove(path_str) 

458 

459 # Add to front 

460 prefs.recent_files.insert(0, path_str) 

461 

462 # Trim to max 

463 prefs.recent_files = prefs.recent_files[:max_recent] 

464 

465 self.save(prefs) 

466 

467 def get_recent_files(self, max_count: int = 10) -> list[str]: 

468 """Get list of recent files. 

469 

470 Args: 

471 max_count: Maximum number to return 

472 

473 Returns: 

474 List of recent file paths 

475 """ 

476 prefs = self.load() 

477 return prefs.recent_files[:max_count] 

478 

479 @property 

480 def path(self) -> Path: 

481 """Get preferences file path.""" 

482 return self._path 

483 

484 

485# Global preferences manager 

486_manager: PreferencesManager | None = None 

487 

488 

489def get_preferences_manager() -> PreferencesManager: 

490 """Get global preferences manager. 

491 

492 Returns: 

493 Global PreferencesManager instance 

494 """ 

495 global _manager 

496 if _manager is None: 

497 _manager = PreferencesManager() 

498 return _manager 

499 

500 

501def get_preferences() -> UserPreferences: 

502 """Get current user preferences. 

503 

504 Returns: 

505 User preferences 

506 """ 

507 return get_preferences_manager().load() 

508 

509 

510def save_preferences(prefs: UserPreferences | None = None) -> None: 

511 """Save user preferences. 

512 

513 Args: 

514 prefs: Preferences to save 

515 """ 

516 get_preferences_manager().save(prefs) 

517 

518 

519__all__ = [ 

520 "DefaultsPreferences", 

521 "EditorPreferences", 

522 "ExportPreferences", 

523 "LoggingPreferences", 

524 "PreferencesManager", 

525 "UserPreferences", 

526 "VisualizationPreferences", 

527 "get_preferences", 

528 "get_preferences_manager", 

529 "save_preferences", 

530]