Python · 4954 bytes Raw Blame History
1 """Consistent CLI error reporter.
2
3 Three tiers of error presentation so users see the right level of
4 detail without wading through Python tracebacks:
5
6 1. **Parse errors** (`DlmParseError` and subclasses) — already carry
7 `path:line:col` via `_format()`. The reporter prints them verbatim
8 with a red prefix.
9 2. **Typed domain errors** (`PreflightError`, `GatedModelError`,
10 `TrainingError`, etc.) — shown as a single red-prefixed line plus a
11 remediation hint when the error class carries one.
12 3. **Uncaught exceptions** — a single-line class name + message; the
13 full traceback is gated behind `--verbose` (or the `DLM_VERBOSE=1`
14 env var for crash-dump scenarios).
15
16 Color/format output respects `NO_COLOR=1` and TTY detection via Rich's
17 Console defaults. The `install_excepthook` wrapper routes anything
18 that escapes a subcommand through this reporter instead of the
19 default Python traceback.
20 """
21
22 from __future__ import annotations
23
24 import logging
25 import os
26 import sys
27 from collections.abc import Callable
28 from typing import TYPE_CHECKING
29
30 if TYPE_CHECKING:
31 from rich.console import Console
32
33
34 def _is_verbose() -> bool:
35 """Return True when the user asked for full tracebacks.
36
37 Two signals: (a) logger level set to DEBUG by the root callback,
38 (b) `DLM_VERBOSE=1` env var for detached crash-dump runs where the
39 callback didn't fire.
40 """
41 if os.environ.get("DLM_VERBOSE") == "1":
42 return True
43 return logging.getLogger().isEnabledFor(logging.DEBUG)
44
45
46 def _stderr_console() -> Console:
47 from rich.console import Console
48
49 return Console(stderr=True)
50
51
52 def report_exception(exc: BaseException) -> int:
53 """Print `exc` per the tier rules and return the exit code.
54
55 Callers can use this to funnel all uncaught errors into one code
56 path, keeping CLI exit-code semantics consistent (2 for typed
57 domain errors → CLI usage problems, 1 for unexpected failures).
58 """
59 console = _stderr_console()
60
61 # Tier 1: parse errors. Their str() already has file:line:col.
62 try:
63 from dlm.doc.errors import DlmParseError
64
65 if isinstance(exc, DlmParseError):
66 console.print(f"[red]parse:[/red] {exc}")
67 return 1
68 except ImportError: # pragma: no cover — dlm.doc always importable
69 pass
70
71 # Tier 2: typed domain errors. Pick a short prefix per module.
72 prefix = _prefix_for(exc)
73 if prefix is not None:
74 console.print(f"[red]{prefix}:[/red] {exc}")
75 if _is_verbose():
76 _print_traceback(console, exc)
77 return 1
78
79 # Tier 3: unexpected. Compact one-liner; full traceback only when
80 # --verbose or DLM_VERBOSE=1.
81 console.print(
82 f"[red]error:[/red] {type(exc).__name__}: {exc}\n"
83 " re-run with [bold]--verbose[/bold] for the full traceback."
84 )
85 if _is_verbose():
86 _print_traceback(console, exc)
87 return 1
88
89
90 def _prefix_for(exc: BaseException) -> str | None:
91 """Map a typed domain error to a short display prefix, or None.
92
93 `None` means "let tier 3 handle it" — the class is outside our
94 known hierarchy. Keeps this module from importing every sibling
95 package eagerly; each check is a narrow import.
96 """
97 mod = type(exc).__module__
98 name = type(exc).__name__
99
100 if mod.startswith("dlm.base_models"):
101 if name == "GatedModelError":
102 return "license"
103 return "base_model"
104 if mod.startswith("dlm.doc"):
105 return "doc"
106 if mod.startswith("dlm.store"):
107 return "store"
108 if mod.startswith("dlm.train"):
109 return "train"
110 if mod.startswith("dlm.export"):
111 return "export"
112 if mod.startswith("dlm.inference"):
113 return "inference"
114 if mod.startswith("dlm.hardware"):
115 return "doctor"
116 return None
117
118
119 def _print_traceback(console: Console, exc: BaseException) -> None:
120 from rich.traceback import Traceback
121
122 tb = Traceback.from_exception(
123 type(exc),
124 exc,
125 exc.__traceback__,
126 show_locals=False,
127 )
128 console.print(tb)
129
130
131 def run_with_reporter(app: Callable[[], None]) -> int:
132 """Invoke a Typer `app` and route any escaping exception through the reporter.
133
134 Returns the exit code so the installed entry point can propagate it
135 to the shell. `typer.Exit` and `SystemExit` are re-raised so their
136 user-intended exit codes reach the interpreter unchanged.
137 """
138 try:
139 app()
140 return 0
141 except SystemExit as exc: # typer.Exit inherits from SystemExit
142 code = exc.code
143 if isinstance(code, int):
144 return code
145 if code is None:
146 return 0
147 # String-valued SystemExit is legacy — print and fail.
148 sys.stderr.write(f"{code}\n")
149 return 1
150 except KeyboardInterrupt:
151 _stderr_console().print("[yellow]interrupted[/yellow]")
152 return 130
153 except BaseException as exc:
154 return report_exception(exc)