1import logging
2from datetime import datetime
3from logging import Handler, LogRecord
4from pathlib import Path
5from types import ModuleType
6from typing import ClassVar, Iterable, List, Optional, Type, Union
7
8from pip._vendor.rich._null_file import NullFile
9
10from . import get_console
11from ._log_render import FormatTimeCallable, LogRender
12from .console import Console, ConsoleRenderable
13from .highlighter import Highlighter, ReprHighlighter
14from .text import Text
15from .traceback import Traceback
16
17
18class RichHandler(Handler):
19 """A logging handler that renders output with Rich. The time / level / message and file are displayed in columns.
20 The level is color coded, and the message is syntax highlighted.
21
22 Note:
23 Be careful when enabling console markup in log messages if you have configured logging for libraries not
24 under your control. If a dependency writes messages containing square brackets, it may not produce the intended output.
25
26 Args:
27 level (Union[int, str], optional): Log level. Defaults to logging.NOTSET.
28 console (:class:`~rich.console.Console`, optional): Optional console instance to write logs.
29 Default will use a global console instance writing to stdout.
30 show_time (bool, optional): Show a column for the time. Defaults to True.
31 omit_repeated_times (bool, optional): Omit repetition of the same time. Defaults to True.
32 show_level (bool, optional): Show a column for the level. Defaults to True.
33 show_path (bool, optional): Show the path to the original log call. Defaults to True.
34 enable_link_path (bool, optional): Enable terminal link of path column to file. Defaults to True.
35 highlighter (Highlighter, optional): Highlighter to style log messages, or None to use ReprHighlighter. Defaults to None.
36 markup (bool, optional): Enable console markup in log messages. Defaults to False.
37 rich_tracebacks (bool, optional): Enable rich tracebacks with syntax highlighting and formatting. Defaults to False.
38 tracebacks_width (Optional[int], optional): Number of characters used to render tracebacks, or None for full width. Defaults to None.
39 tracebacks_code_width (int, optional): Number of code characters used to render tracebacks, or None for full width. Defaults to 88.
40 tracebacks_extra_lines (int, optional): Additional lines of code to render tracebacks, or None for full width. Defaults to None.
41 tracebacks_theme (str, optional): Override pygments theme used in traceback.
42 tracebacks_word_wrap (bool, optional): Enable word wrapping of long tracebacks lines. Defaults to True.
43 tracebacks_show_locals (bool, optional): Enable display of locals in tracebacks. Defaults to False.
44 tracebacks_suppress (Sequence[Union[str, ModuleType]]): Optional sequence of modules or paths to exclude from traceback.
45 tracebacks_max_frames (int, optional): Optional maximum number of frames returned by traceback.
46 locals_max_length (int, optional): Maximum length of containers before abbreviating, or None for no abbreviation.
47 Defaults to 10.
48 locals_max_string (int, optional): Maximum length of string before truncating, or None to disable. Defaults to 80.
49 log_time_format (Union[str, TimeFormatterCallable], optional): If ``log_time`` is enabled, either string for strftime or callable that formats the time. Defaults to "[%x %X] ".
50 keywords (List[str], optional): List of words to highlight instead of ``RichHandler.KEYWORDS``.
51 """
52
53 KEYWORDS: ClassVar[Optional[List[str]]] = [
54 "GET",
55 "POST",
56 "HEAD",
57 "PUT",
58 "DELETE",
59 "OPTIONS",
60 "TRACE",
61 "PATCH",
62 ]
63 HIGHLIGHTER_CLASS: ClassVar[Type[Highlighter]] = ReprHighlighter
64
65 def __init__(
66 self,
67 level: Union[int, str] = logging.NOTSET,
68 console: Optional[Console] = None,
69 *,
70 show_time: bool = True,
71 omit_repeated_times: bool = True,
72 show_level: bool = True,
73 show_path: bool = True,
74 enable_link_path: bool = True,
75 highlighter: Optional[Highlighter] = None,
76 markup: bool = False,
77 rich_tracebacks: bool = False,
78 tracebacks_width: Optional[int] = None,
79 tracebacks_code_width: Optional[int] = 88,
80 tracebacks_extra_lines: int = 3,
81 tracebacks_theme: Optional[str] = None,
82 tracebacks_word_wrap: bool = True,
83 tracebacks_show_locals: bool = False,
84 tracebacks_suppress: Iterable[Union[str, ModuleType]] = (),
85 tracebacks_max_frames: int = 100,
86 locals_max_length: int = 10,
87 locals_max_string: int = 80,
88 log_time_format: Union[str, FormatTimeCallable] = "[%x %X]",
89 keywords: Optional[List[str]] = None,
90 ) -> None:
91 super().__init__(level=level)
92 self.console = console or get_console()
93 self.highlighter = highlighter or self.HIGHLIGHTER_CLASS()
94 self._log_render = LogRender(
95 show_time=show_time,
96 show_level=show_level,
97 show_path=show_path,
98 time_format=log_time_format,
99 omit_repeated_times=omit_repeated_times,
100 level_width=None,
101 )
102 self.enable_link_path = enable_link_path
103 self.markup = markup
104 self.rich_tracebacks = rich_tracebacks
105 self.tracebacks_width = tracebacks_width
106 self.tracebacks_extra_lines = tracebacks_extra_lines
107 self.tracebacks_theme = tracebacks_theme
108 self.tracebacks_word_wrap = tracebacks_word_wrap
109 self.tracebacks_show_locals = tracebacks_show_locals
110 self.tracebacks_suppress = tracebacks_suppress
111 self.tracebacks_max_frames = tracebacks_max_frames
112 self.tracebacks_code_width = tracebacks_code_width
113 self.locals_max_length = locals_max_length
114 self.locals_max_string = locals_max_string
115 self.keywords = keywords
116
117 def get_level_text(self, record: LogRecord) -> Text:
118 """Get the level name from the record.
119
120 Args:
121 record (LogRecord): LogRecord instance.
122
123 Returns:
124 Text: A tuple of the style and level name.
125 """
126 level_name = record.levelname
127 level_text = Text.styled(
128 level_name.ljust(8), f"logging.level.{level_name.lower()}"
129 )
130 return level_text
131
132 def emit(self, record: LogRecord) -> None:
133 """Invoked by logging."""
134 message = self.format(record)
135 traceback = None
136 if (
137 self.rich_tracebacks
138 and record.exc_info
139 and record.exc_info != (None, None, None)
140 ):
141 exc_type, exc_value, exc_traceback = record.exc_info
142 assert exc_type is not None
143 assert exc_value is not None
144 traceback = Traceback.from_exception(
145 exc_type,
146 exc_value,
147 exc_traceback,
148 width=self.tracebacks_width,
149 code_width=self.tracebacks_code_width,
150 extra_lines=self.tracebacks_extra_lines,
151 theme=self.tracebacks_theme,
152 word_wrap=self.tracebacks_word_wrap,
153 show_locals=self.tracebacks_show_locals,
154 locals_max_length=self.locals_max_length,
155 locals_max_string=self.locals_max_string,
156 suppress=self.tracebacks_suppress,
157 max_frames=self.tracebacks_max_frames,
158 )
159 message = record.getMessage()
160 if self.formatter:
161 record.message = record.getMessage()
162 formatter = self.formatter
163 if hasattr(formatter, "usesTime") and formatter.usesTime():
164 record.asctime = formatter.formatTime(record, formatter.datefmt)
165 message = formatter.formatMessage(record)
166
167 message_renderable = self.render_message(record, message)
168 log_renderable = self.render(
169 record=record, traceback=traceback, message_renderable=message_renderable
170 )
171 if isinstance(self.console.file, NullFile):
172 # Handles pythonw, where stdout/stderr are null, and we return NullFile
173 # instance from Console.file. In this case, we still want to make a log record
174 # even though we won't be writing anything to a file.
175 self.handleError(record)
176 else:
177 try:
178 self.console.print(log_renderable)
179 except Exception:
180 self.handleError(record)
181
182 def render_message(self, record: LogRecord, message: str) -> "ConsoleRenderable":
183 """Render message text in to Text.
184
185 Args:
186 record (LogRecord): logging Record.
187 message (str): String containing log message.
188
189 Returns:
190 ConsoleRenderable: Renderable to display log message.
191 """
192 use_markup = getattr(record, "markup", self.markup)
193 message_text = Text.from_markup(message) if use_markup else Text(message)
194
195 highlighter = getattr(record, "highlighter", self.highlighter)
196 if highlighter:
197 message_text = highlighter(message_text)
198
199 if self.keywords is None:
200 self.keywords = self.KEYWORDS
201
202 if self.keywords:
203 message_text.highlight_words(self.keywords, "logging.keyword")
204
205 return message_text
206
207 def render(
208 self,
209 *,
210 record: LogRecord,
211 traceback: Optional[Traceback],
212 message_renderable: "ConsoleRenderable",
213 ) -> "ConsoleRenderable":
214 """Render log for display.
215
216 Args:
217 record (LogRecord): logging Record.
218 traceback (Optional[Traceback]): Traceback instance or None for no Traceback.
219 message_renderable (ConsoleRenderable): Renderable (typically Text) containing log message contents.
220
221 Returns:
222 ConsoleRenderable: Renderable to display log.
223 """
224 path = Path(record.pathname).name
225 level = self.get_level_text(record)
226 time_format = None if self.formatter is None else self.formatter.datefmt
227 log_time = datetime.fromtimestamp(record.created)
228
229 log_renderable = self._log_render(
230 self.console,
231 [message_renderable] if not traceback else [message_renderable, traceback],
232 log_time=log_time,
233 time_format=time_format,
234 level=level,
235 path=path,
236 line_no=record.lineno,
237 link_path=record.pathname if self.enable_link_path else None,
238 )
239 return log_renderable
240
241
242if __name__ == "__main__": # pragma: no cover
243 from time import sleep
244
245 FORMAT = "%(message)s"
246 # FORMAT = "%(asctime)-15s - %(levelname)s - %(message)s"
247 logging.basicConfig(
248 level="NOTSET",
249 format=FORMAT,
250 datefmt="[%X]",
251 handlers=[RichHandler(rich_tracebacks=True, tracebacks_show_locals=True)],
252 )
253 log = logging.getLogger("rich")
254
255 log.info("Server starting...")
256 log.info("Listening on http://127.0.0.1:8080")
257 sleep(1)
258
259 log.info("GET /index.html 200 1298")
260 log.info("GET /imgs/backgrounds/back1.jpg 200 54386")
261 log.info("GET /css/styles.css 200 54386")
262 log.warning("GET /favicon.ico 404 242")
263 sleep(1)
264
265 log.debug(
266 "JSONRPC request\n--> %r\n<-- %r",
267 {
268 "version": "1.1",
269 "method": "confirmFruitPurchase",
270 "params": [["apple", "orange", "mangoes", "pomelo"], 1.123],
271 "id": "194521489",
272 },
273 {"version": "1.1", "result": True, "error": None, "id": "194521489"},
274 )
275 log.debug(
276 "Loading configuration file /adasd/asdasd/qeqwe/qwrqwrqwr/sdgsdgsdg/werwerwer/dfgerert/ertertert/ertetert/werwerwer"
277 )
278 log.error("Unable to find 'pomelo' in database!")
279 log.info("POST /jsonrpc/ 200 65532")
280 log.info("POST /admin/ 401 42234")
281 log.warning("password was rejected for admin site.")
282
283 def divide() -> None:
284 number = 1
285 divisor = 0
286 foos = ["foo"] * 100
287 log.debug("in divide")
288 try:
289 number / divisor
290 except:
291 log.exception("An error of some kind occurred!")
292
293 divide()
294 sleep(1)
295 log.critical("Out of memory!")
296 log.info("Server exited with code=-1")
297 log.info("[bold]EXITING...[/bold]", extra=dict(markup=True))