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.
@@ -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.
@@ -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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )