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.
- python_traceprint-0.1.2.dist-info/METADATA +158 -0
- python_traceprint-0.1.2.dist-info/RECORD +8 -0
- python_traceprint-0.1.2.dist-info/WHEEL +4 -0
- python_traceprint-0.1.2.dist-info/licenses/LICENSE +21 -0
- traceprint/__init__.py +25 -0
- traceprint/colors.py +32 -0
- traceprint/py.typed +1 -0
- traceprint/tracer.py +520 -0
|
@@ -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,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)
|