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