Coverage for python / lsst / utils / logging.py: 45%
124 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 08:37 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-30 08:37 +0000
1# This file is part of utils.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12from __future__ import annotations
14__all__ = (
15 "TRACE",
16 "VERBOSE",
17 "LsstLogAdapter",
18 "LsstLoggers",
19 "PeriodicLogger",
20 "getLogger",
21 "getTraceLogger",
22 "trace_set_at",
23)
25import logging
26import sys
27import time
28from collections.abc import Generator
29from contextlib import contextmanager
30from logging import LoggerAdapter
31from typing import TYPE_CHECKING, Any, TypeAlias, TypeGuard
33try:
34 import lsst.log.utils as logUtils
35except ImportError:
36 logUtils = None
38try:
39 from structlog import get_context as get_structlog_context
40except ImportError:
41 get_structlog_context = None # type: ignore[assignment]
44if TYPE_CHECKING:
45 try:
46 from structlog.typing import BindableLogger
47 except ImportError:
48 BindableLogger: TypeAlias = Any # type: ignore[no-redef]
50# log level for trace (verbose debug).
51TRACE = 5
52logging.addLevelName(TRACE, "TRACE")
54# Verbose logging is midway between INFO and DEBUG.
55VERBOSE = (logging.INFO + logging.DEBUG) // 2
56logging.addLevelName(VERBOSE, "VERBOSE")
59def _is_structlog_logger(
60 logger: logging.Logger | LsstLogAdapter | BindableLogger,
61) -> TypeGuard[BindableLogger]:
62 """Check if the given logger is a structlog logger."""
63 if get_structlog_context is None:
64 return False # type: ignore[unreachable]
66 try:
67 # Returns a dict for structlog loggers; raises for stdlib logger
68 # objects.
69 get_structlog_context(logger) # type: ignore[arg-type]
70 return True
71 except Exception:
72 # In practice this is usually ValueError or AttributeError.
73 return False
76def _calculate_base_stacklevel(default: int, offset: int) -> int:
77 """Calculate the default logging stacklevel to use.
79 Parameters
80 ----------
81 default : `int`
82 The stacklevel to use in Python 3.11 and newer where the only
83 thing to take into account is the number of levels above the core
84 Python logging infrastructure.
85 offset : `int`
86 The offset to apply for older Python implementations that need to
87 take into account internal call stacks.
89 Returns
90 -------
91 stacklevel : `int`
92 The stack level to pass to internal logging APIs that should result
93 in the log messages being reported in caller lines.
95 Notes
96 -----
97 In Python 3.11 the logging infrastructure was fixed such that we no
98 longer need to understand that a LoggerAdapter log messages need to
99 have a stack level one higher than a Logger would need. ``stacklevel=1``
100 now always means "log from the caller's line" without the caller having
101 to understand internal implementation details.
102 """
103 stacklevel = default
104 if sys.version_info < (3, 11, 0): 104 ↛ 105line 104 didn't jump to line 105 because the condition on line 104 was never true
105 stacklevel += offset
106 return stacklevel
109def trace_set_at(name: str, number: int) -> None:
110 """Adjust logging level to display messages with the trace number being
111 less than or equal to the provided value.
113 Parameters
114 ----------
115 name : `str`
116 Name of the logger.
117 number : `int`
118 The trace number threshold for display.
120 Examples
121 --------
122 .. code-block:: python
124 lsst.utils.logging.trace_set_at("lsst.afw", 3)
126 This will set loggers ``TRACE0.lsst.afw`` to ``TRACE3.lsst.afw`` to
127 ``DEBUG`` and ``TRACE4.lsst.afw`` and ``TRACE5.lsst.afw`` to ``INFO``.
129 Notes
130 -----
131 Loggers ``TRACE0.`` to ``TRACE5.`` are set. All loggers above
132 the specified threshold are set to ``INFO`` and those below the threshold
133 are set to ``DEBUG``. The expectation is that ``TRACE`` loggers only
134 issue ``DEBUG`` log messages.
136 If ``lsst.log`` is installed, this function will also call
137 `lsst.log.utils.traceSetAt` to ensure that non-Python loggers are
138 also configured correctly.
139 """
140 for i in range(6):
141 level = logging.INFO if i > number else logging.DEBUG
142 getTraceLogger(name, i).setLevel(level)
144 # if lsst log is available also set the trace loggers there.
145 if logUtils is not None:
146 logUtils.traceSetAt(name, number)
149class _F:
150 """Format, supporting `str.format()` syntax.
152 Notes
153 -----
154 This follows the recommendation from
155 https://docs.python.org/3/howto/logging-cookbook.html#using-custom-message-objects
156 """
158 def __init__(self, fmt: str, /, *args: Any, **kwargs: Any):
159 self.fmt = fmt
160 self.args = args
161 self.kwargs = kwargs
163 def __str__(self) -> str:
164 return self.fmt.format(*self.args, **self.kwargs)
167class LsstLogAdapter(LoggerAdapter):
168 """A special logging adapter to provide log features for LSST code.
170 Expected to be instantiated initially by a call to `getLogger()`.
172 This class provides enhancements over `logging.Logger` that include:
174 * Methods for issuing trace and verbose level log messages.
175 * Provision of a context manager to temporarily change the log level.
176 * Attachment of logging level constants to the class to make it easier
177 for a Task writer to access a specific log level without having to
178 know the underlying logger class.
179 """
181 # Store logging constants in the class for convenience. This is not
182 # something supported by Python loggers but can simplify some
183 # logic if the logger is available.
184 CRITICAL = logging.CRITICAL
185 ERROR = logging.ERROR
186 DEBUG = logging.DEBUG
187 INFO = logging.INFO
188 WARNING = logging.WARNING
190 # Python supports these but prefers they are not used.
191 FATAL = logging.FATAL
192 WARN = logging.WARN
194 # These are specific to Tasks
195 TRACE = TRACE
196 VERBOSE = VERBOSE
198 # The stack level to use when issuing log messages. For Python 3.11
199 # this is generally 2 (this method and the internal infrastructure).
200 # For older python we need one higher because of the extra indirection
201 # via LoggingAdapter internals.
202 _stacklevel = _calculate_base_stacklevel(2, 1)
204 @contextmanager
205 def temporary_log_level(self, level: int | str) -> Generator:
206 """Temporarily set the level of this logger.
208 Parameters
209 ----------
210 level : `int`
211 The new temporary log level.
212 """
213 old = self.level
214 self.setLevel(level)
215 try:
216 yield
217 finally:
218 self.setLevel(old)
220 @property
221 def level(self) -> int:
222 """Return current level of this logger (``int``)."""
223 return self.logger.level
225 def getChild(self, name: str) -> LsstLogAdapter:
226 """Get the named child logger.
228 Parameters
229 ----------
230 name : `str`
231 Name of the child relative to this logger.
233 Returns
234 -------
235 child : `LsstLogAdapter`
236 The child logger.
237 """
238 return getLogger(name=name, logger=self.logger)
240 def _process_stacklevel(self, kwargs: dict[str, Any], offset: int = 0) -> int:
241 # Return default stacklevel, taking into account kwargs[stacklevel].
242 stacklevel = self._stacklevel
243 if "stacklevel" in kwargs:
244 # External user expects stacklevel=1 to mean "report from their
245 # line" but the code here is already trying to achieve that by
246 # default. Therefore if an external stacklevel is specified we
247 # adjust their stacklevel request by 1.
248 stacklevel = stacklevel + kwargs.pop("stacklevel") - 1
250 # The offset can be used to indicate that we have to take into account
251 # additional internal layers before calling python logging.
252 return _calculate_base_stacklevel(stacklevel, offset)
254 def fatal(self, msg: str, *args: Any, **kwargs: Any) -> None:
255 # Python does not provide this method in LoggerAdapter but does
256 # not formally deprecate it in favor of critical() either.
257 # Provide it without deprecation message for consistency with Python.
258 # Have to adjust stacklevel on Python 3.10 and older to account
259 # for call through self.critical.
260 stacklevel = self._process_stacklevel(kwargs, offset=1)
261 self.critical(msg, *args, **kwargs, stacklevel=stacklevel)
263 def verbose(self, fmt: str, *args: Any, **kwargs: Any) -> None:
264 """Issue a VERBOSE level log message.
266 Arguments are as for `logging.info`.
267 ``VERBOSE`` is between ``DEBUG`` and ``INFO``.
269 Parameters
270 ----------
271 fmt : `str`
272 Log message.
273 *args : `~typing.Any`
274 Parameters references by log message.
275 **kwargs : `~typing.Any`
276 Parameters forwarded to underlying logger.
277 """
278 # There is no other way to achieve this other than a special logger
279 # method.
280 # Stacklevel is passed in so that the correct line is reported
281 stacklevel = self._process_stacklevel(kwargs)
282 self.log(VERBOSE, fmt, *args, **kwargs, stacklevel=stacklevel)
284 def trace(self, fmt: str, *args: Any, **kwargs: Any) -> None:
285 """Issue a TRACE level log message.
287 Arguments are as for `logging.info`.
288 ``TRACE`` is lower than ``DEBUG``.
290 Parameters
291 ----------
292 fmt : `str`
293 Log message.
294 *args : `~typing.Any`
295 Parameters references by log message.
296 **kwargs : `~typing.Any`
297 Parameters forwarded to underlying logger.
298 """
299 # There is no other way to achieve this other than a special logger
300 # method.
301 stacklevel = self._process_stacklevel(kwargs)
302 self.log(TRACE, fmt, *args, **kwargs, stacklevel=stacklevel)
304 def setLevel(self, level: int | str) -> None:
305 """Set the level for the logger, trapping lsst.log values.
307 Parameters
308 ----------
309 level : `int`
310 The level to use. If the level looks too big to be a Python
311 logging level it is assumed to be a lsst.log level.
312 """
313 if isinstance(level, int) and level > logging.CRITICAL:
314 self.logger.warning(
315 "Attempting to set level to %d -- looks like an lsst.log level so scaling it accordingly.",
316 level,
317 )
318 level //= 1000
320 self.logger.setLevel(level)
322 @property
323 def handlers(self) -> list[logging.Handler]:
324 """Log handlers associated with this logger."""
325 return self.logger.handlers
327 def addHandler(self, handler: logging.Handler) -> None:
328 """Add a handler to this logger.
330 Parameters
331 ----------
332 handler : `logging.Handler`
333 Handler to add. The handler is forwarded to the underlying logger.
334 """
335 self.logger.addHandler(handler)
337 def removeHandler(self, handler: logging.Handler) -> None:
338 """Remove the given handler from the underlying logger.
340 Parameters
341 ----------
342 handler : `logging.Handler`
343 Handler to remove.
344 """
345 self.logger.removeHandler(handler)
348def getLogger(name: str | None = None, logger: logging.Logger | None = None) -> LsstLogAdapter:
349 """Get a logger compatible with LSST usage.
351 Parameters
352 ----------
353 name : `str`, optional
354 Name of the logger. Root logger if `None`.
355 logger : `logging.Logger` or `LsstLogAdapter`
356 If given the logger is converted to the relevant logger class.
357 If ``name`` is given the logger is assumed to be a child of the
358 supplied logger.
360 Returns
361 -------
362 logger : `LsstLogAdapter`
363 The relevant logger.
365 Notes
366 -----
367 A `logging.LoggerAdapter` is used since it is easier to provide a more
368 uniform interface than when using `logging.setLoggerClass`. An adapter
369 can be wrapped around the root logger and the `~logging.setLoggerClass`
370 will return the logger first given that name even if the name was
371 used before the `~lsst.pipe.base.Task` was created.
372 """
373 if not logger:
374 logger = logging.getLogger(name)
375 elif name:
376 logger = logger.getChild(name)
377 return LsstLogAdapter(logger, {})
380LsstLoggers: TypeAlias = logging.Logger | LsstLogAdapter
383def getTraceLogger(logger: str | LsstLoggers, trace_level: int) -> LsstLogAdapter:
384 """Get a logger with the appropriate TRACE name.
386 Parameters
387 ----------
388 logger : `logging.Logger` or `LsstLogAdapter` or `lsst.log.Log` or `str`
389 A logger to be used to derive the new trace logger. Can be a logger
390 object that supports the ``name`` property or a string.
391 trace_level : `int`
392 The trace level to use for the logger.
394 Returns
395 -------
396 trace_logger : `LsstLogAdapter`
397 A new trace logger. The name will be derived by pre-pending ``TRACEn.``
398 to the name of the supplied logger. If the root logger is given
399 the returned logger will be named ``TRACEn``.
400 """
401 name = getattr(logger, "name", str(logger))
402 trace_name = f"TRACE{trace_level}.{name}" if name else f"TRACE{trace_level}"
403 return getLogger(trace_name)
406class PeriodicLogger:
407 """Issue log messages if a time threshold has elapsed.
409 This class can be used in long-running sections of code where it would
410 be useful to issue a log message periodically to show that the
411 algorithm is progressing.
413 The first time threshold is counted from object construction, so in general
414 the first call to `log` does not log.
416 Parameters
417 ----------
418 logger : `logging.Logger` or `LsstLogAdapter`
419 Logger to use when issuing a message.
420 interval : `float`
421 The minimum interval in seconds between log messages. If `None`,
422 `LOGGING_INTERVAL` will be used.
423 level : `int`, optional
424 Log level to use when issuing messages, default is
425 `~logging.INFO`.
426 """
428 LOGGING_INTERVAL = 600.0
429 """Default interval between log messages in seconds."""
431 def __init__(self, logger: LsstLoggers, interval: float | None = None, level: int = logging.INFO):
432 self.logger = logger
433 # None -> LOGGING_INTERVAL conversion done so that unit tests (e.g., in
434 # pipe_base) can tweak log interval without access to the constructor.
435 self.interval = interval if interval is not None else self.LOGGING_INTERVAL
436 self.level = level
437 self.next_log_time = time.time() + self.interval
438 self.num_issued = 0
440 # The stacklevel we need to issue logs is determined by the type
441 # of logger we have been given. A LoggerAdapter has an extra
442 # level of indirection. In Python 3.11 the logging infrastructure
443 # takes care to check for internal logging stack frames so there
444 # is no need for a difference.
445 self._stacklevel = _calculate_base_stacklevel(2, 1 if isinstance(self.logger, LoggerAdapter) else 0)
447 def log(self, msg: str, *args: Any) -> bool:
448 """Issue a log message if the interval has elapsed.
450 The interval is measured from the previous call to ``log``, or from the
451 creation of this object.
453 Parameters
454 ----------
455 msg : `str`
456 Message to issue if the time has been exceeded.
457 *args : Any
458 Arguments to be merged into the message string, as described under
459 `logging.Logger.debug`.
461 Returns
462 -------
463 issued : `bool`
464 Returns `True` if a log message was sent to the logging system.
465 Returns `False` if the interval has not yet elapsed. Returning
466 `True` does not indicate whether the log message was in fact
467 issued by the logging system.
468 """
469 if (current_time := time.time()) > self.next_log_time:
470 self.logger.log(self.level, msg, *args, stacklevel=self._stacklevel)
471 self.next_log_time = current_time + self.interval
472 self.num_issued += 1
473 return True
474 return False