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

1"""Application settings management system. 

2 

3This module provides application-wide settings management for TraceKit, 

4including feature flags, CLI defaults, output formats, and runtime 

5configuration options. 

6 

7 

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""" 

15 

16from __future__ import annotations 

17 

18import json 

19import logging 

20from dataclasses import dataclass, field 

21from pathlib import Path 

22from typing import Any 

23 

24from tracekit.core.exceptions import ConfigurationError 

25 

26logger = logging.getLogger(__name__) 

27 

28 

29@dataclass 

30class CLIDefaults: 

31 """CLI default settings. 

32 

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 """ 

40 

41 output_format: str = "text" 

42 verbosity: int = 1 

43 color_output: bool = True 

44 progress_bar: bool = True 

45 parallel_workers: int = 4 

46 

47 

48@dataclass 

49class AnalysisSettings: 

50 """Analysis configuration settings. 

51 

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 """ 

59 

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 

65 

66 

67@dataclass 

68class OutputSettings: 

69 """Output and export configuration. 

70 

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 """ 

78 

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" 

84 

85 

86@dataclass 

87class Settings: 

88 """Application-wide settings. 

89 

90 Attributes: 

91 cli: CLI defaults 

92 analysis: Analysis settings 

93 output: Output settings 

94 features: Feature flags 

95 custom: Custom user-defined settings 

96 

97 Example: 

98 >>> settings = Settings() 

99 >>> settings.cli.verbosity = 2 

100 >>> settings.analysis.max_trace_size = 1024**3 # 1 GB 

101 """ 

102 

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) 

108 

109 def enable_feature(self, name: str) -> None: 

110 """Enable a feature flag. 

111 

112 Args: 

113 name: Feature name 

114 

115 Example: 

116 >>> settings.enable_feature("advanced_analysis") 

117 """ 

118 self.features[name] = True 

119 logger.debug(f"Feature enabled: {name}") 

120 

121 def disable_feature(self, name: str) -> None: 

122 """Disable a feature flag. 

123 

124 Args: 

125 name: Feature name 

126 

127 Example: 

128 >>> settings.disable_feature("experimental_mode") 

129 """ 

130 self.features[name] = False 

131 logger.debug(f"Feature disabled: {name}") 

132 

133 def is_feature_enabled(self, name: str) -> bool: 

134 """Check if a feature is enabled. 

135 

136 Args: 

137 name: Feature name 

138 

139 Returns: 

140 True if feature is enabled, False otherwise 

141 

142 Example: 

143 >>> if settings.is_feature_enabled("advanced_analysis"): 

144 ... perform_analysis() 

145 """ 

146 return self.features.get(name, False) 

147 

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

149 """Get setting value by dot-notation key. 

150 

151 Args: 

152 key: Key path (e.g., "cli.verbosity" or "custom.my_setting") 

153 default: Default value if not found 

154 

155 Returns: 

156 Setting value 

157 

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 

166 

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 

176 

177 return obj 

178 

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

180 """Set setting value by dot-notation key. 

181 

182 Args: 

183 key: Key path (e.g., "cli.verbosity" or "custom.my_setting") 

184 value: Value to set 

185 

186 Raises: 

187 KeyError: If path is invalid 

188 

189 Example: 

190 >>> settings.set("cli.verbosity", 2) 

191 >>> settings.set("custom.my_setting", 42) 

192 """ 

193 parts = key.split(".") 

194 

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 

208 

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

216 

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

223 

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

225 """Convert settings to dictionary. 

226 

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 } 

255 

256 @classmethod 

257 def from_dict(cls, data: dict[str, Any]) -> Settings: 

258 """Create settings from dictionary. 

259 

260 Args: 

261 data: Dictionary representation 

262 

263 Returns: 

264 Settings instance 

265 """ 

266 settings = cls() 

267 

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 ) 

278 

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 ) 

289 

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 ) 

300 

301 settings.features = data.get("features", {}) 

302 settings.custom = data.get("custom", {}) 

303 

304 return settings 

305 

306 

307# Global settings instance 

308_global_settings: Settings | None = None 

309 

310 

311def get_settings() -> Settings: 

312 """Get global application settings. 

313 

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 

321 

322 

323def set_settings(settings: Settings) -> None: 

324 """Set global application settings. 

325 

326 Args: 

327 settings: Settings instance to use globally 

328 """ 

329 global _global_settings 

330 _global_settings = settings 

331 logger.debug("Global settings updated") 

332 

333 

334def reset_settings() -> None: 

335 """Reset settings to defaults.""" 

336 global _global_settings 

337 _global_settings = Settings() 

338 logger.debug("Settings reset to defaults") 

339 

340 

341def load_settings(path: Path | str) -> Settings: 

342 """Load settings from a JSON file. 

343 

344 Args: 

345 path: Path to settings file 

346 

347 Returns: 

348 Loaded Settings instance 

349 

350 Raises: 

351 ConfigurationError: If file cannot be read or parsed 

352 

353 Example: 

354 >>> settings = load_settings("settings.json") 

355 """ 

356 try: 

357 path_obj = Path(path).expanduser() 

358 

359 if not path_obj.exists(): 

360 raise ConfigurationError(f"Settings file not found: {path}") 

361 

362 with open(path_obj, encoding="utf-8") as f: 

363 data = json.load(f) 

364 

365 if not isinstance(data, dict): 

366 raise ConfigurationError("Settings file must contain a JSON object") 

367 

368 logger.debug(f"Loaded settings from {path_obj}") 

369 return Settings.from_dict(data) 

370 

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 

377 

378 

379def save_settings(settings: Settings, path: Path | str) -> None: 

380 """Save settings to a JSON file. 

381 

382 Args: 

383 settings: Settings to save 

384 path: Path to save settings to 

385 

386 Raises: 

387 ConfigurationError: If file cannot be written 

388 

389 Example: 

390 >>> settings = get_settings() 

391 >>> save_settings(settings, "settings.json") 

392 """ 

393 try: 

394 path_obj = Path(path).expanduser() 

395 

396 # Create parent directory if needed 

397 path_obj.parent.mkdir(parents=True, exist_ok=True) 

398 

399 with open(path_obj, "w", encoding="utf-8") as f: 

400 json.dump(settings.to_dict(), f, indent=2) 

401 

402 logger.debug(f"Saved settings to {path_obj}") 

403 

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 

408 

409 

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]