[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