Coverage for lynceus/core/lynceus.py: 94%

88 statements  

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

1import configparser 

2import logging.config 

3import logging.handlers 

4from logging import Logger 

5from pathlib import Path 

6 

7from lynceus.core.config import CONFIG_GENERAL_KEY 

8from lynceus.core.config.lynceus_config import LynceusConfig 

9from lynceus.lynceus_exceptions import LynceusConfigError 

10from lynceus.utils import format_exception_human_readable, lookup_root_path 

11 

12 

13class LynceusSession: 

14 """ 

15 Central session management class for the Lynceus framework. 

16 

17 This class manages configuration loading, logging setup, and session lifecycle 

18 for Lynceus applications. It implements a singleton-like pattern using registration 

19 keys to ensure proper session management and prevent memory leaks. 

20 

21 The class handles: 

22 - Configuration file loading (default and user-specific) 

23 - Logger initialization and management 

24 - Session registration and retrieval 

25 - Fine-tuned logging configuration 

26 

27 Attributes: 

28 TRACE (int): Additional log level more verbose than DEBUG 

29 """ 

30 DEFAULT_SALT: str = 'lynceus' 

31 

32 # Additional even more verbose than DEBUG log level. 

33 TRACE = 5 

34 

35 __internal_key_preventing_external_usage_of_init = object() 

36 

37 __REGISTERED_SESSIONS: dict = {} 

38 

39 def __init__(self, *, 

40 salt: str = DEFAULT_SALT, 

41 root_logger_name: str = 'Lynceus', 

42 load_core_default_config: bool = True, 

43 overridden_root_logger_level: int | str | None = None, 

44 _creation_key=None): 

45 """ 

46 Initialize a new Lynceus session with configuration and logging setup. 

47 

48 **WARNING**: This constructor should NEVER be called directly by clients. 

49 Always use :py:meth:`~lynceus.core.lynceus.LynceusSession.get_session` 

50 to obtain session instances. 

51 

52 This method sets up the complete session infrastructure including: 

53 - Configuration file loading (default and user-specific) 

54 - Logger initialization with custom TRACE level 

55 - Root logger configuration with specified level 

56 

57 Parameters 

58 ---------- 

59 salt : str, optional 

60 Unique identifier for this session, used for configuration file lookup 

61 root_logger_name : str, optional 

62 Name prefix for all loggers created by this session. 

63 Used as namespace for :py:meth:`~lynceus.core.lynceus.LynceusSession.get_logger` 

64 load_core_default_config : bool, optional 

65 Whether to load Lynceus framework default configuration. 

66 Strongly recommended for proper framework operation 

67 overridden_root_logger_level : int, str, or None, optional 

68 Override for root logger level from configuration. 

69 Useful for CLI tools with --quiet, --debug options 

70 _creation_key : optional 

71 Internal safeguard key to prevent direct constructor usage. 

72 Should NEVER be specified by external callers 

73 

74 Raises 

75 ------ 

76 RuntimeError 

77 If called directly without proper internal creation key 

78 

79 Notes 

80 ----- 

81 - Automatically registers custom TRACE logging level (more verbose than DEBUG) 

82 - Loads configuration files in order: default → salt-specific (default and user) → salt-flavor-specific (according to merged configuration) → user files 

83 - Sets up root logger with level from config or override parameter 

84 """ 

85 # Safe-guard: prevents direct call to this method. 

86 if _creation_key != LynceusSession.__internal_key_preventing_external_usage_of_init: 

87 raise RuntimeError(f'You should always use LynceusSession.get_session() method when requesting a LynceusSession ({salt=}).') 

88 

89 # Initializes internal Lynceus config. 

90 self.__config = LynceusConfig() 

91 self.__root_logger_name = root_logger_name 

92 

93 # Loads configuration file(s): 

94 # - first the default config according to load_core_default_config toggle 

95 # - then the default and user config corresponding to specify salt, if not the default one 

96 loaded_config_files = self.__load_configuration(salt, load_core_default_config=load_core_default_config) 

97 

98 # Additional even more verbose than DEBUG log level. 

99 logging.TRACE = LynceusSession.TRACE 

100 logging.addLevelName(LynceusSession.TRACE, 'TRACE') 

101 

102 # Setups default/root Lynceus logger. 

103 self.__logger = self.get_logger() 

104 root_level: int | str = logging.getLevelName(overridden_root_logger_level or self.get_config(CONFIG_GENERAL_KEY, 'logger.root.level', default='INFO')) 

105 self.__logger.setLevel(root_level) 

106 

107 # Informs now that Logger is initialized. 

108 self.__logger.info(f'[{salt=}]This is the loading information of looked up configuration files (Format=<Path: isLoaded?>) "{loaded_config_files}".') 

109 

110 @staticmethod 

111 # pylint: disable=protected-access 

112 def get_session(*, 

113 salt: str = DEFAULT_SALT, 

114 registration_key: dict[str, str] | None = None, 

115 root_logger_name: str = 'Lynceus', 

116 load_core_default_config: bool = True, 

117 overridden_root_logger_level: str | None = None) -> 'LynceusSession': 

118 """ 

119 Get or create a Lynceus session with specified configuration. 

120 

121 This is the main entry point for obtaining a LynceusSession instance. 

122 It implements session caching based on registration keys to prevent 

123 duplicate sessions and memory leaks. 

124 

125 Parameters 

126 ---------- 

127 salt : str, optional 

128 Unique identifier for the session (default: 'lynceus') 

129 registration_key : dict or None, optional 

130 Dictionary used to identify and cache sessions. 

131 Required for non-default salt values to prevent memory leaks. 

132 root_logger_name : str, optional 

133 Name prefix for all loggers created by this session 

134 load_core_default_config : bool, optional 

135 Whether to load Lynceus default configuration 

136 overridden_root_logger_level : str or None, optional 

137 Override the root logger level from config 

138 

139 Returns 

140 ------- 

141 LynceusSession 

142 A configured Lynceus session instance 

143 

144 Raises 

145 ------ 

146 LynceusConfigError 

147 If registration_key is missing for non-default salt 

148 """ 

149 # Safe-guard: a registration key is highly recommended if not an internal session (which would be the case with salt == DEFAULT_SALT). 

150 # Important: sometimes caller uses an optional **kwargs which then leads to empty dict (which is not None), and thus must be taken care too. 

151 if not registration_key: 

152 if salt != LynceusSession.DEFAULT_SALT: 

153 raise LynceusConfigError('It is mandatory to provide your own registration_key when getting a session.' + 

154 f' System will automatically consider it as {registration_key}, but you may not be able to retrieve your session.' + 

155 ' And it may lead to memory leak.') 

156 registration_key = {'default': salt} 

157 

158 # Turns the registration_key to an hashable version. 

159 if registration_key is not None: 

160 registration_key = frozenset(registration_key.items()) 

161 

162 # Creates and registers the session if needed. 

163 if registration_key in LynceusSession.__REGISTERED_SESSIONS: 

164 session: LynceusSession = LynceusSession.__REGISTERED_SESSIONS[registration_key] 

165 session.__logger.debug(f'Successfully retrieved cached Session for registration key "{registration_key}": {session}.') 

166 else: 

167 session: LynceusSession = LynceusSession(salt=salt, root_logger_name=root_logger_name, 

168 load_core_default_config=load_core_default_config, 

169 overridden_root_logger_level=overridden_root_logger_level, 

170 _creation_key=LynceusSession.__internal_key_preventing_external_usage_of_init) 

171 

172 LynceusSession.__REGISTERED_SESSIONS[registration_key] = session 

173 session.__logger.debug(f'Successfully registered new Session (new total count={len(LynceusSession.__REGISTERED_SESSIONS)})' 

174 f' for registration key "{registration_key}": {session}.' 

175 f' This is its loaded configuration (salt={salt}):\n' + 

176 f'{LynceusConfig.format_dict_to_string(LynceusConfig.format_config(session.__config), indentation_level=1)}') 

177 

178 # TODO: implement another method allowing to free a specific registered session 

179 # TODO: turn LynceusSession as a Context, allowing usage with the 'with' keyword to automatic free the session once used 

180 

181 # Returns the Lynceus session. 

182 return session 

183 

184 def __do_load_configuration_files(self, config_file_meta_list) -> dict[Path, bool]: 

185 """ 

186 Load configuration files from a list of metadata specifications. 

187 

188 This method processes a list of configuration file metadata and attempts 

189 to load each file into the internal configuration. It handles missing files 

190 gracefully and sets up fine-tuned logging configurations when present. 

191 

192 Parameters 

193 ---------- 

194 config_file_meta_list : list 

195 List of dictionaries containing configuration file metadata with keys: 

196 - 'config_path': Path to the configuration file 

197 - 'root_path': Optional root path for file lookup 

198 

199 Returns 

200 ------- 

201 dict[Path, bool] 

202 Mapping of configuration file paths to loading status 

203 

204 Notes 

205 ----- 

206 Missing configuration files are handled gracefully and logged as debug messages. 

207 Files with logger sections trigger fine-tuned logging configuration. 

208 """ 

209 loaded_config_files: dict[Path, bool] = {} 

210 for config_file_meta in config_file_meta_list: 

211 conf_file_name = config_file_meta['config_path'] 

212 conf_file_root_path = config_file_meta.get('root_path', Path().resolve()) 

213 

214 loaded: bool = False 

215 relative_file: Path = Path(conf_file_name) 

216 try: 

217 full_path: Path = lookup_root_path(relative_file, root_path=conf_file_root_path) / relative_file 

218 

219 # Merges it in internal Lynceus config. 

220 additional_config: configparser.ConfigParser = self.__config.update_from_configuration_file(full_path) 

221 

222 # Updates/Configures fine-tuned logger, if any in this (first or) additional configuration file. 

223 # N.B.: in ideal world, could be interesting to extract logger config from all configuration file(s) merged, and 

224 # create a fake fileConfig in memory, to logging.config.fileConfig once for all, after all loading. 

225 # Without that, handlers and formatters must be redefined each time, in each configuration file definined fine-tuned logger configuration. 

226 if 'loggers' in additional_config.sections(): 

227 self.get_logger('internal').debug(f'Requesting fine-tuned logger configuration with configuration coming from {conf_file_name}.') 

228 # Cf. https://docs.python.org/3.10/library/logging.config.html#logging.config.fileConfig 

229 logging.config.fileConfig(full_path, disable_existing_loggers=False, encoding='utf-8') 

230 

231 loaded = True 

232 except FileNotFoundError as exc: 

233 # Since recent version, there is no more mandatory configuration file to have, and several files can be loaded. 

234 # N.B.: it is valid, there is no logger yet here, so using print ... 

235 self.get_logger('internal').debug(f'WARNING: Unable to load "{conf_file_name}" configuration file,' 

236 f' from "{conf_file_root_path}"' 

237 f' (it is OK, because this configuration file is NOT mandatory) =>' 

238 f' {format_exception_human_readable(exc, quote_message=True)}') 

239 

240 # Registers configuration file loading information. 

241 loaded_config_files[relative_file] = loaded 

242 return loaded_config_files 

243 

244 def __load_configuration(self, salt: str, load_core_default_config: bool) -> dict[Path, bool]: 

245 """ 

246 Load all relevant configuration files for the session. 

247 

248 This method orchestrates the loading of multiple configuration files 

249 in a specific order: 

250 1. Lynceus default configuration (if requested) 

251 2. Salt-specific default configuration 

252 3. Salt-specific configuration file 

253 4. Additional Salt-specific and flavor-specific configuration files 

254 

255 Parameters 

256 ---------- 

257 salt : str 

258 Session identifier used to determine configuration file names 

259 load_core_default_config : bool 

260 Whether to load the Lynceus default config 

261 

262 Returns 

263 ------- 

264 dict[Path, bool] 

265 Mapping of all configuration file paths to their loading status 

266 

267 Notes 

268 ----- 

269 Configuration files are loaded in order of precedence, with later files 

270 potentially overriding settings from earlier ones. 

271 """ 

272 # Defines the potential configuration file to load. 

273 config_file_meta_list = [] 

274 if load_core_default_config: 

275 # Adds the Lynceus default configuration file as requested. 

276 config_file_meta_list.append({'config_path': f'misc/{self.DEFAULT_SALT}.default.conf', 'root_path': Path(__file__).parent}) 

277 

278 # Adds default configuration file corresponding to specified salt, if not the default one. 

279 if salt != self.DEFAULT_SALT: 

280 config_file_meta_list.append({'config_path': f'misc/{salt}.default.conf'}) 

281 

282 # Adds user configuration file corresponding to specified salt. 

283 config_file_meta_list.append({'config_path': f'{salt}.conf'}) 

284 

285 # Looks up for configuration file(s), starting with a "default" one, and then optional user one. 

286 loaded_config_files: dict[Path, bool] = self.__do_load_configuration_files(config_file_meta_list) 

287 

288 # Loads additional configuration files based on the optional conf_flavor_list setting, which can be specified in any of the configuration files that have just been loaded. 

289 conf_flavor_list: list[str] = self.get_config(CONFIG_GENERAL_KEY, 'conf_flavor_list', default=[]) 

290 if conf_flavor_list: 

291 extra_config_file_meta_list = [{'config_path': f'{salt}_{flavor}.conf'} for flavor in conf_flavor_list] 

292 loaded_config_files |= self.__do_load_configuration_files(extra_config_file_meta_list) 

293 

294 # Returns configuration file loading mapping, for information. 

295 return loaded_config_files 

296 

297 def has_config_section(self, section: str) -> bool: 

298 """ 

299 Check if a configuration section exists. 

300 

301 Parameters 

302 ---------- 

303 section : str 

304 Name of the configuration section to check 

305 

306 Returns 

307 ------- 

308 bool 

309 True if the section exists, False otherwise 

310 """ 

311 return self.__config.has_section(section) 

312 

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

314 """ 

315 Retrieve a configuration value from the specified section and key. 

316 

317 Parameters 

318 ---------- 

319 section : str 

320 Configuration section name 

321 key : str 

322 Configuration key within the section 

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

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

325 

326 Returns 

327 ------- 

328 str or int or float or object or list 

329 The configuration value or default. 

330 

331 Raises 

332 ------ 

333 LynceusConfigError 

334 If the key is not found and no default is provided 

335 """ 

336 return self.__config.get_config(section, key, default=default) 

337 

338 def is_bool_config_enabled(self, section: str, key: str) -> bool: 

339 """ 

340 Check if a boolean configuration option is enabled. 

341 

342 Parameters 

343 ---------- 

344 section : str 

345 Configuration section name 

346 key : str 

347 Configuration key within the section 

348 

349 Returns 

350 ------- 

351 bool 

352 True if the configuration value evaluates to True, False otherwise 

353 """ 

354 return self.__config.is_bool_config_enabled(section=section, key=key) 

355 

356 def get_config_section(self, section: str): 

357 """ 

358 Retrieve an entire configuration section. 

359 

360 Parameters 

361 ---------- 

362 section : str 

363 Name of the configuration section to retrieve 

364 

365 Returns 

366 ------- 

367 dict 

368 Configuration section object containing all key-value pairs 

369 

370 Raises 

371 ------ 

372 KeyError 

373 If the section does not exist 

374 """ 

375 return self.__config[section] 

376 

377 def get_lynceus_config_copy(self) -> LynceusConfig: 

378 """ 

379 Create a deep copy of the session's internal configuration. 

380 

381 This method returns a complete copy of the current configuration state, 

382 including all loaded configuration files and merged settings. The copy 

383 is independent of the original and can be safely modified without 

384 affecting the session's configuration. 

385 

386 Returns 

387 ------- 

388 LynceusConfig 

389 Deep copy of the internal configuration with all 

390 loaded settings from default, user, and flavor-specific 

391 configuration files 

392 

393 Notes 

394 ----- 

395 - Useful for creating configuration templates or snapshots 

396 - The returned copy includes all merged configuration from multiple sources 

397 - Changes to the returned copy do not affect the session's configuration 

398 - Can be used to extract default configuration values for documentation 

399 """ 

400 return self.__config.copy() 

401 

402 def get_logger(self, name: str = None, *, parent_propagate: bool = True) -> Logger: 

403 """ 

404 Get a logger instance with the session's root logger as prefix. 

405 

406 Create or retrieve a logger with the full name constructed from 

407 the session's root logger name and the provided name. 

408 

409 Parameters 

410 ---------- 

411 name : str, optional 

412 Specific logger name (optional). If None, returns the root logger 

413 parent_propagate : bool, optional 

414 Whether the logger should propagate to its parent logger 

415 

416 Returns 

417 ------- 

418 Logger 

419 Configured logger instance 

420 """ 

421 complete_name: str = f'{self.__root_logger_name}.{name}' if name else self.__root_logger_name 

422 logger: Logger = logging.getLogger(complete_name) 

423 if logger.parent: 

424 logger.parent.propagate = parent_propagate 

425 

426 return logger