logging-tee 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: logging-tee
3
+ Version: 1.0.0
4
+ Summary: Logging utilities that work well with tqdm
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: tqdm
8
+
9
+ # Installation
10
+
11
+ ```bash
12
+ pip install logging-tee
13
+ ```
14
+
15
+ # Usage
16
+
17
+ ```python
18
+ if __name__ == "__main__":
19
+ setup_logger(log_file="output.log", level=logging.DEBUG)
20
+ logger = logging.getLogger(__name__)
21
+
22
+ for i in tqdm(
23
+ range(2),
24
+ desc="Processing",
25
+ ):
26
+ time.sleep(0.03)
27
+ logger.info("Processing item %d", i)
28
+
29
+ for outer_idx in tqdm(
30
+ range(2),
31
+ desc="Outer",
32
+ ):
33
+ for inner_idx in tqdm(
34
+ range(3),
35
+ desc=f"Inner {outer_idx}",
36
+ ):
37
+ time.sleep(0.02)
38
+ if inner_idx % 5 == 0:
39
+ print(f"Nested step outer={outer_idx} inner={inner_idx}")
40
+ logger.info(f"Completed outer={outer_idx}")
41
+
42
+ print("from print statement")
43
+ print("multiple lines\nfrom print statement 2")
44
+ logger.debug("Single line")
45
+ logger.debug("Multiple lines:\nnext line")
46
+ logger.debug("Another single line")
47
+ logger.debug("Multiple lines:\n%s", "next line\nnext line 2")
48
+ logger.warning("Warning message\nwith multiple lines\nand should be logged properly.")
49
+ logger.error("Error message\nwith multiple lines\nand should be logged properly.")
50
+ raise ValueError("This is an error message\nwith multiple lines\nand should be logged properly.")
51
+ ```
@@ -0,0 +1,43 @@
1
+ # Installation
2
+
3
+ ```bash
4
+ pip install logging-tee
5
+ ```
6
+
7
+ # Usage
8
+
9
+ ```python
10
+ if __name__ == "__main__":
11
+ setup_logger(log_file="output.log", level=logging.DEBUG)
12
+ logger = logging.getLogger(__name__)
13
+
14
+ for i in tqdm(
15
+ range(2),
16
+ desc="Processing",
17
+ ):
18
+ time.sleep(0.03)
19
+ logger.info("Processing item %d", i)
20
+
21
+ for outer_idx in tqdm(
22
+ range(2),
23
+ desc="Outer",
24
+ ):
25
+ for inner_idx in tqdm(
26
+ range(3),
27
+ desc=f"Inner {outer_idx}",
28
+ ):
29
+ time.sleep(0.02)
30
+ if inner_idx % 5 == 0:
31
+ print(f"Nested step outer={outer_idx} inner={inner_idx}")
32
+ logger.info(f"Completed outer={outer_idx}")
33
+
34
+ print("from print statement")
35
+ print("multiple lines\nfrom print statement 2")
36
+ logger.debug("Single line")
37
+ logger.debug("Multiple lines:\nnext line")
38
+ logger.debug("Another single line")
39
+ logger.debug("Multiple lines:\n%s", "next line\nnext line 2")
40
+ logger.warning("Warning message\nwith multiple lines\nand should be logged properly.")
41
+ logger.error("Error message\nwith multiple lines\nand should be logged properly.")
42
+ raise ValueError("This is an error message\nwith multiple lines\nand should be logged properly.")
43
+ ```
@@ -0,0 +1,14 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "logging-tee"
7
+ version = "1.0.0"
8
+ description = "Logging utilities that work well with tqdm"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ dependencies = ["tqdm"]
12
+
13
+ [tool.setuptools.packages.find]
14
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .tee import setup_logger
2
+
3
+ __all__ = ["setup_logger"]
@@ -0,0 +1,324 @@
1
+ # %%
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ import time
7
+ import copy
8
+ import tqdm.std as tqdm_std
9
+ import tqdm.auto as tqdm_auto
10
+ from tqdm.auto import tqdm
11
+
12
+
13
+ class MultilineMixin:
14
+ def iter_formatted_lines(self, record):
15
+ # Picks formatter: self.formatter or a default plain formatter
16
+ fmt = self.formatter or logging.Formatter("%(message)s")
17
+ # Copies the record to avoid mutating shared state used by other handlers
18
+ base_record = copy.copy(record)
19
+ # If you do not clear exec_info/exc_text before formatting line-by-line, traceback can be appended repeatedly or formatting can look duplicated/inconsistent across handlers.
20
+ base_record.args = None
21
+ base_record.exc_info = None
22
+ base_record.exc_text = None
23
+ base_record.stack_info = None
24
+
25
+ # Splits normal message into lines, formats each line as its own full log record.
26
+ message_lines = record.getMessage().splitlines() or [record.getMessage()]
27
+ for line in message_lines:
28
+ line_record = copy.copy(base_record)
29
+ line_record.msg = line
30
+ yield fmt.format(line_record)
31
+
32
+ # If exception exists, formats traceback text and emits each traceback line as its own fully formatted log line.
33
+ if record.exc_info:
34
+ exc_text = fmt.formatException(record.exc_info)
35
+ for line in exc_text.splitlines():
36
+ line_record = copy.copy(base_record)
37
+ line_record.msg = line
38
+ yield fmt.format(line_record)
39
+
40
+ # Same for stack_info lines.
41
+ if record.stack_info:
42
+ stack_text = fmt.formatStack(record.stack_info)
43
+ for line in stack_text.splitlines():
44
+ line_record = copy.copy(base_record)
45
+ line_record.msg = line
46
+ yield fmt.format(line_record)
47
+
48
+
49
+ class TqdmLoggingHandler(MultilineMixin, logging.Handler):
50
+ def __init__(self, level=logging.NOTSET, stream=None):
51
+ """
52
+ Why `sys.stderr`: Because tqdm and logging are sharing terminal space, `stderr` is the safer channel.
53
+ """
54
+ super().__init__(level)
55
+ self.stream = sys.__stderr__ if stream is None else stream
56
+
57
+ def emit(self, record):
58
+ """
59
+ Normal `StreamHandler` writes directly to terminal, which can overwrite or break tqdm lines.
60
+ `TqdmLoggingHandler` uses `tqdm.write(...)`, which is tqdm-aware and prints messages around the bar safely.
61
+ """
62
+ try:
63
+ for line in self.iter_formatted_lines(record):
64
+ tqdm.write(line, file=self.stream)
65
+ except Exception:
66
+ self.handleError(record)
67
+
68
+
69
+ class FileHandler(MultilineMixin, logging.FileHandler):
70
+ def emit(self, record):
71
+ try:
72
+ lines = self.iter_formatted_lines(record)
73
+
74
+ self.acquire()
75
+ try:
76
+ if self.stream is None:
77
+ self.stream = self._open()
78
+ for line in lines:
79
+ self.stream.write(line + self.terminator)
80
+ self.flush()
81
+ finally:
82
+ self.release()
83
+ except Exception:
84
+ self.handleError(record)
85
+
86
+
87
+ class LineBufferLoggerWriter:
88
+ def __init__(self, logger, level):
89
+ self.logger = logger
90
+ self.level = level
91
+ self.buffer = ""
92
+
93
+ def write(self, message):
94
+ """
95
+ `print()` and many libraries call `stream.write(...)`.
96
+ It buffers text until it sees `\n`, then logs complete lines with `self.logger.log(self.level, line)`.
97
+ """
98
+ if not message:
99
+ return 0
100
+
101
+ self.buffer += message
102
+ while "\n" in self.buffer:
103
+ # Why buffer: `print()`/writers may send partial chunks, and logging partial fragments would create broken lines.
104
+ line, self.buffer = self.buffer.split("\n", 1)
105
+ line = line.rstrip("\r")
106
+ if line:
107
+ self.logger.log(self.level, line)
108
+ return len(message)
109
+
110
+ def flush(self):
111
+ """
112
+ Some code calls `flush()` explicitly (or `print(.., flush=True)`).
113
+ This forces any leftover buffered text (without trailing newline) to be logged.
114
+ Without it, the last partial line might never appear in logs.
115
+ """
116
+ if self.buffer:
117
+ line = self.buffer.rstrip("\r")
118
+ if line:
119
+ self.logger.log(self.level, line)
120
+ self.buffer = ""
121
+
122
+ def isatty(self):
123
+ """
124
+ Many tools check `stream.isatty()` to decide whether to use terminal UI behaviors (colors, progress animations, carriage returns).
125
+ Returning `False` tells them "this is not an interactive terminal".
126
+ That avoids TTY-specific formatting on redirected stdout.
127
+ """
128
+ return False
129
+
130
+
131
+ def _log_tqdm_snapshot(logger, pbar, level=logging.INFO, event="progress"):
132
+ if pbar.disable:
133
+ return
134
+
135
+ n = pbar.n
136
+ total = pbar.total
137
+ elapsed = pbar.format_dict.get("elapsed", 0.0) or 0.0
138
+ rate = pbar.format_dict.get("rate", None)
139
+
140
+ if rate is None:
141
+ rate = (n / elapsed) if elapsed > 0 else 0.0
142
+
143
+ percent = (100.0 * n / total) if total else 0.0
144
+ remaining = ((total - n) / rate) if (total and rate > 0) else float("inf")
145
+ remaining_text = "unknown" if remaining == float("inf") else f"{remaining:.1f}s"
146
+ elapsed_text = f"{elapsed:.1f}s"
147
+
148
+ desc = (getattr(pbar, "desc", "") or "tqdm").strip() or "tqdm"
149
+ logger.log(
150
+ level,
151
+ "tqdm[%s] %s: %d/%d (%.1f%%), elapsed %s, %.2f it/s, ETA %s",
152
+ desc,
153
+ event,
154
+ n,
155
+ total if total is not None else -1,
156
+ percent,
157
+ elapsed_text,
158
+ rate,
159
+ remaining_text,
160
+ )
161
+
162
+
163
+ def install_tqdm_logging(logger, interval_seconds=0.5, level=logging.INFO):
164
+ cls = tqdm_std.tqdm
165
+
166
+ if not hasattr(cls, "_auto_log_original_init"):
167
+ cls._auto_log_original_init = cls.__init__
168
+ cls._auto_log_original_update = cls.update
169
+ cls._auto_log_original_close = cls.close
170
+
171
+ def _patched_init(self, *args, **kwargs):
172
+ auto_assign_position = getattr(cls, "_auto_assign_position", False)
173
+ if auto_assign_position and kwargs.get("position", None) is None:
174
+ active_positions = set()
175
+ for inst in list(getattr(cls, "_instances", [])):
176
+ pos = getattr(inst, "pos", None)
177
+ if pos is None:
178
+ continue
179
+ try:
180
+ active_positions.add(abs(int(pos)))
181
+ except (TypeError, ValueError):
182
+ continue
183
+
184
+ if active_positions:
185
+ kwargs["position"] = max(active_positions) + 1
186
+
187
+ cls._auto_log_original_init(self, *args, **kwargs)
188
+ self._auto_log_last_snapshot = time.monotonic()
189
+
190
+ def _patched_update(self, n=1):
191
+ result = cls._auto_log_original_update(self, n)
192
+
193
+ if self.disable:
194
+ return result
195
+
196
+ logger_obj = getattr(cls, "_auto_log_logger", None)
197
+ interval = getattr(cls, "_auto_log_interval", 0.5)
198
+ log_level = getattr(cls, "_auto_log_level", logging.INFO)
199
+
200
+ if logger_obj is None:
201
+ return result
202
+
203
+ now = time.monotonic()
204
+ last = getattr(self, "_auto_log_last_snapshot", now)
205
+ is_final_step = self.total is not None and self.n >= self.total
206
+ if is_final_step or (now - last) >= interval:
207
+ _log_tqdm_snapshot(logger=logger_obj, pbar=self, level=log_level)
208
+ self._auto_log_last_snapshot = now
209
+
210
+ return result
211
+
212
+ def _patched_close(self):
213
+ try:
214
+ logger_obj = getattr(cls, "_auto_log_logger", None)
215
+ log_level = getattr(cls, "_auto_log_level", logging.INFO)
216
+ if logger_obj is not None:
217
+ _log_tqdm_snapshot(logger=logger_obj, pbar=self, level=log_level, event="done")
218
+ finally:
219
+ return cls._auto_log_original_close(self)
220
+
221
+ cls.__init__ = _patched_init
222
+ cls.update = _patched_update
223
+ cls.close = _patched_close
224
+
225
+ cls._auto_log_logger = logger
226
+ cls._auto_log_interval = interval_seconds
227
+ cls._auto_log_level = level
228
+ cls._auto_assign_position = True
229
+
230
+ tqdm_auto.tqdm = cls
231
+ globals()["tqdm"] = cls
232
+
233
+
234
+ def setup_logger(
235
+ log_file,
236
+ name=__name__,
237
+ level=logging.INFO,
238
+ capture_print=True,
239
+ capture_uncaught_exceptions=True,
240
+ auto_log_tqdm=True,
241
+ tqdm_log_interval_seconds=1,
242
+ tqdm_auto_assign_position=True,
243
+ ):
244
+ logger = logging.getLogger(name)
245
+ logger.setLevel(level)
246
+ # Useful when setup code runs multiple times, otherwise handlers stack and the same
247
+ # log can print/write multiple times.
248
+ logger.handlers.clear()
249
+ # Stops this logger from forwarding records to its parent logger (often the root logger).
250
+ # If `True`, the message is handled by this logger's handlers and parent handlers,
251
+ # which often causes double printing.
252
+ logger.propagate = False
253
+
254
+ fmt = logging.Formatter("%(asctime)s %(name)s %(levelname)-9s %(message)s")
255
+
256
+ dirname = os.path.dirname(log_file)
257
+ if dirname:
258
+ os.makedirs(dirname, exist_ok=True)
259
+ file_handler = FileHandler(log_file, mode="w", encoding="utf-8")
260
+ file_handler.setLevel(level)
261
+ file_handler.setFormatter(fmt)
262
+ logger.addHandler(file_handler)
263
+
264
+ console_handler = TqdmLoggingHandler(level=level, stream=sys.__stderr__)
265
+ console_handler.setFormatter(fmt)
266
+ logger.addHandler(console_handler)
267
+
268
+ if capture_print:
269
+ # replaces `sys.stdout` with `LineBuferLoggerWriter`, so `print(...)` becomes logger `INFO`
270
+ sys.stdout = LineBufferLoggerWriter(logger=logger, level=logging.INFO)
271
+
272
+ if capture_uncaught_exceptions:
273
+ def _excepthook(exc_type, exc_value, exc_traceback):
274
+ logger.error("Uncaught exception", exc_info=(exc_type, exc_value, exc_traceback))
275
+
276
+ sys.excepthook = _excepthook
277
+
278
+ if auto_log_tqdm:
279
+ install_tqdm_logging(
280
+ logger,
281
+ interval_seconds=tqdm_log_interval_seconds,
282
+ level=logging.INFO,
283
+ )
284
+ tqdm_std.tqdm._auto_assign_position = tqdm_auto_assign_position
285
+
286
+ return logger
287
+
288
+ # %%
289
+
290
+ if __name__ == "__main__":
291
+ setup_logger(log_file="output.log", level=logging.DEBUG)
292
+ logger = logging.getLogger(__name__)
293
+
294
+ for i in tqdm(
295
+ range(2),
296
+ desc="Processing",
297
+ ):
298
+ time.sleep(0.03)
299
+ logger.info("Processing item %d", i)
300
+
301
+ for outer_idx in tqdm(
302
+ range(2),
303
+ desc="Outer",
304
+ ):
305
+ for inner_idx in tqdm(
306
+ range(3),
307
+ desc=f"Inner {outer_idx}",
308
+ ):
309
+ time.sleep(0.02)
310
+ if inner_idx % 5 == 0:
311
+ print(f"Nested step outer={outer_idx} inner={inner_idx}")
312
+ logger.info(f"Completed outer={outer_idx}")
313
+
314
+ print("from print statement")
315
+ print("multiple lines\nfrom print statement 2")
316
+ logger.debug("Single line")
317
+ logger.debug("Multiple lines:\nnext line")
318
+ logger.debug("Another single line")
319
+ logger.debug("Multiple lines:\n%s", "next line\nnext line 2")
320
+ logger.warning("Warning message\nwith multiple lines\nand should be logged properly.")
321
+ logger.error("Error message\nwith multiple lines\nand should be logged properly.")
322
+ raise ValueError("This is an error message\nwith multiple lines\nand should be logged properly.")
323
+
324
+ # %%
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: logging-tee
3
+ Version: 1.0.0
4
+ Summary: Logging utilities that work well with tqdm
5
+ Requires-Python: >=3.9
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: tqdm
8
+
9
+ # Installation
10
+
11
+ ```bash
12
+ pip install logging-tee
13
+ ```
14
+
15
+ # Usage
16
+
17
+ ```python
18
+ if __name__ == "__main__":
19
+ setup_logger(log_file="output.log", level=logging.DEBUG)
20
+ logger = logging.getLogger(__name__)
21
+
22
+ for i in tqdm(
23
+ range(2),
24
+ desc="Processing",
25
+ ):
26
+ time.sleep(0.03)
27
+ logger.info("Processing item %d", i)
28
+
29
+ for outer_idx in tqdm(
30
+ range(2),
31
+ desc="Outer",
32
+ ):
33
+ for inner_idx in tqdm(
34
+ range(3),
35
+ desc=f"Inner {outer_idx}",
36
+ ):
37
+ time.sleep(0.02)
38
+ if inner_idx % 5 == 0:
39
+ print(f"Nested step outer={outer_idx} inner={inner_idx}")
40
+ logger.info(f"Completed outer={outer_idx}")
41
+
42
+ print("from print statement")
43
+ print("multiple lines\nfrom print statement 2")
44
+ logger.debug("Single line")
45
+ logger.debug("Multiple lines:\nnext line")
46
+ logger.debug("Another single line")
47
+ logger.debug("Multiple lines:\n%s", "next line\nnext line 2")
48
+ logger.warning("Warning message\nwith multiple lines\nand should be logged properly.")
49
+ logger.error("Error message\nwith multiple lines\nand should be logged properly.")
50
+ raise ValueError("This is an error message\nwith multiple lines\nand should be logged properly.")
51
+ ```
@@ -0,0 +1,9 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/logging_tee/__init__.py
4
+ src/logging_tee/tee.py
5
+ src/logging_tee.egg-info/PKG-INFO
6
+ src/logging_tee.egg-info/SOURCES.txt
7
+ src/logging_tee.egg-info/dependency_links.txt
8
+ src/logging_tee.egg-info/requires.txt
9
+ src/logging_tee.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ logging_tee