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 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,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: cli-error
3
+ Version: 0.1.0
4
+ Summary: Error handling and reporting utilities for CLI applications.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: rich
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any