Coverage for src / ezqt_app / services / config / config_service.py: 62.66%

299 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-06 13:12 +0000

1# /////////////////////////////////////////////////////////////// 

2# SERVICES.CONFIG.CONFIG_SERVICE - Config service implementation 

3# Project: ezqt_app 

4# /////////////////////////////////////////////////////////////// 

5 

6"""Configuration service — full implementation (absorbs ConfigManager logic).""" 

7 

8from __future__ import annotations 

9 

10# /////////////////////////////////////////////////////////////// 

11# IMPORTS 

12# /////////////////////////////////////////////////////////////// 

13# Standard library imports 

14import shutil 

15import sys 

16from collections.abc import Mapping, MutableMapping 

17from importlib.resources import files as _pkg_files 

18from pathlib import Path 

19from typing import Any 

20 

21import yaml 

22from pydantic import BaseModel, ConfigDict, ValidationError 

23 

24# Third-party imports 

25from ruamel.yaml import YAML 

26from ruamel.yaml.comments import CommentedMap 

27 

28# Local imports 

29from ...domain.ports.config_service import ConfigServiceProtocol 

30from ...utils.diagnostics import warn_user 

31from ...utils.printer import get_printer 

32from ...utils.runtime_paths import APP_PATH, get_bin_path 

33 

34 

35# /////////////////////////////////////////////////////////////// 

36# PYDANTIC VALIDATION MODELS 

37# /////////////////////////////////////////////////////////////// 

38class _AppSectionSchema(BaseModel): 

39 """Validation schema for the ``app`` section in app config.""" 

40 

41 model_config = ConfigDict(extra="forbid") 

42 

43 name: str | None = None 

44 description: str | None = None 

45 app_width: int | None = None 

46 app_min_width: int | None = None 

47 app_height: int | None = None 

48 app_min_height: int | None = None 

49 debug_printer: bool | None = None 

50 menu_panel_shrinked_width: int | None = None 

51 menu_panel_extended_width: int | None = None 

52 settings_panel_width: int | None = None 

53 time_animation: int | None = None 

54 settings_storage_root: str | None = None 

55 config_version: int | None = None 

56 

57 

58class _SettingsPanelOptionSchema(BaseModel): 

59 """Validation schema for one settings panel entry.""" 

60 

61 model_config = ConfigDict(extra="forbid") 

62 

63 type: str | None = None 

64 label: str | None = None 

65 default: Any = None 

66 description: str | None = None 

67 enabled: bool | None = None 

68 options: list[str] | None = None 

69 min: int | None = None 

70 max: int | None = None 

71 unit: str | None = None 

72 

73 

74class _AppConfigSchema(BaseModel): 

75 """Validation schema for ``app.config.yaml`` payload.""" 

76 

77 model_config = ConfigDict(extra="forbid") 

78 

79 app: _AppSectionSchema | None = None 

80 settings_panel: dict[str, _SettingsPanelOptionSchema] | None = None 

81 

82 

83class _TranslationSectionSchema(BaseModel): 

84 """Validation schema for the ``translation`` section.""" 

85 

86 model_config = ConfigDict(extra="forbid") 

87 

88 collect_strings: bool | None = None 

89 auto_translation_enabled: bool | None = None 

90 auto_translate_new_strings: bool | None = None 

91 save_to_ts_files: bool | None = None 

92 cache_translations: bool | None = None 

93 max_cache_age_days: int | None = None 

94 

95 

96class _LanguageDetectionSectionSchema(BaseModel): 

97 """Validation schema for the ``language_detection`` section.""" 

98 

99 model_config = ConfigDict(extra="forbid") 

100 

101 auto_detect_language: bool | None = None 

102 confidence_threshold: float | None = None 

103 

104 

105class _SupportedLanguageSchema(BaseModel): 

106 """Validation schema for one supported language entry.""" 

107 

108 model_config = ConfigDict(extra="forbid") 

109 

110 code: str 

111 name: str 

112 native_name: str 

113 description: str 

114 

115 

116class _TranslationConfigSchema(BaseModel): 

117 """Validation schema for ``translation.config.yaml`` payload.""" 

118 

119 model_config = ConfigDict(extra="forbid") 

120 

121 translation: _TranslationSectionSchema | None = None 

122 language_detection: _LanguageDetectionSectionSchema | None = None 

123 supported_languages: list[_SupportedLanguageSchema] | None = None 

124 

125 

126class _ThemeConfigSchema(BaseModel): 

127 """Validation schema for ``theme.config.yaml`` payload.""" 

128 

129 model_config = ConfigDict(extra="forbid") 

130 

131 palette: dict[str, Any] 

132 

133 

134# /////////////////////////////////////////////////////////////// 

135# CLASSES 

136# /////////////////////////////////////////////////////////////// 

137class ConfigService(ConfigServiceProtocol): 

138 """Modular configuration service for EzQt_App.""" 

139 

140 def __init__(self): 

141 self._config_cache: dict[str, Any] = {} 

142 self._config_files: dict[str, Path] = {} 

143 self._project_root: Path | None = None 

144 self._yaml_writer = YAML(typ="rt") 

145 self._yaml_writer.preserve_quotes = True 

146 self._yaml_writer.default_flow_style = False 

147 self._yaml_writer.indent(mapping=2, sequence=4, offset=2) 

148 

149 # ----------------------------------------------------------- 

150 # Port methods (ConfigServiceProtocol) 

151 # ----------------------------------------------------------- 

152 

153 def set_project_root(self, project_root: Path | str) -> None: 

154 """Set the active project root directory.""" 

155 self._project_root = ( 

156 project_root if isinstance(project_root, Path) else Path(project_root) 

157 ) 

158 from ...utils.runtime_paths import _sync_bin_path_from_root 

159 

160 _sync_bin_path_from_root(self._project_root / "bin") 

161 

162 def load_config( 

163 self, config_name: str, force_reload: bool = False 

164 ) -> dict[str, Any]: 

165 """Load a named configuration from the first matching path. 

166 

167 Parameters 

168 ---------- 

169 config_name: 

170 Configuration file name (without extension, e.g. ``"app"``). 

171 force_reload: 

172 Bypass cache and reload from disk. 

173 

174 Returns 

175 ------- 

176 dict[str, Any] 

177 Loaded configuration data, or empty dict on failure. 

178 """ 

179 if not force_reload and config_name in self._config_cache: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 return self._config_cache[config_name] 

181 

182 config_file = self._resolve_config_file(config_name) 

183 

184 if not config_file: 

185 warn_user( 

186 code="config.service.missing_file", 

187 user_message=f"No configuration file found for '{config_name}'", 

188 log_message=f"No configuration file found for '{config_name}'", 

189 ) 

190 get_printer().verbose_msg( 

191 f"Searched paths: {self.get_config_paths(config_name)}" 

192 ) 

193 return {} 

194 

195 try: 

196 with open(config_file, encoding="utf-8") as f: 

197 config_data = yaml.safe_load(f) 

198 

199 self._config_cache[config_name] = config_data 

200 self._config_files[config_name] = config_file 

201 

202 get_printer().verbose_msg( 

203 f"Configuration '{config_name}' loaded from: {config_file}" 

204 ) 

205 return config_data 

206 

207 except Exception as e: 

208 get_printer().error(f"Error loading '{config_name}': {e}") 

209 return {} 

210 

211 def get_config_value( 

212 self, config_name: str, key_path: str, default: Any = None 

213 ) -> Any: 

214 """Read a specific value from a configuration using dot-notation key path. 

215 

216 Parameters 

217 ---------- 

218 config_name: 

219 Configuration file name. 

220 key_path: 

221 Dot-separated path (e.g. ``"app.name"`` or ``"palette.dark"``). 

222 default: 

223 Value returned when key is absent. 

224 """ 

225 config = self.load_config(config_name) 

226 keys = key_path.split(".") 

227 current = config 

228 

229 for key in keys: 

230 if isinstance(current, dict) and key in current: 230 ↛ 233line 230 didn't jump to line 233 because the condition on line 230 was always true

231 current = current[key] 

232 else: 

233 return default 

234 

235 return current 

236 

237 def save_config(self, config_name: str, config_data: dict[str, Any]) -> bool: 

238 """Persist a named configuration to the project directory. 

239 

240 Writes use ``ruamel.yaml`` round-trip mode to preserve existing comments, 

241 key ordering, and formatting whenever the target file already exists. 

242 Configuration reads stay on ``PyYAML`` for the current typed read path. 

243 

244 Parameters 

245 ---------- 

246 config_name: 

247 Configuration file name. 

248 config_data: 

249 Data to serialise as YAML. 

250 

251 Returns 

252 ------- 

253 bool 

254 ``True`` if the write succeeded. 

255 """ 

256 config_file: Path 

257 if config_name in self._config_files: 

258 # Keep writes consistent with the file originally loaded. 

259 config_file = self._config_files[config_name] 

260 config_file.parent.mkdir(parents=True, exist_ok=True) 

261 else: 

262 config_dir = get_bin_path() / "config" 

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

264 config_file = config_dir / f"{config_name}.config.yaml" 

265 

266 try: 

267 if not self._validate_config_payload(config_name, config_data): 267 ↛ 268line 267 didn't jump to line 268 because the condition on line 267 was never true

268 return False 

269 

270 existing_doc: MutableMapping[str, Any] | None = None 

271 if config_file.exists(): 

272 with open(config_file, encoding="utf-8") as f: 

273 loaded_doc = self._yaml_writer.load(f) 

274 if isinstance(loaded_doc, MutableMapping): 274 ↛ 277line 274 didn't jump to line 277 because the condition on line 274 was always true

275 existing_doc = loaded_doc 

276 

277 if existing_doc is None: 

278 payload: MutableMapping[str, Any] = self._to_yaml_mapping(config_data) 

279 else: 

280 payload = self._merge_yaml_mapping(existing_doc, config_data) 

281 

282 with open(config_file, "w", encoding="utf-8") as f: 

283 self._yaml_writer.dump(payload, f) 

284 

285 self._config_cache[config_name] = config_data 

286 self._config_files[config_name] = config_file 

287 

288 get_printer().verbose_msg( 

289 f"Configuration '{config_name}' saved: {config_file}" 

290 ) 

291 return True 

292 

293 except Exception as e: 

294 get_printer().error(f"Error saving '{config_name}': {e}") 

295 return False 

296 

297 def _validate_config_payload( 

298 self, config_name: str, config_data: dict[str, Any] 

299 ) -> bool: 

300 """Validate known configuration payloads with permissive Pydantic schemas.""" 

301 schema_map: dict[str, type[BaseModel]] = { 

302 "app": _AppConfigSchema, 

303 "translation": _TranslationConfigSchema, 

304 "theme": _ThemeConfigSchema, 

305 } 

306 

307 schema = schema_map.get(config_name) 

308 if schema is None: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true

309 return True 

310 

311 try: 

312 schema.model_validate(config_data) 

313 return True 

314 except ValidationError as exc: 

315 warn_user( 

316 code="config.service.validation_failed", 

317 user_message=( 

318 f"Invalid '{config_name}' configuration; file was not written." 

319 ), 

320 log_message=( 

321 f"Validation failed for '{config_name}' configuration: {exc}" 

322 ), 

323 ) 

324 get_printer().error( 

325 f"Validation failed for '{config_name}' configuration: {exc}" 

326 ) 

327 return False 

328 

329 def _to_yaml_mapping(self, value: Mapping[str, Any]) -> CommentedMap: 

330 """Convert a standard mapping to a ruamel-compatible ``CommentedMap``.""" 

331 converted = CommentedMap() 

332 for key, item in value.items(): 

333 converted[key] = self._to_yaml_value(item) 

334 return converted 

335 

336 def _to_yaml_value(self, value: Any) -> Any: 

337 """Recursively convert Python containers to ruamel round-trip containers.""" 

338 if isinstance(value, Mapping): 

339 return self._to_yaml_mapping(value) 

340 if isinstance(value, list): 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true

341 return [self._to_yaml_value(item) for item in value] 

342 return value 

343 

344 def _merge_yaml_mapping( 

345 self, 

346 target: MutableMapping[str, Any], 

347 source: Mapping[str, Any], 

348 ) -> MutableMapping[str, Any]: 

349 """Merge ``source`` values into ``target`` while preserving YAML metadata.""" 

350 for existing_key in list(target.keys()): 

351 if existing_key not in source: 351 ↛ 352line 351 didn't jump to line 352 because the condition on line 351 was never true

352 del target[existing_key] 

353 

354 for key, source_value in source.items(): 

355 current_value = target.get(key) 

356 if isinstance(source_value, Mapping) and isinstance( 

357 current_value, MutableMapping 

358 ): 

359 target[key] = self._merge_yaml_mapping(current_value, source_value) 

360 continue 

361 

362 if isinstance(source_value, Mapping): 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true

363 target[key] = self._to_yaml_mapping(source_value) 

364 continue 

365 

366 if isinstance(source_value, list): 366 ↛ 367line 366 didn't jump to line 367 because the condition on line 366 was never true

367 target[key] = [self._to_yaml_value(item) for item in source_value] 

368 continue 

369 

370 target[key] = source_value 

371 

372 return target 

373 

374 # ----------------------------------------------------------- 

375 # Implementation-specific methods 

376 # ----------------------------------------------------------- 

377 

378 def get_config_paths(self, config_name: str) -> list[Path]: 

379 """Return candidate paths for *config_name* in priority order. 

380 

381 Parameters 

382 ---------- 

383 config_name: 

384 Logical configuration name (e.g. ``"app"``, ``"languages"``, 

385 ``"theme"``). Files are resolved as ``<name>.config.yaml``. 

386 """ 

387 config_file = f"{config_name}.config.yaml" 

388 paths: list[Path] = [] 

389 

390 # 1. Configured bin directory 

391 paths.append(get_bin_path() / "config" / config_file) 

392 

393 # 3. Package resources — relative to cwd 

394 paths.append(Path.cwd() / "ezqt_app" / "resources" / "config" / config_file) 

395 

396 # 4. Package resources — relative to APP_PATH 

397 paths.append(APP_PATH / "resources" / "config" / config_file) 

398 

399 return paths 

400 

401 def copy_package_configs_to_project(self) -> bool: 

402 """Copy package configuration files into the child project. 

403 

404 Returns 

405 ------- 

406 bool 

407 ``True`` if the operation succeeded. 

408 """ 

409 if not self._project_root: 

410 get_printer().error("[ConfigService] No project root defined") 

411 return False 

412 

413 package_config_dir = self._find_package_config_dir() 

414 

415 if not package_config_dir: 

416 get_printer().error( 

417 f"[ConfigService] EzQt_App package not found. Tested paths: " 

418 f"{[str(p) for p in [Path.cwd(), APP_PATH]]}" 

419 ) 

420 return False 

421 

422 project_config_dir = get_bin_path() / "config" 

423 project_config_dir.mkdir(parents=True, exist_ok=True) 

424 

425 copied_files: list[str] = [] 

426 

427 try: 

428 for config_file in package_config_dir.glob("*.yaml"): 

429 target_file = project_config_dir / config_file.name 

430 

431 if target_file.exists(): 

432 get_printer().verbose_msg(f"Existing file, ignored: {target_file}") 

433 continue 

434 

435 shutil.copy2(config_file, target_file) 

436 copied_files.append(config_file.name) 

437 get_printer().action( 

438 f"[ConfigService] Configuration copied: {config_file.name}" 

439 ) 

440 

441 if copied_files: 

442 get_printer().action( 

443 "[ConfigService] " 

444 f"{len(copied_files)} configurations copied to project" 

445 ) 

446 

447 return True 

448 

449 except Exception as e: 

450 get_printer().error(f"Error copying configurations: {e}") 

451 return False 

452 

453 def clear_cache(self) -> None: 

454 """Invalidate the in-memory configuration cache.""" 

455 self._config_cache.clear() 

456 self._config_files.clear() 

457 get_printer().verbose_msg("Configuration cache cleared") 

458 

459 def get_loaded_configs(self) -> dict[str, Path]: 

460 """Return a snapshot of currently cached configuration paths.""" 

461 return self._config_files.copy() 

462 

463 # ----------------------------------------------------------- 

464 # Private helpers 

465 # ----------------------------------------------------------- 

466 

467 def _resolve_config_file(self, config_name: str) -> Path | None: 

468 """Return the first existing path for *config_name*, or ``None``.""" 

469 for path in self.get_config_paths(config_name): 

470 if path.exists(): 

471 return path 

472 return None 

473 

474 def _find_package_config_dir(self) -> Path | None: 

475 """Locate the ``resources/config`` directory inside the installed package.""" 

476 package_root = _get_installed_package_root() 

477 if package_root is not None: 

478 installed_config_dir = package_root / "resources" / "config" 

479 if installed_config_dir.exists(): 

480 return installed_config_dir 

481 

482 # 1. Walk up from cwd looking for an ezqt_app package dir 

483 current = Path.cwd() 

484 while current.parent != current: 

485 candidate = current / "ezqt_app" 

486 if candidate.exists() and (candidate / "resources" / "config").exists(): 

487 config_dir = candidate / "resources" / "config" 

488 get_printer().verbose_msg(f"EzQt_App package found: {candidate}") 

489 get_printer().verbose_msg(f"Configuration directory: {config_dir}") 

490 return config_dir 

491 current = current.parent 

492 

493 # 2. Search sys.path entries 

494 for path in sys.path: 

495 candidate = Path(path) / "ezqt_app" 

496 if candidate.exists() and (candidate / "resources" / "config").exists(): 

497 return candidate / "resources" / "config" 

498 

499 # 3. Fallback — legacy known paths 

500 for fallback in [ 

501 Path.cwd() / "ezqt_app" / "resources" / "config", 

502 APP_PATH / "resources" / "config", 

503 APP_PATH / "ezqt_app" / "resources" / "config", 

504 ]: 

505 if fallback.exists(): 

506 get_printer().verbose_msg( 

507 f"Configuration directory found (fallback): {fallback}" 

508 ) 

509 return fallback 

510 

511 return None 

512 

513 

514# /////////////////////////////////////////////////////////////// 

515# SINGLETONS / FUNCTIONS 

516# /////////////////////////////////////////////////////////////// 

517 

518 

519def _get_installed_package_root() -> Path | None: 

520 """Return installed ``ezqt_app`` package root when discoverable.""" 

521 try: 

522 import importlib.util 

523 

524 spec = importlib.util.find_spec("ezqt_app") 

525 if spec is not None: 525 ↛ 541line 525 didn't jump to line 541 because the condition on line 525 was always true

526 if spec.submodule_search_locations: 526 ↛ 532line 526 didn't jump to line 532 because the condition on line 526 was always true

527 location = next(iter(spec.submodule_search_locations), None) 

528 if location: 528 ↛ 532line 528 didn't jump to line 532 because the condition on line 528 was always true

529 candidate = Path(location) 

530 if candidate.exists(): 530 ↛ 532line 530 didn't jump to line 532 because the condition on line 530 was always true

531 return candidate 

532 if spec.origin: 

533 candidate = Path(spec.origin).resolve().parent 

534 if candidate.exists(): 

535 return candidate 

536 except Exception as e: 

537 get_printer().verbose_msg( 

538 f"Could not resolve installed package root for ezqt_app: {e}" 

539 ) 

540 

541 return None 

542 

543 

544def _resource_candidates(resource_path: str) -> list[Path]: 

545 """Build candidate paths for a package resource in priority order.""" 

546 rel_path = Path(resource_path.replace("\\", "/")) 

547 candidates: list[Path] = [] 

548 

549 package_root = _get_installed_package_root() 

550 if package_root is not None: 550 ↛ 553line 550 didn't jump to line 553 because the condition on line 550 was always true

551 candidates.append(package_root / rel_path) 

552 

553 candidates.extend( 

554 [ 

555 APP_PATH / "ezqt_app" / rel_path, 

556 APP_PATH / rel_path, 

557 Path.cwd() / "ezqt_app" / rel_path, 

558 ] 

559 ) 

560 return candidates 

561 

562 

563# /////////////////////////////////////////////////////////////// 

564# FUNCTIONS 

565# /////////////////////////////////////////////////////////////// 

566def get_config_service() -> ConfigService: 

567 """Return the singleton configuration service instance.""" 

568 from .._registry import ServiceRegistry 

569 

570 return ServiceRegistry.get(ConfigService, ConfigService) 

571 

572 

573def get_package_resource(resource_path: str) -> Path: 

574 """Return the filesystem path of an installed package resource. 

575 

576 Parameters 

577 ---------- 

578 resource_path: 

579 Resource path relative to the ``ezqt_app`` package root. 

580 

581 Returns 

582 ------- 

583 Path 

584 Resolved path to the resource. 

585 """ 

586 for candidate in _resource_candidates(resource_path): 586 ↛ 590line 586 didn't jump to line 590 because the loop on line 586 didn't complete

587 if candidate.exists(): 587 ↛ 586line 587 didn't jump to line 586 because the condition on line 587 was always true

588 return candidate 

589 

590 try: 

591 pkg_candidate = Path(str(_pkg_files("ezqt_app").joinpath(resource_path))) 

592 if pkg_candidate.exists(): 

593 return pkg_candidate 

594 except Exception as e: 

595 get_printer().verbose_msg( 

596 f"importlib.resources lookup failed for '{resource_path}': {e}" 

597 ) 

598 

599 package_root = _get_installed_package_root() 

600 if package_root is not None: 

601 return package_root / Path(resource_path.replace("\\", "/")) 

602 return APP_PATH / "ezqt_app" / Path(resource_path.replace("\\", "/")) 

603 

604 

605def get_package_resource_content(resource_path: str) -> str: 

606 """Return the decoded UTF-8 content of an installed package resource. 

607 

608 Parameters 

609 ---------- 

610 resource_path: 

611 Resource path relative to the ``ezqt_app`` package root. 

612 

613 Returns 

614 ------- 

615 str 

616 Resource file content. 

617 

618 Raises 

619 ------ 

620 FileNotFoundError 

621 If the resource cannot be located by any strategy. 

622 """ 

623 try: 

624 return ( 

625 _pkg_files("ezqt_app").joinpath(resource_path).read_text(encoding="utf-8") 

626 ) 

627 except Exception: 

628 file_path = get_package_resource(resource_path) 

629 if file_path.exists(): 

630 with open(file_path, encoding="utf-8") as f: 

631 return f.read() 

632 raise FileNotFoundError(f"Resource not found: {resource_path}") from None