Coverage for lynceus/core/config/lynceus_config.py: 99%

243 statements  

« prev     ^ index     » next       coverage.py v7.10.0, created at 2025-07-29 08:46 +0000

1import configparser 

2import json 

3from collections.abc import (Iterable, 

4 MutableMapping) 

5from configparser import ConfigParser 

6from copy import deepcopy 

7from json import (JSONDecodeError, 

8 JSONEncoder) 

9from logging import Logger 

10from pathlib import Path 

11from typing import Any, Callable 

12 

13from lynceus.core.config import CONFIG_JSON_DUMP_KEY_END_KEYWORD 

14from lynceus.lynceus_exceptions import LynceusConfigError 

15from lynceus.utils import lookup_root_path 

16 

17 

18# pylint: disable=too-many-public-methods 

19class LynceusConfig(MutableMapping): 

20 """A dictionary that applies an arbitrary key-altering 

21 function before accessing the keys. 

22 

23 IMPORTANT: when a key does not exist, it is automatically created to ease definition like ['X']['Y']['Z'] = 'XXX' 

24 BUT: it means the 'in' operator should NOT be used, because it will always return True, because the checked key will be created if not existing, 

25 for such situation, you should use the lynceus.core.config.lynceus_config.LynceusConfig.has_section() method. 

26 """ 

27 

28 UNDEFINED_VALUE: str = '$*_UNDEFINED_*$' 

29 

30 def __init__(self, *args, **kwargs): 

31 """ 

32 Initialize a new LynceusConfig instance. 

33 

34 Parameters 

35 ---------- 

36 *args 

37 Positional arguments to initialize the config dictionary. 

38 **kwargs 

39 Keyword arguments to initialize the config dictionary. 

40 """ 

41 self.__store = {} 

42 self.update(dict(*args, **kwargs)) # use the free update to set keys 

43 

44 def __getitem__(self, key): 

45 """ 

46 Get an item from the config, automatically creating sub-dictionaries if needed. 

47 

48 Parameters 

49 ---------- 

50 key 

51 The key to retrieve. 

52 

53 Returns 

54 ------- 

55 dict 

56 The value associated with the key. 

57 """ 

58 # Creates automatically sub dictionary if needed. 

59 if LynceusConfig._keytransform(key) not in self.__store: 

60 self.__store[LynceusConfig._keytransform(key)] = {} 

61 

62 return self.__store[LynceusConfig._keytransform(key)] 

63 

64 def __setitem__(self, key, value): 

65 """ 

66 Set an item in the config. 

67 

68 Parameters 

69 ---------- 

70 key 

71 The key to set. 

72 value 

73 The value to associate with the key. 

74 """ 

75 self.__store[LynceusConfig._keytransform(key)] = LynceusConfig._valuetransform(value) 

76 

77 def __delitem__(self, key): 

78 """ 

79 Delete an item from the config. 

80 

81 Parameters 

82 ---------- 

83 key 

84 The key to delete. 

85 """ 

86 del self.__store[LynceusConfig._keytransform(key)] 

87 

88 def __iter__(self): 

89 """ 

90 Iterate over the config keys. 

91 

92 Returns 

93 ------- 

94 iterator 

95 Iterator over the config keys. 

96 """ 

97 return iter(self.__store) 

98 

99 def __len__(self): 

100 """ 

101 Get the number of items in the config. 

102 

103 Returns 

104 ------- 

105 int 

106 Number of items in the config. 

107 """ 

108 return len(self.__store) 

109 

110 @staticmethod 

111 def _keytransform(key): 

112 """ 

113 Transform a key before using it in the store. 

114 

115 Parameters 

116 ---------- 

117 key 

118 The key to transform. 

119 

120 Returns 

121 ------- 

122 str 

123 The transformed key. 

124 """ 

125 return key 

126 

127 @staticmethod 

128 def _valuetransform(value): 

129 """ 

130 Transform a value before storing it. 

131 

132 Parameters 

133 ---------- 

134 value 

135 The value to transform. 

136 

137 Returns 

138 ------- 

139 Any 

140 The transformed value. 

141 """ 

142 if isinstance(value, LynceusConfig): 

143 # pylint: disable=protected-access 

144 return value.__store 

145 

146 return value 

147 

148 def copy(self): 

149 """ 

150 Create a deep copy of this LynceusConfig instance. 

151 

152 Returns 

153 ------- 

154 LynceusConfig 

155 A new LynceusConfig instance with copied data. 

156 """ 

157 lynceus_config_copy: LynceusConfig = LynceusConfig() 

158 # pylint: disable=protected-access,unused-private-member 

159 lynceus_config_copy.__store = deepcopy(self.__store) 

160 return lynceus_config_copy 

161 

162 def as_dict(self): 

163 """ 

164 Convert this LynceusConfig to a standard dictionary. 

165 

166 Returns 

167 ------- 

168 dict 

169 A deep copy of the internal store as a dictionary. 

170 """ 

171 return deepcopy(self.__store) 

172 

173 @staticmethod 

174 def dump_config_parser(config_parser: ConfigParser) -> str: 

175 """ 

176 Convert a ConfigParser to a string representation. 

177 

178 Parameters 

179 ---------- 

180 config_parser : ConfigParser 

181 The ConfigParser instance to dump. 

182 

183 Returns 

184 ------- 

185 str 

186 String representation of the configuration. 

187 """ 

188 result: str = '' 

189 for config_section in config_parser.sections(): 

190 result += f'[{config_section}]\n' 

191 for key, value in config_parser.items(config_section): 

192 result += f'\t{key}={value}\n' 

193 return result 

194 

195 def get_config(self, section: str, key: str, *, default: str | int | float | object | list = UNDEFINED_VALUE) -> str | int | float | object | list: 

196 """ 

197 Get a configuration value from the specified section and key. 

198 

199 Parameters 

200 ---------- 

201 section : str 

202 The configuration section. 

203 key : str 

204 The configuration key. 

205 default : str or int or float or object or list, optional 

206 Default value to return if the config doesn't exist. 

207 

208 Returns 

209 ------- 

210 str or int or float or object or list 

211 The configuration value or default. 

212 

213 Raises 

214 ------ 

215 LynceusConfigError 

216 If the config doesn't exist and no default is provided. 

217 """ 

218 try: 

219 return self[section][key] 

220 except KeyError as error: 

221 # Safe-guard: raise an error if configuration not found and no default specified. 

222 if default == self.UNDEFINED_VALUE: 

223 raise LynceusConfigError(f'Configuration [\'{section}\'][\'{key}\'] does not exist in your configuration.') from error 

224 return default 

225 

226 def check_config_exists(self, section: str, key: str, *, transform_func=None) -> bool: 

227 """ 

228 Check if a configuration option exists and optionally transform its value. 

229 

230 This method verifies the existence of a configuration key under the specified 

231 section and can apply an optional transformation function to the value if found. 

232 The transformation is applied in-place, modifying the stored configuration value. 

233 

234 Args: 

235 section: Configuration section name to check 

236 key: Configuration key to verify within the section 

237 transform_func: Optional function to transform the value if it exists. 

238 Can be used for type casting or any value processing. 

239 Caller must handle any exceptions during transformation. 

240 

241 Returns: 

242 bool: True if the configuration option exists, False otherwise 

243 

244 Note: 

245 If transform_func is provided and the configuration exists, the transformed 

246 value replaces the original value in the configuration store. 

247 """ 

248 value = self.get_config(section, key, default=None) 

249 if value is None: 

250 return False 

251 

252 # Checks if there is a trasnformation to apply. 

253 if transform_func: 

254 # Transforms (and registers) the value for further usage. 

255 self[section][key] = transform_func(value) 

256 

257 # Returns the config option exists. 

258 return True 

259 

260 @staticmethod 

261 def to_bool(value: str | None) -> bool | None: 

262 """ 

263 Convert a string value to boolean. 

264 

265 Parameters 

266 ---------- 

267 value : str or None 

268 The value to convert. 

269 

270 Returns 

271 ------- 

272 bool or None 

273 True for '1', 'True', or True; False otherwise; None for None input. 

274 """ 

275 if value is None: 

276 return None 

277 return value in ['1', 'True', True] 

278 

279 def is_bool_config_enabled(self, section: str, key: str, *, default: bool = False) -> bool: 

280 """ 

281 Check if a boolean configuration option is enabled. 

282 

283 Parameters 

284 ---------- 

285 section : str 

286 The configuration section. 

287 key : str 

288 The configuration key. 

289 default : bool, optional 

290 Default value if the config doesn't exist. 

291 

292 Returns 

293 ------- 

294 bool 

295 True if the configuration is enabled, False otherwise. 

296 """ 

297 return LynceusConfig.to_bool(self.get_config(section=section, key=key, default=default)) 

298 

299 def get_config_as_dict(self, section: str, key: str): 

300 """ 

301 Get a configuration value as a dictionary. 

302 

303 Parameters 

304 ---------- 

305 section : str 

306 The configuration section. 

307 key : str 

308 The configuration key. 

309 

310 Returns 

311 ------- 

312 dict 

313 The configuration value as a dictionary. 

314 """ 

315 return self.get_config(section=section, key=key) 

316 

317 def has_section(self, section: str) -> bool: 

318 """ 

319 Check if a configuration section exists. 

320 

321 Parameters 

322 ---------- 

323 section : str 

324 The section name to check. 

325 

326 Returns 

327 ------- 

328 bool 

329 True if the section exists, False otherwise. 

330 """ 

331 # Important: we must check the specified section on a list of keys, and not on the result of keys(), otherwise the automatic 

332 # key addition will be performed in __getitem__() method, and this method will always return True. 

333 return section in list(self.keys()) 

334 

335 @staticmethod 

336 def __cleanse_value(value: str) -> str: 

337 """ 

338 Remove quotes from string values. 

339 

340 Parameters 

341 ---------- 

342 value : str 

343 The value to cleanse. 

344 

345 Returns 

346 ------- 

347 str 

348 The cleansed value with quotes removed. 

349 """ 

350 return value if not isinstance(value, str) else str(value).strip("'").strip('"') 

351 

352 @staticmethod 

353 def transform_value_inner_keys(value, key_transform_func): 

354 """ 

355 Recursively transform keys of a mapping using the provided key_transform_func. 

356 

357 Parameters 

358 ---------- 

359 value 

360 The value to transform. 

361 key_transform_func 

362 Function to transform keys. 

363 

364 Returns 

365 ------- 

366 dict or Any 

367 The value with transformed keys. 

368 """ 

369 if not isinstance(value, (dict, MutableMapping)) or key_transform_func is None: 

370 return value 

371 

372 return {key_transform_func(k): LynceusConfig.transform_value_inner_keys(v, key_transform_func) for k, v in value.items()} 

373 

374 @staticmethod 

375 def merge_iterables(dest, src): 

376 """ 

377 Helper function to merge iterable values. 

378 

379 Parameters 

380 ---------- 

381 dest 

382 Destination iterable to merge into. 

383 src 

384 Source iterable to merge from. 

385 

386 Raises 

387 ------ 

388 NotImplementedError 

389 If merging is not implemented for the destination type. 

390 """ 

391 if isinstance(dest, list): 

392 dest.extend(src) 

393 elif isinstance(dest, set): 

394 dest.update(src) 

395 else: 

396 raise NotImplementedError(f'Merging not implemented for type {type(dest)}.') 

397 

398 @staticmethod 

399 # pylint: disable=too-many-branches 

400 def recursive_merge(dict1: dict | MutableMapping, 

401 dict2: dict | MutableMapping, 

402 *, 

403 override_section: bool = False, 

404 override_value: bool = False, 

405 key_transform_func: Callable[[str], str] | None = None): 

406 """ 

407 Recursively merge configuration data from source into destination dictionary. 

408 

409 This method performs a deep merge of two dictionaries, preserving existing values 

410 when possible and handling nested structures intelligently. It supports key 

411 transformation and configurable override behavior for sections and values. 

412 

413 Args: 

414 dict1: Destination dictionary to merge data into (modified in-place) 

415 dict2: Source dictionary containing data to merge from 

416 override_section: If True, replaces existing sections completely. 

417 If False, merges section contents recursively (default) 

418 override_value: If True, replaces existing non-mapping values. 

419 If False, raises ValueError on conflicts (default) 

420 key_transform_func: Optional function to transform keys during merge. 

421 Applied to both keys and nested dictionary keys 

422 

423 Raises: 

424 ValueError: When value conflicts occur and override_value is False 

425 NotImplementedError: For unsupported iterable merge operations 

426 

427 Note: 

428 - Automatically handles iterable values (lists, sets) by extending/updating 

429 - Creates new keys that don't exist in destination 

430 - Applies key transformation recursively to nested structures 

431 - Special handling for LynceusConfig objects to avoid auto-creation 

432 """ 

433 for key, value in dict2.items(): 

434 # Transform the key if a transformation function is provided. 

435 transformed_key = key_transform_func(key) if key_transform_func else key 

436 transformed_value = LynceusConfig.transform_value_inner_keys(value, key_transform_func) if key_transform_func else value 

437 

438 # Checks if the corresponding key already exists in destination dict. 

439 # Important: do NOT use the in operator if first argument is a LynceusConfig, to avoid automatic creation of section here. 

440 if (isinstance(dict1, LynceusConfig) and not dict1.has_section(transformed_key)) or transformed_key not in dict1: 

441 dict1[transformed_key] = transformed_value 

442 continue 

443 

444 # Handles iterable values that are not mappings. 

445 if isinstance(dict1[transformed_key], Iterable) and not isinstance(dict1[transformed_key], (dict, MutableMapping, str)): 

446 # Checks if "new" value is iterable 

447 if isinstance(transformed_value, Iterable) and not isinstance(transformed_value, str): 

448 LynceusConfig.merge_iterables(dict1[transformed_key], transformed_value) 

449 continue 

450 

451 # Handles non-mapping values. 

452 if not isinstance(dict1[transformed_key], (dict, MutableMapping)): 

453 if override_value or dict1[transformed_key] == transformed_value: 

454 dict1[transformed_key] = transformed_value 

455 continue 

456 raise ValueError(f'Conflict at key "{transformed_key}": cannot merge non-mapping values' 

457 f' ({override_value=}; types {type(dict1[transformed_key])} vs {type(transformed_value)}).') 

458 

459 # Handles mapping values. 

460 if isinstance(transformed_value, (dict, MutableMapping)): 

461 if override_section: 

462 dict1[transformed_key] = transformed_value 

463 else: 

464 LynceusConfig.recursive_merge(dict1[transformed_key], 

465 transformed_value, 

466 override_section=override_section, 

467 override_value=override_value, 

468 key_transform_func=key_transform_func) 

469 else: 

470 raise ValueError(f'Conflict at key "{transformed_key}": source value is not a mapping' 

471 f' ({override_value=}; types {type(dict1[transformed_key])} vs {type(transformed_value)}).') 

472 

473 def merge(self, config_map: dict[str, Any] | MutableMapping, *, 

474 set_only_as_default_if_not_exist: bool = False, override_section: bool = False, override_value: bool = False, key_transform_func=None): 

475 """ 

476 Merge a configuration dictionary into this LynceusConfig instance. 

477 

478 This method provides flexible merging strategies for configuration data, 

479 supporting both additive merging and default-only merging modes. It automatically 

480 processes JSON dump parameters and handles complex nested structures. 

481 

482 Args: 

483 config_map: Configuration dictionary or mapping to merge into this instance 

484 set_only_as_default_if_not_exist: If True, only sets values for keys that 

485 don't already exist (preserves existing values). 

486 If False, performs full recursive merge (default) 

487 override_section: Whether to replace existing sections completely rather 

488 than merging their contents (default: False) 

489 override_value: Whether to replace existing non-mapping values rather 

490 than raising conflicts (default: False) 

491 key_transform_func: Optional function to transform keys during merge. 

492 Useful for namespacing or prefixing configuration keys. 

493 Cannot be used with set_only_as_default_if_not_exist=True 

494 

495 Raises: 

496 NotImplementedError: If key_transform_func is used with set_only_as_default_if_not_exist=True 

497 

498 Note: 

499 - Automatically processes JSON dump parameters using special key endings 

500 - In default mode, uses Python 3.9+ dictionary merge operator for efficiency 

501 - In recursive mode, preserves existing nested structures while adding new data 

502 """ 

503 # Processes automatically specified config_map to manage optional json dump options. 

504 config_map = LynceusConfig.load_json_params_from_lynceus_config(config_map).as_dict() 

505 

506 if set_only_as_default_if_not_exist: 

507 if key_transform_func: 

508 raise NotImplementedError('key_transform_func option can not be used with set_only_as_default_if_not_exist option while merging config map.') 

509 

510 # Uses the Python 3.9 ability to update Dictionary (considering existing values as the 

511 # precedence ones). 

512 # TODO: it may be needed to iterate on each (key, value) pairs and recursively execute the | operator on them if value is dict-like instance ... 

513 self.__store = config_map | self.__store 

514 return 

515 

516 # In this version, we need to merge everything, keeping existing inner (key; value) pairs. 

517 LynceusConfig.recursive_merge(self, config_map, override_section=override_section, override_value=override_value, key_transform_func=key_transform_func) 

518 

519 def merge_from_lynceus_config(self, lynceus_config, *, override_section: bool = False, override_value: bool = False): 

520 """ 

521 Merge configuration from another LynceusConfig instance. 

522 

523 Parameters 

524 ---------- 

525 lynceus_config : LynceusConfig 

526 The LynceusConfig instance to merge from. 

527 override_section : bool, optional 

528 Whether to override existing sections. 

529 override_value : bool, optional 

530 Whether to override existing values. 

531 """ 

532 # pylint: disable=protected-access 

533 self.merge(lynceus_config.__store, override_section=override_section, override_value=override_value) 

534 

535 def merge_from_config_parser(self, config_parser: ConfigParser, *, override_section: bool = False, key_transform_func=None): 

536 """ 

537 Merge configuration data from a ConfigParser instance. 

538 

539 This method converts ConfigParser sections and key-value pairs into dictionary 

540 format and merges them into this LynceusConfig instance. It automatically cleanses 

541 string values by removing quotes and supports key transformation. 

542 

543 Args: 

544 config_parser: ConfigParser instance containing configuration data to merge 

545 override_section: If True, replaces existing sections completely. 

546 If False, merges section contents with existing data (default) 

547 key_transform_func: Optional function to transform keys during merge. 

548 Useful for adding prefixes or namespacing configuration keys 

549 

550 Note: 

551 - Automatically removes quotes from string values during conversion 

552 - All merged values override existing non-mapping values (override_value=True) 

553 - ConfigParser sections become top-level keys in the LynceusConfig 

554 """ 

555 # Merges each config parser section, as a dictionary, in this instance of Lynceus config. 

556 config_parser_to_dict = {config_section: {key: self.__cleanse_value(value) for key, value in config_parser.items(config_section)} 

557 for config_section in config_parser.sections()} 

558 self.merge(config_parser_to_dict, override_section=override_section, override_value=True, key_transform_func=key_transform_func) 

559 

560 def lookup_configuration_file_and_update_from_it(self, config_file_name: Path | str, *, 

561 must_exist: bool = True, 

562 root_path: Path = Path().resolve(), 

563 logger: Logger | None = None, 

564 **kwargs): 

565 """ 

566 Look up a configuration file and update this config from it. 

567 

568 Parameters 

569 ---------- 

570 config_file_name : Path or str 

571 Name of the configuration file to look up. 

572 must_exist : bool, optional 

573 Whether the file must exist. 

574 root_path : Path, optional 

575 Root path to start the lookup from. 

576 logger : Logger, optional 

577 Optional logger for logging messages. 

578 **kwargs 

579 Additional arguments to pass to update_from_configuration_file. 

580 

581 Raises 

582 ------ 

583 FileNotFoundError 

584 If the file doesn't exist and must_exist is True. 

585 """ 

586 try: 

587 # Retrieves the complete path of the configuration file, if exists. 

588 config_file_path = lookup_root_path(config_file_name, root_path=root_path) / Path(config_file_name) 

589 

590 # Merges it in this instance. 

591 self.update_from_configuration_file(config_file_path, **kwargs) 

592 

593 if logger: 

594 logger.info(f'Successfully load and merge configuration options from file "{config_file_name}".') 

595 except FileNotFoundError: 

596 # Raises FileNotFoundError only if configuration file should exist. 

597 if must_exist: 

598 raise 

599 

600 if logger: 

601 logger.info(f'Configuration file "{config_file_name}" does not exist, so it will not be loaded (nor an error because {must_exist=}).') 

602 

603 def update_from_configuration_file(self, file_path: Path, *, 

604 override_section: bool = False, key_transform_func=None) -> configparser.ConfigParser: 

605 """ 

606 Load and merge configuration from a file into this LynceusConfig instance. 

607 

608 This method reads a configuration file using ConfigParser, merges its contents 

609 into this LynceusConfig instance, and returns the loaded ConfigParser for 

610 additional operations such as fine-tuned logger configuration. 

611 

612 Args: 

613 file_path: Path to the configuration file to load and merge 

614 override_section: If True, replaces existing sections completely. 

615 If False, merges section contents with existing data (default) 

616 key_transform_func: Optional function to transform keys during merge. 

617 Useful for adding prefixes or namespacing configuration keys 

618 

619 Returns: 

620 configparser.ConfigParser: The loaded ConfigParser instance for additional operations 

621 

622 Raises: 

623 FileNotFoundError: If the specified configuration file does not exist 

624 

625 Note: 

626 - Uses UTF-8 encoding for file reading 

627 - Automatically merges all sections from the file into this instance 

628 - The returned ConfigParser can be used for logger configuration setup 

629 """ 

630 if not file_path.exists(): 

631 raise FileNotFoundError(f'Specified "{file_path}" configuration file path does not exist.') 

632 

633 # Reads configuration file. 

634 config_parser: configparser.ConfigParser = configparser.RawConfigParser() 

635 config_parser.read(str(file_path), encoding='utf8') 

636 

637 # Merges it into this instance. 

638 self.merge_from_config_parser(config_parser, override_section=override_section, key_transform_func=key_transform_func) 

639 

640 return config_parser 

641 

642 @staticmethod 

643 def format_config(config: MutableMapping | dict, *, 

644 obfuscation_value: str | None = None, 

645 sensitive_keys: list[str] | None = None, 

646 obfuscation_value_from_length: int | None = None 

647 ) -> dict: 

648 """ 

649 Format a configuration dictionary with sensitive value obfuscation. 

650 

651 Recursively processes a configuration dictionary to obfuscate sensitive 

652 values based on key patterns and value length. Useful for logging and 

653 debugging without exposing secrets. 

654 

655 Args: 

656 config: Configuration dictionary to format 

657 obfuscation_value: String to replace sensitive values (default: '<secret>') 

658 sensitive_keys: List of key patterns to treat as sensitive 

659 obfuscation_value_from_length: Minimum length for automatic obfuscation 

660 

661 Returns: 

662 dict: Formatted configuration with obfuscated sensitive values 

663 """ 

664 obfuscation_value = obfuscation_value or '<secret>' 

665 sensitive_keys = sensitive_keys or ['secret', 'password', 'pwd', 'token'] 

666 

667 def obfuscator(key, value): 

668 """ 

669 Apply obfuscation logic to key-value pairs. 

670 

671 Args: 

672 key: Configuration key to check for sensitivity 

673 value: Configuration value to potentially obfuscate 

674 

675 Returns: 

676 The original value or obfuscation string based on sensitivity rules 

677 """ 

678 # Safe-guard: ensures key is iterable. 

679 if not isinstance(value, Iterable): 

680 return value 

681 

682 # Returns obfuscation_value if key is in any specified sensitive ones. 

683 if any(sensitive_key in key for sensitive_key in sensitive_keys): 

684 return obfuscation_value 

685 

686 # Returns untouched value if no length limit has been specified. 

687 if obfuscation_value_from_length is None: 

688 return value 

689 

690 # Returns obfuscation_value if value has specified length or greater. 

691 return obfuscation_value if len(str(value)) >= obfuscation_value_from_length else value 

692 

693 def process_value(key, value): 

694 """ 

695 Recursively process configuration values for obfuscation. 

696 

697 Args: 

698 key: Configuration key being processed 

699 value: Configuration value to process (dict, list, or scalar) 

700 

701 Returns: 

702 Processed value with appropriate obfuscation applied 

703 """ 

704 if isinstance(value, dict): 

705 return LynceusConfig.format_config(value, 

706 obfuscation_value=obfuscation_value, 

707 sensitive_keys=sensitive_keys, 

708 obfuscation_value_from_length=obfuscation_value_from_length) 

709 

710 if isinstance(value, list): 

711 return [process_value(key, item) for item in value] 

712 

713 return obfuscator(key, value) 

714 

715 return {key: process_value(key, value) for key, value in dict(config).items()} 

716 

717 @staticmethod 

718 def format_dict_to_string(dict_to_convert: MutableMapping, indentation_level: int = 0) -> str: 

719 """ 

720 Convert a dictionary to a formatted, indented string representation. 

721 

722 Recursively formats nested dictionaries and LynceusConfig objects into 

723 a hierarchical string with proper indentation. Useful for configuration 

724 display and logging. 

725 

726 Args: 

727 dict_to_convert: Dictionary to convert to string 

728 indentation_level: Current indentation level for nested formatting 

729 

730 Returns: 

731 str: Formatted string representation with proper indentation 

732 """ 

733 return '\n'.join('\t' * indentation_level + f'{key}={str(value)}' 

734 if not isinstance(value, dict) and not isinstance(value, LynceusConfig) 

735 else '\t' * indentation_level + f'[{key}]\n' + LynceusConfig.format_dict_to_string(value, indentation_level + 1) 

736 for key, value in dict_to_convert.items()) 

737 

738 def dump_for_config_file(self) -> str: 

739 """ 

740 Dump this config to a string suitable for saving to a configuration file. 

741 

742 Returns 

743 ------- 

744 str 

745 String representation of the configuration. 

746 """ 

747 return LynceusConfig.dump_to_config_str(self.__store) 

748 

749 @staticmethod 

750 def dump_to_config_str(value) -> str: 

751 """ 

752 Dump a value to a JSON configuration string. 

753 

754 Parameters 

755 ---------- 

756 value 

757 The value to dump. 

758 

759 Returns 

760 ------- 

761 str 

762 JSON string representation of the value. 

763 """ 

764 return json.dumps(value, cls=LynceusConfigJSONEncoder) 

765 

766 @staticmethod 

767 def load_from_config_str(lynceus_config_dump: str | dict[str, Any]): 

768 """ 

769 Load a LynceusConfig from a JSON configuration string. 

770 

771 Parameters 

772 ---------- 

773 lynceus_config_dump : str or dict 

774 JSON string or dict to load from. 

775 

776 Returns 

777 ------- 

778 LynceusConfig or dict 

779 Loaded configuration data. 

780 

781 Raises 

782 ------ 

783 LynceusConfigError 

784 If the JSON cannot be loaded. 

785 """ 

786 # TODO: may be better to instantiate an empty LynceusConfig, and use merge method here too. 

787 try: 

788 loaded_collection = json.loads(lynceus_config_dump) 

789 except JSONDecodeError as exc: 

790 # pylint: disable=raise-missing-from 

791 raise LynceusConfigError(f'Unable to JSON load config from specified string: "{lynceus_config_dump}".', from_exception=exc) 

792 

793 if isinstance(loaded_collection, dict): 

794 return LynceusConfig(loaded_collection) 

795 

796 return loaded_collection 

797 

798 @staticmethod 

799 def load_json_params_from_lynceus_config(config_map: dict[str, Any] | MutableMapping): 

800 """ 

801 Load JSON parameters from a configuration map. 

802 

803 Parameters 

804 ---------- 

805 config_map : dict or MutableMapping 

806 Configuration map to load from. 

807 

808 Returns 

809 ------- 

810 LynceusConfig 

811 Loaded LynceusConfig instance. 

812 

813 Raises 

814 ------ 

815 LynceusConfigError 

816 If the config_map is not a dict or MutableMapping. 

817 """ 

818 # Safe-guard: ensures specified parameter is a dict. 

819 if not isinstance(config_map, (dict, MutableMapping)): 

820 raise LynceusConfigError(f'Specified parameter {config_map=} should be a dict ({type(config_map)} actually.') 

821 

822 # Loads the specified config (e.g. custom user Project Profile metadata, jobs overriding/configuration options): 

823 # - if key ends with the special json keyword, loads it 

824 # - otherwise keep the key, value metadata pair unchanged 

825 loaded_lynceus_config: LynceusConfig = LynceusConfig() 

826 for config_param_with_json_key in list(config_map): 

827 # Checks if it is a json dump metadata. 

828 config_param_with_json_key_name: str = str(config_param_with_json_key) 

829 if not config_param_with_json_key_name.endswith(CONFIG_JSON_DUMP_KEY_END_KEYWORD): 

830 # It is NOT a json dump. 

831 

832 # Checks if the value is a complex one. 

833 if isinstance(config_map[config_param_with_json_key_name], (dict, MutableMapping)): 

834 # Calls recursively this method on the complex value. 

835 loaded_lynceus_config[config_param_with_json_key_name].update(LynceusConfig.load_json_params_from_lynceus_config(config_map[config_param_with_json_key_name]).as_dict()) 

836 else: 

837 # Registers this single value. 

838 LynceusConfig.recursive_merge(loaded_lynceus_config, {config_param_with_json_key_name: config_map[config_param_with_json_key_name]}) 

839 

840 continue 

841 

842 # Loads the json value, and merges it recursively. 

843 loaded_metadata_key = config_param_with_json_key_name.removesuffix(CONFIG_JSON_DUMP_KEY_END_KEYWORD) 

844 value_dump = config_map[config_param_with_json_key] 

845 LynceusConfig.recursive_merge(loaded_lynceus_config, {loaded_metadata_key: LynceusConfig.load_from_config_str(value_dump)}) 

846 

847 return loaded_lynceus_config 

848 

849 def save_partial_to_config_file(self, *, section_key_map: list[tuple], file_path: Path, 

850 key_transform_func=None, 

851 logger: Logger | None = None, log_prefix: str | None = ''): 

852 """ 

853 Save specific configuration options to a configuration file. 

854 

855 This method extracts specified configuration options from this LynceusConfig 

856 instance and saves them to a configuration file in standard INI format. 

857 It handles different value types appropriately, using JSON serialization 

858 for complex types and string representation for simple types. 

859 

860 Args: 

861 section_key_map: List of (section, key) tuples specifying which configuration 

862 options to save. Non-existing options are silently skipped 

863 file_path: Path where the configuration file should be saved 

864 key_transform_func: Optional function to transform keys before saving. 

865 Useful for adding prefixes or namespacing 

866 logger: Optional logger for recording save operations and debugging 

867 log_prefix: Optional prefix string for log messages 

868 

869 Note: 

870 - Path objects are saved as strings, not JSON 

871 - Non-string complex types are saved as JSON with special key suffix 

872 - String values are saved directly without modification 

873 - Creates ConfigParser sections automatically as needed 

874 - Uses UTF-8 encoding for file writing 

875 """ 

876 config_parser: ConfigParser = ConfigParser() 

877 not_existing_value: str = 'no/th/ing' 

878 

879 # Defines the metadata lynceus file, from complete configuration file, if defined. 

880 for metadata_option in section_key_map: 

881 section: str = metadata_option[0] 

882 key: str = metadata_option[1] 

883 

884 # Attempts to retrieve the value. 

885 value = self.get_config(section, key, default=not_existing_value) 

886 if value == not_existing_value: 

887 continue 

888 

889 # Ensures section exists. 

890 if not config_parser.has_section(section): 

891 config_parser.add_section(section) 

892 

893 # Transforms key if func is specified (useful to save project template config options, while processing the project to score). 

894 if key_transform_func: 

895 key = key_transform_func(key) 

896 

897 # Registers it. 

898 if isinstance(value, Path): 

899 # - Path are saved as a string (not as a json dump, which is not loadable). 

900 value = str(value) 

901 elif not isinstance(value, str): 

902 # - all other type, but string, are saved as Json dump. 

903 key += CONFIG_JSON_DUMP_KEY_END_KEYWORD 

904 value = self.dump_to_config_str(value) 

905 # - registers it inside config_parser. 

906 config_parser.set(section, key, value) 

907 

908 if not config_parser.sections(): 

909 logger.info(f'{log_prefix} no configuration option found to create Lynceus metadata file content among "{section_key_map}"') 

910 return 

911 

912 if logger: 

913 logger.debug(f'{log_prefix} created Lynceus metadata file content:\n{LynceusConfig.dump_config_parser(config_parser)}') 

914 

915 with open(file_path, 'w', encoding='utf8') as file: 

916 config_parser.write(file) 

917 

918 if logger: 

919 logger.debug(f'{log_prefix} successfully written Lynceus metadata to file "{file_path}".') 

920 

921 def is_empty(self): 

922 """ 

923 Check if the configuration is empty. 

924 

925 Returns 

926 ------- 

927 bool 

928 True if the configuration has no items, False otherwise. 

929 """ 

930 return len(self.__store) == 0 

931 

932 def __repr__(self): 

933 """ 

934 Return the string representation of this LynceusConfig. 

935 

936 Returns 

937 ------- 

938 str 

939 Formatted string representation of the configuration. 

940 """ 

941 return str(LynceusConfig.format_config(self)) 

942 

943 def __str__(self): 

944 """ 

945 Return the string representation of this LynceusConfig. 

946 

947 Returns 

948 ------- 

949 str 

950 Formatted string representation of the configuration. 

951 """ 

952 return LynceusConfig.format_dict_to_string(LynceusConfig.format_config(self)) 

953 

954 def __or__(self, other): 

955 """ 

956 Implement the | operator for merging configurations. 

957 

958 Parameters 

959 ---------- 

960 other : LynceusConfig or dict 

961 Another LynceusConfig or dict to merge with. 

962 

963 Returns 

964 ------- 

965 LynceusConfig 

966 New LynceusConfig instance with merged data. 

967 

968 Raises 

969 ------ 

970 ValueError 

971 If other is not a LynceusConfig or dict. 

972 """ 

973 if isinstance(other, LynceusConfig): 

974 # pylint: disable=protected-access 

975 return LynceusConfig(self.__store | other.__store) 

976 

977 # Implements Python 3.9+ dict | operator allowing mixing between LynceusConfig and dict. 

978 if isinstance(other, dict): 

979 return LynceusConfig(self.__store | other) 

980 

981 raise ValueError(f'| operator can not be used on "{type(self)}" with' 

982 f' an instance of type {type(other)}.') 

983 

984 

985class LynceusConfigJSONEncoder(JSONEncoder): 

986 """ 

987 Custom JSON encoder for LynceusConfig objects. 

988 """ 

989 

990 def default(self, o): 

991 """ 

992 Convert objects to JSON-serializable format. 

993 

994 Parameters 

995 ---------- 

996 o 

997 Object to encode. 

998 

999 Returns 

1000 ------- 

1001 Any 

1002 JSON-serializable representation of the object. 

1003 """ 

1004 if isinstance(o, LynceusConfig): 

1005 return dict(o) 

1006 

1007 if isinstance(o, set): 

1008 return list(o) 

1009 

1010 if isinstance(o, Path): 

1011 return str(o) 

1012 

1013 # Let the base class default method raise the TypeError 

1014 return json.JSONEncoder.default(self, o)