Coverage for src / ezpl / ezpl.py: 70.49%

269 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-13 19:35 +0000

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

2# EZPL - Main logging singleton 

3# Project: ezpl 

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

5 

6from __future__ import annotations 

7 

8# /////////////////////////////////////////////////////////////// 

9# IMPORTS 

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

11# Standard library imports 

12import sys 

13import threading 

14import warnings 

15from collections.abc import Generator 

16from contextlib import contextmanager 

17from pathlib import Path 

18from typing import Any 

19from uuid import uuid4 

20 

21# Third-party imports 

22from loguru import logger 

23 

24# Local imports 

25from .config import ConfigurationManager 

26from .core.exceptions import EzplError, InitializationError 

27from .handlers import EzLogger, EzPrinter 

28 

29# /////////////////////////////////////////////////////////////// 

30# GLOBALS 

31# /////////////////////////////////////////////////////////////// 

32 

33APP_PATH = Path(sys.argv[0]).parent 

34 

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

36# CLASSES 

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

38 

39 

40class Ezpl: 

41 """ 

42 Main logging singleton for the Ezpl framework. 

43 

44 Ezpl provides a unified, thread-safe interface for console and file logging 

45 with advanced features including indentation management, pattern-based logging, 

46 and dynamic progress bars. It implements the Singleton pattern to ensure only 

47 one instance exists throughout the application lifecycle. 

48 

49 Attributes: 

50 _instance: The singleton instance of Ezpl 

51 _lock: Thread lock for synchronized access 

52 _config_locked: Whether configuration can be modified 

53 _log_file: Path to the log file 

54 _printer: Console output handler 

55 _logger: File logging handler 

56 _config_manager: Configuration manager instance 

57 

58 Note: 

59 Once initialized, Ezpl cannot be re-configured unless reset. 

60 Access it via the singleton pattern or module-level functions. 

61 

62 Example: 

63 >>> log = Ezpl() 

64 >>> log.printer.log("INFO", "Application started") 

65 >>> log.logger.log("INFO", "Starting logging to file") 

66 """ 

67 

68 _instance: Ezpl | None = None 

69 _lock: threading.RLock = threading.RLock() 

70 _config_locked: bool = False 

71 _config_lock_owner: str | None = None 

72 _config_lock_token: str | None = None 

73 _log_file: Path 

74 _printer: EzPrinter 

75 _logger: EzLogger 

76 _config_manager: ConfigurationManager 

77 

78 # /////////////////////////////////////////////////////////////// 

79 # INIT 

80 # /////////////////////////////////////////////////////////////// 

81 

82 @classmethod 

83 def is_initialized(cls) -> bool: 

84 """ 

85 Return True if the Ezpl singleton has already been created. 

86 

87 Useful for libraries that want to know whether they are the first 

88 to initialize Ezpl or if they should avoid re-configuring it. 

89 """ 

90 return cls._instance is not None 

91 

92 def __new__( 

93 cls, 

94 log_file: Path | str | None = None, 

95 log_level: str | None = None, 

96 printer_level: str | None = None, 

97 file_logger_level: str | None = None, 

98 log_rotation: str | None = None, 

99 log_retention: str | None = None, 

100 log_compression: str | None = None, 

101 indent_step: int | None = None, 

102 indent_symbol: str | None = None, 

103 base_indent_symbol: str | None = None, 

104 ) -> Ezpl: 

105 """ 

106 Creates and returns a new instance of Ezpl if none exists. 

107 

108 **Notes:** 

109 Ensures only one instance of Ezpl exists (Singleton pattern). 

110 

111 **Priority order for configuration (for each parameter):** 

112 1. Arguments passed directly (highest priority) 

113 2. Environment variables (EZPL_*) 

114 3. Configuration file (~/.ezpl/config.json) 

115 4. Default values (lowest priority) 

116 

117 **Args:** 

118 

119 * `log_file` (Path | str, optional): Path to the log file 

120 * `log_level` (str, optional): Global log level (applies to both printer and logger) 

121 * `printer_level` (str, optional): Printer log level 

122 * `file_logger_level` (str, optional): File logger level 

123 * `log_rotation` (str, optional): Rotation setting (e.g., "10 MB", "1 day") 

124 * `log_retention` (str, optional): Retention period (e.g., "7 days") 

125 * `log_compression` (str, optional): Compression format (e.g., "zip", "gz") 

126 * `indent_step` (int, optional): Indentation step size 

127 * `indent_symbol` (str, optional): Symbol for indentation 

128 * `base_indent_symbol` (str, optional): Base indentation symbol 

129 

130 **Returns:** 

131 

132 * `Ezpl`: The singleton instance of the Ezpl class. 

133 

134 **Raises:** 

135 

136 * `None`. 

137 """ 

138 

139 # ////// 

140 # Double-checked locking pattern for thread-safe singleton 

141 if cls._instance is None: 

142 with cls._lock: 

143 # Check again after acquiring lock (double-checked locking) 

144 if cls._instance is None: 144 ↛ 269line 144 didn't jump to line 269

145 logger.remove() 

146 

147 # Initialize configuration manager 

148 cls._config_manager = ConfigurationManager() 

149 

150 # Determine configuration values with priority: arg > env > config file > default 

151 # Helper function to get value with priority order 

152 def get_config_value( 

153 arg_value, config_key: str, getter_method 

154 ) -> Any: 

155 """ 

156 Get configuration value with priority: arg > env > config file > default 

157 

158 Args: 

159 arg_value: Value from argument (can be None) 

160 config_key: Configuration key name 

161 getter_method: Method to get default value from config manager 

162 

163 Returns: 

164 Final configuration value 

165 """ 

166 # Priority 1: Argument direct 

167 if arg_value is not None: 

168 return arg_value 

169 

170 # Priority 2: Environment variable (already loaded in config_manager) 

171 # Priority 3: Config file (already loaded in config_manager) 

172 # Priority 4: Default (via getter method) 

173 # The config_manager already has the correct priority (env > file > default) 

174 config_value = cls._config_manager.get(config_key) 

175 if config_value is not None: 175 ↛ 179line 175 didn't jump to line 179 because the condition on line 175 was always true

176 return config_value 

177 

178 # Fallback to default via getter 

179 return getter_method() 

180 

181 # Log file 

182 if log_file: 

183 cls._log_file = Path(log_file) 

184 else: 

185 cls._log_file = cls._config_manager.get_log_file() 

186 

187 # Log level (global) 

188 final_log_level = get_config_value( 

189 log_level, "log-level", cls._config_manager.get_log_level 

190 ) 

191 

192 # Printer level 

193 final_printer_level = get_config_value( 

194 printer_level, 

195 "printer-level", 

196 cls._config_manager.get_printer_level, 

197 ) 

198 

199 # File logger level 

200 final_file_logger_level = get_config_value( 

201 file_logger_level, 

202 "file-logger-level", 

203 cls._config_manager.get_file_logger_level, 

204 ) 

205 

206 # Rotation settings (can be None) 

207 # Priority: arg > env > config file > default 

208 # Note: If arg is None (default), we check env/config/default 

209 # If user wants to explicitly set None, they can pass None or use configure() 

210 final_rotation = ( 

211 log_rotation 

212 if log_rotation is not None 

213 else cls._config_manager.get_log_rotation() 

214 ) 

215 final_retention = ( 

216 log_retention 

217 if log_retention is not None 

218 else cls._config_manager.get_log_retention() 

219 ) 

220 final_compression = ( 

221 log_compression 

222 if log_compression is not None 

223 else cls._config_manager.get_log_compression() 

224 ) 

225 

226 # Indent settings 

227 final_indent_step = get_config_value( 

228 indent_step, "indent-step", cls._config_manager.get_indent_step 

229 ) 

230 final_indent_symbol = get_config_value( 

231 indent_symbol, 

232 "indent-symbol", 

233 cls._config_manager.get_indent_symbol, 

234 ) 

235 final_base_indent_symbol = get_config_value( 

236 base_indent_symbol, 

237 "base-indent-symbol", 

238 cls._config_manager.get_base_indent_symbol, 

239 ) 

240 

241 instance = object.__new__(cls) 

242 cls._instance = instance 

243 

244 # Initialize printer with resolved configuration 

245 cls._printer = EzPrinter( 

246 level=final_printer_level, 

247 indent_step=final_indent_step, 

248 indent_symbol=final_indent_symbol, 

249 base_indent_symbol=final_base_indent_symbol, 

250 ) 

251 

252 # Initialize logger with resolved configuration 

253 cls._logger = EzLogger( 

254 log_file=cls._log_file, 

255 level=final_file_logger_level, 

256 rotation=final_rotation, 

257 retention=final_retention, 

258 compression=final_compression, 

259 ) 

260 

261 # Apply global log level with priority: specific > global 

262 cls._instance._apply_level_priority( 

263 printer_level=printer_level, 

264 file_logger_level=file_logger_level, 

265 global_level=final_log_level, 

266 ) 

267 

268 # Type narrowing: _instance is guaranteed to be set at this point 

269 if cls._instance is None: 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true

270 raise InitializationError("Singleton initialization failed unexpectedly") 

271 return cls._instance 

272 

273 # /////////////////////////////////////////////////////////////// 

274 # PRIVATE HELPERS 

275 # /////////////////////////////////////////////////////////////// 

276 

277 def _apply_level_priority( 

278 self, 

279 *, 

280 printer_level: str | None = None, 

281 file_logger_level: str | None = None, 

282 global_level: str | None = None, 

283 force: bool = True, 

284 owner: str | None = None, 

285 token: str | None = None, 

286 ) -> None: 

287 """ 

288 Apply log levels with priority: specific level > global level. 

289 

290 Only sets levels when a non-None value can be resolved. 

291 If a specific level is not provided, global_level is used as fallback. 

292 """ 

293 effective_printer = printer_level or global_level 

294 effective_logger = file_logger_level or global_level 

295 

296 if effective_printer: 

297 if printer_level and global_level and printer_level != global_level: 

298 logger.debug( 

299 f"Ezpl: printer_level='{printer_level}' overrides global_level='{global_level}'" 

300 ) 

301 self.set_printer_level( 

302 effective_printer, 

303 force=force, 

304 owner=owner, 

305 token=token, 

306 ) 

307 

308 if effective_logger: 

309 if file_logger_level and global_level and file_logger_level != global_level: 

310 logger.debug( 

311 f"Ezpl: file_logger_level='{file_logger_level}' overrides global_level='{global_level}'" 

312 ) 

313 self.set_logger_level( 

314 effective_logger, 

315 force=force, 

316 owner=owner, 

317 token=token, 

318 ) 

319 

320 def _rebuild_logger( 

321 self, 

322 *, 

323 level: str | None = None, 

324 ) -> None: 

325 """ 

326 Close the current file logger and reinitialize it with current configuration. 

327 

328 Args: 

329 level: Override the log level. If None, preserves the current level. 

330 """ 

331 current_level = ( 

332 level 

333 or ( 

334 self._logger.level 

335 if hasattr(self, "_logger") and self._logger 

336 else None 

337 ) 

338 or self._config_manager.get_file_logger_level() 

339 ) 

340 try: 

341 if hasattr(self, "_logger") and self._logger: 341 ↛ 345line 341 didn't jump to line 345 because the condition on line 341 was always true

342 self._logger.close() 

343 except (EzplError, OSError, RuntimeError) as e: 

344 logger.error(f"Error while closing logger: {e}") 

345 self._logger = EzLogger( 

346 log_file=self._log_file, 

347 level=current_level, 

348 rotation=self._config_manager.get_log_rotation(), 

349 retention=self._config_manager.get_log_retention(), 

350 compression=self._config_manager.get_log_compression(), 

351 ) 

352 

353 def _rebuild_printer( 

354 self, 

355 *, 

356 level: str | None = None, 

357 ) -> None: 

358 """ 

359 Reinitialize the console printer with current configuration. 

360 

361 Args: 

362 level: Override the log level. If None, preserves the current level. 

363 """ 

364 current_level = ( 

365 level 

366 or ( 

367 self._printer.level 

368 if hasattr(self, "_printer") and self._printer 

369 else None 

370 ) 

371 or self._config_manager.get_printer_level() 

372 ) 

373 self._printer = EzPrinter( 

374 level=current_level, 

375 indent_step=self._config_manager.get_indent_step(), 

376 indent_symbol=self._config_manager.get_indent_symbol(), 

377 base_indent_symbol=self._config_manager.get_base_indent_symbol(), 

378 ) 

379 

380 # /////////////////////////////////////////////////////////////// 

381 # GETTER 

382 # /////////////////////////////////////////////////////////////// 

383 

384 def get_printer(self) -> EzPrinter: 

385 """ 

386 Returns the EzPrinter instance. 

387 

388 **Returns:** 

389 

390 * EzPrinter: The console printer instance providing info(), debug(), success(), etc. 

391 Implements PrinterProtocol for type safety. 

392 """ 

393 return self._printer 

394 

395 # /////////////////////////////////////////////////////////////// 

396 

397 def get_logger(self) -> EzLogger: 

398 """ 

399 Returns the EzLogger instance. 

400 

401 **Returns:** 

402 

403 * EzLogger: The file logger instance for file logging. 

404 Use logger.info(), logger.debug(), etc. directly. 

405 For advanced loguru features, use logger.get_loguru() 

406 Implements LoggerProtocol for type safety. 

407 """ 

408 return self._logger 

409 

410 # /////////////////////////////////////////////////////////////// 

411 # UTILS METHODS 

412 # /////////////////////////////////////////////////////////////// 

413 

414 @property 

415 def printer_level(self) -> str: 

416 """Return the current printer logging level.""" 

417 return self._printer.level 

418 

419 @property 

420 def logger_level(self) -> str: 

421 """Return the current file logger logging level.""" 

422 return self._logger.level 

423 

424 def set_level( 

425 self, 

426 level: str, 

427 *, 

428 force: bool = False, 

429 owner: str | None = None, 

430 token: str | None = None, 

431 ) -> None: 

432 """ 

433 Set the log level for both the printer and the logger simultaneously (compatibility method). 

434 

435 **Args:** 

436 

437 * `level` (str): The desired log level (e.g., "INFO", "WARNING"). 

438 * `force` (bool): If True, bypasses the configuration lock. 

439 

440 **Returns:** 

441 

442 * `None`. 

443 """ 

444 if not self._can_write_config(force=force, owner=owner, token=token): 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true

445 warnings.warn( 

446 "Ezpl configuration is locked. Call Ezpl.unlock_config() or pass a " 

447 "valid owner/token with force=True to override.", 

448 UserWarning, 

449 stacklevel=2, 

450 ) 

451 return 

452 self.set_logger_level(level, force=force, owner=owner, token=token) 

453 self.set_printer_level(level, force=force, owner=owner, token=token) 

454 

455 def set_printer_level( 

456 self, 

457 level: str, 

458 *, 

459 force: bool = False, 

460 owner: str | None = None, 

461 token: str | None = None, 

462 ) -> None: 

463 """ 

464 Set the log level for the printer only. 

465 

466 **Args:** 

467 

468 * `level` (str): The desired log level for the printer. 

469 * `force` (bool): If True, bypasses the configuration lock. 

470 

471 **Returns:** 

472 

473 * `None`. 

474 """ 

475 if not self._can_write_config(force=force, owner=owner, token=token): 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true

476 warnings.warn( 

477 "Ezpl configuration is locked. Call Ezpl.unlock_config() or pass a " 

478 "valid owner/token with force=True to override.", 

479 UserWarning, 

480 stacklevel=2, 

481 ) 

482 return 

483 self._printer.set_level(level) 

484 

485 def set_logger_level( 

486 self, 

487 level: str, 

488 *, 

489 force: bool = False, 

490 owner: str | None = None, 

491 token: str | None = None, 

492 ) -> None: 

493 """ 

494 Set the log level for the file logger only. 

495 

496 **Args:** 

497 

498 * `level` (str): The desired log level for the file logger. 

499 * `force` (bool): If True, bypasses the configuration lock. 

500 

501 **Returns:** 

502 

503 * `None`. 

504 """ 

505 if not self._can_write_config(force=force, owner=owner, token=token): 505 ↛ 506line 505 didn't jump to line 506 because the condition on line 505 was never true

506 warnings.warn( 

507 "Ezpl configuration is locked. Call Ezpl.unlock_config() or pass a " 

508 "valid owner/token with force=True to override.", 

509 UserWarning, 

510 stacklevel=2, 

511 ) 

512 return 

513 self._logger.set_level(level) 

514 

515 # /////////////////////////////////////////////////////////////// 

516 

517 def add_separator(self) -> None: 

518 """ 

519 Adds a separator to the log file. 

520 

521 **Returns:** 

522 

523 * `None`. 

524 """ 

525 self._logger.add_separator() 

526 

527 # /////////////////////////////////////////////////////////////// 

528 

529 @contextmanager 

530 def manage_indent(self) -> Generator[None, None, None]: 

531 """ 

532 Context manager to manage indentation level. 

533 

534 **Returns:** 

535 

536 * `None`. 

537 """ 

538 with self._printer.manage_indent(): 

539 yield 

540 

541 # /////////////////////////////////////////////////////////////// 

542 # ENHANCED METHODS 

543 # /////////////////////////////////////////////////////////////// 

544 

545 @classmethod 

546 def reset(cls) -> None: 

547 """ 

548 Reset the singleton instance (useful for testing). 

549 

550 Warning: This will destroy the current instance and all its state. 

551 """ 

552 if cls._instance is not None: 

553 # Close logger handlers to release file handles (important on Windows) 

554 try: 

555 if hasattr(cls._instance, "_logger") and cls._instance._logger: 555 ↛ 559line 555 didn't jump to line 559 because the condition on line 555 was always true

556 cls._instance._logger.close() 

557 except (EzplError, OSError, RuntimeError) as e: 

558 logger.error(f"Error during cleanup: {e}") 

559 cls._instance = None 

560 # Also reset configuration lock 

561 cls._config_locked = False 

562 cls._config_lock_owner = None 

563 cls._config_lock_token = None 

564 

565 # ------------------------------------------------ 

566 # CONFIG LOCK CONTROL 

567 # ------------------------------------------------ 

568 

569 @classmethod 

570 def lock_config(cls, owner: str = "app") -> str | None: 

571 """ 

572 Lock Ezpl configuration so that future configure() calls are ignored 

573 unless explicitly forced. 

574 

575 Intended usage: 

576 1. Root application configures Ezpl once 

577 2. Calls Ezpl.lock_config() 

578 3. Libraries calling configure() later will not override settings 

579 """ 

580 with cls._lock: 

581 normalized_owner = owner.strip() if owner.strip() else "app" 

582 

583 if cls._config_locked and cls._config_lock_owner not in ( 

584 None, 

585 normalized_owner, 

586 ): 

587 warnings.warn( 

588 f"Ezpl configuration is already locked by '{cls._config_lock_owner}'.", 

589 UserWarning, 

590 stacklevel=2, 

591 ) 

592 return None 

593 

594 cls._config_locked = True 

595 cls._config_lock_owner = normalized_owner 

596 cls._config_lock_token = uuid4().hex 

597 return cls._config_lock_token 

598 

599 @classmethod 

600 def unlock_config( 

601 cls, 

602 *, 

603 owner: str | None = None, 

604 token: str | None = None, 

605 force: bool = False, 

606 ) -> bool: 

607 """ 

608 Unlock Ezpl configuration. 

609 

610 Use with care: this allows configure() to change global logging 

611 configuration again. 

612 """ 

613 with cls._lock: 

614 if not cls._config_locked: 614 ↛ 615line 614 didn't jump to line 615 because the condition on line 614 was never true

615 return True 

616 

617 owner_match = owner is not None and owner == cls._config_lock_owner 

618 token_match = token is not None and token == cls._config_lock_token 

619 

620 if not force and not owner_match and not token_match: 

621 warnings.warn( 

622 "Unlock denied: provide matching owner or token, or use force=True.", 

623 UserWarning, 

624 stacklevel=2, 

625 ) 

626 return False 

627 

628 cls._config_locked = False 

629 cls._config_lock_owner = None 

630 cls._config_lock_token = None 

631 return True 

632 

633 @classmethod 

634 def config_lock_info(cls) -> dict[str, Any]: 

635 """Return current configuration lock state for diagnostics.""" 

636 return { 

637 "locked": cls._config_locked, 

638 "owner": cls._config_lock_owner, 

639 "has_token": cls._config_lock_token is not None, 

640 } 

641 

642 @classmethod 

643 def _can_write_config( 

644 cls, 

645 *, 

646 force: bool = False, 

647 owner: str | None = None, 

648 token: str | None = None, 

649 ) -> bool: 

650 """Return True when a configuration write is authorized under lock rules.""" 

651 if not cls._config_locked: 

652 return True 

653 

654 if not force: 654 ↛ 655line 654 didn't jump to line 655 because the condition on line 654 was never true

655 return False 

656 

657 # Backward compatibility: if no owner/token metadata exists, force still works. 

658 if cls._config_lock_owner is None and cls._config_lock_token is None: 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true

659 return True 

660 

661 owner_match = owner is not None and owner == cls._config_lock_owner 

662 token_match = token is not None and token == cls._config_lock_token 

663 return owner_match or token_match 

664 

665 def set_log_file(self, log_file: Path | str) -> None: 

666 """ 

667 Change the log file (requires reinitialization of the logger). 

668 

669 Args: 

670 log_file: New path to the log file 

671 

672 Note: This will reinitialize the file logger but keep the singleton instance. 

673 """ 

674 new_log_file = Path(log_file) 

675 if new_log_file != self._log_file: 675 ↛ exitline 675 didn't return from function 'set_log_file' because the condition on line 675 was always true

676 self._log_file = new_log_file 

677 # Update configuration 

678 self._config_manager.set("log-file", str(new_log_file)) 

679 self._rebuild_logger() 

680 

681 def get_log_file(self) -> Path: 

682 """ 

683 Get the current log file path. 

684 

685 Returns: 

686 Path to the current log file 

687 """ 

688 return self._log_file 

689 

690 def get_config(self) -> ConfigurationManager: 

691 """ 

692 Get the current configuration manager. 

693 

694 Returns: 

695 ConfigurationManager instance for accessing and modifying configuration 

696 """ 

697 return self._config_manager 

698 

699 # /////////////////////////////////////////////////////////////// 

700 # HANDLER OVERRIDE METHODS 

701 # /////////////////////////////////////////////////////////////// 

702 

703 def set_printer_class( 

704 self, 

705 printer_class: type[EzPrinter] | EzPrinter, 

706 **init_kwargs: Any, 

707 ) -> None: 

708 """ 

709 Replace the current printer with a custom printer class or instance. 

710 

711 Allows users to override the default printer with a custom class that 

712 inherits from EzPrinter. The method preserves 

713 current configuration values (level, indentation settings) unless 

714 explicitly overridden in init_kwargs. 

715 

716 Args: 

717 printer_class: Custom printer class inheriting from EzPrinter, 

718 or an already instantiated EzPrinter instance 

719 **init_kwargs: Optional initialization parameters for the printer 

720 class. If not provided, current configuration values are used. 

721 

722 Raises: 

723 TypeError: If printer_class is not a valid class or instance 

724 ValidationError: If initialization parameters are invalid 

725 

726 Example: 

727 >>> from ezpl import Ezpl, EzPrinter 

728 >>> 

729 >>> class CustomPrinter(EzPrinter): 

730 ... def info(self, message): 

731 ... super().info(f"[CUSTOM] {message}") 

732 >>> 

733 >>> ezpl = Ezpl() 

734 >>> ezpl.set_printer_class(CustomPrinter, level="DEBUG") 

735 >>> ezpl.get_printer().info("Test") 

736 [CUSTOM] Test 

737 """ 

738 from .core.exceptions import ValidationError 

739 

740 # If it's already an instance, use it directly 

741 if isinstance(printer_class, EzPrinter): 

742 new_printer = printer_class 

743 # If it's a class, instantiate it 

744 elif isinstance(printer_class, type): 

745 # Validate that it's a subclass of EzPrinter 

746 if not issubclass(printer_class, EzPrinter): 

747 raise TypeError( 

748 f"printer_class must be a subclass of {EzPrinter.__name__}, " 

749 f"got {printer_class.__name__}" 

750 ) 

751 

752 # Preserve current configuration values if not provided 

753 current_level = ( 

754 self._printer.level 

755 if hasattr(self, "_printer") and self._printer 

756 else self._config_manager.get_printer_level() 

757 ) 

758 current_indent_step = ( 

759 self._printer.indent_step 

760 if hasattr(self, "_printer") and self._printer 

761 else self._config_manager.get_indent_step() 

762 ) 

763 current_indent_symbol = ( 

764 self._printer.indent_symbol 

765 if hasattr(self, "_printer") and self._printer 

766 else self._config_manager.get_indent_symbol() 

767 ) 

768 current_base_indent_symbol = ( 

769 self._printer.base_indent_symbol 

770 if hasattr(self, "_printer") and self._printer 

771 else self._config_manager.get_base_indent_symbol() 

772 ) 

773 

774 # Merge kwargs with default values 

775 init_params = { 

776 "level": init_kwargs.pop("level", current_level), 

777 "indent_step": init_kwargs.pop("indent_step", current_indent_step), 

778 "indent_symbol": init_kwargs.pop( 

779 "indent_symbol", current_indent_symbol 

780 ), 

781 "base_indent_symbol": init_kwargs.pop( 

782 "base_indent_symbol", current_base_indent_symbol 

783 ), 

784 } 

785 init_params.update(init_kwargs) 

786 

787 # Create new instance 

788 try: 

789 new_printer = printer_class(**init_params) 

790 except Exception as e: 

791 raise ValidationError( 

792 f"Failed to initialize printer class {printer_class.__name__}: {e}", 

793 "printer_class", 

794 str(printer_class), 

795 ) from e 

796 else: 

797 raise TypeError( 

798 f"printer_class must be a class or an instance of {EzPrinter.__name__}, " 

799 f"got {type(printer_class).__name__}" 

800 ) 

801 

802 # Replace the instance 

803 self._printer = new_printer 

804 

805 def set_logger_class( 

806 self, 

807 logger_class: type[EzLogger] | EzLogger, 

808 **init_kwargs: Any, 

809 ) -> None: 

810 """ 

811 Replace the current logger with a custom logger class or instance. 

812 

813 Allows users to override the default logger with a custom class that 

814 inherits from EzLogger. The method preserves current 

815 configuration values (level, rotation, retention, compression) unless 

816 explicitly overridden in init_kwargs. 

817 

818 Args: 

819 logger_class: Custom logger class inheriting from EzLogger, 

820 or an already instantiated EzLogger instance 

821 **init_kwargs: Optional initialization parameters for the logger 

822 class. If not provided, current configuration values are used. 

823 

824 Raises: 

825 TypeError: If logger_class is not a valid class or instance 

826 ValidationError: If initialization parameters are invalid 

827 FileOperationError: If file operations fail during logger creation 

828 (may be raised by the logger class constructor) 

829 

830 Example: 

831 >>> from ezpl import Ezpl, EzLogger 

832 >>> 

833 >>> class CustomLogger(EzLogger): 

834 ... def info(self, message): 

835 ... super().info(f"[CUSTOM LOG] {message}") 

836 >>> 

837 >>> ezpl = Ezpl() 

838 >>> ezpl.set_logger_class(CustomLogger, log_file="custom.log") 

839 >>> ezpl.get_logger().info("Test") 

840 """ 

841 from .core.exceptions import ValidationError 

842 

843 # If it's already an instance, use it directly 

844 if isinstance(logger_class, EzLogger): 

845 new_logger = logger_class 

846 # If it's a class, instantiate it 

847 elif isinstance(logger_class, type): 

848 # Validate that it's a subclass of EzLogger 

849 if not issubclass(logger_class, EzLogger): 

850 raise TypeError( 

851 f"logger_class must be a subclass of {EzLogger.__name__}, " 

852 f"got {logger_class.__name__}" 

853 ) 

854 

855 # Preserve current configuration values if not provided 

856 current_level = ( 

857 self._logger.level 

858 if hasattr(self, "_logger") and self._logger 

859 else self._config_manager.get_file_logger_level() 

860 ) 

861 current_log_file = ( 

862 self._log_file 

863 if hasattr(self, "_log_file") 

864 else self._config_manager.get_log_file() 

865 ) 

866 current_rotation = ( 

867 self._logger.rotation 

868 if hasattr(self, "_logger") and self._logger 

869 else self._config_manager.get_log_rotation() 

870 ) 

871 current_retention = ( 

872 self._logger.retention 

873 if hasattr(self, "_logger") and self._logger 

874 else self._config_manager.get_log_retention() 

875 ) 

876 current_compression = ( 

877 self._logger.compression 

878 if hasattr(self, "_logger") and self._logger 

879 else self._config_manager.get_log_compression() 

880 ) 

881 

882 # Merge kwargs with default values 

883 init_params = { 

884 "log_file": init_kwargs.pop("log_file", current_log_file), 

885 "level": init_kwargs.pop("level", current_level), 

886 "rotation": init_kwargs.pop("rotation", current_rotation), 

887 "retention": init_kwargs.pop("retention", current_retention), 

888 "compression": init_kwargs.pop("compression", current_compression), 

889 } 

890 init_params.update(init_kwargs) 

891 

892 # Close previous logger before creating new one to avoid resource leaks 

893 try: 

894 if hasattr(self, "_logger") and self._logger: 

895 self._logger.close() 

896 except (EzplError, OSError, RuntimeError) as e: 

897 logger.error(f"Error while closing previous logger: {e}") 

898 

899 # Create new instance 

900 try: 

901 new_logger = logger_class(**init_params) 

902 except Exception as e: 

903 raise ValidationError( 

904 f"Failed to initialize logger class {logger_class.__name__}: {e}", 

905 "logger_class", 

906 str(logger_class), 

907 ) from e 

908 else: 

909 raise TypeError( 

910 f"logger_class must be a class or an instance of {EzLogger.__name__}, " 

911 f"got {type(logger_class).__name__}" 

912 ) 

913 

914 # Replace the instance 

915 self._logger = new_logger 

916 

917 # /////////////////////////////////////////////////////////////// 

918 # CONFIGURATION METHODS 

919 # /////////////////////////////////////////////////////////////// 

920 

921 def reload_config(self) -> None: 

922 """ 

923 Reload configuration from file and environment variables. 

924 

925 This method reloads the configuration and reapplies it to handlers. 

926 Useful when environment variables or the config file have changed 

927 after the singleton was initialized. 

928 

929 Note: This will reinitialize handlers with the new configuration. 

930 """ 

931 # Reload configuration 

932 self._config_manager.reload() 

933 

934 # Get configuration values 

935 printer_level = self._config_manager.get_printer_level() 

936 file_logger_level = self._config_manager.get_file_logger_level() 

937 global_log_level = self._config_manager.get_log_level() 

938 

939 # Check if specific levels are explicitly set (not just defaults) 

940 # Priority: specific levels > global level 

941 # Only apply global level if specific levels are not explicitly set 

942 printer_level_explicit = self._config_manager.has_key("printer-level") 

943 file_logger_level_explicit = self._config_manager.has_key("file-logger-level") 

944 global_log_level_explicit = self._config_manager.has_key("log-level") 

945 

946 # Respect manually set levels: don't override if set via set_level() 

947 printer_manually_set = self._printer.level_manually_set 

948 logger_manually_set = self._logger.level_manually_set 

949 

950 # Reapply to handlers with priority logic (skip if manually set) 

951 if not printer_manually_set: 951 ↛ 952line 951 didn't jump to line 952 because the condition on line 951 was never true

952 effective_printer = ( 

953 printer_level 

954 if printer_level_explicit 

955 else global_log_level 

956 if global_log_level_explicit 

957 else printer_level 

958 ) 

959 self.set_printer_level(effective_printer, force=True) 

960 self._printer.mark_level_as_configured() 

961 

962 if not logger_manually_set: 962 ↛ 963line 962 didn't jump to line 963 because the condition on line 962 was never true

963 effective_logger = ( 

964 file_logger_level 

965 if file_logger_level_explicit 

966 else ( 

967 global_log_level if global_log_level_explicit else file_logger_level 

968 ) 

969 ) 

970 self.set_logger_level(effective_logger, force=True) 

971 self._logger.mark_level_as_configured() 

972 

973 # Reinitialize logger with new rotation / retention / compression settings 

974 self._rebuild_logger() 

975 if not logger_manually_set: 975 ↛ 976line 975 didn't jump to line 976 because the condition on line 975 was never true

976 self._logger.mark_level_as_configured() 

977 

978 # Reinitialize printer with new indent settings 

979 self._rebuild_printer() 

980 if not printer_manually_set: 980 ↛ 981line 980 didn't jump to line 981 because the condition on line 980 was never true

981 self._printer.mark_level_as_configured() 

982 

983 def configure( 

984 self, config_dict: dict[str, Any] | None = None, **kwargs: Any 

985 ) -> bool: 

986 """ 

987 Configure Ezpl dynamically. 

988 

989 Args: 

990 config_dict: Dictionary of configuration values to update 

991 **kwargs: Configuration options (alternative to config_dict): 

992 - log_file or log-file: Path to log file 

993 - printer_level or printer-level: Printer log level 

994 - logger_level or file-logger-level: File logger level 

995 - level or log-level: Set both printer and logger level 

996 - log_rotation or log-rotation: Rotation setting (e.g., "10 MB", "1 day") 

997 - log_retention or log-retention: Retention period (e.g., "7 days") 

998 - log_compression or log-compression: Compression format (e.g., "zip", "gz") 

999 - indent_step or indent-step: Indentation step size 

1000 - indent_symbol or indent-symbol: Symbol for indentation 

1001 - base_indent_symbol or base-indent-symbol: Base indentation symbol 

1002 

1003 Returns: 

1004 True if configuration was applied, False if it was blocked by lock. 

1005 

1006 Note: Changes are persisted to the configuration file. 

1007 """ 

1008 # Merge config_dict and kwargs 

1009 if config_dict: 

1010 kwargs.update(config_dict) 

1011 

1012 # Special control flag (not stored in configuration): 

1013 # - force=True allows configure() even when configuration is locked 

1014 force = kwargs.pop("force", False) 

1015 owner = kwargs.pop("owner", None) 

1016 token = kwargs.pop("token", None) 

1017 

1018 # If configuration is locked and not forced, warn and return False 

1019 if not self._can_write_config(force=force, owner=owner, token=token): 

1020 warnings.warn( 

1021 "Ezpl configuration is locked. Call Ezpl.unlock_config() or pass a " 

1022 "valid owner/token with force=True to override.", 

1023 UserWarning, 

1024 stacklevel=2, 

1025 ) 

1026 return False 

1027 

1028 # Normalize keys: convert underscores to hyphens for consistency 

1029 normalized_config = {} 

1030 key_mapping = { 

1031 "log_file": "log-file", 

1032 "printer_level": "printer-level", 

1033 "logger_level": "file-logger-level", 

1034 "level": "log-level", 

1035 "log_rotation": "log-rotation", 

1036 "log_retention": "log-retention", 

1037 "log_compression": "log-compression", 

1038 "indent_step": "indent-step", 

1039 "indent_symbol": "indent-symbol", 

1040 "base_indent_symbol": "base-indent-symbol", 

1041 } 

1042 

1043 for key, value in kwargs.items(): 

1044 # Use normalized key if mapping exists, otherwise keep original 

1045 normalized_key = key_mapping.get(key, key) 

1046 normalized_config[normalized_key] = value 

1047 

1048 # Update configuration manager 

1049 self._config_manager.update(normalized_config) 

1050 self._config_manager.save() 

1051 

1052 # Apply changes to handlers 

1053 if "log-file" in normalized_config: 1053 ↛ 1054line 1053 didn't jump to line 1054 because the condition on line 1053 was never true

1054 self.set_log_file(normalized_config["log-file"]) 

1055 

1056 # Handle log level changes with priority: specific > global 

1057 self._apply_level_priority( 

1058 printer_level=normalized_config.get("printer-level"), 

1059 file_logger_level=normalized_config.get("file-logger-level"), 

1060 global_level=normalized_config.get("log-level"), 

1061 force=force, 

1062 owner=owner, 

1063 token=token, 

1064 ) 

1065 

1066 # Reinitialize logger if rotation settings changed 

1067 rotation_changed = any( 

1068 key in normalized_config 

1069 for key in ["log-rotation", "log-retention", "log-compression"] 

1070 ) 

1071 if rotation_changed: 

1072 self._rebuild_logger() 

1073 

1074 # Reinitialize printer if indent settings changed 

1075 indent_changed = any( 

1076 key in normalized_config 

1077 for key in ["indent-step", "indent-symbol", "base-indent-symbol"] 

1078 ) 

1079 if indent_changed: 1079 ↛ 1080line 1079 didn't jump to line 1080 because the condition on line 1079 was never true

1080 self._rebuild_printer() 

1081 

1082 return True