oaknut-exception 11.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1,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,6 @@
|
|
|
1
|
+
oaknut/exception/__init__.py,sha256=u_vSkrLNN69rssf8XfOA5KDZYd4yDRT_DqkzOlFwP18,10290
|
|
2
|
+
oaknut_exception-11.0.0.dist-info/licenses/LICENSE,sha256=L_Uw2MQC3xsCy2nOzWmY_DYRQMHC-Yu2_zTvUTadNAY,1074
|
|
3
|
+
oaknut_exception-11.0.0.dist-info/METADATA,sha256=lpHuVnMT57HMONc4e-Qe0um7drdEKtvmwh6ZFbCrFvw,2621
|
|
4
|
+
oaknut_exception-11.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
oaknut_exception-11.0.0.dist-info/top_level.txt,sha256=QLKweXQ1HlrA4vuJ4fj2AKq-_qs54zd2M4O0DahuKzw,7
|
|
6
|
+
oaknut_exception-11.0.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
oaknut
|