Repository navigation
Expand file tree
/
Copy path_loggers.py
More file actions
274 lines (219 loc) · 8.15 KB
/
Copy path_loggers.py
File metadata and controls
274 lines (219 loc) · 8.15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# Copyright 2026 Marimo. All rights reserved.
from __future__ import annotations
import io
import logging
from contextlib import contextmanager
from logging.handlers import TimedRotatingFileHandler
from typing import TYPE_CHECKING
from marimo._utils.log_formatter import LogFormatter
if TYPE_CHECKING:
from collections.abc import Generator, Iterator
from pathlib import Path
# This file manages and creates loggers used throughout marimo.
#
# It contains a global log level, which can be updated and all handlers
# will be updated to use the new level.
#
# Our loggers contain two handlers:
# - A StreamHandler to stdout
# - A FileHandler to a rotating log file
#
# The stream handler is set to the global log level, but the file handler
# is set to either INFO or DEBUG, depending on the global log level.
#
# NB: As is best practice for Python libraries, we do not configure the
# root logger, and in particular we don't call basicConfig() (which would
# preclude client of our library from configuring the root logger to their
# own end).
# See https://docs.python.org/3/howto/logging.html#configuring-logging-for-a-library
# Global log level for loggers
_LOG_LEVEL: int = logging.WARNING
# Custom log formatter
_LOG_FORMATTER = LogFormatter()
# Cache of initialized loggers
_LOGGERS: dict[str, logging.Logger] = {}
class WindowsSafeRotatingFileHandler(TimedRotatingFileHandler):
"""
A Windows-compatible rotating file handler that closes the file
before rotation to avoid PermissionError on Windows.
On Windows, TimedRotatingFileHandler.doRollover() can fail with
PermissionError when attempting to rename a file that's still open
by another process or handler.
"""
def doRollover(self) -> None:
"""
Override doRollover to close the stream before rotation on Windows.
"""
if self.stream:
self.stream.close()
self.stream = None # type: ignore[assignment]
try:
super().doRollover()
except PermissionError:
# If rotation fails, log the error but don't crash
# The log file will continue to grow until the lock is released
pass
finally:
# Reopen the stream
if self.stream is None:
self.stream = self._open()
def log_level_string_to_int(level: str) -> int:
level = level.upper()
if level == "DEBUG":
return logging.DEBUG
elif level == "INFO":
return logging.INFO
elif level == "WARN" or level == "WARNING":
return logging.WARNING
elif level == "ERROR":
return logging.ERROR
elif level == "CRITICAL":
return logging.CRITICAL
else:
raise ValueError(f"Unrecognized log level {level}")
def set_level(level: str | int = logging.WARNING) -> None:
"""Globally set the log level for all loggers."""
global _LOG_LEVEL
if isinstance(level, str):
_LOG_LEVEL = log_level_string_to_int(level)
elif level not in [
logging.DEBUG,
logging.INFO,
logging.WARNING,
logging.ERROR,
logging.CRITICAL,
]:
raise ValueError(f"Unrecognized log level {level}")
else:
_LOG_LEVEL = level
for logger in _LOGGERS.values():
# We have to set update the logger's level in order
# for its handlers level's to be respected.
# but it needs to be set to the minimum LOG_LEVEL and INFO
# so the file handler can still pick it up
logger.setLevel(min(_LOG_LEVEL, logging.INFO))
for handler in logger.handlers:
if isinstance(handler, logging.FileHandler):
# Don't increase the log level of a file handler
handler.setLevel(min(_LOG_LEVEL, handler.level))
elif isinstance(handler, logging.StreamHandler):
handler.setLevel(_LOG_LEVEL)
def get_logger(name: str, level: int | None = None) -> logging.Logger:
"""
Get a logger with a given name and level.
If the logger is already created, we return the cached logger.
Otherwise, we create a new logger and cache it.
"""
# Cache loggers
if name in _LOGGERS:
return _LOGGERS[name]
# Create logger
logger = logging.getLogger(name)
# Stream to stdout
# We set the level on the StreamHandler, instead of the Logger,
# because the FileHandler may have a different level
stream_handler = logging.StreamHandler()
stream_handler.setFormatter(_LOG_FORMATTER)
if level is None:
stream_handler.setLevel(_LOG_LEVEL)
logger.setLevel(_LOG_LEVEL)
else:
stream_handler.setLevel(level)
logger.setLevel(level)
logger.addHandler(stream_handler)
# Cache logger
_LOGGERS[name] = logger
# Don't propagate to parent loggers
logger.propagate = False
return logger
has_added_handler = False
def marimo_logger() -> logging.Logger:
"""Get a logger for marimo."""
logger = get_logger("marimo")
# Add file handler to log to file
global has_added_handler
if not has_added_handler:
has_added_handler = True
try:
logger.addHandler(_file_handler())
except Exception as e:
logger.debug(f"Failed to add file handler: {e}")
return logger
def get_log_directory() -> Path:
from marimo._utils.xdg import marimo_log_dir
return marimo_log_dir()
def make_log_directory() -> None:
try:
log_dir = get_log_directory()
log_dir.mkdir(parents=True, exist_ok=True)
except Exception as e:
marimo_logger().debug(f"Failed to create log directory: {e}")
def _file_handler() -> logging.FileHandler:
from marimo._utils.platform import is_windows
make_log_directory()
# Use Windows-safe handler on Windows to avoid PermissionError during rotation
handler_class = (
WindowsSafeRotatingFileHandler
if is_windows()
else TimedRotatingFileHandler
)
# We log to the same file daily, and keep the last 7 days of logs
file_handler = handler_class(
get_log_directory() / "marimo.log",
when="D",
interval=1,
backupCount=7,
encoding="utf-8",
)
file_log_formatter = LogFormatter(
fmt="[%(asctime)s] %(levelname)-8s %(name)s:%(lineno)d - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
color=False,
)
file_handler.setFormatter(file_log_formatter)
# We set this to either INFO or DEBUG, depending on the global log level
file_handler.setLevel(min(_LOG_LEVEL, logging.INFO))
return file_handler
class ListHandler(logging.Handler):
"""Log handler that captures records in a list for processing."""
def __init__(self, level: int = logging.NOTSET) -> None:
super().__init__(level)
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
@contextmanager
def capture_output(
*, log_level: int = logging.DEBUG
) -> Iterator[tuple[io.StringIO, io.StringIO, list[logging.LogRecord]]]:
"""
Capture stdout, stderr, and log records during execution.
Yields: (stdout_buf, stderr_buf, log_records)
- log_records are raw LogRecord objects with full metadata (name, level, pathname, lineno, created, etc.).
- Log emission to existing handlers is silenced inside the context.
"""
import contextlib
logger = marimo_logger()
out, err = io.StringIO(), io.StringIO()
h: ListHandler = ListHandler(log_level)
# Snapshot and suppress existing handlers
old_handlers, old_propagate = logger.handlers[:], logger.propagate
logger.addHandler(h)
logger.handlers = [h]
logger.propagate = False
try:
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
yield out, err, h.records
finally:
logger.removeHandler(h)
logger.handlers = old_handlers
logger.propagate = old_propagate
@contextmanager
def suppress_warnings_logs(name: str) -> Generator[None, None, None]:
"""Suppress logs for a given logger."""
logger = get_logger(name)
original_level = logger.level
logger.setLevel(logging.ERROR)
try:
yield
finally:
logger.setLevel(original_level)