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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Passive logger and printer proxies for library authors.
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.
13Recommended imports for library code:
15 from ezpl.lib_mode import get_logger, get_printer
17These imports are intentionally lightweight — no dependency on rich or loguru
18is triggered at import time. The lazy delegation happens at call time only.
20Usage pattern in a library:
22 from ezpl.lib_mode import get_logger, get_printer
24 log = get_logger(__name__) # stdlib Logger, silent by default
25 printer = get_printer() # lazy EzPrinter proxy, silent by default
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"""
32from __future__ import annotations
34# ///////////////////////////////////////////////////////////////
35# IMPORTS
36# ///////////////////////////////////////////////////////////////
37# Standard library imports
38import logging
39from collections.abc import Generator
40from contextlib import contextmanager
41from typing import Any
43# ///////////////////////////////////////////////////////////////
44# CLASSES — PRINTER PROXIES
45# ///////////////////////////////////////////////////////////////
48class _LazyWizard:
49 """
50 Lazy proxy for RichWizard.
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 """
57 def _get_real(self) -> Any:
58 from .ezpl import Ezpl
60 if Ezpl.is_initialized() and Ezpl.is_lib_printer_hook_enabled():
61 return Ezpl().get_printer().wizard
62 return None
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
71class _LazyPrinter:
72 """
73 Lazy proxy for EzPrinter.
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.
79 The proxy holds a _LazyWizard so that printer.wizard.xxx() calls also
80 resolve correctly.
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 """
88 def __init__(self) -> None:
89 self._lazy_wizard = _LazyWizard()
91 # --- Internal ---
93 def _get_real(self) -> Any:
94 from .ezpl import Ezpl
96 if Ezpl.is_initialized() and Ezpl.is_lib_printer_hook_enabled():
97 return Ezpl().get_printer()
98 return None
100 # --- Properties ---
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"
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
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 ">"
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 "~"
126 @property
127 def wizard(self) -> _LazyWizard:
128 """Return the RichWizard proxy (lazy delegation)."""
129 return self._lazy_wizard
131 # --- Core log methods ---
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)
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)
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)
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)
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)
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)
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)
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)
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)
191 # --- Pattern methods ---
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)
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)
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)
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)
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)
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)
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)
235 # --- Rich features ---
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)
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)
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)
263 # --- Indentation ---
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 "~"
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()
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()
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()
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
298 # --- Misc ---
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)
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()
312 def __str__(self) -> str:
313 real = self._get_real()
314 return str(real) if real is not None else "LazyPrinter(uninitialized)"
316 def __repr__(self) -> str:
317 real = self._get_real()
318 return repr(real) if real is not None else "LazyPrinter(uninitialized)"
321# ///////////////////////////////////////////////////////////////
322# MODULE-LEVEL SINGLETONS
323# ///////////////////////////////////////////////////////////////
325# Single shared LazyPrinter instance — stateless proxy, safe to share
326_PRINTER: _LazyPrinter = _LazyPrinter()
328# ///////////////////////////////////////////////////////////////
329# FUNCTIONS
330# ///////////////////////////////////////////////////////////////
333def get_logger(name: str) -> logging.Logger:
334 """
335 Return a stdlib-compatible logger for use in library code.
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.
343 This follows the official Python recommendation for library logging:
344 https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library
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.
350 Returns:
351 logging.Logger: A stdlib logger, passive by design.
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
366def get_printer() -> _LazyPrinter:
367 """
368 Return the shared lazy printer proxy for use in library code.
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.
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.
378 No configuration is triggered by calling this function — it is safe
379 to call at module level in library code.
381 Returns:
382 _LazyPrinter: A lazy proxy implementing the full EzPrinter interface.
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
395# ///////////////////////////////////////////////////////////////
396# PUBLIC API
397# ///////////////////////////////////////////////////////////////
399__all__ = [
400 "get_logger",
401 "get_printer",
402]