lucidlog 0.1.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.
- lucidlog-0.1.0/PKG-INFO +112 -0
- lucidlog-0.1.0/README.md +93 -0
- lucidlog-0.1.0/pyproject.toml +33 -0
- lucidlog-0.1.0/setup.cfg +4 -0
- lucidlog-0.1.0/src/lucidlog/__init__.py +37 -0
- lucidlog-0.1.0/src/lucidlog/api.py +151 -0
- lucidlog-0.1.0/src/lucidlog/capture.py +197 -0
- lucidlog-0.1.0/src/lucidlog/config.py +89 -0
- lucidlog-0.1.0/src/lucidlog/diagnosis.py +86 -0
- lucidlog-0.1.0/src/lucidlog/integrations/__init__.py +1 -0
- lucidlog-0.1.0/src/lucidlog/integrations/asyncio.py +72 -0
- lucidlog-0.1.0/src/lucidlog/integrations/logging.py +27 -0
- lucidlog-0.1.0/src/lucidlog/integrations/threading.py +51 -0
- lucidlog-0.1.0/src/lucidlog/models.py +173 -0
- lucidlog-0.1.0/src/lucidlog/redaction.py +56 -0
- lucidlog-0.1.0/src/lucidlog/render.py +146 -0
- lucidlog-0.1.0/src/lucidlog/rules.py +355 -0
- lucidlog-0.1.0/src/lucidlog/serializers.py +11 -0
- lucidlog-0.1.0/src/lucidlog/source.py +59 -0
- lucidlog-0.1.0/src/lucidlog.egg-info/PKG-INFO +112 -0
- lucidlog-0.1.0/src/lucidlog.egg-info/SOURCES.txt +24 -0
- lucidlog-0.1.0/src/lucidlog.egg-info/dependency_links.txt +1 -0
- lucidlog-0.1.0/src/lucidlog.egg-info/requires.txt +4 -0
- lucidlog-0.1.0/src/lucidlog.egg-info/top_level.txt +1 -0
- lucidlog-0.1.0/tests/test_core.py +100 -0
- lucidlog-0.1.0/tests/test_enterprise.py +341 -0
lucidlog-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lucidlog
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Source-aware exception diagnostics with conservative explanations
|
|
5
|
+
Author: LucidLog contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: debugging,exceptions,diagnostics,tracebacks
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
18
|
+
Requires-Dist: pytest-cov>=4.0; extra == "dev"
|
|
19
|
+
|
|
20
|
+
# LucidLog
|
|
21
|
+
|
|
22
|
+
LucidLog is a lightweight, source-aware exception diagnostic library for Python.
|
|
23
|
+
|
|
24
|
+
It keeps the original exception behavior intact, adds a clearer reading of the relevant traceback frame, and provides deterministic built-in explanations without requiring a remote service.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
import lucidlog
|
|
30
|
+
|
|
31
|
+
lucidlog.install()
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
value = {}["missing"]
|
|
35
|
+
except Exception:
|
|
36
|
+
lucidlog.exception("Lookup failed")
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Complete example
|
|
40
|
+
|
|
41
|
+
Run the bundled demo for a full tour of the library's features:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
python examples/complete_demo.py
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
The demo covers chained exceptions, redaction, render output, custom rules, logging payloads, and the threading/asyncio integrations.
|
|
48
|
+
|
|
49
|
+
## Public API
|
|
50
|
+
|
|
51
|
+
- `lucidlog.install()`
|
|
52
|
+
- `lucidlog.uninstall()`
|
|
53
|
+
- `lucidlog.exception(message, *, stream=None, full=False, config=None)`
|
|
54
|
+
- `lucidlog.diagnose(exc, *, config=None)`
|
|
55
|
+
- `lucidlog.log_exception(logger, message, *, full=False, config=None)`
|
|
56
|
+
- `lucidlog.render_diagnostic(diagnostic, *, full=False, stream=None, color=None)`
|
|
57
|
+
|
|
58
|
+
## Configuration and output
|
|
59
|
+
|
|
60
|
+
Pass a frozen `lucidlog.Config` to `install`, `diagnose`, `exception`, or `log_exception` to configure capture and output:
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import lucidlog
|
|
64
|
+
from lucidlog import Config
|
|
65
|
+
|
|
66
|
+
lucidlog.install(
|
|
67
|
+
config=Config(
|
|
68
|
+
color=True,
|
|
69
|
+
include_full_traceback=True,
|
|
70
|
+
max_chain_depth=6,
|
|
71
|
+
redact_patterns=(r"(?i)customer-id=[A-Z0-9]+",),
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Colors are disabled for non-interactive streams by default and honor `NO_COLOR` and `TERM=dumb`. `color=True` forces ANSI output; `color=False` disables it. Exception colors distinguish common diagnostic families, while the severity badge uses a separate palette (`critical`, `error`, `warning`, or `info`). The JSON representation contains the plain text values, never ANSI codes.
|
|
77
|
+
|
|
78
|
+
Cause/context chains and exception groups are represented as bounded nested data and included in the compact output. Limits (`max_chain_depth`, `max_group_exceptions`, `max_frames`, `max_message_length`, `max_traceback_chars`, `max_source_file_bytes`, and `max_diagnosis_evidence`) constrain diagnostic capture. `Diagnostic.to_dict()` and `to_json()` include a schema version, severity, fingerprint-like ID, cause relationships, and truncation indicator. Setting `include_local_variables=True` or `allow_network=True` is rejected; those capabilities are deliberately not part of the core.
|
|
79
|
+
|
|
80
|
+
The optional runtime integrations are explicit:
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from lucidlog.integrations.threading import install_threading_hook
|
|
84
|
+
from lucidlog.integrations.asyncio import install_asyncio_exception_handler
|
|
85
|
+
|
|
86
|
+
install_threading_hook()
|
|
87
|
+
install_asyncio_exception_handler()
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Each has a corresponding `uninstall_*` function. Asyncio leaves an already-installed handler in place unless `chain_previous=True`; thread and main hooks restore prior handlers when removed. Worker-thread and event-loop hooks do not cover child processes.
|
|
91
|
+
|
|
92
|
+
## Privacy notes
|
|
93
|
+
|
|
94
|
+
LucidLog never reads local variables or makes network requests. It redacts common credential-shaped strings and user-configured patterns from messages, source context, causes, and traceback text before those values enter the diagnostic model. Absolute paths under the home directory are shortened by default; `root_path` can shorten project paths. Redaction is best-effort: exception messages and source code are arbitrary application data, so review output before storing or forwarding it and supply additional patterns for your environment. `Config` validates regex syntax and output limits up front.
|
|
95
|
+
|
|
96
|
+
Logging integration writes one structured log record with a `lucidlog` dictionary in `LogRecord` extra fields. It deliberately omits `exc_info` to avoid bypassing redaction with a raw traceback. The original exception is not modified, and `log_exception` requires an active exception handler.
|
|
97
|
+
|
|
98
|
+
## Tests
|
|
99
|
+
|
|
100
|
+
Run the unit and integration tests with:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
python -m pytest
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The suite covers chained and grouped exceptions, cycle/size limits, redaction, terminal colors, hook restoration, threading, asyncio, structured logging, and uncaught-process exit behavior.
|
|
107
|
+
|
|
108
|
+
## Notes
|
|
109
|
+
|
|
110
|
+
- The core package is standard-library only.
|
|
111
|
+
- It does not capture local variables by default.
|
|
112
|
+
- It keeps the original traceback available and does not replace the application's original exception flow.
|
lucidlog-0.1.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# LucidLog
|
|
2
|
+
|
|
3
|
+
LucidLog is a lightweight, source-aware exception diagnostic library for Python.
|
|
4
|
+
|
|
5
|
+
It keeps the original exception behavior intact, adds a clearer reading of the relevant traceback frame, and provides deterministic built-in explanations without requiring a remote service.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
import lucidlog
|
|
11
|
+
|
|
12
|
+
lucidlog.install()
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
value = {}["missing"]
|
|
16
|
+
except Exception:
|
|
17
|
+
lucidlog.exception("Lookup failed")
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Complete example
|
|
21
|
+
|
|
22
|
+
Run the bundled demo for a full tour of the library's features:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
python examples/complete_demo.py
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The demo covers chained exceptions, redaction, render output, custom rules, logging payloads, and the threading/asyncio integrations.
|
|
29
|
+
|
|
30
|
+
## Public API
|
|
31
|
+
|
|
32
|
+
- `lucidlog.install()`
|
|
33
|
+
- `lucidlog.uninstall()`
|
|
34
|
+
- `lucidlog.exception(message, *, stream=None, full=False, config=None)`
|
|
35
|
+
- `lucidlog.diagnose(exc, *, config=None)`
|
|
36
|
+
- `lucidlog.log_exception(logger, message, *, full=False, config=None)`
|
|
37
|
+
- `lucidlog.render_diagnostic(diagnostic, *, full=False, stream=None, color=None)`
|
|
38
|
+
|
|
39
|
+
## Configuration and output
|
|
40
|
+
|
|
41
|
+
Pass a frozen `lucidlog.Config` to `install`, `diagnose`, `exception`, or `log_exception` to configure capture and output:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
import lucidlog
|
|
45
|
+
from lucidlog import Config
|
|
46
|
+
|
|
47
|
+
lucidlog.install(
|
|
48
|
+
config=Config(
|
|
49
|
+
color=True,
|
|
50
|
+
include_full_traceback=True,
|
|
51
|
+
max_chain_depth=6,
|
|
52
|
+
redact_patterns=(r"(?i)customer-id=[A-Z0-9]+",),
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Colors are disabled for non-interactive streams by default and honor `NO_COLOR` and `TERM=dumb`. `color=True` forces ANSI output; `color=False` disables it. Exception colors distinguish common diagnostic families, while the severity badge uses a separate palette (`critical`, `error`, `warning`, or `info`). The JSON representation contains the plain text values, never ANSI codes.
|
|
58
|
+
|
|
59
|
+
Cause/context chains and exception groups are represented as bounded nested data and included in the compact output. Limits (`max_chain_depth`, `max_group_exceptions`, `max_frames`, `max_message_length`, `max_traceback_chars`, `max_source_file_bytes`, and `max_diagnosis_evidence`) constrain diagnostic capture. `Diagnostic.to_dict()` and `to_json()` include a schema version, severity, fingerprint-like ID, cause relationships, and truncation indicator. Setting `include_local_variables=True` or `allow_network=True` is rejected; those capabilities are deliberately not part of the core.
|
|
60
|
+
|
|
61
|
+
The optional runtime integrations are explicit:
|
|
62
|
+
|
|
63
|
+
```python
|
|
64
|
+
from lucidlog.integrations.threading import install_threading_hook
|
|
65
|
+
from lucidlog.integrations.asyncio import install_asyncio_exception_handler
|
|
66
|
+
|
|
67
|
+
install_threading_hook()
|
|
68
|
+
install_asyncio_exception_handler()
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Each has a corresponding `uninstall_*` function. Asyncio leaves an already-installed handler in place unless `chain_previous=True`; thread and main hooks restore prior handlers when removed. Worker-thread and event-loop hooks do not cover child processes.
|
|
72
|
+
|
|
73
|
+
## Privacy notes
|
|
74
|
+
|
|
75
|
+
LucidLog never reads local variables or makes network requests. It redacts common credential-shaped strings and user-configured patterns from messages, source context, causes, and traceback text before those values enter the diagnostic model. Absolute paths under the home directory are shortened by default; `root_path` can shorten project paths. Redaction is best-effort: exception messages and source code are arbitrary application data, so review output before storing or forwarding it and supply additional patterns for your environment. `Config` validates regex syntax and output limits up front.
|
|
76
|
+
|
|
77
|
+
Logging integration writes one structured log record with a `lucidlog` dictionary in `LogRecord` extra fields. It deliberately omits `exc_info` to avoid bypassing redaction with a raw traceback. The original exception is not modified, and `log_exception` requires an active exception handler.
|
|
78
|
+
|
|
79
|
+
## Tests
|
|
80
|
+
|
|
81
|
+
Run the unit and integration tests with:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python -m pytest
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The suite covers chained and grouped exceptions, cycle/size limits, redaction, terminal colors, hook restoration, threading, asyncio, structured logging, and uncaught-process exit behavior.
|
|
88
|
+
|
|
89
|
+
## Notes
|
|
90
|
+
|
|
91
|
+
- The core package is standard-library only.
|
|
92
|
+
- It does not capture local variables by default.
|
|
93
|
+
- It keeps the original traceback available and does not replace the application's original exception flow.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "lucidlog"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Source-aware exception diagnostics with conservative explanations"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "LucidLog contributors" }]
|
|
13
|
+
keywords = ["debugging", "exceptions", "diagnostics", "tracebacks"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.10",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = [
|
|
25
|
+
"pytest>=7.0",
|
|
26
|
+
"pytest-cov>=4.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
package-dir = {"" = "src"}
|
|
31
|
+
|
|
32
|
+
[tool.setuptools.packages.find]
|
|
33
|
+
where = ["src"]
|
lucidlog-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""LucidLog public API."""
|
|
2
|
+
|
|
3
|
+
from .api import (
|
|
4
|
+
clear_diagnostic_rules,
|
|
5
|
+
diagnose,
|
|
6
|
+
exception,
|
|
7
|
+
get_registered_rules,
|
|
8
|
+
install,
|
|
9
|
+
log_exception,
|
|
10
|
+
register_diagnostic_rule,
|
|
11
|
+
render_diagnostic,
|
|
12
|
+
render_exception,
|
|
13
|
+
uninstall,
|
|
14
|
+
)
|
|
15
|
+
from .config import Config, default_config
|
|
16
|
+
from .rules import DiagnosticRule, clear_rules, list_rules, register_rule
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Config",
|
|
20
|
+
"DiagnosticRule",
|
|
21
|
+
"clear_diagnostic_rules",
|
|
22
|
+
"clear_rules",
|
|
23
|
+
"default_config",
|
|
24
|
+
"diagnose",
|
|
25
|
+
"exception",
|
|
26
|
+
"get_registered_rules",
|
|
27
|
+
"install",
|
|
28
|
+
"list_rules",
|
|
29
|
+
"log_exception",
|
|
30
|
+
"register_diagnostic_rule",
|
|
31
|
+
"register_rule",
|
|
32
|
+
"render_diagnostic",
|
|
33
|
+
"render_exception",
|
|
34
|
+
"uninstall",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import sys
|
|
5
|
+
import traceback
|
|
6
|
+
from typing import TextIO
|
|
7
|
+
|
|
8
|
+
from .config import Config, default_config
|
|
9
|
+
from .diagnosis import build_diagnostic
|
|
10
|
+
from .models import Diagnostic
|
|
11
|
+
from .redaction import sanitize_text
|
|
12
|
+
from .render import render_diagnostic as _render_diagnostic
|
|
13
|
+
from .rules import DiagnosticRule, clear_rules, list_rules, register_rule
|
|
14
|
+
|
|
15
|
+
_previous_excepthook = None
|
|
16
|
+
_installed = False
|
|
17
|
+
_installed_config = default_config()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def diagnose(exc: BaseException, *, config: Config | None = None) -> Diagnostic:
|
|
21
|
+
return build_diagnostic(exc, config=config)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def register_diagnostic_rule(rule: DiagnosticRule) -> None:
|
|
25
|
+
register_rule(rule)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def clear_diagnostic_rules() -> None:
|
|
29
|
+
clear_rules()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_registered_rules() -> tuple[DiagnosticRule, ...]:
|
|
33
|
+
return list_rules()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _handle_uncaught(exc_type, exc_value, exc_traceback) -> None:
|
|
37
|
+
global _previous_excepthook
|
|
38
|
+
try:
|
|
39
|
+
diagnostic = build_diagnostic(exc_value, config=_installed_config)
|
|
40
|
+
_render_diagnostic(
|
|
41
|
+
diagnostic,
|
|
42
|
+
full=_installed_config.include_full_traceback,
|
|
43
|
+
stream=_installed_config.output_stream,
|
|
44
|
+
color=_installed_config.color,
|
|
45
|
+
)
|
|
46
|
+
except BaseException:
|
|
47
|
+
if _previous_excepthook is not None:
|
|
48
|
+
_previous_excepthook(exc_type, exc_value, exc_traceback)
|
|
49
|
+
return
|
|
50
|
+
traceback.print_exception(exc_type, exc_value, exc_traceback)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def install(*, config: Config | None = None) -> None:
|
|
54
|
+
global _previous_excepthook, _installed, _installed_config
|
|
55
|
+
if _installed:
|
|
56
|
+
return
|
|
57
|
+
_installed_config = config or default_config()
|
|
58
|
+
_previous_excepthook = sys.excepthook
|
|
59
|
+
sys.excepthook = _handle_uncaught
|
|
60
|
+
_installed = True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def uninstall() -> None:
|
|
64
|
+
global _previous_excepthook, _installed, _installed_config
|
|
65
|
+
if not _installed:
|
|
66
|
+
return
|
|
67
|
+
if sys.excepthook is _handle_uncaught:
|
|
68
|
+
sys.excepthook = _previous_excepthook
|
|
69
|
+
_previous_excepthook = None
|
|
70
|
+
_installed = False
|
|
71
|
+
_installed_config = default_config()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def exception(
|
|
75
|
+
message: str | None = None,
|
|
76
|
+
*,
|
|
77
|
+
stream: TextIO | None = None,
|
|
78
|
+
full: bool = False,
|
|
79
|
+
config: Config | None = None,
|
|
80
|
+
) -> Diagnostic:
|
|
81
|
+
exc = sys.exc_info()[1]
|
|
82
|
+
if exc is None:
|
|
83
|
+
raise RuntimeError("lucidlog.exception() must be called from within an active exception handler.")
|
|
84
|
+
selected_config = config or default_config()
|
|
85
|
+
diagnostic = build_diagnostic(exc, config=selected_config)
|
|
86
|
+
_render_diagnostic(
|
|
87
|
+
diagnostic,
|
|
88
|
+
full=full,
|
|
89
|
+
stream=stream if stream is not None else selected_config.output_stream,
|
|
90
|
+
color=selected_config.color,
|
|
91
|
+
)
|
|
92
|
+
if message:
|
|
93
|
+
target = stream if stream is not None else selected_config.output_stream
|
|
94
|
+
if target is None:
|
|
95
|
+
target = sys.stderr
|
|
96
|
+
target.write(sanitize_text(message, selected_config) + "\n")
|
|
97
|
+
return diagnostic
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def log_exception(
|
|
101
|
+
logger: logging.Logger,
|
|
102
|
+
message: str,
|
|
103
|
+
*,
|
|
104
|
+
full: bool = False,
|
|
105
|
+
config: Config | None = None,
|
|
106
|
+
) -> Diagnostic:
|
|
107
|
+
exc = sys.exc_info()[1]
|
|
108
|
+
if exc is None:
|
|
109
|
+
raise RuntimeError("No active exception to log.")
|
|
110
|
+
selected_config = config or default_config()
|
|
111
|
+
diagnostic = build_diagnostic(exc, config=selected_config)
|
|
112
|
+
level = {
|
|
113
|
+
"critical": logging.CRITICAL,
|
|
114
|
+
"error": logging.ERROR,
|
|
115
|
+
"warning": logging.WARNING,
|
|
116
|
+
"info": logging.INFO,
|
|
117
|
+
}[diagnostic.severity]
|
|
118
|
+
logger.log(
|
|
119
|
+
level,
|
|
120
|
+
sanitize_text(message, selected_config),
|
|
121
|
+
extra={"lucidlog": diagnostic.to_dict(), "lucidlog_full": full},
|
|
122
|
+
)
|
|
123
|
+
return diagnostic
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def render_exception(
|
|
127
|
+
exc: BaseException,
|
|
128
|
+
*,
|
|
129
|
+
full: bool = False,
|
|
130
|
+
stream: TextIO | None = None,
|
|
131
|
+
color: bool | None = None,
|
|
132
|
+
config: Config | None = None,
|
|
133
|
+
) -> str:
|
|
134
|
+
selected_config = config or default_config()
|
|
135
|
+
diagnostic = build_diagnostic(exc, config=selected_config)
|
|
136
|
+
return _render_diagnostic(
|
|
137
|
+
diagnostic,
|
|
138
|
+
full=full,
|
|
139
|
+
stream=stream if stream is not None else selected_config.output_stream,
|
|
140
|
+
color=selected_config.color if color is None else color,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def render_diagnostic(
|
|
145
|
+
diag: Diagnostic,
|
|
146
|
+
*,
|
|
147
|
+
full: bool = False,
|
|
148
|
+
stream: TextIO | None = None,
|
|
149
|
+
color: bool | None = None,
|
|
150
|
+
) -> str:
|
|
151
|
+
return _render_diagnostic(diag, full=full, stream=stream, color=color)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import traceback
|
|
4
|
+
import builtins
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .config import Config, default_config
|
|
8
|
+
from .models import DiagnosticCause, ExceptionContext, FrameInfo, SourceLine
|
|
9
|
+
from .redaction import redact_path, sanitize_text
|
|
10
|
+
from .source import read_source_context
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _safe_message(exc: BaseException, config: Config) -> str:
|
|
14
|
+
try:
|
|
15
|
+
message = str(exc)
|
|
16
|
+
except BaseException:
|
|
17
|
+
message = "<exception message could not be rendered>"
|
|
18
|
+
return sanitize_text(message, config)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _build_frame(frame: traceback.FrameSummary, config: Config) -> FrameInfo:
|
|
22
|
+
source_line, context = read_source_context(
|
|
23
|
+
frame.filename,
|
|
24
|
+
frame.lineno,
|
|
25
|
+
context_lines=config.max_source_context,
|
|
26
|
+
max_file_bytes=config.max_source_file_bytes,
|
|
27
|
+
)
|
|
28
|
+
safe_context = tuple(
|
|
29
|
+
SourceLine(
|
|
30
|
+
line_number=line.line_number,
|
|
31
|
+
text=sanitize_text(line.text, config, limit=config.max_context_line_length),
|
|
32
|
+
is_error_line=line.is_error_line,
|
|
33
|
+
start_column=line.start_column,
|
|
34
|
+
end_column=line.end_column,
|
|
35
|
+
)
|
|
36
|
+
for line in context
|
|
37
|
+
)
|
|
38
|
+
return FrameInfo(
|
|
39
|
+
filename=sanitize_text(
|
|
40
|
+
redact_path(frame.filename, config),
|
|
41
|
+
config,
|
|
42
|
+
limit=config.max_message_length,
|
|
43
|
+
),
|
|
44
|
+
line_number=frame.lineno,
|
|
45
|
+
function=sanitize_text(frame.name, config, limit=256),
|
|
46
|
+
source_line=(
|
|
47
|
+
sanitize_text(source_line, config, limit=config.max_context_line_length)
|
|
48
|
+
if source_line is not None
|
|
49
|
+
else None
|
|
50
|
+
),
|
|
51
|
+
context=safe_context,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _group_members(exc: BaseException) -> tuple[BaseException, ...]:
|
|
56
|
+
group_type: Any = getattr(builtins, "BaseExceptionGroup", None)
|
|
57
|
+
if group_type is None or not isinstance(exc, group_type):
|
|
58
|
+
return ()
|
|
59
|
+
return tuple(exc.exceptions)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _capture_relationships(
|
|
63
|
+
exc: BaseException,
|
|
64
|
+
*,
|
|
65
|
+
config: Config,
|
|
66
|
+
seen: set[int],
|
|
67
|
+
budget: list[int],
|
|
68
|
+
depth: int = 0,
|
|
69
|
+
) -> tuple[tuple[DiagnosticCause, ...], bool]:
|
|
70
|
+
if not config.include_exception_chain:
|
|
71
|
+
return (), False
|
|
72
|
+
if depth >= config.max_chain_depth:
|
|
73
|
+
return (), bool(exc.__cause__ or (exc.__context__ and not exc.__suppress_context__) or _group_members(exc))
|
|
74
|
+
|
|
75
|
+
related: list[DiagnosticCause] = []
|
|
76
|
+
truncated = False
|
|
77
|
+
next_exc = exc.__cause__
|
|
78
|
+
relation = "cause"
|
|
79
|
+
if next_exc is None and not exc.__suppress_context__:
|
|
80
|
+
next_exc = exc.__context__
|
|
81
|
+
relation = "context"
|
|
82
|
+
def related_exceptions():
|
|
83
|
+
if next_exc is not None:
|
|
84
|
+
yield next_exc, relation
|
|
85
|
+
for member in _group_members(exc):
|
|
86
|
+
yield member, "group_member"
|
|
87
|
+
|
|
88
|
+
for candidate_count, (child, child_relation) in enumerate(related_exceptions()):
|
|
89
|
+
if candidate_count >= config.max_group_exceptions:
|
|
90
|
+
truncated = True
|
|
91
|
+
break
|
|
92
|
+
child_id = id(child)
|
|
93
|
+
if child_id in seen or budget[0] >= config.max_group_exceptions:
|
|
94
|
+
truncated = True
|
|
95
|
+
continue
|
|
96
|
+
seen.add(child_id)
|
|
97
|
+
budget[0] += 1
|
|
98
|
+
tb = child.__traceback__
|
|
99
|
+
child_frames: tuple[FrameInfo, ...] = ()
|
|
100
|
+
location = "<unknown>"
|
|
101
|
+
if tb is not None:
|
|
102
|
+
extracted = traceback.extract_tb(tb, limit=-config.max_frames)
|
|
103
|
+
child_frames = tuple(_build_frame(frame, config) for frame in extracted)
|
|
104
|
+
if child_frames:
|
|
105
|
+
location = child_frames[-1].location
|
|
106
|
+
children, child_truncated = _capture_relationships(
|
|
107
|
+
child,
|
|
108
|
+
config=config,
|
|
109
|
+
seen=seen,
|
|
110
|
+
budget=budget,
|
|
111
|
+
depth=depth + 1,
|
|
112
|
+
)
|
|
113
|
+
related.append(
|
|
114
|
+
DiagnosticCause(
|
|
115
|
+
exception_type=type(child).__name__,
|
|
116
|
+
message=_safe_message(child, config),
|
|
117
|
+
location=location,
|
|
118
|
+
summary="Nested exception" if child_relation != "group_member" else "Exception group member",
|
|
119
|
+
relation=child_relation,
|
|
120
|
+
children=children,
|
|
121
|
+
frames=child_frames,
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
truncated = truncated or child_truncated
|
|
125
|
+
return tuple(related), truncated
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _format_frame(frame: FrameInfo) -> list[str]:
|
|
129
|
+
lines = [f' File "{frame.filename}", line {frame.line_number}, in {frame.function}']
|
|
130
|
+
if frame.source_line:
|
|
131
|
+
lines.append(f" {frame.source_line.strip()}")
|
|
132
|
+
return lines
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _format_cause(cause: DiagnosticCause, depth: int = 0) -> list[str]:
|
|
136
|
+
lines: list[str] = []
|
|
137
|
+
relation = {
|
|
138
|
+
"cause": "Caused by",
|
|
139
|
+
"context": "During handling of",
|
|
140
|
+
"group_member": "Exception group member",
|
|
141
|
+
}.get(cause.relation, "Related exception")
|
|
142
|
+
lines.append(f"{' ' * depth}{relation}:")
|
|
143
|
+
for frame in cause.frames:
|
|
144
|
+
lines.extend(f"{' ' * depth}{line}" for line in _format_frame(frame))
|
|
145
|
+
lines.append(f"{' ' * depth}{cause.exception_type}: {cause.message}")
|
|
146
|
+
for child in cause.children:
|
|
147
|
+
lines.extend(_format_cause(child, depth + 1))
|
|
148
|
+
return lines
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _format_traceback(
|
|
152
|
+
exception_type: str,
|
|
153
|
+
message: str,
|
|
154
|
+
frames: tuple[FrameInfo, ...],
|
|
155
|
+
causes: tuple[DiagnosticCause, ...],
|
|
156
|
+
config: Config,
|
|
157
|
+
) -> str:
|
|
158
|
+
lines = ["Traceback (most recent call last):"]
|
|
159
|
+
for frame in frames:
|
|
160
|
+
lines.extend(_format_frame(frame))
|
|
161
|
+
lines.append(f"{exception_type}: {message}")
|
|
162
|
+
for cause in causes:
|
|
163
|
+
lines.extend(_format_cause(cause))
|
|
164
|
+
return sanitize_text("\n".join(lines), config, limit=config.max_traceback_chars)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def capture_exception(exc: BaseException, *, config: Config | None = None) -> ExceptionContext:
|
|
168
|
+
config = config or default_config()
|
|
169
|
+
frames: list[FrameInfo] = []
|
|
170
|
+
if exc.__traceback__ is not None:
|
|
171
|
+
extracted = traceback.extract_tb(exc.__traceback__, limit=-config.max_frames)
|
|
172
|
+
frames = [_build_frame(frame, config) for frame in extracted]
|
|
173
|
+
primary_frame = frames[-1] if frames else None
|
|
174
|
+
causes, causes_truncated = _capture_relationships(
|
|
175
|
+
exc,
|
|
176
|
+
config=config,
|
|
177
|
+
seen={id(exc)},
|
|
178
|
+
budget=[0],
|
|
179
|
+
)
|
|
180
|
+
message = _safe_message(exc, config)
|
|
181
|
+
formatted_traceback = _format_traceback(
|
|
182
|
+
type(exc).__name__,
|
|
183
|
+
message,
|
|
184
|
+
tuple(frames),
|
|
185
|
+
causes,
|
|
186
|
+
config,
|
|
187
|
+
)
|
|
188
|
+
return ExceptionContext(
|
|
189
|
+
exception_type=type(exc).__name__,
|
|
190
|
+
message=message,
|
|
191
|
+
exception_mro=tuple(base.__name__ for base in type(exc).__mro__[1:]),
|
|
192
|
+
frames=tuple(frames),
|
|
193
|
+
primary_frame=primary_frame,
|
|
194
|
+
formatted_traceback=formatted_traceback,
|
|
195
|
+
causes=causes,
|
|
196
|
+
causes_truncated=causes_truncated,
|
|
197
|
+
)
|