cli-error 0.1.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.
- cli_error/__init__.py +21 -0
- cli_error/_console.py +40 -0
- cli_error/_errors.py +92 -0
- cli_error/_render.py +41 -0
- cli_error/_reporter.py +108 -0
- cli_error/py.typed +0 -0
- cli_error-0.1.0.dist-info/METADATA +6 -0
- cli_error-0.1.0.dist-info/RECORD +9 -0
- cli_error-0.1.0.dist-info/WHEEL +4 -0
cli_error/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Error handling and reporting utilities for CLI applications."""
|
|
2
|
+
|
|
3
|
+
__all__ = [
|
|
4
|
+
"CliError",
|
|
5
|
+
"CliExit",
|
|
6
|
+
"CliReporter",
|
|
7
|
+
"DEFAULT_STYLES",
|
|
8
|
+
"escape",
|
|
9
|
+
"make_console",
|
|
10
|
+
"make_theme",
|
|
11
|
+
"print_error",
|
|
12
|
+
"render_error",
|
|
13
|
+
"render_template",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
from rich.markup import escape
|
|
17
|
+
|
|
18
|
+
from ._console import DEFAULT_STYLES, make_console, make_theme
|
|
19
|
+
from ._errors import CliError, CliExit, print_error
|
|
20
|
+
from ._render import render_error, render_template
|
|
21
|
+
from ._reporter import CliReporter
|
cli_error/_console.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from collections.abc import Mapping
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.style import Style
|
|
5
|
+
from rich.theme import Theme
|
|
6
|
+
|
|
7
|
+
DEFAULT_STYLES: Mapping[str, str] = {
|
|
8
|
+
"id": "green",
|
|
9
|
+
"data": "cyan",
|
|
10
|
+
"path": "dim",
|
|
11
|
+
"cmd": "blue",
|
|
12
|
+
"misc": "dim",
|
|
13
|
+
"warn": "yellow",
|
|
14
|
+
"err": "red",
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def make_theme(*, styles: Mapping[str, str | Style] | None = None) -> Theme:
|
|
19
|
+
"""Return a Rich theme built from the default roles plus ``styles``"""
|
|
20
|
+
return Theme({**DEFAULT_STYLES, **(styles or {})})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_console(
|
|
24
|
+
*,
|
|
25
|
+
styles: Mapping[str, str | Style] | None = None,
|
|
26
|
+
no_color: bool = False,
|
|
27
|
+
stderr: bool = False,
|
|
28
|
+
) -> Console:
|
|
29
|
+
"""Return a Rich console themed with the default style roles.
|
|
30
|
+
|
|
31
|
+
``styles`` overrides or extends :data:`DEFAULT_STYLES` for this console.
|
|
32
|
+
Set ``no_color`` to render styled markup as plain text (no ANSI escapes).
|
|
33
|
+
Set ``stderr`` to route output to ``sys.stderr`` instead of ``sys.stdout``.
|
|
34
|
+
"""
|
|
35
|
+
return Console(
|
|
36
|
+
theme=make_theme(styles=styles),
|
|
37
|
+
highlight=False,
|
|
38
|
+
stderr=stderr,
|
|
39
|
+
no_color=no_color,
|
|
40
|
+
)
|
cli_error/_errors.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from typing import Any, Self
|
|
2
|
+
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.markup import escape
|
|
5
|
+
|
|
6
|
+
from ._render import (
|
|
7
|
+
ErrorDesc,
|
|
8
|
+
render_error,
|
|
9
|
+
render_template,
|
|
10
|
+
strip_markup,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class CliError(Exception):
|
|
15
|
+
"""An application error carrying a Rich-markup message."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, template: str, /, **args: Any) -> None:
|
|
18
|
+
self.desc = ErrorDesc(render_template(template, **args))
|
|
19
|
+
super().__init__(self.desc.message)
|
|
20
|
+
|
|
21
|
+
def hint(self, template: str, /, **args: Any) -> Self:
|
|
22
|
+
self.desc.hint = render_template(template, **args)
|
|
23
|
+
return self
|
|
24
|
+
|
|
25
|
+
def detail(self, text: str) -> Self:
|
|
26
|
+
self.desc.detail = render_template("[misc]{detail}[/misc]", detail=text)
|
|
27
|
+
return self
|
|
28
|
+
|
|
29
|
+
def prop(self, key: str, template: str, /, **args: Any) -> Self:
|
|
30
|
+
self.desc.props[key] = render_template(template, **args)
|
|
31
|
+
return self
|
|
32
|
+
|
|
33
|
+
def prop_id(self, key: str, value: Any) -> Self:
|
|
34
|
+
return self.prop(key, "[id]{value}[/id]", value=value)
|
|
35
|
+
|
|
36
|
+
def prop_path(self, key: str, value: Any) -> Self:
|
|
37
|
+
return self.prop(key, "[path]{value}[/path]", value=value)
|
|
38
|
+
|
|
39
|
+
def prop_data(self, key: str, value: Any) -> Self:
|
|
40
|
+
return self.prop(key, "[data]{value}[/data]", value=value)
|
|
41
|
+
|
|
42
|
+
def prop_cmd(self, key: str, value: Any) -> Self:
|
|
43
|
+
return self.prop(key, "[cmd]{value}[/cmd]", value=value)
|
|
44
|
+
|
|
45
|
+
def prop_misc(self, key: str, value: Any) -> Self:
|
|
46
|
+
return self.prop(key, "[misc]{value}[/misc]", value=value)
|
|
47
|
+
|
|
48
|
+
def __str__(self) -> str:
|
|
49
|
+
return strip_markup(self.desc.message)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class CliExit(Exception):
|
|
53
|
+
"""A clean-exit signal carrying a message."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, template: str, /, **args: Any) -> None:
|
|
56
|
+
self.message = render_template(template, **args)
|
|
57
|
+
super().__init__(self.message)
|
|
58
|
+
|
|
59
|
+
def __str__(self) -> str:
|
|
60
|
+
return strip_markup(self.message)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def print_error(ex: Exception, console: Console) -> None:
|
|
64
|
+
if isinstance(ex, CliError):
|
|
65
|
+
console.print(f"[err]Error:[/err] {render_error(ex.desc)}")
|
|
66
|
+
else:
|
|
67
|
+
console.print(
|
|
68
|
+
render_template("[err]Error:[/err] {ex}", ex=_message_or_type(ex))
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
seen = {id(ex)}
|
|
72
|
+
cause = _next_link(ex)
|
|
73
|
+
while cause is not None and id(cause) not in seen:
|
|
74
|
+
# note: markup-stripped for CliError via its __str__,
|
|
75
|
+
# not the full props/detail/hint layout
|
|
76
|
+
console.print(f" caused by: {escape(_message_or_type(cause))}")
|
|
77
|
+
seen.add(id(cause))
|
|
78
|
+
cause = _next_link(cause)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _message_or_type(exc: BaseException) -> str:
|
|
82
|
+
text = str(exc)
|
|
83
|
+
if text.strip():
|
|
84
|
+
return text
|
|
85
|
+
return type(exc).__name__
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _next_link(exc: BaseException) -> BaseException | None:
|
|
89
|
+
if exc.__cause__ is not None:
|
|
90
|
+
return exc.__cause__
|
|
91
|
+
|
|
92
|
+
return None if exc.__suppress_context__ else exc.__context__
|
cli_error/_render.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from rich.markup import escape
|
|
5
|
+
from rich.text import Text
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ErrorDesc:
|
|
10
|
+
message: str
|
|
11
|
+
props: dict[str, str] = field(default_factory=dict)
|
|
12
|
+
detail: str = ""
|
|
13
|
+
hint: str = ""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def render_error(desc: ErrorDesc) -> str:
|
|
17
|
+
r = []
|
|
18
|
+
r.append(desc.message)
|
|
19
|
+
|
|
20
|
+
for key, value in desc.props.items():
|
|
21
|
+
r.append(f" {escape(key)}: {value}")
|
|
22
|
+
|
|
23
|
+
if desc.detail:
|
|
24
|
+
r.append(desc.detail)
|
|
25
|
+
|
|
26
|
+
if desc.hint:
|
|
27
|
+
r.append("")
|
|
28
|
+
r.append(desc.hint)
|
|
29
|
+
|
|
30
|
+
return "\n".join(r)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def render_template(template: str, /, **args: Any) -> str:
|
|
34
|
+
if not args:
|
|
35
|
+
return template
|
|
36
|
+
|
|
37
|
+
return template.format(**{key: escape(str(value)) for key, value in args.items()})
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def strip_markup(text: str) -> str:
|
|
41
|
+
return Text.from_markup(text).plain
|
cli_error/_reporter.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import sys
|
|
3
|
+
from collections.abc import Generator
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from ._console import make_console
|
|
11
|
+
from ._errors import CliExit, print_error
|
|
12
|
+
from ._render import render_template
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CliReporter:
|
|
16
|
+
"""Single integration point for a CLI's terminal output."""
|
|
17
|
+
|
|
18
|
+
debug: bool = False
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
console: Console,
|
|
23
|
+
*,
|
|
24
|
+
debug: bool = False,
|
|
25
|
+
console_err: Console | None = None,
|
|
26
|
+
) -> None:
|
|
27
|
+
self.debug = debug
|
|
28
|
+
self.console = console
|
|
29
|
+
self.console_err = console_err or make_console(stderr=True)
|
|
30
|
+
|
|
31
|
+
def print(self, template: str, /, *, end: str = "\n", **args: Any) -> None:
|
|
32
|
+
"""Render a trusted template with escaped args to the stdout console."""
|
|
33
|
+
_emit(self.console, template, end, **args)
|
|
34
|
+
|
|
35
|
+
def debug_print(self, template: str, /, *, end: str = "\n", **args: Any) -> None:
|
|
36
|
+
"""Render a trusted template with escaped args to the stderr console.
|
|
37
|
+
|
|
38
|
+
Silent no-op unless ``debug`` is set. The template is emitted unchanged
|
|
39
|
+
(no forced styling), so a consumer can emit e.g. a ``[warn]`` line.
|
|
40
|
+
"""
|
|
41
|
+
if self.debug:
|
|
42
|
+
_emit(self.console_err, template, end, **args)
|
|
43
|
+
|
|
44
|
+
def debug_traceback(self) -> None:
|
|
45
|
+
"""Emit the currently-handled traceback to the stderr console.
|
|
46
|
+
|
|
47
|
+
No-op unless ``debug`` is set. Safely no-ops outside an ``except``
|
|
48
|
+
block: ``print_exception`` reads ``sys.exc_info()``.
|
|
49
|
+
"""
|
|
50
|
+
if not self.debug:
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
if sys.exc_info()[0] is None:
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
self.console_err.print_exception()
|
|
57
|
+
|
|
58
|
+
def debug_cmd(self, cmd: list[str], cwd: Path | None = None) -> None:
|
|
59
|
+
"""Emit a subprocess command (and optional cwd) as a debug diagnostic."""
|
|
60
|
+
if not self.debug:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
self.debug_print("[misc]COMMAND: {cmd}[/misc]", cmd=shlex.join(cmd))
|
|
64
|
+
if cwd is not None:
|
|
65
|
+
self.debug_print("[misc] cwd: {cwd}[/misc]", cwd=cwd)
|
|
66
|
+
|
|
67
|
+
def debug_output(self, stdout: str, stderr: str) -> None:
|
|
68
|
+
"""Emit captured subprocess output as debug diagnostics.
|
|
69
|
+
|
|
70
|
+
Each non-empty stream is emitted as a header line followed by the
|
|
71
|
+
captured text as one escaped ``misc`` block; empty streams are skipped.
|
|
72
|
+
"""
|
|
73
|
+
if not self.debug:
|
|
74
|
+
return
|
|
75
|
+
|
|
76
|
+
for label, text in (("stdout", stdout), ("stderr", stderr)):
|
|
77
|
+
trimmed = text.rstrip()
|
|
78
|
+
if not trimmed:
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
# TODO: this layout is ugly, lets prepend each line 'stdout: ' prefix
|
|
82
|
+
self.debug_print("[misc] {label}:[/misc]", label=label)
|
|
83
|
+
self.debug_print("[misc]{text}[/misc]", text=trimmed)
|
|
84
|
+
|
|
85
|
+
@contextmanager
|
|
86
|
+
def handler(self) -> Generator[None]:
|
|
87
|
+
"""Report errors and translate them into process exit codes.
|
|
88
|
+
|
|
89
|
+
* ``CliExit`` prints its message and exits 0;
|
|
90
|
+
* any other ``Exception`` is rendered via ``print_error`` and exits 1,
|
|
91
|
+
additionally emitting a full traceback to the ``stderr`` console when
|
|
92
|
+
``debug`` is set.
|
|
93
|
+
* ``KeyboardInterrupt``, ``SystemExit`` and any other ``BaseException``
|
|
94
|
+
propagate untouched.
|
|
95
|
+
"""
|
|
96
|
+
try:
|
|
97
|
+
yield
|
|
98
|
+
except CliExit as ex:
|
|
99
|
+
self.print(ex.message)
|
|
100
|
+
raise SystemExit(0) from None
|
|
101
|
+
except Exception as ex:
|
|
102
|
+
self.debug_traceback()
|
|
103
|
+
print_error(ex, self.console)
|
|
104
|
+
raise SystemExit(1) from None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _emit(console: Console, template: str, end: str, /, **args: Any) -> None:
|
|
108
|
+
console.print(render_template(template, **args), end=end)
|
cli_error/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
cli_error/__init__.py,sha256=bTz2QGYBmn9yIZ7flKU_a2oulilKaCV5b4CKnOBuiLs,502
|
|
2
|
+
cli_error/_console.py,sha256=7mVYKI-6kUhuFi8I8bIX_Mw9LxX5yXs3i3HMSpSGZ_s,1114
|
|
3
|
+
cli_error/_errors.py,sha256=FTdUC1WhaDwbirPWfwXmwjql4v6GpH-0Um5H5zwUe8I,2853
|
|
4
|
+
cli_error/_render.py,sha256=v6pSrU8EPhTSZCbbR9ZuXCxPYA3NZX-xFkySUsTiFIU,849
|
|
5
|
+
cli_error/_reporter.py,sha256=0b2C8oJ5yd5GNTZrOVnuHOaD4m2XgxV8eZqcL_NLOUc,3748
|
|
6
|
+
cli_error/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
cli_error-0.1.0.dist-info/METADATA,sha256=m3RKE4Xske0gGjsvA7vjzga_mJxnylBBMfKZPfL7k5U,167
|
|
8
|
+
cli_error-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
cli_error-0.1.0.dist-info/RECORD,,
|