python-traceprint 0.1.2__py3-none-any.whl

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,158 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-traceprint
3
+ Version: 0.1.2
4
+ Summary: Readable print and logging traces with timing, labels, and nesting.
5
+ Project-URL: Documentation, https://lll-devs.github.io/python-traceprint/
6
+ Project-URL: Repository, https://github.com/LLL-Devs/python-traceprint
7
+ Project-URL: Issues, https://github.com/LLL-Devs/python-traceprint/issues
8
+ Project-URL: Changelog, https://github.com/LLL-Devs/python-traceprint/blob/main/CHANGELOG.md
9
+ Author: Lucas Lopez
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: debugging,logging,stdout,tracing
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.12
22
+ Description-Content-Type: text/markdown
23
+
24
+ # python-traceprint
25
+
26
+ Readable print and logging output with timestamps, elapsed time, labels, nesting
27
+ depth and optional terminal colors. Requires Python 3.12 or later and has no
28
+ runtime dependencies. The API is alpha.
29
+
30
+ The distribution is named **python-traceprint**; Python code imports **traceprint**.
31
+ Importing it does not replace process streams. Install a tracer explicitly when
32
+ you want to format ordinary `print()` calls.
33
+
34
+ ## Installation
35
+
36
+ From a local checkout:
37
+
38
+ ```bash
39
+ python -m pip install .
40
+ ```
41
+
42
+ Install the public distribution from PyPI:
43
+
44
+ ```bash
45
+ python -m pip install python-traceprint
46
+ ```
47
+
48
+ The first public release is `0.1.2`; the API remains alpha.
49
+
50
+ [Documentation](https://lll-devs.github.io/python-traceprint/) ·
51
+ [PyPI](https://pypi.org/project/python-traceprint/)
52
+
53
+ ## Quick start
54
+
55
+ ```python
56
+ from traceprint import Tracer
57
+
58
+ tracer = Tracer(use_colors=False, use_tabulation=True)
59
+ tracer.install()
60
+ try:
61
+ with tracer.span("load"):
62
+ print("loaded", 42, "records")
63
+ finally:
64
+ tracer.uninstall()
65
+ ```
66
+
67
+ Each nonblank line gets one prefix, even when `print()` writes several arguments.
68
+ Partial writes appear immediately. Nesting adds labels and, when enabled,
69
+ indentation. Timestamps use UTC; elapsed times use a monotonic performance clock.
70
+
71
+ To avoid replacing `sys.stdout`, pass a tracer directly to `print()`:
72
+
73
+ ```python
74
+ from traceprint import Tracer, trace
75
+
76
+ tracer = Tracer()
77
+
78
+
79
+ @trace(tracer_instance=tracer, label="work")
80
+ def work() -> None:
81
+ print("doing the thing", file=tracer)
82
+
83
+
84
+ work()
85
+ ```
86
+
87
+ Python logging can use the same formatting:
88
+
89
+ ```python
90
+ import logging
91
+
92
+ from traceprint import Tracer, TracerHandler
93
+
94
+ tracer = Tracer()
95
+ handler = TracerHandler(tracer_instance=tracer)
96
+ logger = logging.getLogger("example")
97
+ logger.addHandler(handler)
98
+ logger.setLevel(logging.INFO)
99
+ logger.propagate = False
100
+ logger.info("ready")
101
+ logger.removeHandler(handler)
102
+ handler.close()
103
+ ```
104
+
105
+ Tracer labels and stream installation are shared mutable state. Use this library
106
+ for scripts and development output; it does not provide isolated thread or async
107
+ task contexts or distributed tracing.
108
+
109
+ ## Documentation and examples
110
+
111
+ - [Usage guide](docs/usage.rst): configuration, spans, decorators, logging and files.
112
+ - [API reference](docs/api.rst): generated signatures and public methods.
113
+ - [Contributing](CONTRIBUTING.md): setup, checks and building HTML documentation.
114
+ - [Changelog](CHANGELOG.md): changes awaiting release.
115
+ - [Release preparation](docs/releasing.rst): PyPI account setup and release gates.
116
+ - [Transformer example](examples/sklearn_style_transformer.py): a scikit-learn-style
117
+ transformer with empty stand-in base classes; scikit-learn is not required.
118
+ - [Main-loop example](examples/main_loop.py): logging and nested labels.
119
+
120
+ Build the searchable HTML documentation locally:
121
+
122
+ ```bash
123
+ uv sync --dev
124
+ uv run sphinx-build -W --keep-going -b html docs docs/_build/html
125
+ ```
126
+
127
+ Open `docs/_build/html/index.html` in a browser, or use the published
128
+ [documentation site](https://lll-devs.github.io/python-traceprint/).
129
+
130
+ ## Development
131
+
132
+ ```bash
133
+ uv sync --dev
134
+ uv run macrostates check
135
+ uv run macrostates change-check
136
+ uv run pytest
137
+ uv run ruff check .
138
+ uv run ruff format --check .
139
+ uv run pyright
140
+ uv build
141
+ ```
142
+
143
+ The canonical repository is
144
+ [LLL-Devs/python-traceprint](https://github.com/LLL-Devs/python-traceprint).
145
+ Specifications and implementation records live under `.macrostates/`. The
146
+ development environment pins Macrostates CLI 0.5.0. The distribution version has
147
+ its own authority in `.macrostates/implementation/deliverables.yaml`, independent
148
+ of the initial `specs-1.0.0` and `imp-1.0.0` repository baselines. See the
149
+ [release policy](.macrostates/specs/900_traceprint/release.md).
150
+
151
+ The local GitHub Actions workflow checks tests across Python 3.12–3.15 on Linux
152
+ and Python 3.12 on Windows and macOS, plus lint, types, documentation and package
153
+ builds. Documentation is built and deployed by the GitHub Pages workflow.
154
+
155
+ ## License
156
+
157
+ [MIT](LICENSE), copyright © 2026 Lucas Lopez. Vendored Macrostates specifications
158
+ retain their upstream licenses.
@@ -0,0 +1,8 @@
1
+ traceprint/__init__.py,sha256=KHaZ5DHcgYTZX7kXsq0CmUzf-SjNNWDyblU8k2nnmQk,381
2
+ traceprint/colors.py,sha256=IH_WNO5d5T1RNe1K8f6wLTAaQs-QTJxfNKlSHFNLsh4,868
3
+ traceprint/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
4
+ traceprint/tracer.py,sha256=78nQps8C7VIyv_VT-nLGTNrjxXw0o1VDSDsp_yE0izQ,18278
5
+ python_traceprint-0.1.2.dist-info/METADATA,sha256=NGeJ_o6-TK_BXRBVVj-jnbIHR2S9E8OuWohWAAaBMJ0,5059
6
+ python_traceprint-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ python_traceprint-0.1.2.dist-info/licenses/LICENSE,sha256=-zbP10KnicDnhD2_IcOnRCoT433uTfrw-nr-C4fwbvY,1068
8
+ python_traceprint-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lucas Lopez
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
traceprint/__init__.py ADDED
@@ -0,0 +1,25 @@
1
+ """Public API for traceprint."""
2
+
3
+ from traceprint.tracer import (
4
+ DARK_GREY,
5
+ DEFAULT_COLORS,
6
+ RESET,
7
+ LoggerMixin,
8
+ Tracer,
9
+ TracerFileHandler,
10
+ TracerHandler,
11
+ trace,
12
+ tracer,
13
+ )
14
+
15
+ __all__ = [
16
+ "DARK_GREY",
17
+ "DEFAULT_COLORS",
18
+ "RESET",
19
+ "LoggerMixin",
20
+ "Tracer",
21
+ "TracerFileHandler",
22
+ "TracerHandler",
23
+ "trace",
24
+ "tracer",
25
+ ]
traceprint/colors.py ADDED
@@ -0,0 +1,32 @@
1
+ """ANSI color helpers used by traceprint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ RESET = "\033[0m"
6
+ DARK_GREY = "\033[90m"
7
+
8
+
9
+ def hex_to_ansi(hexstr: str) -> str:
10
+ """Convert a ``#RRGGBB`` value to an ANSI truecolor foreground sequence."""
11
+ value = hexstr.removeprefix("#")
12
+ if len(value) != 6:
13
+ raise ValueError(f"Expected #RRGGBB color, got {hexstr!r}")
14
+
15
+ try:
16
+ red, green, blue = (int(value[index : index + 2], 16) for index in (0, 2, 4))
17
+ except ValueError as exc:
18
+ raise ValueError(f"Expected #RRGGBB color, got {hexstr!r}") from exc
19
+
20
+ return f"\033[38;2;{red};{green};{blue}m"
21
+
22
+
23
+ DEFAULT_COLORS = [
24
+ hex_to_ansi("#52C97F"),
25
+ hex_to_ansi("#68BCCD"),
26
+ hex_to_ansi("#7D7FD2"),
27
+ hex_to_ansi("#C891D8"),
28
+ hex_to_ansi("#DDA5BE"),
29
+ hex_to_ansi("#E4C9B9"),
30
+ hex_to_ansi("#E5EACC"),
31
+ hex_to_ansi("#E0F2DF"),
32
+ ]
traceprint/py.typed ADDED
@@ -0,0 +1 @@
1
+
traceprint/tracer.py ADDED
@@ -0,0 +1,520 @@
1
+ """Trace-friendly stdout and logging helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import logging
7
+ import os
8
+ import re
9
+ import sys
10
+ import time
11
+ from collections.abc import Callable, Iterable
12
+ from contextlib import AbstractContextManager
13
+ from datetime import UTC, datetime
14
+ from pathlib import Path
15
+ from types import TracebackType
16
+ from typing import IO, Any, TextIO
17
+
18
+ from traceprint.colors import DARK_GREY, DEFAULT_COLORS, RESET
19
+
20
+ ANSI_ESCAPE_PATTERN = re.compile(
21
+ r"\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)" # OSC, including hyperlinks
22
+ r"|\x1b\[[0-?]*[ -/]*[@-~]" # CSI, including colors and cursor controls
23
+ r"|\x1b[ -/]*[@-Z\\-_]" # Other terminal escape sequences
24
+ )
25
+ DEFAULT_OUTPUT_FORMAT = (
26
+ "[{timestamp}][{cumulative}][{elapsed}][{label_full}] {original_message}"
27
+ )
28
+
29
+ DepthFilter = range | Iterable[int] | Callable[[int], bool] | None
30
+
31
+
32
+ class Tracer:
33
+ """Format text output with timing, labels, nesting, and optional colors."""
34
+
35
+ def __init__(
36
+ self,
37
+ *,
38
+ use_colors: bool | None = None,
39
+ use_tabulation: bool = False,
40
+ color_scheme: list[str] | None = None,
41
+ metacolor: str | None = DARK_GREY,
42
+ print_transitions: bool = False,
43
+ filter_levels: DepthFilter = None,
44
+ start_enabled: bool = True,
45
+ output_format: str = DEFAULT_OUTPUT_FORMAT,
46
+ timestamp_format: str = "%Y-%m-%d %H:%M:%S",
47
+ stream: TextIO | None = None,
48
+ error_stream: TextIO | None = None,
49
+ intercept_stdout: bool = False,
50
+ intercept_stderr: bool = False,
51
+ ) -> None:
52
+ self._stream = stream or sys.stdout
53
+ self._stream_explicit = stream is not None
54
+ self._error_stream = error_stream or sys.stderr
55
+ self._error_stream_explicit = error_stream is not None
56
+ self._orig_stdout: TextIO | None = None
57
+ self._orig_stderr: TextIO | None = None
58
+ self._intercept_stdout = False
59
+ self._intercept_stderr = False
60
+
61
+ if use_colors is None:
62
+ use_colors = self._stream.isatty()
63
+ self.use_colors = use_colors
64
+ self.use_tabulation = use_tabulation
65
+ self.color_scheme = color_scheme or DEFAULT_COLORS
66
+ self.metacolor = metacolor
67
+ self.print_transitions = print_transitions
68
+ self.filter_levels = filter_levels
69
+ self.output_format = output_format
70
+ self.timestamp_format = timestamp_format
71
+
72
+ self.depth = 0
73
+ self.labels: list[str] = []
74
+ self._last_time = time.perf_counter()
75
+ self._start_time = self._last_time
76
+ self._locked = False
77
+ self._enabled = start_enabled
78
+ self._line_started = False
79
+ self._file_handlers: list[TracerFileHandler] = []
80
+
81
+ if intercept_stdout or intercept_stderr:
82
+ self.install(stdout=intercept_stdout, stderr=intercept_stderr)
83
+
84
+ @property
85
+ def enabled(self) -> bool:
86
+ """Return whether formatting is currently enabled."""
87
+ return self._enabled
88
+
89
+ def install(self, *, stdout: bool = True, stderr: bool = False) -> None:
90
+ """Install this tracer as ``sys.stdout`` and optionally ``sys.stderr``."""
91
+ if stdout and not self._intercept_stdout:
92
+ self._orig_stdout = sys.stdout
93
+ if not self._stream_explicit:
94
+ self._stream = sys.stdout
95
+ sys.stdout = self
96
+ self._intercept_stdout = True
97
+ if stderr and not self._intercept_stderr:
98
+ self._orig_stderr = sys.stderr
99
+ if not stdout and not self._stream_explicit:
100
+ self._stream = sys.stderr
101
+ sys.stderr = self
102
+ self._intercept_stderr = True
103
+
104
+ def uninstall(self) -> None:
105
+ """Restore streams captured by :meth:`install`."""
106
+ if self._intercept_stdout and self._orig_stdout is not None:
107
+ sys.stdout = self._orig_stdout
108
+ if self._intercept_stderr and self._orig_stderr is not None:
109
+ sys.stderr = self._orig_stderr
110
+ self._intercept_stdout = False
111
+ self._intercept_stderr = False
112
+ self._orig_stdout = None
113
+ self._orig_stderr = None
114
+
115
+ def enable(self) -> None:
116
+ """Enable formatted output and depth tracking."""
117
+ self._enabled = True
118
+
119
+ def disable(self) -> None:
120
+ """Disable formatted output and forward text to the underlying stream."""
121
+ self._enabled = False
122
+
123
+ def lock(self) -> None:
124
+ """Prevent further depth changes."""
125
+ self._locked = True
126
+
127
+ def unlock(self) -> None:
128
+ """Allow depth changes."""
129
+ self._locked = False
130
+
131
+ def configure(self, **kwargs: Any) -> None:
132
+ """Update tracer configuration after initialization."""
133
+ if "intercept_stdout" in kwargs or "intercept_stderr" in kwargs:
134
+ stdout = bool(kwargs.pop("intercept_stdout", self._intercept_stdout))
135
+ stderr = bool(kwargs.pop("intercept_stderr", self._intercept_stderr))
136
+ self.uninstall()
137
+ self.install(stdout=stdout, stderr=stderr)
138
+
139
+ if "use_colors" in kwargs:
140
+ value = kwargs.pop("use_colors")
141
+ self.use_colors = self._stream.isatty() if value is None else bool(value)
142
+
143
+ if "color_scheme" in kwargs:
144
+ value = kwargs.pop("color_scheme")
145
+ self.color_scheme = value or DEFAULT_COLORS
146
+
147
+ for key, value in kwargs.items():
148
+ if hasattr(self, key):
149
+ setattr(self, key, value)
150
+ else:
151
+ raise AttributeError(f"Tracer has no attribute {key}")
152
+
153
+ def add_file_handler(self, handler: TracerFileHandler) -> None:
154
+ """Mirror formatted stream writes to a file handler."""
155
+ self._file_handlers.append(handler)
156
+
157
+ def remove_file_handler(self, handler: TracerFileHandler) -> None:
158
+ """Stop mirroring formatted stream writes to a file handler."""
159
+ if handler in self._file_handlers:
160
+ self._file_handlers.remove(handler)
161
+
162
+ def format(self, message: str) -> str:
163
+ """Format a single message without writing it."""
164
+ now = time.perf_counter()
165
+ elapsed = now - self._last_time
166
+ cumulative = now - self._start_time
167
+ self._last_time = now
168
+
169
+ label_full = "/".join(self.labels) if self.labels else ""
170
+ label = self.labels[-1] if self.labels else ""
171
+ if label_full and not (message.startswith(">") or message.startswith("<")):
172
+ label_full = f"{label_full}/>"
173
+ label = f"{label}/>"
174
+
175
+ text = self.output_format.format(
176
+ timestamp=datetime.now(UTC).strftime(self.timestamp_format),
177
+ cumulative=f"{cumulative:.3f}s",
178
+ elapsed=f"+{elapsed:.3f}s",
179
+ label=label,
180
+ label_full=label_full,
181
+ original_message=message,
182
+ )
183
+
184
+ indent = (" " * self.depth) if self.use_tabulation else ""
185
+ line_color = self.color_scheme[self.depth % len(self.color_scheme)]
186
+ if not self.metacolor:
187
+ if not self.use_colors:
188
+ return indent + text
189
+ return f"{line_color}{indent}{text}{RESET}"
190
+
191
+ split_index = text.find(message)
192
+ if split_index == -1:
193
+ if not self.use_colors:
194
+ return indent + text
195
+ return f"{self.metacolor}{indent}{text}{RESET}"
196
+
197
+ meta_part = text[:split_index]
198
+ msg_part = text[split_index:]
199
+ if not self.use_colors:
200
+ return meta_part + indent + msg_part
201
+ return (
202
+ f"{self.metacolor}{meta_part}{RESET}{line_color}{indent}{msg_part}{RESET}"
203
+ )
204
+
205
+ def write(self, data: str) -> int:
206
+ """Write immediately, adding metadata once per nonblank output line."""
207
+ if not self._enabled:
208
+ self._stream.write(data)
209
+ if data:
210
+ self._line_started = not data.endswith(("\n", "\r"))
211
+ return len(data)
212
+
213
+ for chunk in data.splitlines(keepends=True):
214
+ if self._filter_out():
215
+ continue
216
+ message = chunk.rstrip("\r\n")
217
+ ending = chunk[len(message) :]
218
+ if message.strip():
219
+ if not self._line_started:
220
+ formatted = self.format(message)
221
+ elif self.use_colors:
222
+ color = self.color_scheme[self.depth % len(self.color_scheme)]
223
+ formatted = f"{color}{message}{RESET}"
224
+ else:
225
+ formatted = message
226
+ self._write_raw(formatted + ending)
227
+ self._line_started = True
228
+ else:
229
+ self._write_raw(chunk)
230
+ if ending:
231
+ self._line_started = False
232
+ return len(data)
233
+
234
+ def flush(self) -> None:
235
+ """Flush the underlying stream and attached file handlers."""
236
+ self._stream.flush()
237
+ for handler in self._file_handlers:
238
+ handler.flush()
239
+
240
+ def push(self, label: str | None = None) -> None:
241
+ """Increase depth and optionally record a label."""
242
+ if not self._enabled:
243
+ return
244
+ if not self._locked:
245
+ self.depth += 1
246
+ self.labels.append(label or f"level{self.depth}")
247
+ if label and self.print_transitions and not self._filter_out():
248
+ self.write(f"> {label}\n")
249
+
250
+ def pop(self, label: str | None = None) -> None:
251
+ """Decrease depth and remove the most recent label."""
252
+ if not self._enabled:
253
+ return
254
+ popped = None
255
+ if not self._locked and self.depth > 0:
256
+ self.depth -= 1
257
+ popped = self.labels.pop()
258
+ transition_label = label or popped
259
+ if transition_label and self.print_transitions and not self._filter_out():
260
+ self.write(f"< {transition_label}\n")
261
+
262
+ def span(self, label: str) -> Span:
263
+ """Create a context-managed labeled span."""
264
+ return Span(self, label)
265
+
266
+ def _filter_out(self) -> bool:
267
+ if self.filter_levels is None:
268
+ return False
269
+ if isinstance(self.filter_levels, range):
270
+ return self.depth not in self.filter_levels
271
+ if callable(self.filter_levels):
272
+ return not self.filter_levels(self.depth)
273
+ return self.depth not in self.filter_levels
274
+
275
+ def _write_raw(self, data: str) -> None:
276
+ self._stream.write(data)
277
+ for handler in self._file_handlers:
278
+ handler.write(data)
279
+
280
+
281
+ class Span(AbstractContextManager["Span"]):
282
+ """Context manager for tracer labels and depth."""
283
+
284
+ def __init__(self, tracer_instance: Tracer, label: str) -> None:
285
+ self.tracer = tracer_instance
286
+ self.label = label
287
+
288
+ def __enter__(self) -> Span:
289
+ if self.tracer.enabled:
290
+ if self.tracer.print_transitions and not self.tracer._filter_out():
291
+ self.tracer.write(f"> {self.label}\n")
292
+ if not self.tracer._locked:
293
+ self.tracer.depth += 1
294
+ self.tracer.labels.append(self.label)
295
+ return self
296
+
297
+ def __exit__(
298
+ self,
299
+ exc_type: type[BaseException] | None,
300
+ exc_val: BaseException | None,
301
+ exc_tb: TracebackType | None,
302
+ ) -> bool | None:
303
+ if self.tracer.enabled:
304
+ if not self.tracer._locked:
305
+ self.tracer.depth -= 1
306
+ self.tracer.labels.pop()
307
+ if self.tracer.print_transitions and not self.tracer._filter_out():
308
+ suffix = f" [failed: {exc_type.__name__}]" if exc_type else ""
309
+ self.tracer.write(f"< {self.label}{suffix}\n")
310
+ return None
311
+
312
+
313
+ def trace(
314
+ _func: Callable[..., Any] | None = None,
315
+ *,
316
+ label: str | None = None,
317
+ force: bool = False,
318
+ tracer_instance: Tracer | None = None,
319
+ ) -> Callable[..., Any]:
320
+ """Decorate a function so calls are reflected in tracer depth and labels."""
321
+
322
+ target = tracer_instance or tracer
323
+
324
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
325
+ @functools.wraps(func)
326
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
327
+ if not target.enabled:
328
+ return func(*args, **kwargs)
329
+
330
+ entry_label = label or func.__name__
331
+ filtered = target._filter_out()
332
+ if target.print_transitions and (not filtered or force):
333
+ target.write(f"> {entry_label}()\n")
334
+ if not target._locked:
335
+ target.depth += 1
336
+ target.labels.append(entry_label)
337
+ try:
338
+ return func(*args, **kwargs)
339
+ finally:
340
+ if not target._locked:
341
+ target.depth -= 1
342
+ target.labels.pop()
343
+ if target.print_transitions and (not filtered or force):
344
+ target.write(f"< {entry_label}()\n")
345
+
346
+ return wrapper
347
+
348
+ if _func is None:
349
+ return decorator
350
+ return decorator(_func)
351
+
352
+
353
+ class TracerHandler(logging.Handler):
354
+ """A logging handler that writes records through a tracer."""
355
+
356
+ def __init__(
357
+ self,
358
+ level: int = logging.NOTSET,
359
+ *,
360
+ tracer_instance: Tracer | None = None,
361
+ ) -> None:
362
+ super().__init__(level=level)
363
+ self.tracer = tracer_instance
364
+
365
+ def emit(self, record: logging.LogRecord) -> None:
366
+ try:
367
+ target = self.tracer or tracer
368
+ target.write(self.format(record) + "\n")
369
+ except Exception:
370
+ self.handleError(record)
371
+
372
+
373
+ class TracerFileHandler(logging.Handler):
374
+ """A logging handler that writes trace-formatted records to a file."""
375
+
376
+ def __init__(
377
+ self,
378
+ filename: str | os.PathLike[str],
379
+ mode: str = "a",
380
+ level: int = logging.NOTSET,
381
+ encoding: str = "utf-8",
382
+ *,
383
+ tracer_instance: Tracer | None = None,
384
+ ) -> None:
385
+ super().__init__(level=level)
386
+ self.filename = Path(filename)
387
+ self.mode = mode
388
+ self.encoding = encoding
389
+ self.tracer = tracer_instance
390
+ self.stream: IO[str] | None = None
391
+ self._open_file()
392
+
393
+ def emit(self, record: logging.LogRecord) -> None:
394
+ try:
395
+ if self.stream is None:
396
+ self._open_file()
397
+ if self.stream is None:
398
+ return
399
+
400
+ target = self.tracer or tracer
401
+ original_use_colors = target.use_colors
402
+ target.use_colors = False
403
+ try:
404
+ full_msg = target.format(self.format(record))
405
+ finally:
406
+ target.use_colors = original_use_colors
407
+
408
+ self.write(full_msg + "\n")
409
+ self.stream.flush()
410
+ except Exception:
411
+ self.handleError(record)
412
+
413
+ def write(self, data: str) -> None:
414
+ """Write already-formatted tracer output directly to the file."""
415
+ if self.stream is None:
416
+ self._open_file()
417
+ if self.stream is not None:
418
+ self.stream.write(ANSI_ESCAPE_PATTERN.sub("", data))
419
+
420
+ def flush(self) -> None:
421
+ if self.stream is not None:
422
+ self.stream.flush()
423
+
424
+ def close(self) -> None:
425
+ """Close the file and release the standard logging handler."""
426
+ if self.stream is not None:
427
+ try:
428
+ self.stream.close()
429
+ finally:
430
+ self.stream = None
431
+ super().close()
432
+
433
+ def _open_file(self) -> None:
434
+ self.filename.parent.mkdir(parents=True, exist_ok=True)
435
+ self.stream = self.filename.open(self.mode, encoding=self.encoding)
436
+
437
+
438
+ class _LoggerProxy:
439
+ def __init__(self, owner: LoggerMixin) -> None:
440
+ self._owner = owner
441
+
442
+ def log(self, level: int, msg: object, *args: Any, **kwargs: Any) -> None:
443
+ if self._owner.verbose:
444
+ self._owner.logger.log(level, msg, *args, **kwargs)
445
+
446
+ def debug(self, msg: object, *args: Any, **kwargs: Any) -> None:
447
+ self.log(logging.DEBUG, msg, *args, **kwargs)
448
+
449
+ def info(self, msg: object, *args: Any, **kwargs: Any) -> None:
450
+ self.log(logging.INFO, msg, *args, **kwargs)
451
+
452
+ def warning(self, msg: object, *args: Any, **kwargs: Any) -> None:
453
+ self.log(logging.WARNING, msg, *args, **kwargs)
454
+
455
+ def error(self, msg: object, *args: Any, **kwargs: Any) -> None:
456
+ self.log(logging.ERROR, msg, *args, **kwargs)
457
+
458
+ def critical(self, msg: object, *args: Any, **kwargs: Any) -> None:
459
+ self.log(logging.CRITICAL, msg, *args, **kwargs)
460
+
461
+
462
+ class LoggerMixin:
463
+ """Mixin that provides an instance logger exposed as ``self.log``."""
464
+
465
+ def __init__(
466
+ self,
467
+ *,
468
+ verbose: bool = True,
469
+ logging_level: int = logging.INFO,
470
+ logging_passthrough: bool = False,
471
+ lazy_init: bool = False,
472
+ ) -> None:
473
+ self.verbose = verbose
474
+ self.logging_level = logging_level
475
+ self.logging_passthrough = logging_passthrough
476
+ self.lazy_init = lazy_init
477
+ self._logger: logging.Logger | None = None
478
+ self._log_proxy = _LoggerProxy(self)
479
+ if not self.lazy_init:
480
+ self._ensure_logger()
481
+
482
+ @property
483
+ def logger(self) -> logging.Logger:
484
+ """Return the shared class-named standard logger."""
485
+ self._ensure_logger()
486
+ assert self._logger is not None
487
+ return self._logger
488
+
489
+ @property
490
+ def log(self) -> _LoggerProxy:
491
+ """Return the logging proxy gated by this instance's verbose flag."""
492
+ self._ensure_logger()
493
+ return self._log_proxy
494
+
495
+ def _ensure_logger(self) -> None:
496
+ if self._logger is not None:
497
+ return
498
+
499
+ self._logger = logging.getLogger(
500
+ f"{self.__class__.__module__}.{self.__class__.__name__}"
501
+ )
502
+ self._logger.setLevel(self.logging_level)
503
+
504
+ if self.logging_passthrough:
505
+ self._logger.handlers.clear()
506
+ tracer_handler = TracerHandler()
507
+ root_logger = logging.getLogger()
508
+ if root_logger.handlers:
509
+ tracer_handler.setFormatter(root_logger.handlers[0].formatter)
510
+ self._logger.addHandler(tracer_handler)
511
+ self._logger.propagate = False
512
+
513
+
514
+ tracer = Tracer()
515
+
516
+ _initial_labels = os.environ.get("TRACEPRINT_INITIAL_LABELS", "").strip()
517
+ if _initial_labels:
518
+ for _label in _initial_labels.split("/"):
519
+ if stripped := _label.strip():
520
+ tracer.push(stripped)