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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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