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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""User preferences management system.
3This module provides persistent user preferences for TraceKit including
4visualization settings, default parameters, export options, and UI
5preferences.
6"""
8from __future__ import annotations
10import logging
11import os
12from dataclasses import dataclass, field
13from pathlib import Path
14from typing import Any
16import yaml
18from tracekit.config.schema import validate_against_schema
19from tracekit.core.exceptions import ConfigurationError
21logger = logging.getLogger(__name__)
24@dataclass
25class VisualizationPreferences:
26 """Visualization preferences.
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 """
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
45@dataclass
46class DefaultsPreferences:
47 """Default analysis parameters.
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 """
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"
64@dataclass
65class ExportPreferences:
66 """Export preferences.
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 """
75 default_format: str = "csv"
76 precision: int = 6
77 include_metadata: bool = True
78 compression: str = "gzip"
81@dataclass
82class LoggingPreferences:
83 """Logging preferences.
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 """
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
98@dataclass
99class EditorPreferences:
100 """Editor/REPL preferences.
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 """
109 history_size: int = 1000
110 auto_save: bool = True
111 syntax_highlighting: bool = True
112 tab_completion: bool = True
115@dataclass
116class UserPreferences:
117 """Complete user preferences.
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
128 Example:
129 >>> prefs = UserPreferences()
130 >>> prefs.visualization.dark_mode = True
131 >>> prefs.defaults.sample_rate = 2e9
132 >>> prefs.save()
133 """
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)
143 def get(self, key: str, default: Any = None) -> Any:
144 """Get preference value by dot-notation key.
146 Args:
147 key: Key path (e.g., "visualization.dpi")
148 default: Default value if not found
150 Returns:
151 Preference value
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
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
172 return obj
174 def set(self, key: str, value: Any) -> None:
175 """Set preference value by dot-notation key.
177 Args:
178 key: Key path (e.g., "visualization.dpi")
179 value: Value to set
181 Raises:
182 KeyError: If preference path is invalid or unknown.
184 Example:
185 >>> prefs.set("visualization.dpi", 150)
186 >>> prefs.set("custom.my_setting", 42)
187 """
188 parts = key.split(".")
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
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}")
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}")
219 def to_dict(self) -> dict[str, Any]:
220 """Convert preferences to dictionary.
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 }
263 @classmethod
264 def from_dict(cls, data: dict[str, Any]) -> UserPreferences:
265 """Create preferences from dictionary.
267 Args:
268 data: Dictionary representation
270 Returns:
271 UserPreferences instance
272 """
273 prefs = cls()
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 )
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 )
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 )
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 )
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 )
330 prefs.recent_files = data.get("recent_files", [])
331 prefs.custom = data.get("custom", {})
333 return prefs
336class PreferencesManager:
337 """Manager for loading and saving user preferences.
339 Handles preferences file location, loading, saving, and migration.
341 Example:
342 >>> manager = PreferencesManager()
343 >>> prefs = manager.load()
344 >>> prefs.visualization.dark_mode = True
345 >>> manager.save(prefs)
346 """
348 def __init__(self, path: Path | None = None):
349 """Initialize preferences manager.
351 Args:
352 path: Override preferences file path
353 """
354 self._path = path or self._get_default_path()
355 self._cached: UserPreferences | None = None
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"
362 config_dir = base / "tracekit"
363 config_dir.mkdir(parents=True, exist_ok=True)
364 return config_dir / "preferences.yaml"
366 def load(self, use_cache: bool = True) -> UserPreferences:
367 """Load preferences from file.
369 Args:
370 use_cache: Use cached preferences if available
372 Returns:
373 User preferences
374 """
375 if use_cache and self._cached is not None:
376 return self._cached
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
383 try:
384 with open(self._path, encoding="utf-8") as f:
385 data = yaml.safe_load(f)
387 if data is None:
388 data = {}
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}")
396 self._cached = UserPreferences.from_dict(data)
397 logger.debug(f"Loaded preferences from {self._path}")
398 return self._cached
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
405 def save(self, prefs: UserPreferences | None = None) -> None:
406 """Save preferences to file.
408 Args:
409 prefs: Preferences to save (uses cached if None)
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()
418 try:
419 data = prefs.to_dict()
421 # Ensure parent directory exists
422 self._path.parent.mkdir(parents=True, exist_ok=True)
424 with open(self._path, "w", encoding="utf-8") as f:
425 yaml.dump(data, f, default_flow_style=False)
427 self._cached = prefs
428 logger.debug(f"Saved preferences to {self._path}")
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
434 def reset(self) -> UserPreferences:
435 """Reset preferences to defaults.
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
445 def add_recent_file(self, path: str | Path, max_recent: int = 10) -> None:
446 """Add file to recent files list.
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)
455 # Remove if already in list
456 if path_str in prefs.recent_files:
457 prefs.recent_files.remove(path_str)
459 # Add to front
460 prefs.recent_files.insert(0, path_str)
462 # Trim to max
463 prefs.recent_files = prefs.recent_files[:max_recent]
465 self.save(prefs)
467 def get_recent_files(self, max_count: int = 10) -> list[str]:
468 """Get list of recent files.
470 Args:
471 max_count: Maximum number to return
473 Returns:
474 List of recent file paths
475 """
476 prefs = self.load()
477 return prefs.recent_files[:max_count]
479 @property
480 def path(self) -> Path:
481 """Get preferences file path."""
482 return self._path
485# Global preferences manager
486_manager: PreferencesManager | None = None
489def get_preferences_manager() -> PreferencesManager:
490 """Get global preferences manager.
492 Returns:
493 Global PreferencesManager instance
494 """
495 global _manager
496 if _manager is None:
497 _manager = PreferencesManager()
498 return _manager
501def get_preferences() -> UserPreferences:
502 """Get current user preferences.
504 Returns:
505 User preferences
506 """
507 return get_preferences_manager().load()
510def save_preferences(prefs: UserPreferences | None = None) -> None:
511 """Save user preferences.
513 Args:
514 prefs: Preferences to save
515 """
516 get_preferences_manager().save(prefs)
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]