Coverage for install/scipp/logging.py: 1%
113 statements
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-17 01:51 +0000
« prev ^ index » next coverage.py v7.6.1, created at 2024-11-17 01:51 +0000
1# SPDX-License-Identifier: BSD-3-Clause
2# Copyright (c) 2023 Scipp contributors (https://github.com/scipp)
3# @author Jan-Lukas Wynen
4"""
5Utilities for managing Scipp's logger and log widget.
7See the https://scipp.github.io/reference/logging.html for an overview.
8"""
10import html
11import logging
12import time
13from copy import copy
14from dataclasses import dataclass
15from typing import Any
17from .core import DataArray, Dataset, Variable
18from .utils import running_in_jupyter
19from .visualization import make_html
20from .visualization.resources import load_style
23def get_logger() -> logging.Logger:
24 """
25 Return the global logger used by Scipp.
26 """
27 return logging.getLogger('scipp')
30@dataclass
31class WidgetLogRecord:
32 """
33 Preprocessed data for display in ``LogWidget``.
34 """
36 name: str
37 levelname: str
38 time_stamp: str
39 message: str
42def make_log_widget(**widget_kwargs):
43 if not running_in_jupyter():
44 raise RuntimeError(
45 'Cannot create a logging widget because '
46 'Python is not running in a Jupyter notebook.'
47 )
49 from ipywidgets import HTML
51 class LogWidget(HTML):
52 """
53 Widget that displays log messages in a table.
55 :seealso: :py:class:`scipp.logging.WidgetHandler`
56 """
58 def __init__(self, **kwargs):
59 super().__init__(**kwargs)
60 self._rows_str = ''
61 self._update()
63 def add_message(self, record: WidgetLogRecord) -> None:
64 """
65 Add a message to the output.
66 :param record: Log record formatted for the widget.
67 """
68 self._rows_str += self._format_row(record)
69 self._update()
71 @staticmethod
72 def _format_row(record: WidgetLogRecord) -> str:
73 # The message is assumed to be safe HTML.
74 # It is WidgetHandler's responsibility to ensure that.
75 return (
76 f'<tr class="sc-log sc-log-{html.escape(record.levelname.lower())}">'
77 f'<td class="sc-log-time-stamp">[{html.escape(record.time_stamp)}]</td>'
78 f'<td class="sc-log-level">{html.escape(record.levelname)}</td>'
79 f'<td class="sc-log-message">{record.message}</td>'
80 f'<td class="sc-log-name"><{html.escape(record.name)}></td>'
81 '</tr>'
82 )
84 def _update(self) -> None:
85 self.value = (
86 '<div class="sc-log">'
87 f'<table class="sc-log">{self._rows_str}</table>'
88 '</div>'
89 )
91 def clear(self) -> None:
92 """
93 Remove all output from the widget.
94 """
95 self._rows_str = ''
96 self._update()
98 return LogWidget(**widget_kwargs)
101def get_log_widget():
102 """
103 Return the log widget used by the Scipp logger.
104 If multiple widget handlers are installed, only the first one is returned.
105 If no widget handler is installed, returns ``None``.
106 """
107 handler = get_widget_handler()
108 if handler is None:
109 return None
110 return handler.widget
113def display_logs() -> None:
114 """
115 Display the log widget associated with the Scipp logger.
116 Only works in Jupyter notebooks.
117 """
118 widget = get_log_widget()
119 if widget is None:
120 raise RuntimeError(
121 'Cannot display log widgets because no widget handler is installed.'
122 )
124 from IPython.display import display
125 from ipywidgets import HTML, VBox
127 display(VBox([HTML(value=load_style()), widget]).add_class('sc-log-wrap'))
130def clear_log_widget() -> None:
131 """
132 Remove the current output of the log widget of the Scipp logger
133 if there is one.
134 """
135 widget = get_log_widget()
136 if widget is None:
137 return
138 widget.clear()
141def _has_html_repr(x: Any) -> bool:
142 return isinstance(x, DataArray | Dataset | Variable)
145def _make_html(x) -> str:
146 return f'<div class="sc-log-html-payload">{make_html(x)}</div>'
149# This class is used with the log formatter to distinguish between str and repr.
150# str produces a pattern that can be replaced with HTML and
151# repr produces a plain string for the argument.
152class _ReplacementPattern:
153 _PATTERN = '$__SCIPP_HTML_REPLACEMENT_{:02d}__'
155 def __init__(self, i: int, arg: Any):
156 self._i = i
157 self._arg = arg
159 def __str__(self):
160 return self._PATTERN.format(self._i)
162 def __repr__(self):
163 return repr(self._arg)
166def _preprocess_format_args(args) -> tuple[tuple, dict[str, Any]]:
167 format_args = []
168 replacements = {}
169 for i, arg in enumerate(args):
170 if _has_html_repr(arg):
171 tag = _ReplacementPattern(i, arg)
172 format_args.append(tag)
173 replacements[str(tag)] = arg
174 else:
175 format_args.append(arg)
176 return tuple(format_args), replacements
179def _replace_html_repr(message: str, replacements: dict[str, str]) -> str:
180 # Do separate check `key in message` in order to avoid calling
181 # _make_html unnecessarily. Linear string searches are likely less
182 # expensive than HTML formatting.
183 for key, repl in replacements.items():
184 if key in message:
185 message = message.replace(key, _make_html(repl))
186 return message
189class WidgetHandler(logging.Handler):
190 """
191 Logging handler that sends messages to a ``LogWidget``
192 for display in Jupyter notebooks.
194 Messages are formatted into a ``WidgetLogRecord`` and not into a string.
196 This handler introduces special formatting for objects with an HTML representation.
197 If the log message is a single such object, its HTML repr is embedded in the widget.
198 Strings are formatted to replace %s with the HTML repr and %r with a plain string
199 repr using ``str(x)`` and ``repr(x)`` is inaccessible.
200 """
202 def __init__(self, level: int, widget):
203 super().__init__(level)
204 self.widget = widget
205 self._rows = []
207 def format(self, record: logging.LogRecord) -> WidgetLogRecord:
208 """
209 Format the specified record for consumption by a ``LogWidget``.
210 """
211 message = (
212 self._format_html(record)
213 if _has_html_repr(record.msg)
214 else self._format_text(record)
215 )
216 return WidgetLogRecord(
217 name=record.name,
218 levelname=record.levelname,
219 time_stamp=time.strftime(
220 '%Y-%m-%dT%H:%M:%S', time.localtime(record.created)
221 ),
222 message=message,
223 )
225 def _format_text(self, record: logging.LogRecord) -> str:
226 args, replacements = _preprocess_format_args(record.args)
227 record = copy(record)
228 record.args = tuple(args)
229 message = html.escape(super().format(record))
230 return _replace_html_repr(message, replacements)
232 @staticmethod
233 def _format_html(record: logging.LogRecord) -> str:
234 if record.args:
235 raise TypeError('not all arguments converted during string formatting')
236 return _make_html(record.msg)
238 def emit(self, record: logging.LogRecord) -> None:
239 """
240 Send the formatted record to the widget.
241 """
242 self.widget.add_message(self.format(record))
245def make_widget_handler() -> WidgetHandler:
246 """
247 Create a new widget log handler.
248 :raises RuntimeError: If Python is not running in Jupyter.
249 """
250 handler = WidgetHandler(level=logging.INFO, widget=make_log_widget())
251 return handler
254def get_widget_handler() -> WidgetHandler | None:
255 """
256 Return the widget handler installed in the Scipp logger.
257 If multiple widget handlers are installed, only the first one is returned.
258 If no widget handler is installed, returns ``None``.
259 """
260 try:
261 return next( # type: ignore[return-value]
262 filter(
263 lambda handler: isinstance(handler, WidgetHandler),
264 get_logger().handlers,
265 )
266 )
267 except StopIteration:
268 return None