Coverage for src/ezplog/lib_mode.py: 38.81%

161 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-10-16 19:04 +0000

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

2# LIB_MODE - Passive stdlib-compatible logger and printer for library authors 

3# Project: ezpl 

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

5 

6""" 

7Passive logger and printer proxies for library authors. 

8 

9Libraries should never configure logging or printing themselves. Use the 

10functions below to obtain objects that are silent by default and automatically 

11active once the host application initializes Ezpl. 

12 

13Recommended imports for library code: 

14 

15 from ezpl.lib_mode import get_logger, get_printer 

16 

17These imports are intentionally lightweight — no dependency on rich or loguru 

18is triggered at import time. The lazy delegation happens at call time only. 

19 

20Usage pattern in a library: 

21 

22 from ezpl.lib_mode import get_logger, get_printer 

23 

24 log = get_logger(__name__) # stdlib Logger, silent by default 

25 printer = get_printer() # lazy EzPrinter proxy, silent by default 

26 

27 def initialize(): 

28 log.info("Service ready") # captured if app enables logger hooks 

29 printer.success("Service ready") # delegated to real EzPrinter if app initialized Ezpl 

30""" 

31 

32from __future__ import annotations 

33 

34# /////////////////////////////////////////////////////////////// 

35# IMPORTS 

36# /////////////////////////////////////////////////////////////// 

37# Standard library imports 

38import logging 

39from collections.abc import Generator 

40from contextlib import contextmanager 

41from typing import Any 

42 

43# /////////////////////////////////////////////////////////////// 

44# CLASSES — PRINTER PROXIES 

45# /////////////////////////////////////////////////////////////// 

46 

47 

48class _LazyWizard: 

49 """ 

50 Lazy proxy for RichWizard. 

51 

52 Delegates any attribute access / method call to the real RichWizard 

53 when Ezpl is initialized. Returns a silent no-op callable otherwise. 

54 This covers the full RichWizard API without enumerating every method. 

55 """ 

56 

57 def _get_real(self) -> Any: 

58 from .ezpl import Ezpl 

59 

60 if Ezpl.is_initialized() and Ezpl.is_lib_printer_hook_enabled(): 

61 return Ezpl().get_printer().wizard 

62 return None 

63 

64 def __getattr__(self, name: str) -> Any: 

65 real = self._get_real() 

66 if real is not None: 

67 return getattr(real, name) 

68 return lambda *_args, **_kwargs: None 

69 

70 

71class _LazyPrinter: 

72 """ 

73 Lazy proxy for EzPrinter. 

74 

75 All method calls are silently discarded when Ezpl is not initialized. 

76 Once the host application calls Ezpl(...), every subsequent call is 

77 transparently forwarded to the real EzPrinter instance. 

78 

79 The proxy holds a _LazyWizard so that printer.wizard.xxx() calls also 

80 resolve correctly. 

81 

82 Unlike `_LazyWizard`, this proxy declares its log methods explicitly 

83 rather than relying on `__getattr__` — any new EzPrinter log method must 

84 be added here by hand, or its arguments would be silently dropped in 

85 lib mode. 

86 """ 

87 

88 def __init__(self) -> None: 

89 self._lazy_wizard = _LazyWizard() 

90 

91 # --- Internal --- 

92 

93 def _get_real(self) -> Any: 

94 from .ezpl import Ezpl 

95 

96 if Ezpl.is_initialized() and Ezpl.is_lib_printer_hook_enabled(): 

97 return Ezpl().get_printer() 

98 return None 

99 

100 # --- Properties --- 

101 

102 @property 

103 def level(self) -> str: 

104 """Return the current logging level, or 'INFO' if Ezpl is not initialized.""" 

105 real = self._get_real() 

106 return real.level if real is not None else "INFO" 

107 

108 @property 

109 def indent_step(self) -> int: 

110 """Return the configured indentation step, or 3 if Ezpl is not initialized.""" 

111 real = self._get_real() 

112 return real.indent_step if real is not None else 3 

113 

114 @property 

115 def indent_symbol(self) -> str: 

116 """Return the configured indentation symbol, or '>' if Ezpl is not initialized.""" 

117 real = self._get_real() 

118 return real.indent_symbol if real is not None else ">" 

119 

120 @property 

121 def base_indent_symbol(self) -> str: 

122 """Return the base indentation symbol, or '~' if Ezpl is not initialized.""" 

123 real = self._get_real() 

124 return real.base_indent_symbol if real is not None else "~" 

125 

126 @property 

127 def wizard(self) -> _LazyWizard: 

128 """Return the RichWizard proxy (lazy delegation).""" 

129 return self._lazy_wizard 

130 

131 # --- Core log methods --- 

132 

133 def log(self, level: str, message: Any, *args: Any, **kwargs: Any) -> None: 

134 """Log a message at the given level (no-op if Ezpl not initialized).""" 

135 real = self._get_real() 

136 if real is not None: 

137 real.log(level, message, *args, **kwargs) 

138 

139 def trace(self, message: Any, *args: Any, **kwargs: Any) -> None: 

140 """Log a trace message (no-op if Ezpl not initialized).""" 

141 real = self._get_real() 

142 if real is not None: 

143 real.trace(message, *args, **kwargs) 

144 

145 def info(self, message: Any, *args: Any, **kwargs: Any) -> None: 

146 """Log an info message (no-op if Ezpl not initialized).""" 

147 real = self._get_real() 

148 if real is not None: 

149 real.info(message, *args, **kwargs) 

150 

151 def debug(self, message: Any, *args: Any, **kwargs: Any) -> None: 

152 """Log a debug message (no-op if Ezpl not initialized).""" 

153 real = self._get_real() 

154 if real is not None: 

155 real.debug(message, *args, **kwargs) 

156 

157 def success(self, message: Any, *args: Any, **kwargs: Any) -> None: 

158 """Log a success message (no-op if Ezpl not initialized).""" 

159 real = self._get_real() 

160 if real is not None: 

161 real.success(message, *args, **kwargs) 

162 

163 def warning(self, message: Any, *args: Any, **kwargs: Any) -> None: 

164 """Log a warning message (no-op if Ezpl not initialized).""" 

165 real = self._get_real() 

166 if real is not None: 

167 real.warning(message, *args, **kwargs) 

168 

169 def error( 

170 self, message: Any, *args: Any, exc_info: bool = False, **kwargs: Any 

171 ) -> None: 

172 """Log an error message (no-op if Ezpl not initialized).""" 

173 real = self._get_real() 

174 if real is not None: 174 ↛ exitline 174 didn't return from function 'error' because the condition on line 174 was always true

175 real.error(message, *args, exc_info=exc_info, **kwargs) 

176 

177 def critical( 

178 self, message: Any, *args: Any, exc_info: bool = False, **kwargs: Any 

179 ) -> None: 

180 """Log a critical message (no-op if Ezpl not initialized).""" 

181 real = self._get_real() 

182 if real is not None: 

183 real.critical(message, *args, exc_info=exc_info, **kwargs) 

184 

185 def exception(self, message: Any, *args: Any, **kwargs: Any) -> None: 

186 """Log an exception with traceback (no-op if Ezpl not initialized).""" 

187 real = self._get_real() 

188 if real is not None: 

189 real.exception(message, *args, **kwargs) 

190 

191 # --- Pattern methods --- 

192 

193 def tip(self, message: Any) -> None: 

194 """Display a tip message (no-op if Ezpl not initialized).""" 

195 real = self._get_real() 

196 if real is not None: 

197 real.tip(message) 

198 

199 def system(self, message: Any) -> None: 

200 """Display a system message (no-op if Ezpl not initialized).""" 

201 real = self._get_real() 

202 if real is not None: 

203 real.system(message) 

204 

205 def install(self, message: Any) -> None: 

206 """Display an install message (no-op if Ezpl not initialized).""" 

207 real = self._get_real() 

208 if real is not None: 

209 real.install(message) 

210 

211 def detect(self, message: Any) -> None: 

212 """Display a detect message (no-op if Ezpl not initialized).""" 

213 real = self._get_real() 

214 if real is not None: 

215 real.detect(message) 

216 

217 def config(self, message: Any) -> None: 

218 """Display a config message (no-op if Ezpl not initialized).""" 

219 real = self._get_real() 

220 if real is not None: 

221 real.config(message) 

222 

223 def deps(self, message: Any) -> None: 

224 """Display a deps message (no-op if Ezpl not initialized).""" 

225 real = self._get_real() 

226 if real is not None: 

227 real.deps(message) 

228 

229 def print_pattern(self, pattern: Any, message: Any, level: str = "INFO") -> None: 

230 """Display a message with pattern format (no-op if Ezpl not initialized).""" 

231 real = self._get_real() 

232 if real is not None: 

233 real.print_pattern(pattern, message, level) 

234 

235 # --- Rich features --- 

236 

237 def print_table(self, data: list[dict[str, Any]], title: str | None = None) -> None: 

238 """Display a table (no-op if Ezpl not initialized).""" 

239 real = self._get_real() 

240 if real is not None: 

241 real.print_table(data, title=title) 

242 

243 def print_panel( 

244 self, content: str, title: str | None = None, style: str = "blue" 

245 ) -> None: 

246 """Display a panel (no-op if Ezpl not initialized).""" 

247 real = self._get_real() 

248 if real is not None: 

249 real.print_panel(content, title=title, style=style) 

250 

251 def print_json( 

252 self, 

253 data: str | dict | list, 

254 title: str | None = None, 

255 indent: int | None = None, 

256 highlight: bool = True, 

257 ) -> None: 

258 """Display JSON data (no-op if Ezpl not initialized).""" 

259 real = self._get_real() 

260 if real is not None: 

261 real.print_json(data, title=title, indent=indent, highlight=highlight) 

262 

263 # --- Indentation --- 

264 

265 def get_indent(self) -> str: 

266 """Return the current indentation string, or '~' if Ezpl not initialized.""" 

267 real = self._get_real() 

268 return real.get_indent() if real is not None else "~" 

269 

270 def add_indent(self) -> None: 

271 """Increase indentation level (no-op if Ezpl not initialized).""" 

272 real = self._get_real() 

273 if real is not None: 

274 real.add_indent() 

275 

276 def del_indent(self) -> None: 

277 """Decrease indentation level (no-op if Ezpl not initialized).""" 

278 real = self._get_real() 

279 if real is not None: 

280 real.del_indent() 

281 

282 def reset_indent(self) -> None: 

283 """Reset indentation to zero (no-op if Ezpl not initialized).""" 

284 real = self._get_real() 

285 if real is not None: 

286 real.reset_indent() 

287 

288 @contextmanager 

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

290 """Context manager for temporary indentation (pass-through if Ezpl not initialized).""" 

291 real = self._get_real() 

292 if real is not None: 

293 with real.manage_indent(): 

294 yield 

295 else: 

296 yield 

297 

298 # --- Misc --- 

299 

300 def set_level(self, level: str) -> None: 

301 """Set the logging level (no-op if Ezpl not initialized).""" 

302 real = self._get_real() 

303 if real is not None: 

304 real.set_level(level) 

305 

306 def mark_level_as_configured(self) -> None: 

307 """Mark the level as configured (no-op if Ezpl not initialized).""" 

308 real = self._get_real() 

309 if real is not None: 

310 real.mark_level_as_configured() 

311 

312 def __str__(self) -> str: 

313 real = self._get_real() 

314 return str(real) if real is not None else "LazyPrinter(uninitialized)" 

315 

316 def __repr__(self) -> str: 

317 real = self._get_real() 

318 return repr(real) if real is not None else "LazyPrinter(uninitialized)" 

319 

320 

321# /////////////////////////////////////////////////////////////// 

322# MODULE-LEVEL SINGLETONS 

323# /////////////////////////////////////////////////////////////// 

324 

325# Single shared LazyPrinter instance — stateless proxy, safe to share 

326_PRINTER: _LazyPrinter = _LazyPrinter() 

327 

328# /////////////////////////////////////////////////////////////// 

329# FUNCTIONS 

330# /////////////////////////////////////////////////////////////// 

331 

332 

333def get_logger(name: str) -> logging.Logger: 

334 """ 

335 Return a stdlib-compatible logger for use in library code. 

336 

337 The returned logger has a NullHandler attached so that no output is 

338 produced when the host application has not configured any handler. 

339 When the host application enables logger hooks in Ezpl, all 

340 records emitted by this logger are automatically forwarded to the 

341 Rich/loguru pipeline. 

342 

343 This follows the official Python recommendation for library logging: 

344 https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library 

345 

346 Args: 

347 name: Logger name. Use __name__ to follow the module-name convention, 

348 which allows the host application to filter by package prefix. 

349 

350 Returns: 

351 logging.Logger: A stdlib logger, passive by design. 

352 

353 Example: 

354 >>> from ezpl.lib_mode import get_logger 

355 >>> log = get_logger(__name__) 

356 >>> log.info("Service initialized") # silent without host config 

357 >>> log.warning("Unexpected state") # forwarded if intercepted 

358 """ 

359 log = logging.getLogger(name) 

360 # Attach NullHandler only once — avoids duplicate handlers on re-import 

361 if not log.handlers: 361 ↛ 363line 361 didn't jump to line 363 because the condition on line 361 was always true

362 log.addHandler(logging.NullHandler()) 

363 return log 

364 

365 

366def get_printer() -> _LazyPrinter: 

367 """ 

368 Return the shared lazy printer proxy for use in library code. 

369 

370 The returned proxy silently discards all method calls when the host 

371 application has not initialized Ezpl. Once the app calls Ezpl(...), 

372 every subsequent call is transparently forwarded to the real EzPrinter. 

373 

374 The same instance is returned on every call (module-level singleton). 

375 The proxy is stateless — it holds no indentation or level state of its 

376 own, delegating everything to the real EzPrinter when available. 

377 

378 No configuration is triggered by calling this function — it is safe 

379 to call at module level in library code. 

380 

381 Returns: 

382 _LazyPrinter: A lazy proxy implementing the full EzPrinter interface. 

383 

384 Example: 

385 >>> from ezpl.lib_mode import get_printer 

386 >>> printer = get_printer() 

387 >>> printer.success("Service ready") # silent without host config 

388 >>> printer.info("Processing...") # delegated once app initializes Ezpl 

389 >>> with printer.manage_indent(): 

390 ... printer.debug("detail") 

391 """ 

392 return _PRINTER 

393 

394 

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

396# PUBLIC API 

397# /////////////////////////////////////////////////////////////// 

398 

399__all__ = [ 

400 "get_logger", 

401 "get_printer", 

402]