[elbe-devel] [PATCH 1/3] elbepack: introduce cli exception handling framework
Thomas Weißschuh
thomas.weissschuh at linutronix.de
Thu Jul 18 14:47:24 CEST 2024
The current practice of sprinkling explicit calls to sys.exit() all
through the codebase has various issues:
* The stacktrace is always lost, making debugging harder
* Code calling sys.exit() is non-composable
* Any changes to the error-reporting have to applied all over the codebase
Introduce a new reporting framework which works through sys.excepthook
and a small helper to attach additional information to arbitrary exceptions.
This helper avoids the mentioned pitfalls.
It also preserves the possibility for per-error exitcodes and allows a
stepwise migration process.
In addition, other exceptions that have no explicit handling set up,
while currently printing a enduser-unfriendly stacktrace now
automatically use nicer formatting.
Signed-off-by: Thomas Weißschuh <thomas.weissschuh at linutronix.de>
---
elbepack/cli.py | 91 ++++++++++++++++++++++++++++++++++++++++++
elbepack/tests/test_cli.py | 98 ++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 189 insertions(+)
diff --git a/elbepack/cli.py b/elbepack/cli.py
new file mode 100644
index 000000000000..9f16e2d8d809
--- /dev/null
+++ b/elbepack/cli.py
@@ -0,0 +1,91 @@
+# ELBE - Debian Based Embedded Rootfilesystem Builder
+# SPDX-License-Identifier: GPL-3.0-or-later
+# SPDX-FileCopyrightText: 2024 Linutronix GmbH
+
+import dataclasses
+import inspect
+import os.path
+import traceback
+import types
+import typing
+
+
+ at dataclasses.dataclass
+class _CliDetails:
+ message: str
+ exitcode: int
+
+
+_cli_details_attr_name = __name__ + '.__cli_details'
+
+
+def with_cli_details(exc, exitcode=1, message=None):
+ """
+ Extend a given exception with additional information which will be used when this
+ exception is stopping the process.
+ """
+ setattr(exc, _cli_details_attr_name, _CliDetails(
+ message=message,
+ exitcode=exitcode,
+ ))
+ return exc
+
+
+def _get_cli_details(exc):
+ return getattr(exc, _cli_details_attr_name, None)
+
+
+class CliError(RuntimeError):
+ """
+ Exception type for errors not attached to an existing exception.
+ """
+ def __init__(self, exitcode=1, message=None):
+ with_cli_details(self, exitcode=exitcode, message=message)
+ self.args = (message,)
+
+
+def _last_frame_in_package(tb, package):
+ frame = tb.tb_frame
+
+ while tb.tb_next is not None:
+ tb = tb.tb_next
+ mod = inspect.getmodule(tb)
+ name = mod.__spec__.name
+ if name and (name == package or name.startswith(package + '.')):
+ frame = tb.tb_frame
+
+ return frame
+
+
+class _SupportsStrWrite(typing.Protocol):
+ def write(self, value: str): ...
+
+
+def format_exception(exc: Exception,
+ output: _SupportsStrWrite,
+ verbose: bool,
+ base_module: types.ModuleType):
+ """
+ Format an exception `exc` for user consumption to `output`.
+ If `verbose` is True print the full stacktrace, otherwise only provide the
+ message and source location.
+ The source location is limited to the stack frames within `base_module`.
+ """
+ tb = exc.__traceback__
+ cli_details = _get_cli_details(exc)
+
+ if cli_details is not None and cli_details.message is not None:
+ print(cli_details.message, file=output)
+
+ if verbose:
+ traceback.print_exception(None, value=exc, tb=tb, file=output)
+ else:
+ frame = _last_frame_in_package(tb, base_module.__name__)
+ filename = os.path.normpath(frame.f_code.co_filename)
+ if isinstance(exc, CliError):
+ print(f'{filename}:{frame.f_lineno}', file=output)
+ else:
+ print(f'{filename}:{frame.f_lineno}: '
+ f'{type(exc).__name__}: {exc}', file=output)
+
+ return cli_details.exitcode if cli_details is not None else 1
diff --git a/elbepack/tests/test_cli.py b/elbepack/tests/test_cli.py
new file mode 100644
index 000000000000..60fa27fa637b
--- /dev/null
+++ b/elbepack/tests/test_cli.py
@@ -0,0 +1,98 @@
+# ELBE - Debian Based Embedded Rootfilesystem Builder
+# SPDX-License-Identifier: GPL-3.0-or-later
+# SPDX-FileCopyrightText: 2024 Linutronix GmbH
+
+import io
+import re
+import textwrap
+
+import elbepack.cli
+
+
+def _strip_file_and_lineno(s):
+ s = re.sub(
+ re.escape(__file__) + r':\d+',
+ '__file__:00',
+ s,
+ )
+ s = re.sub(
+ r'"' + re.escape(__file__) + r'", line \d+',
+ '"__file__", line 00',
+ s,
+ )
+ return s
+
+
+def _test_excepthook(exception, exitcode, output, *, verbose):
+ buf = io.StringIO()
+
+ assert exception.__traceback__
+ actual_exitcode = elbepack.cli.format_exception(
+ exception,
+ output=buf, verbose=verbose, base_package=elbepack,
+ )
+
+ assert actual_exitcode == exitcode
+ assert _strip_file_and_lineno(buf.getvalue()) == output
+
+
+def _test_exception():
+ try:
+ raise ValueError('some error')
+ except ValueError as e:
+ return e
+
+
+def test_excepthook_without_info():
+ _test_excepthook(
+ _test_exception(),
+ 1,
+ '__file__:00: ValueError: some error\n',
+ verbose=False,
+ )
+
+
+def test_excepthook_without_info_verbose():
+ _test_excepthook(
+ _test_exception(),
+ 1,
+ textwrap.dedent("""
+ Traceback (most recent call last):
+ File "__file__", line 00, in _test_exception
+ raise ValueError('some error')
+ ValueError: some error
+ """).lstrip(),
+ verbose=True,
+ )
+
+
+def test_excepthook_with_info():
+ _test_excepthook(
+ elbepack.cli.with_cli_details(
+ _test_exception(),
+ exitcode=4,
+ message='some message',
+ ),
+ 4,
+ 'some message\n__file__:00: ValueError: some error\n',
+ verbose=False,
+ )
+
+
+def test_excepthook_with_info_verbose():
+ _test_excepthook(
+ elbepack.cli.with_cli_details(
+ _test_exception(),
+ exitcode=4,
+ message='some message',
+ ),
+ 4,
+ textwrap.dedent("""
+ some message
+ Traceback (most recent call last):
+ File "__file__", line 00, in _test_exception
+ raise ValueError('some error')
+ ValueError: some error
+ """).lstrip(),
+ verbose=True,
+ )
--
2.45.2
More information about the elbe-devel
mailing list