Error handling¶
Every error oaknut raises by intent inherits from
oaknut.exception.OaknutException — the single root every
package shares. A small set of category subclasses sits directly
under it, and every domain-specific exception (FSError, the
DFSError / ADFSError / AFSError trees, future package
errors) inherits from one of those categories.
The hierarchy¶
OaknutException # root; do not raise directly
├── DataError # ExitCode.DATA_ERR (65)
│ └── FSError # shared filesystem base (oaknut.file)
│ ├── DFSError # oaknut.dfs
│ │ └── ...
│ ├── ADFSError # oaknut.adfs
│ │ └── ...
│ └── AFSError # oaknut.afs
│ └── ...
├── ConfigurationError # ExitCode.CONFIG (78)
└── InternalError # ExitCode.SOFTWARE (70)
DataError— the operation could not be satisfied with the bytes on disc or the names the caller asked for. Path-not-found, already-exists, disc-full, locked, malformed catalogue. The CLI boundary prints these without a traceback.ConfigurationError— a runtime-environment problem. Missing host-side resources, unwritable cache directory, config file referencing a missing profile. The CLI boundary prints these without a traceback.InternalError— something went wrong inside the library that we can’t cleanly translate for a user. The CLI boundary lets these propagate with a stack trace, because that is the report-an-issue signal.
Exit codes live on the exception¶
Each subclass declares its own ExitCode as a class attribute, so
the same exception classification drives both the CLI exit code and
any Python except chain you write. There is no separate mapping
table — exc.exit_code is the truth.
from oaknut.exception import DataError
from oaknut.dfs.exceptions import DiscFullError
exc = DiscFullError("no room")
exc.exit_code # ExitCode.CANT_CREATE
int(exc.exit_code) # 73
isinstance(exc, DataError) # True
A constructor exit_code= keyword overrides the class default for
one instance — useful when a library wants to raise a specific code
for a one-off case without inventing a private subclass:
from exit_codes import ExitCode
from oaknut.file.exceptions import FSError
raise FSError("path not found: $.MISSING", exit_code=ExitCode.OS_FILE)
Catching errors¶
Three idiomatic patterns:
from oaknut.exception import DataError, OaknutException
from oaknut.dfs.exceptions import FileLocked
# 1. Catch a specific failure (when you know what to do about it).
try:
do_dfs_thing()
except FileLocked:
prompt_user_for_force_flag()
# 2. Catch the broad category (when you want a uniform UX for any
# "the operation was asked to do something it can't").
try:
do_anything()
except DataError as exc:
log.warning("failed: %s", exc)
return None
# 3. Catch every oaknut error (when crossing a process boundary).
try:
run_pipeline()
except OaknutException as exc:
send_to_error_reporting(exc)
raise
InternalError is a peer of DataError — it does not inherit
from it. Catching DataError only catches user / data problems;
internal errors keep their tracebacks visible.
Embedding handled_errors¶
Programs that build on top of oaknut-disc (or that wrap library
calls behind their own CLI surface) can reuse the same boundary:
from oaknut.exception import handled_errors
def main():
with handled_errors(): # prints to stderr, exits with code
run_my_pipeline()
The context manager catches DataError and
ConfigurationError (and any ExceptionGroup of them),
walks each one’s __cause__ chain and __notes__, hands the
rendered lines to a printer callback, and exits with the first
caught leaf’s exit_code. Pass your own
printer to customise output:
def shouty_printer(line, is_continuation):
prefix = " " if is_continuation else "!!! "
print(f"{prefix}{line.upper()}")
with handled_errors(shouty_printer):
run_my_pipeline()
Pass debug=True to re-raise the caught exception after printing,
so the full Python traceback is visible — convenient during
development.
For symmetry with the CLI’s –debug option (see
Exit codes), library code can read an
environment variable or its own flag to decide whether to set
debug=True.