oaknut-exception 11.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oaknut_exception-11.0.0/LICENSE +21 -0
- oaknut_exception-11.0.0/PKG-INFO +59 -0
- oaknut_exception-11.0.0/README.md +35 -0
- oaknut_exception-11.0.0/pyproject.toml +48 -0
- oaknut_exception-11.0.0/setup.cfg +4 -0
- oaknut_exception-11.0.0/src/oaknut/exception/__init__.py +281 -0
- oaknut_exception-11.0.0/src/oaknut_exception.egg-info/PKG-INFO +59 -0
- oaknut_exception-11.0.0/src/oaknut_exception.egg-info/SOURCES.txt +12 -0
- oaknut_exception-11.0.0/src/oaknut_exception.egg-info/dependency_links.txt +1 -0
- oaknut_exception-11.0.0/src/oaknut_exception.egg-info/requires.txt +1 -0
- oaknut_exception-11.0.0/src/oaknut_exception.egg-info/top_level.txt +1 -0
- oaknut_exception-11.0.0/tests/test_exception_hierarchy.py +95 -0
- oaknut_exception-11.0.0/tests/test_handled_errors.py +174 -0
- oaknut_exception-11.0.0/tests/test_render_error.py +66 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Robert Smallshire
|
|
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.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oaknut-exception
|
|
3
|
+
Version: 11.0.0
|
|
4
|
+
Summary: Categorised exceptions and CLI error-reporting boundary for the oaknut package family.
|
|
5
|
+
Author-email: Robert Smallshire <robert@smallshire.org.uk>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rob-smallshire/oaknut
|
|
8
|
+
Project-URL: Repository, https://github.com/rob-smallshire/oaknut
|
|
9
|
+
Project-URL: Issues, https://github.com/rob-smallshire/oaknut/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: exit-codes>=1.3
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# oaknut-exception
|
|
26
|
+
|
|
27
|
+
Categorised exceptions and a CLI error-reporting boundary for the
|
|
28
|
+
`oaknut-*` family of packages.
|
|
29
|
+
|
|
30
|
+
The package defines a small exception hierarchy that every other
|
|
31
|
+
`oaknut-*` package's domain errors slot into:
|
|
32
|
+
|
|
33
|
+
- `OaknutException` — root of the hierarchy. Carries an `exit_code`
|
|
34
|
+
property derived from the BSD `sysexits.h` set (via the `exit-codes`
|
|
35
|
+
package).
|
|
36
|
+
- `DataError` — the operation failed because of the *data* it was given
|
|
37
|
+
(a user-supplied path, a corrupted on-disc structure, an unsupported
|
|
38
|
+
filetype). No traceback at the CLI boundary.
|
|
39
|
+
- `ConfigurationError` — the operation failed because of a runtime
|
|
40
|
+
environment / configuration issue. No traceback at the CLI boundary.
|
|
41
|
+
- `InternalError` — the operation failed because something went wrong
|
|
42
|
+
inside the library. **Traceback retained** at the CLI boundary so
|
|
43
|
+
the report-an-issue path is obvious.
|
|
44
|
+
|
|
45
|
+
Plus a single error-handling boundary helper:
|
|
46
|
+
|
|
47
|
+
- `handled_errors` — a context manager (also usable as a decorator)
|
|
48
|
+
that catches `DataError` and `ConfigurationError`, prints them via a
|
|
49
|
+
caller-supplied printer, and exits with the most appropriate
|
|
50
|
+
`ExitCode`. `InternalError` and unexpected exceptions propagate so
|
|
51
|
+
their tracebacks reach the user. `KeyboardInterrupt` exits with the
|
|
52
|
+
conventional `128 + SIGINT` status.
|
|
53
|
+
|
|
54
|
+
This package is what every `oaknut-disc`-like CLI uses to map library
|
|
55
|
+
errors onto stable exit codes without dropping into try/except in every
|
|
56
|
+
command.
|
|
57
|
+
|
|
58
|
+
See the [oaknut documentation](https://rob-smallshire.github.io/oaknut/)
|
|
59
|
+
for the full API reference.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# oaknut-exception
|
|
2
|
+
|
|
3
|
+
Categorised exceptions and a CLI error-reporting boundary for the
|
|
4
|
+
`oaknut-*` family of packages.
|
|
5
|
+
|
|
6
|
+
The package defines a small exception hierarchy that every other
|
|
7
|
+
`oaknut-*` package's domain errors slot into:
|
|
8
|
+
|
|
9
|
+
- `OaknutException` — root of the hierarchy. Carries an `exit_code`
|
|
10
|
+
property derived from the BSD `sysexits.h` set (via the `exit-codes`
|
|
11
|
+
package).
|
|
12
|
+
- `DataError` — the operation failed because of the *data* it was given
|
|
13
|
+
(a user-supplied path, a corrupted on-disc structure, an unsupported
|
|
14
|
+
filetype). No traceback at the CLI boundary.
|
|
15
|
+
- `ConfigurationError` — the operation failed because of a runtime
|
|
16
|
+
environment / configuration issue. No traceback at the CLI boundary.
|
|
17
|
+
- `InternalError` — the operation failed because something went wrong
|
|
18
|
+
inside the library. **Traceback retained** at the CLI boundary so
|
|
19
|
+
the report-an-issue path is obvious.
|
|
20
|
+
|
|
21
|
+
Plus a single error-handling boundary helper:
|
|
22
|
+
|
|
23
|
+
- `handled_errors` — a context manager (also usable as a decorator)
|
|
24
|
+
that catches `DataError` and `ConfigurationError`, prints them via a
|
|
25
|
+
caller-supplied printer, and exits with the most appropriate
|
|
26
|
+
`ExitCode`. `InternalError` and unexpected exceptions propagate so
|
|
27
|
+
their tracebacks reach the user. `KeyboardInterrupt` exits with the
|
|
28
|
+
conventional `128 + SIGINT` status.
|
|
29
|
+
|
|
30
|
+
This package is what every `oaknut-disc`-like CLI uses to map library
|
|
31
|
+
errors onto stable exit codes without dropping into try/except in every
|
|
32
|
+
command.
|
|
33
|
+
|
|
34
|
+
See the [oaknut documentation](https://rob-smallshire.github.io/oaknut/)
|
|
35
|
+
for the full API reference.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "oaknut-exception"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
authors = [{ name = "Robert Smallshire", email = "robert@smallshire.org.uk" }]
|
|
9
|
+
description = "Categorised exceptions and CLI error-reporting boundary for the oaknut package family."
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"exit-codes>=1.3",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/rob-smallshire/oaknut"
|
|
31
|
+
Repository = "https://github.com/rob-smallshire/oaknut"
|
|
32
|
+
Issues = "https://github.com/rob-smallshire/oaknut/issues"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
test = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
]
|
|
38
|
+
dev = [
|
|
39
|
+
"bump-my-version>=0.28.0",
|
|
40
|
+
"pre-commit>=3.0",
|
|
41
|
+
{include-group = "test"},
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.dynamic]
|
|
45
|
+
version = { attr = "oaknut.exception.__version__" }
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Categorised exceptions and CLI error-reporting boundary for oaknut.
|
|
2
|
+
|
|
3
|
+
This module supplies three things every ``oaknut-*`` library and CLI
|
|
4
|
+
shares:
|
|
5
|
+
|
|
6
|
+
1. A small exception hierarchy — :class:`OaknutException` root and the
|
|
7
|
+
:class:`DataError`/:class:`ConfigurationError`/:class:`InternalError`
|
|
8
|
+
category subclasses. Each carries a stable :class:`ExitCode` so the
|
|
9
|
+
library decides how an error classifies; the CLI just observes.
|
|
10
|
+
|
|
11
|
+
2. A :func:`handled_errors` context manager (also usable as a
|
|
12
|
+
decorator, via :class:`contextlib.ContextDecorator`) that catches
|
|
13
|
+
:class:`DataError` and :class:`ConfigurationError`, prints them via
|
|
14
|
+
a caller-supplied printer, and exits with the most appropriate
|
|
15
|
+
:class:`ExitCode`. :class:`InternalError` and other exceptions
|
|
16
|
+
propagate so their tracebacks reach the user. ``KeyboardInterrupt``
|
|
17
|
+
exits with the conventional ``128 + SIGINT`` status.
|
|
18
|
+
|
|
19
|
+
3. A :func:`render_error` helper that walks an exception's ``__cause__``
|
|
20
|
+
chain and any ``__notes__`` so the boundary printer can format the
|
|
21
|
+
full story rather than just the leaf message.
|
|
22
|
+
|
|
23
|
+
The design is intentionally close to ``demonstrable-exception`` — we
|
|
24
|
+
have permission to borrow the shape — with a few opinionated
|
|
25
|
+
refinements: per-instance exit-code override, deterministic
|
|
26
|
+
first-wins selection across exception groups, an explicit
|
|
27
|
+
:func:`render_error` separation, and a printer protocol that is easy
|
|
28
|
+
to wrap with format-aware adapters at the CLI layer.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import signal
|
|
34
|
+
import sys
|
|
35
|
+
from collections.abc import Callable, Iterable
|
|
36
|
+
from contextlib import contextmanager
|
|
37
|
+
from typing import Iterator, Optional
|
|
38
|
+
|
|
39
|
+
from exit_codes import ExitCode
|
|
40
|
+
|
|
41
|
+
__version__ = "11.0.0"
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"OaknutException",
|
|
45
|
+
"DataError",
|
|
46
|
+
"ConfigurationError",
|
|
47
|
+
"InternalError",
|
|
48
|
+
"Printer",
|
|
49
|
+
"handled_errors",
|
|
50
|
+
"render_error",
|
|
51
|
+
"exit_code_for",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Exception hierarchy
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class OaknutException(Exception):
|
|
61
|
+
"""Base class for every domain exception in the oaknut family.
|
|
62
|
+
|
|
63
|
+
Do not raise this directly — raise one of the category subclasses
|
|
64
|
+
(:class:`DataError`, :class:`ConfigurationError`,
|
|
65
|
+
:class:`InternalError`) or a domain-specific class that derives
|
|
66
|
+
from one of them.
|
|
67
|
+
|
|
68
|
+
Every instance has an :attr:`exit_code` of type :class:`ExitCode`.
|
|
69
|
+
The class attribute :attr:`_exit_code` provides the default for a
|
|
70
|
+
given category or subclass; a constructor argument overrides it for
|
|
71
|
+
a single instance::
|
|
72
|
+
|
|
73
|
+
raise DataError("path not found: $.HELLO", exit_code=ExitCode.OS_FILE)
|
|
74
|
+
|
|
75
|
+
The override is what lets a library raise the right BSD code for a
|
|
76
|
+
specific case (path-not-found vs. permission-denied vs. file-full)
|
|
77
|
+
without needing a private exception class for every variant.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
#: The default :class:`ExitCode` for this class. Subclasses override.
|
|
81
|
+
_exit_code: ExitCode = ExitCode.SOFTWARE
|
|
82
|
+
|
|
83
|
+
def __init__(self, *args: object, exit_code: Optional[ExitCode] = None) -> None:
|
|
84
|
+
super().__init__(*args)
|
|
85
|
+
self._instance_exit_code: Optional[ExitCode] = exit_code
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def exit_code(self) -> ExitCode:
|
|
89
|
+
"""The :class:`ExitCode` that should accompany this error.
|
|
90
|
+
|
|
91
|
+
The per-instance override, if one was passed to the
|
|
92
|
+
constructor; otherwise the class default.
|
|
93
|
+
"""
|
|
94
|
+
if self._instance_exit_code is not None:
|
|
95
|
+
return self._instance_exit_code
|
|
96
|
+
return self._exit_code
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class DataError(OaknutException):
|
|
100
|
+
"""An error caused by the *data* the operation was asked to act on.
|
|
101
|
+
|
|
102
|
+
Covers both user input (a path that doesn't exist, an invalid name,
|
|
103
|
+
a file the user pointed at) and on-disc data (a corrupted catalogue,
|
|
104
|
+
an inconsistent free-space map). The CLI boundary prints these
|
|
105
|
+
without a stack trace — they are not bugs, they are problems with
|
|
106
|
+
the input.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
_exit_code = ExitCode.DATA_ERR
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ConfigurationError(OaknutException):
|
|
113
|
+
"""An error caused by a runtime-environment configuration issue.
|
|
114
|
+
|
|
115
|
+
Things like "configured cache directory is not writable", "required
|
|
116
|
+
extension binary not on PATH", or "config file references a missing
|
|
117
|
+
profile". The CLI boundary prints these without a stack trace —
|
|
118
|
+
they are problems with the *setup*, not the *data* or the code.
|
|
119
|
+
"""
|
|
120
|
+
|
|
121
|
+
_exit_code = ExitCode.CONFIG
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class InternalError(OaknutException):
|
|
125
|
+
"""An error caused by something going unexpectedly wrong inside the library.
|
|
126
|
+
|
|
127
|
+
The CLI boundary lets these propagate with a stack trace because
|
|
128
|
+
that is the report-an-issue signal: there is no clean way to
|
|
129
|
+
explain it to a user — the maintainers need the traceback to fix
|
|
130
|
+
the root cause.
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
_exit_code = ExitCode.SOFTWARE
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
# Helpers
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
#: A printer callable. Takes the rendered error message string and a
|
|
142
|
+
#: hint about whether the rendering came from the leaf exception
|
|
143
|
+
#: (``False``) or a chained cause / note (``True``). CLIs typically
|
|
144
|
+
#: render leaves in red and continuations in a muted colour.
|
|
145
|
+
Printer = Callable[[str, bool], None]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def exit_code_for(exc: BaseException) -> ExitCode:
|
|
149
|
+
"""Return the :class:`ExitCode` for *exc*.
|
|
150
|
+
|
|
151
|
+
For :class:`OaknutException` instances, returns the instance's
|
|
152
|
+
:attr:`~OaknutException.exit_code`. For everything else, returns
|
|
153
|
+
:data:`ExitCode.SOFTWARE` so an unexpected exception still maps to
|
|
154
|
+
a sensible non-zero code if a caller chooses to swallow it.
|
|
155
|
+
"""
|
|
156
|
+
if isinstance(exc, OaknutException):
|
|
157
|
+
return exc.exit_code
|
|
158
|
+
return ExitCode.SOFTWARE
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def render_error(exc: BaseException) -> Iterator[tuple[str, bool]]:
|
|
162
|
+
"""Yield ``(line, is_continuation)`` pairs for *exc* and its causes.
|
|
163
|
+
|
|
164
|
+
The leaf exception's ``str()`` is yielded first with
|
|
165
|
+
``is_continuation=False``. Any ``__notes__`` (PEP 678, Python 3.11+)
|
|
166
|
+
follow with ``is_continuation=True``. If the exception was raised
|
|
167
|
+
with ``raise X from Y``, the cause chain is walked and rendered
|
|
168
|
+
recursively, each line prefixed with ``"caused by: "``.
|
|
169
|
+
|
|
170
|
+
Callers feed each ``(line, is_continuation)`` pair to a
|
|
171
|
+
:data:`Printer` and the printer decides the styling — the splitting
|
|
172
|
+
is the same regardless of whether the printer is colour-aware,
|
|
173
|
+
pipe-friendly, or JSON.
|
|
174
|
+
"""
|
|
175
|
+
yield str(exc) or type(exc).__name__, False
|
|
176
|
+
for note in getattr(exc, "__notes__", ()):
|
|
177
|
+
yield str(note), True
|
|
178
|
+
|
|
179
|
+
cause = exc.__cause__
|
|
180
|
+
while cause is not None:
|
|
181
|
+
yield f"caused by: {cause or type(cause).__name__}", True
|
|
182
|
+
for note in getattr(cause, "__notes__", ()):
|
|
183
|
+
yield str(note), True
|
|
184
|
+
cause = cause.__cause__
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# handled_errors — the CLI boundary helper
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
DEFAULT_EXIT_CODE = ExitCode.SOFTWARE
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _default_printer(line: str, _is_continuation: bool) -> None:
|
|
196
|
+
print(line, file=sys.stderr)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@contextmanager
|
|
200
|
+
def handled_errors(
|
|
201
|
+
printer: Optional[Printer] = None,
|
|
202
|
+
*,
|
|
203
|
+
exit_func: Callable[[int], None] = sys.exit,
|
|
204
|
+
debug: bool = False,
|
|
205
|
+
) -> Iterator[None]:
|
|
206
|
+
"""Catch :class:`DataError`/:class:`ConfigurationError`, print, exit.
|
|
207
|
+
|
|
208
|
+
Use at the CLI boundary, either as a context manager wrapping a
|
|
209
|
+
body of work::
|
|
210
|
+
|
|
211
|
+
with handled_errors(print_error):
|
|
212
|
+
do_the_work()
|
|
213
|
+
|
|
214
|
+
or — because :func:`contextlib.contextmanager` produces a
|
|
215
|
+
:class:`contextlib.ContextDecorator` — as a decorator on a Click
|
|
216
|
+
command callback::
|
|
217
|
+
|
|
218
|
+
@handled_errors(print_error)
|
|
219
|
+
def cmd(...): ...
|
|
220
|
+
|
|
221
|
+
Behaviour:
|
|
222
|
+
|
|
223
|
+
- :class:`DataError` / :class:`ConfigurationError` and any
|
|
224
|
+
:class:`ExceptionGroup` containing them are caught, each leaf
|
|
225
|
+
rendered via :func:`render_error`, and the process is exited
|
|
226
|
+
with the **first** leaf's exit code (deterministic; matches the
|
|
227
|
+
order errors were raised).
|
|
228
|
+
- :class:`InternalError` and any other exception propagate.
|
|
229
|
+
- ``KeyboardInterrupt`` exits with ``128 + SIGINT`` after printing
|
|
230
|
+
itself.
|
|
231
|
+
- When *debug* is ``True``, :class:`DataError` and
|
|
232
|
+
:class:`ConfigurationError` are *re-raised* after being printed
|
|
233
|
+
so the caller still sees the full Python traceback — useful
|
|
234
|
+
during development; off by default for users.
|
|
235
|
+
|
|
236
|
+
*printer* defaults to a plain stderr writer. *exit_func* defaults
|
|
237
|
+
to :func:`sys.exit`; tests inject a custom function to capture the
|
|
238
|
+
code without terminating the test runner.
|
|
239
|
+
"""
|
|
240
|
+
if printer is None:
|
|
241
|
+
printer = _default_printer
|
|
242
|
+
|
|
243
|
+
pending_exit: Optional[int] = None
|
|
244
|
+
pending_reraise: Optional[BaseException] = None
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
yield
|
|
248
|
+
except* (DataError, ConfigurationError) as group:
|
|
249
|
+
leaves = list(_iter_leaves(group))
|
|
250
|
+
for leaf in leaves:
|
|
251
|
+
for line, is_continuation in render_error(leaf):
|
|
252
|
+
printer(line, is_continuation)
|
|
253
|
+
if debug and leaves:
|
|
254
|
+
pending_reraise = leaves[0]
|
|
255
|
+
else:
|
|
256
|
+
# First-wins: the first leaf's code, falling back to the
|
|
257
|
+
# category default if somehow none classify.
|
|
258
|
+
first_code = next(
|
|
259
|
+
(exit_code_for(leaf) for leaf in leaves if isinstance(leaf, OaknutException)),
|
|
260
|
+
DEFAULT_EXIT_CODE,
|
|
261
|
+
)
|
|
262
|
+
pending_exit = int(first_code)
|
|
263
|
+
except* KeyboardInterrupt as group:
|
|
264
|
+
for leaf in _iter_leaves(group):
|
|
265
|
+
for line, is_continuation in render_error(leaf):
|
|
266
|
+
printer(line, is_continuation)
|
|
267
|
+
pending_exit = 128 + signal.SIGINT
|
|
268
|
+
|
|
269
|
+
if pending_reraise is not None:
|
|
270
|
+
raise pending_reraise
|
|
271
|
+
if pending_exit is not None:
|
|
272
|
+
exit_func(pending_exit)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _iter_leaves(group: BaseExceptionGroup) -> Iterable[BaseException]:
|
|
276
|
+
"""Yield leaf exceptions from a (possibly nested) exception group."""
|
|
277
|
+
for exc in group.exceptions:
|
|
278
|
+
if isinstance(exc, BaseExceptionGroup):
|
|
279
|
+
yield from _iter_leaves(exc)
|
|
280
|
+
else:
|
|
281
|
+
yield exc
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oaknut-exception
|
|
3
|
+
Version: 11.0.0
|
|
4
|
+
Summary: Categorised exceptions and CLI error-reporting boundary for the oaknut package family.
|
|
5
|
+
Author-email: Robert Smallshire <robert@smallshire.org.uk>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/rob-smallshire/oaknut
|
|
8
|
+
Project-URL: Repository, https://github.com/rob-smallshire/oaknut
|
|
9
|
+
Project-URL: Issues, https://github.com/rob-smallshire/oaknut/issues
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: exit-codes>=1.3
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# oaknut-exception
|
|
26
|
+
|
|
27
|
+
Categorised exceptions and a CLI error-reporting boundary for the
|
|
28
|
+
`oaknut-*` family of packages.
|
|
29
|
+
|
|
30
|
+
The package defines a small exception hierarchy that every other
|
|
31
|
+
`oaknut-*` package's domain errors slot into:
|
|
32
|
+
|
|
33
|
+
- `OaknutException` — root of the hierarchy. Carries an `exit_code`
|
|
34
|
+
property derived from the BSD `sysexits.h` set (via the `exit-codes`
|
|
35
|
+
package).
|
|
36
|
+
- `DataError` — the operation failed because of the *data* it was given
|
|
37
|
+
(a user-supplied path, a corrupted on-disc structure, an unsupported
|
|
38
|
+
filetype). No traceback at the CLI boundary.
|
|
39
|
+
- `ConfigurationError` — the operation failed because of a runtime
|
|
40
|
+
environment / configuration issue. No traceback at the CLI boundary.
|
|
41
|
+
- `InternalError` — the operation failed because something went wrong
|
|
42
|
+
inside the library. **Traceback retained** at the CLI boundary so
|
|
43
|
+
the report-an-issue path is obvious.
|
|
44
|
+
|
|
45
|
+
Plus a single error-handling boundary helper:
|
|
46
|
+
|
|
47
|
+
- `handled_errors` — a context manager (also usable as a decorator)
|
|
48
|
+
that catches `DataError` and `ConfigurationError`, prints them via a
|
|
49
|
+
caller-supplied printer, and exits with the most appropriate
|
|
50
|
+
`ExitCode`. `InternalError` and unexpected exceptions propagate so
|
|
51
|
+
their tracebacks reach the user. `KeyboardInterrupt` exits with the
|
|
52
|
+
conventional `128 + SIGINT` status.
|
|
53
|
+
|
|
54
|
+
This package is what every `oaknut-disc`-like CLI uses to map library
|
|
55
|
+
errors onto stable exit codes without dropping into try/except in every
|
|
56
|
+
command.
|
|
57
|
+
|
|
58
|
+
See the [oaknut documentation](https://rob-smallshire.github.io/oaknut/)
|
|
59
|
+
for the full API reference.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/oaknut/exception/__init__.py
|
|
5
|
+
src/oaknut_exception.egg-info/PKG-INFO
|
|
6
|
+
src/oaknut_exception.egg-info/SOURCES.txt
|
|
7
|
+
src/oaknut_exception.egg-info/dependency_links.txt
|
|
8
|
+
src/oaknut_exception.egg-info/requires.txt
|
|
9
|
+
src/oaknut_exception.egg-info/top_level.txt
|
|
10
|
+
tests/test_exception_hierarchy.py
|
|
11
|
+
tests/test_handled_errors.py
|
|
12
|
+
tests/test_render_error.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
exit-codes>=1.3
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
oaknut
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Tests for the OaknutException hierarchy and exit-code resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from exit_codes import ExitCode
|
|
7
|
+
from oaknut.exception import (
|
|
8
|
+
ConfigurationError,
|
|
9
|
+
DataError,
|
|
10
|
+
InternalError,
|
|
11
|
+
OaknutException,
|
|
12
|
+
exit_code_for,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestCategoryDefaults:
|
|
17
|
+
"""Each category exposes its sysexits.h default through `.exit_code`."""
|
|
18
|
+
|
|
19
|
+
def test_data_error_default(self) -> None:
|
|
20
|
+
assert DataError("nope").exit_code is ExitCode.DATA_ERR
|
|
21
|
+
|
|
22
|
+
def test_configuration_error_default(self) -> None:
|
|
23
|
+
assert ConfigurationError("bad cfg").exit_code is ExitCode.CONFIG
|
|
24
|
+
|
|
25
|
+
def test_internal_error_default(self) -> None:
|
|
26
|
+
assert InternalError("bug").exit_code is ExitCode.SOFTWARE
|
|
27
|
+
|
|
28
|
+
def test_root_default(self) -> None:
|
|
29
|
+
# The root is meant to be subclassed, but if anyone instantiates
|
|
30
|
+
# it directly the fallback should be SOFTWARE so the resulting
|
|
31
|
+
# exit code is at least non-zero and not misleadingly DATA_ERR.
|
|
32
|
+
assert OaknutException("raw").exit_code is ExitCode.SOFTWARE
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TestInstanceOverride:
|
|
36
|
+
"""A constructor `exit_code=` overrides the class default for one
|
|
37
|
+
instance, without needing a private subclass per variant."""
|
|
38
|
+
|
|
39
|
+
def test_override_takes_precedence(self) -> None:
|
|
40
|
+
exc = DataError("missing", exit_code=ExitCode.OS_FILE)
|
|
41
|
+
assert exc.exit_code is ExitCode.OS_FILE
|
|
42
|
+
|
|
43
|
+
def test_override_is_per_instance(self) -> None:
|
|
44
|
+
overridden = DataError("missing", exit_code=ExitCode.OS_FILE)
|
|
45
|
+
default = DataError("missing")
|
|
46
|
+
assert overridden.exit_code is ExitCode.OS_FILE
|
|
47
|
+
assert default.exit_code is ExitCode.DATA_ERR
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestSubclassDefault:
|
|
51
|
+
"""A subclass can override `_exit_code` to pin a more specific code
|
|
52
|
+
for every instance of that class. This is the canonical pattern
|
|
53
|
+
used by oaknut-file's FSError tree."""
|
|
54
|
+
|
|
55
|
+
class _PathNotFound(DataError):
|
|
56
|
+
_exit_code = ExitCode.OS_FILE
|
|
57
|
+
|
|
58
|
+
def test_subclass_uses_its_own_default(self) -> None:
|
|
59
|
+
assert self._PathNotFound("hi").exit_code is ExitCode.OS_FILE
|
|
60
|
+
|
|
61
|
+
def test_subclass_default_still_overridable(self) -> None:
|
|
62
|
+
exc = self._PathNotFound("hi", exit_code=ExitCode.NO_PERM)
|
|
63
|
+
assert exc.exit_code is ExitCode.NO_PERM
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class TestExitCodeFor:
|
|
67
|
+
"""The free function form is what CLI boundary code uses when it
|
|
68
|
+
only has an exception object, not a known type."""
|
|
69
|
+
|
|
70
|
+
def test_oaknut_exception_returns_its_code(self) -> None:
|
|
71
|
+
exc = DataError("x", exit_code=ExitCode.OS_FILE)
|
|
72
|
+
assert exit_code_for(exc) is ExitCode.OS_FILE
|
|
73
|
+
|
|
74
|
+
def test_non_oaknut_exception_falls_back_to_software(self) -> None:
|
|
75
|
+
# A library that raises a stdlib exception by accident still
|
|
76
|
+
# gets a non-zero code if the boundary catches it explicitly.
|
|
77
|
+
assert exit_code_for(ValueError("not ours")) is ExitCode.SOFTWARE
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class TestInheritance:
|
|
81
|
+
"""Sanity: the category structure is what callers expect."""
|
|
82
|
+
|
|
83
|
+
def test_categories_share_root(self) -> None:
|
|
84
|
+
assert issubclass(DataError, OaknutException)
|
|
85
|
+
assert issubclass(ConfigurationError, OaknutException)
|
|
86
|
+
assert issubclass(InternalError, OaknutException)
|
|
87
|
+
|
|
88
|
+
def test_categories_are_distinct(self) -> None:
|
|
89
|
+
assert not issubclass(DataError, ConfigurationError)
|
|
90
|
+
assert not issubclass(ConfigurationError, InternalError)
|
|
91
|
+
assert not issubclass(DataError, InternalError)
|
|
92
|
+
|
|
93
|
+
def test_caught_as_OaknutException(self) -> None:
|
|
94
|
+
with pytest.raises(OaknutException):
|
|
95
|
+
raise DataError("test")
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Tests for the handled_errors boundary helper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import signal
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from exit_codes import ExitCode
|
|
9
|
+
from oaknut.exception import (
|
|
10
|
+
ConfigurationError,
|
|
11
|
+
DataError,
|
|
12
|
+
InternalError,
|
|
13
|
+
handled_errors,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _capture_exit():
|
|
18
|
+
"""Return (captured_codes, exit_func) so tests can intercept sys.exit."""
|
|
19
|
+
captured: list[int] = []
|
|
20
|
+
return captured, captured.append
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _capture_printer():
|
|
24
|
+
"""Return (lines, printer) capturing (text, is_continuation) pairs."""
|
|
25
|
+
lines: list[tuple[str, bool]] = []
|
|
26
|
+
|
|
27
|
+
def printer(text: str, is_continuation: bool) -> None:
|
|
28
|
+
lines.append((text, is_continuation))
|
|
29
|
+
|
|
30
|
+
return lines, printer
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TestNormalCompletion:
|
|
34
|
+
def test_no_error_does_not_exit(self) -> None:
|
|
35
|
+
codes, exit_func = _capture_exit()
|
|
36
|
+
with handled_errors(exit_func=exit_func):
|
|
37
|
+
pass
|
|
38
|
+
assert codes == []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestDataError:
|
|
42
|
+
def test_caught_and_exits_with_data_code(self) -> None:
|
|
43
|
+
codes, exit_func = _capture_exit()
|
|
44
|
+
lines, printer = _capture_printer()
|
|
45
|
+
with handled_errors(printer, exit_func=exit_func):
|
|
46
|
+
raise DataError("bad input")
|
|
47
|
+
assert codes == [int(ExitCode.DATA_ERR)]
|
|
48
|
+
assert lines == [("bad input", False)]
|
|
49
|
+
|
|
50
|
+
def test_per_instance_override_is_honoured(self) -> None:
|
|
51
|
+
codes, exit_func = _capture_exit()
|
|
52
|
+
_, printer = _capture_printer()
|
|
53
|
+
with handled_errors(printer, exit_func=exit_func):
|
|
54
|
+
raise DataError("missing", exit_code=ExitCode.OS_FILE)
|
|
55
|
+
assert codes == [int(ExitCode.OS_FILE)]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestConfigurationError:
|
|
59
|
+
def test_caught_and_exits_with_config_code(self) -> None:
|
|
60
|
+
codes, exit_func = _capture_exit()
|
|
61
|
+
_, printer = _capture_printer()
|
|
62
|
+
with handled_errors(printer, exit_func=exit_func):
|
|
63
|
+
raise ConfigurationError("bad config")
|
|
64
|
+
assert codes == [int(ExitCode.CONFIG)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class TestInternalError:
|
|
68
|
+
"""InternalError propagates — the traceback is the report-an-issue signal."""
|
|
69
|
+
|
|
70
|
+
def test_propagates_unchanged(self) -> None:
|
|
71
|
+
codes, exit_func = _capture_exit()
|
|
72
|
+
with pytest.raises(InternalError, match="oops"):
|
|
73
|
+
with handled_errors(exit_func=exit_func):
|
|
74
|
+
raise InternalError("oops")
|
|
75
|
+
# No call to exit_func because the error propagated.
|
|
76
|
+
assert codes == []
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class TestOtherExceptions:
|
|
80
|
+
"""Anything outside the OaknutException tree is left alone."""
|
|
81
|
+
|
|
82
|
+
def test_value_error_propagates(self) -> None:
|
|
83
|
+
codes, exit_func = _capture_exit()
|
|
84
|
+
with pytest.raises(ValueError):
|
|
85
|
+
with handled_errors(exit_func=exit_func):
|
|
86
|
+
raise ValueError("not ours")
|
|
87
|
+
assert codes == []
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TestKeyboardInterrupt:
|
|
91
|
+
def test_caught_and_exits_with_128_plus_sigint(self) -> None:
|
|
92
|
+
codes, exit_func = _capture_exit()
|
|
93
|
+
_, printer = _capture_printer()
|
|
94
|
+
with handled_errors(printer, exit_func=exit_func):
|
|
95
|
+
raise KeyboardInterrupt
|
|
96
|
+
assert codes == [128 + signal.SIGINT]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class TestExceptionGroup:
|
|
100
|
+
"""The except* branches in handled_errors mean an ExceptionGroup
|
|
101
|
+
of categorised errors is also handled correctly."""
|
|
102
|
+
|
|
103
|
+
def test_group_of_data_errors_uses_first_code(self) -> None:
|
|
104
|
+
codes, exit_func = _capture_exit()
|
|
105
|
+
lines, printer = _capture_printer()
|
|
106
|
+
with handled_errors(printer, exit_func=exit_func):
|
|
107
|
+
raise ExceptionGroup(
|
|
108
|
+
"two errors",
|
|
109
|
+
[
|
|
110
|
+
DataError("first", exit_code=ExitCode.OS_FILE),
|
|
111
|
+
DataError("second", exit_code=ExitCode.NO_PERM),
|
|
112
|
+
],
|
|
113
|
+
)
|
|
114
|
+
# First-wins: deterministic, matches reading order.
|
|
115
|
+
assert codes == [int(ExitCode.OS_FILE)]
|
|
116
|
+
# Both errors were rendered.
|
|
117
|
+
assert ("first", False) in lines
|
|
118
|
+
assert ("second", False) in lines
|
|
119
|
+
|
|
120
|
+
def test_mixed_group_with_internal_error_propagates_partial(self) -> None:
|
|
121
|
+
# Python's except* automatically splits the group: the
|
|
122
|
+
# DataError half is consumed by our handler, the InternalError
|
|
123
|
+
# half propagates as its own group.
|
|
124
|
+
codes, exit_func = _capture_exit()
|
|
125
|
+
_, printer = _capture_printer()
|
|
126
|
+
with pytest.raises(ExceptionGroup) as exc_info:
|
|
127
|
+
with handled_errors(printer, exit_func=exit_func):
|
|
128
|
+
raise ExceptionGroup(
|
|
129
|
+
"mixed",
|
|
130
|
+
[DataError("data"), InternalError("internal")],
|
|
131
|
+
)
|
|
132
|
+
# The propagated group should contain only the internal half.
|
|
133
|
+
leaves = [type(leaf).__name__ for leaf in exc_info.value.exceptions]
|
|
134
|
+
assert leaves == ["InternalError"]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class TestDebugMode:
|
|
138
|
+
"""In debug mode, even DataError gets re-raised after being printed
|
|
139
|
+
so developers see the full traceback."""
|
|
140
|
+
|
|
141
|
+
def test_data_error_reraised_after_printing(self) -> None:
|
|
142
|
+
codes, exit_func = _capture_exit()
|
|
143
|
+
lines, printer = _capture_printer()
|
|
144
|
+
with pytest.raises(DataError, match="for the dev"):
|
|
145
|
+
with handled_errors(printer, exit_func=exit_func, debug=True):
|
|
146
|
+
raise DataError("for the dev")
|
|
147
|
+
# Printed once before re-raising.
|
|
148
|
+
assert lines == [("for the dev", False)]
|
|
149
|
+
# No exit call because we re-raised.
|
|
150
|
+
assert codes == []
|
|
151
|
+
|
|
152
|
+
def test_internal_error_unaffected_by_debug(self) -> None:
|
|
153
|
+
codes, exit_func = _capture_exit()
|
|
154
|
+
_, printer = _capture_printer()
|
|
155
|
+
with pytest.raises(InternalError):
|
|
156
|
+
with handled_errors(printer, exit_func=exit_func, debug=True):
|
|
157
|
+
raise InternalError("propagates either way")
|
|
158
|
+
assert codes == []
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestDecoratorUsage:
|
|
162
|
+
"""contextmanager from contextlib produces ContextDecorator, so the
|
|
163
|
+
helper is usable as @handled_errors() too."""
|
|
164
|
+
|
|
165
|
+
def test_decorates_a_function(self) -> None:
|
|
166
|
+
codes, exit_func = _capture_exit()
|
|
167
|
+
_, printer = _capture_printer()
|
|
168
|
+
|
|
169
|
+
@handled_errors(printer, exit_func=exit_func)
|
|
170
|
+
def boom():
|
|
171
|
+
raise DataError("decorated")
|
|
172
|
+
|
|
173
|
+
boom()
|
|
174
|
+
assert codes == [int(ExitCode.DATA_ERR)]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Tests for render_error — the cause-chain + notes walker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from oaknut.exception import DataError, InternalError, render_error
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _lines(exc):
|
|
9
|
+
return list(render_error(exc))
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestLeaf:
|
|
13
|
+
def test_message_is_first_pair(self) -> None:
|
|
14
|
+
line, is_continuation = _lines(DataError("hello"))[0]
|
|
15
|
+
assert line == "hello"
|
|
16
|
+
assert is_continuation is False
|
|
17
|
+
|
|
18
|
+
def test_empty_message_uses_class_name(self) -> None:
|
|
19
|
+
# str(exc) is empty when no args are passed; the renderer
|
|
20
|
+
# falls back to the class name so the user sees *something*.
|
|
21
|
+
line, _ = _lines(InternalError())[0]
|
|
22
|
+
assert line == "InternalError"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TestNotes:
|
|
26
|
+
def test_notes_render_as_continuations(self) -> None:
|
|
27
|
+
exc = DataError("primary")
|
|
28
|
+
exc.add_note("first note")
|
|
29
|
+
exc.add_note("second note")
|
|
30
|
+
lines = _lines(exc)
|
|
31
|
+
assert lines == [
|
|
32
|
+
("primary", False),
|
|
33
|
+
("first note", True),
|
|
34
|
+
("second note", True),
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TestCauseChain:
|
|
39
|
+
def test_walks_the_cause_chain(self) -> None:
|
|
40
|
+
# Build a chained exception manually so we don't need an
|
|
41
|
+
# actual `raise X from Y` in the test body.
|
|
42
|
+
original = ValueError("root cause")
|
|
43
|
+
intermediate = DataError("intermediate")
|
|
44
|
+
intermediate.__cause__ = original
|
|
45
|
+
leaf = DataError("leaf")
|
|
46
|
+
leaf.__cause__ = intermediate
|
|
47
|
+
|
|
48
|
+
lines = _lines(leaf)
|
|
49
|
+
assert lines == [
|
|
50
|
+
("leaf", False),
|
|
51
|
+
("caused by: intermediate", True),
|
|
52
|
+
("caused by: root cause", True),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
def test_notes_on_cause_chain(self) -> None:
|
|
56
|
+
original = ValueError("root cause")
|
|
57
|
+
original.add_note("a note on the root")
|
|
58
|
+
leaf = DataError("leaf")
|
|
59
|
+
leaf.__cause__ = original
|
|
60
|
+
|
|
61
|
+
lines = _lines(leaf)
|
|
62
|
+
assert lines == [
|
|
63
|
+
("leaf", False),
|
|
64
|
+
("caused by: root cause", True),
|
|
65
|
+
("a note on the root", True),
|
|
66
|
+
]
|