Coverage for src / tracekit / config / settings.py: 94%
134 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"""Application settings management system.
3This module provides application-wide settings management for TraceKit,
4including feature flags, CLI defaults, output formats, and runtime
5configuration options.
8Example:
9 >>> from tracekit.config.settings import get_settings, Settings
10 >>> settings = get_settings()
11 >>> settings.enable_feature("advanced_analysis")
12 >>> if settings.is_feature_enabled("advanced_analysis"):
13 ... perform_advanced_analysis()
14"""
16from __future__ import annotations
18import json
19import logging
20from dataclasses import dataclass, field
21from pathlib import Path
22from typing import Any
24from tracekit.core.exceptions import ConfigurationError
26logger = logging.getLogger(__name__)
29@dataclass
30class CLIDefaults:
31 """CLI default settings.
33 Attributes:
34 output_format: Default output format (json, yaml, text)
35 verbosity: Default verbosity level (0-3)
36 color_output: Enable colored output
37 progress_bar: Show progress bars
38 parallel_workers: Number of parallel workers
39 """
41 output_format: str = "text"
42 verbosity: int = 1
43 color_output: bool = True
44 progress_bar: bool = True
45 parallel_workers: int = 4
48@dataclass
49class AnalysisSettings:
50 """Analysis configuration settings.
52 Attributes:
53 max_trace_size: Maximum trace size in bytes (0 = unlimited)
54 enable_caching: Enable result caching
55 cache_dir: Cache directory path
56 timeout: Default timeout for analysis in seconds
57 streaming_mode: Enable streaming mode for large files
58 """
60 max_trace_size: int = 0
61 enable_caching: bool = True
62 cache_dir: str | None = None
63 timeout: float = 300.0
64 streaming_mode: bool = False
67@dataclass
68class OutputSettings:
69 """Output and export configuration.
71 Attributes:
72 default_format: Default export format (csv, json, hdf5)
73 include_raw_data: Include raw waveform data in exports
74 compress_output: Compress output files
75 decimal_places: Decimal precision for numeric output
76 timestamp_format: Format for timestamps
77 """
79 default_format: str = "csv"
80 include_raw_data: bool = False
81 compress_output: bool = False
82 decimal_places: int = 6
83 timestamp_format: str = "iso8601"
86@dataclass
87class Settings:
88 """Application-wide settings.
90 Attributes:
91 cli: CLI defaults
92 analysis: Analysis settings
93 output: Output settings
94 features: Feature flags
95 custom: Custom user-defined settings
97 Example:
98 >>> settings = Settings()
99 >>> settings.cli.verbosity = 2
100 >>> settings.analysis.max_trace_size = 1024**3 # 1 GB
101 """
103 cli: CLIDefaults = field(default_factory=CLIDefaults)
104 analysis: AnalysisSettings = field(default_factory=AnalysisSettings)
105 output: OutputSettings = field(default_factory=OutputSettings)
106 features: dict[str, bool] = field(default_factory=dict)
107 custom: dict[str, Any] = field(default_factory=dict)
109 def enable_feature(self, name: str) -> None:
110 """Enable a feature flag.
112 Args:
113 name: Feature name
115 Example:
116 >>> settings.enable_feature("advanced_analysis")
117 """
118 self.features[name] = True
119 logger.debug(f"Feature enabled: {name}")
121 def disable_feature(self, name: str) -> None:
122 """Disable a feature flag.
124 Args:
125 name: Feature name
127 Example:
128 >>> settings.disable_feature("experimental_mode")
129 """
130 self.features[name] = False
131 logger.debug(f"Feature disabled: {name}")
133 def is_feature_enabled(self, name: str) -> bool:
134 """Check if a feature is enabled.
136 Args:
137 name: Feature name
139 Returns:
140 True if feature is enabled, False otherwise
142 Example:
143 >>> if settings.is_feature_enabled("advanced_analysis"):
144 ... perform_analysis()
145 """
146 return self.features.get(name, False)
148 def get(self, key: str, default: Any = None) -> Any:
149 """Get setting value by dot-notation key.
151 Args:
152 key: Key path (e.g., "cli.verbosity" or "custom.my_setting")
153 default: Default value if not found
155 Returns:
156 Setting value
158 Example:
159 >>> settings.get("cli.verbosity")
160 1
161 >>> settings.get("custom.undefined", "fallback")
162 'fallback'
163 """
164 parts = key.split(".")
165 obj: Any = self
167 for part in parts:
168 if isinstance(obj, dict):
169 obj = obj.get(part, default)
170 if obj is default: 170 ↛ 171line 170 didn't jump to line 171 because the condition on line 170 was never true
171 return default
172 elif hasattr(obj, part):
173 obj = getattr(obj, part)
174 else:
175 return default
177 return obj
179 def set(self, key: str, value: Any) -> None:
180 """Set setting value by dot-notation key.
182 Args:
183 key: Key path (e.g., "cli.verbosity" or "custom.my_setting")
184 value: Value to set
186 Raises:
187 KeyError: If path is invalid
189 Example:
190 >>> settings.set("cli.verbosity", 2)
191 >>> settings.set("custom.my_setting", 42)
192 """
193 parts = key.split(".")
195 if parts[0] == "custom":
196 # Custom settings are a dict
197 if len(parts) == 2:
198 self.custom[parts[1]] = value
199 else:
200 # Nested custom settings
201 current = self.custom
202 for part in parts[1:-1]:
203 if part not in current: 203 ↛ 205line 203 didn't jump to line 205 because the condition on line 203 was always true
204 current[part] = {}
205 current = current[part]
206 current[parts[-1]] = value
207 return
209 # Navigate to parent
210 obj: Any = self
211 for part in parts[:-1]:
212 if hasattr(obj, part):
213 obj = getattr(obj, part)
214 else:
215 raise KeyError(f"Invalid setting path: {key}")
217 # Set value
218 final_part = parts[-1]
219 if hasattr(obj, final_part):
220 setattr(obj, final_part, value)
221 else:
222 raise KeyError(f"Unknown setting: {key}")
224 def to_dict(self) -> dict[str, Any]:
225 """Convert settings to dictionary.
227 Returns:
228 Dictionary representation
229 """
230 return {
231 "cli": {
232 "output_format": self.cli.output_format,
233 "verbosity": self.cli.verbosity,
234 "color_output": self.cli.color_output,
235 "progress_bar": self.cli.progress_bar,
236 "parallel_workers": self.cli.parallel_workers,
237 },
238 "analysis": {
239 "max_trace_size": self.analysis.max_trace_size,
240 "enable_caching": self.analysis.enable_caching,
241 "cache_dir": self.analysis.cache_dir,
242 "timeout": self.analysis.timeout,
243 "streaming_mode": self.analysis.streaming_mode,
244 },
245 "output": {
246 "default_format": self.output.default_format,
247 "include_raw_data": self.output.include_raw_data,
248 "compress_output": self.output.compress_output,
249 "decimal_places": self.output.decimal_places,
250 "timestamp_format": self.output.timestamp_format,
251 },
252 "features": self.features,
253 "custom": self.custom,
254 }
256 @classmethod
257 def from_dict(cls, data: dict[str, Any]) -> Settings:
258 """Create settings from dictionary.
260 Args:
261 data: Dictionary representation
263 Returns:
264 Settings instance
265 """
266 settings = cls()
268 # CLI settings
269 if "cli" in data:
270 c = data["cli"]
271 settings.cli = CLIDefaults(
272 output_format=c.get("output_format", settings.cli.output_format),
273 verbosity=c.get("verbosity", settings.cli.verbosity),
274 color_output=c.get("color_output", settings.cli.color_output),
275 progress_bar=c.get("progress_bar", settings.cli.progress_bar),
276 parallel_workers=c.get("parallel_workers", settings.cli.parallel_workers),
277 )
279 # Analysis settings
280 if "analysis" in data:
281 a = data["analysis"]
282 settings.analysis = AnalysisSettings(
283 max_trace_size=a.get("max_trace_size", settings.analysis.max_trace_size),
284 enable_caching=a.get("enable_caching", settings.analysis.enable_caching),
285 cache_dir=a.get("cache_dir", settings.analysis.cache_dir),
286 timeout=a.get("timeout", settings.analysis.timeout),
287 streaming_mode=a.get("streaming_mode", settings.analysis.streaming_mode),
288 )
290 # Output settings
291 if "output" in data:
292 o = data["output"]
293 settings.output = OutputSettings(
294 default_format=o.get("default_format", settings.output.default_format),
295 include_raw_data=o.get("include_raw_data", settings.output.include_raw_data),
296 compress_output=o.get("compress_output", settings.output.compress_output),
297 decimal_places=o.get("decimal_places", settings.output.decimal_places),
298 timestamp_format=o.get("timestamp_format", settings.output.timestamp_format),
299 )
301 settings.features = data.get("features", {})
302 settings.custom = data.get("custom", {})
304 return settings
307# Global settings instance
308_global_settings: Settings | None = None
311def get_settings() -> Settings:
312 """Get global application settings.
314 Returns:
315 Global Settings instance
316 """
317 global _global_settings
318 if _global_settings is None: 318 ↛ 319line 318 didn't jump to line 319 because the condition on line 318 was never true
319 _global_settings = Settings()
320 return _global_settings
323def set_settings(settings: Settings) -> None:
324 """Set global application settings.
326 Args:
327 settings: Settings instance to use globally
328 """
329 global _global_settings
330 _global_settings = settings
331 logger.debug("Global settings updated")
334def reset_settings() -> None:
335 """Reset settings to defaults."""
336 global _global_settings
337 _global_settings = Settings()
338 logger.debug("Settings reset to defaults")
341def load_settings(path: Path | str) -> Settings:
342 """Load settings from a JSON file.
344 Args:
345 path: Path to settings file
347 Returns:
348 Loaded Settings instance
350 Raises:
351 ConfigurationError: If file cannot be read or parsed
353 Example:
354 >>> settings = load_settings("settings.json")
355 """
356 try:
357 path_obj = Path(path).expanduser()
359 if not path_obj.exists():
360 raise ConfigurationError(f"Settings file not found: {path}")
362 with open(path_obj, encoding="utf-8") as f:
363 data = json.load(f)
365 if not isinstance(data, dict):
366 raise ConfigurationError("Settings file must contain a JSON object")
368 logger.debug(f"Loaded settings from {path_obj}")
369 return Settings.from_dict(data)
371 except json.JSONDecodeError as e:
372 raise ConfigurationError(f"Failed to parse settings JSON: {e}") from e
373 except OSError as e:
374 raise ConfigurationError(f"Failed to read settings file: {e}") from e
375 except Exception as e:
376 raise ConfigurationError(f"Error loading settings: {e}") from e
379def save_settings(settings: Settings, path: Path | str) -> None:
380 """Save settings to a JSON file.
382 Args:
383 settings: Settings to save
384 path: Path to save settings to
386 Raises:
387 ConfigurationError: If file cannot be written
389 Example:
390 >>> settings = get_settings()
391 >>> save_settings(settings, "settings.json")
392 """
393 try:
394 path_obj = Path(path).expanduser()
396 # Create parent directory if needed
397 path_obj.parent.mkdir(parents=True, exist_ok=True)
399 with open(path_obj, "w", encoding="utf-8") as f:
400 json.dump(settings.to_dict(), f, indent=2)
402 logger.debug(f"Saved settings to {path_obj}")
404 except OSError as e:
405 raise ConfigurationError(f"Failed to write settings file: {e}") from e
406 except Exception as e:
407 raise ConfigurationError(f"Error saving settings: {e}") from e
410__all__ = [
411 "AnalysisSettings",
412 "CLIDefaults",
413 "OutputSettings",
414 "Settings",
415 "get_settings",
416 "load_settings",
417 "reset_settings",
418 "save_settings",
419 "set_settings",
420]