lib-cli-exit-tools 2.2.1__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.
- lib_cli_exit_tools/__init__.py +47 -0
- lib_cli_exit_tools/__init__conf__.py +74 -0
- lib_cli_exit_tools/__main__.py +13 -0
- lib_cli_exit_tools/adapters/__init__.py +16 -0
- lib_cli_exit_tools/adapters/signals.py +178 -0
- lib_cli_exit_tools/application/__init__.py +16 -0
- lib_cli_exit_tools/application/runner.py +714 -0
- lib_cli_exit_tools/cli.py +261 -0
- lib_cli_exit_tools/core/__init__.py +16 -0
- lib_cli_exit_tools/core/configuration.py +155 -0
- lib_cli_exit_tools/core/exit_codes.py +420 -0
- lib_cli_exit_tools/lib_cli_exit_tools.py +83 -0
- lib_cli_exit_tools/py.typed +0 -0
- lib_cli_exit_tools-2.2.1.dist-info/METADATA +647 -0
- lib_cli_exit_tools-2.2.1.dist-info/RECORD +18 -0
- lib_cli_exit_tools-2.2.1.dist-info/WHEEL +4 -0
- lib_cli_exit_tools-2.2.1.dist-info/entry_points.txt +4 -0
- lib_cli_exit_tools-2.2.1.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
"""Application orchestration for lib_cli_exit_tools CLIs.
|
|
2
|
+
|
|
3
|
+
Purpose:
|
|
4
|
+
Provide reusable helpers that execute Click commands with shared signal
|
|
5
|
+
handling, traceback rendering, and exit-code translation across entry
|
|
6
|
+
points.
|
|
7
|
+
Contents:
|
|
8
|
+
* :func:`handle_cli_exception` – maps exceptions to exit codes and renders
|
|
9
|
+
diagnostics.
|
|
10
|
+
* :func:`run_cli` – orchestrates signal installation, command execution, and
|
|
11
|
+
cleanup.
|
|
12
|
+
* Supporting utilities for Rich-based output and stream management.
|
|
13
|
+
System Integration:
|
|
14
|
+
Imported by the package root and CLI adapters to keep behaviour consistent
|
|
15
|
+
between console scripts and ``python -m`` execution while remaining
|
|
16
|
+
testable via dependency injection.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import sys
|
|
22
|
+
from contextlib import contextmanager, nullcontext, suppress
|
|
23
|
+
from typing import Callable, ContextManager, Iterable, Iterator, Literal, Mapping, Protocol, Sequence, TextIO, TypedDict, cast
|
|
24
|
+
|
|
25
|
+
import rich_click as click
|
|
26
|
+
from rich_click import rich_click as rich_config
|
|
27
|
+
from rich.console import Console
|
|
28
|
+
from rich.text import Text
|
|
29
|
+
from rich.traceback import Traceback
|
|
30
|
+
|
|
31
|
+
from ..adapters.signals import SignalSpec, default_signal_specs, install_signal_handlers
|
|
32
|
+
from ..core.configuration import config, config_overrides
|
|
33
|
+
from ..core.exit_codes import get_system_exit_code
|
|
34
|
+
|
|
35
|
+
RichColorSystem = Literal["auto", "standard", "256", "truecolor", "windows"]
|
|
36
|
+
ExitResolver = Callable[[BaseException], int | None]
|
|
37
|
+
|
|
38
|
+
# Configuration field name constants to ensure type-safe access
|
|
39
|
+
# These correspond to fields in _Config dataclass (core/configuration.py)
|
|
40
|
+
_CONFIG_TRACEBACK = "traceback"
|
|
41
|
+
_CONFIG_TRACEBACK_FORCE_COLOR = "traceback_force_color"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionOverrides(TypedDict, total=False):
|
|
45
|
+
"""Type-safe override mapping for cli_session configuration.
|
|
46
|
+
|
|
47
|
+
Fields match the _Config dataclass in core/configuration.py.
|
|
48
|
+
All fields are optional to support partial configuration.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
traceback: bool
|
|
52
|
+
exit_code_style: Literal["errno", "sysexits"]
|
|
53
|
+
broken_pipe_exit_code: int
|
|
54
|
+
traceback_force_color: bool
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ClickCommand(Protocol):
|
|
58
|
+
"""Protocol capturing the subset of Click commands used by the runner."""
|
|
59
|
+
|
|
60
|
+
def main(
|
|
61
|
+
self,
|
|
62
|
+
args: Sequence[str] | None = ...,
|
|
63
|
+
prog_name: str | None = ...,
|
|
64
|
+
complete_var: str | None = ...,
|
|
65
|
+
standalone_mode: bool = ...,
|
|
66
|
+
**_: object,
|
|
67
|
+
) -> None: ...
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"handle_cli_exception",
|
|
72
|
+
"print_exception_message",
|
|
73
|
+
"flush_streams",
|
|
74
|
+
"run_cli",
|
|
75
|
+
"cli_session",
|
|
76
|
+
"SessionOverrides",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class _Echo(Protocol):
|
|
81
|
+
"""Protocol describing the echo interface expected by error handlers."""
|
|
82
|
+
|
|
83
|
+
def __call__(self, message: str, *, err: bool = ...) -> None: ... # pragma: no cover - structural typing
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _default_echo(message: str, *, err: bool = True) -> None:
|
|
87
|
+
"""Proxy to :func:`click.echo` used when callers do not supply one.
|
|
88
|
+
|
|
89
|
+
Why:
|
|
90
|
+
Keep :func:`handle_cli_exception` testable without importing Click in the
|
|
91
|
+
call site while still providing a sensible default stderr writer.
|
|
92
|
+
Parameters:
|
|
93
|
+
message: Text to emit.
|
|
94
|
+
err: When ``True`` (default) the message targets stderr; Click routes to
|
|
95
|
+
stdout otherwise.
|
|
96
|
+
Side Effects:
|
|
97
|
+
Writes a newline-terminated string via Click's IO abstraction.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
click.echo(message, err=err)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def flush_streams() -> None:
|
|
104
|
+
"""Flush standard streams so diagnostics do not linger in buffers.
|
|
105
|
+
|
|
106
|
+
Why:
|
|
107
|
+
Rich tracebacks and click output use buffering; flushing ensures users
|
|
108
|
+
see diagnostics even when the process exits immediately afterward.
|
|
109
|
+
Returns:
|
|
110
|
+
``None``.
|
|
111
|
+
Side Effects:
|
|
112
|
+
Calls ``flush`` on ``sys.stdout`` and ``sys.stderr`` when available.
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
for stream in _streams_to_flush():
|
|
116
|
+
_flush_stream(stream)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _streams_to_flush() -> Iterable[object]:
|
|
120
|
+
"""Yield stream objects that should be flushed before exiting.
|
|
121
|
+
|
|
122
|
+
Why:
|
|
123
|
+
Factoring iteration into a helper simplifies testing and keeps the
|
|
124
|
+
flush logic symmetric for stdout and stderr.
|
|
125
|
+
Returns:
|
|
126
|
+
Generator producing available stream objects.
|
|
127
|
+
"""
|
|
128
|
+
for stream_name in ("stdout", "stderr"):
|
|
129
|
+
stream = getattr(sys, stream_name, None)
|
|
130
|
+
if stream is not None:
|
|
131
|
+
yield stream
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _flush_stream(stream: object) -> None:
|
|
135
|
+
"""Flush ``stream`` if it exposes a callable ``flush`` attribute.
|
|
136
|
+
|
|
137
|
+
Parameters:
|
|
138
|
+
stream: Object potentially offering a ``flush`` method.
|
|
139
|
+
Returns:
|
|
140
|
+
``None``; silently ignores errors because flushing is best-effort.
|
|
141
|
+
"""
|
|
142
|
+
flush = getattr(stream, "flush", None)
|
|
143
|
+
if callable(flush): # pragma: no branch - simple guard
|
|
144
|
+
with suppress(Exception): # pragma: no cover - best effort
|
|
145
|
+
flush()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_console(
|
|
149
|
+
stream: TextIO | None = None,
|
|
150
|
+
*,
|
|
151
|
+
force_terminal: bool | None = None,
|
|
152
|
+
color_system: RichColorSystem | None = None,
|
|
153
|
+
) -> Console:
|
|
154
|
+
"""Construct a Rich console aligned with the active rich-click settings.
|
|
155
|
+
|
|
156
|
+
Why:
|
|
157
|
+
Centralises console creation so traceback rendering and error summaries
|
|
158
|
+
inherit the same colour/terminal configuration as Click's help output.
|
|
159
|
+
Parameters:
|
|
160
|
+
stream: Target stream; defaults to ``sys.stderr`` when omitted.
|
|
161
|
+
force_terminal: Explicit override for Rich's terminal detection.
|
|
162
|
+
color_system: Explicit Rich colour system override; ``None`` reuses the
|
|
163
|
+
global setting from rich-click.
|
|
164
|
+
Returns:
|
|
165
|
+
Configured :class:`Console` instance ready for rendering tracebacks.
|
|
166
|
+
"""
|
|
167
|
+
|
|
168
|
+
target_stream = stream or sys.stderr
|
|
169
|
+
force_flag = rich_config.FORCE_TERMINAL if force_terminal is None else force_terminal
|
|
170
|
+
default_color = cast(RichColorSystem | None, getattr(rich_config, "COLOR_SYSTEM", None))
|
|
171
|
+
color_flag = default_color if color_system is None else color_system
|
|
172
|
+
return Console(
|
|
173
|
+
file=target_stream,
|
|
174
|
+
force_terminal=force_flag,
|
|
175
|
+
color_system=color_flag,
|
|
176
|
+
soft_wrap=True,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _print_output(exc_info: object, attr: str, stream: TextIO | None = None) -> None:
|
|
181
|
+
"""Print captured subprocess output stored on ``exc_info``.
|
|
182
|
+
|
|
183
|
+
Why:
|
|
184
|
+
``click`` surfaces subprocess errors by attaching ``stdout``/``stderr``
|
|
185
|
+
to exceptions; mirroring that output aids debugging.
|
|
186
|
+
Parameters:
|
|
187
|
+
exc_info: Exception object potentially carrying the output attribute.
|
|
188
|
+
attr: Attribute name to inspect (``"stdout"`` or ``"stderr"``).
|
|
189
|
+
stream: Destination stream; defaults to ``sys.stderr`` when ``None``.
|
|
190
|
+
Returns:
|
|
191
|
+
``None``.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
target = stream or sys.stderr
|
|
195
|
+
if not hasattr(exc_info, attr):
|
|
196
|
+
return
|
|
197
|
+
|
|
198
|
+
text = _decode_output(getattr(exc_info, attr))
|
|
199
|
+
if text:
|
|
200
|
+
print(f"{attr.upper()}: {text}", file=target)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _decode_output(output: object) -> str | None:
|
|
204
|
+
"""Convert subprocess output into text, tolerating bytes and ``None``.
|
|
205
|
+
|
|
206
|
+
Parameters:
|
|
207
|
+
output: Raw value stored on an exception object.
|
|
208
|
+
Returns:
|
|
209
|
+
Decoded string when possible; ``None`` when the value is unusable.
|
|
210
|
+
"""
|
|
211
|
+
if output is None:
|
|
212
|
+
return None
|
|
213
|
+
if isinstance(output, bytes):
|
|
214
|
+
try:
|
|
215
|
+
return output.decode("utf-8", errors="replace")
|
|
216
|
+
except Exception:
|
|
217
|
+
return None
|
|
218
|
+
if isinstance(output, str):
|
|
219
|
+
return output
|
|
220
|
+
return None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def print_exception_message(
|
|
224
|
+
trace_back: bool | None = None,
|
|
225
|
+
length_limit: int = 500,
|
|
226
|
+
stream: TextIO | None = None,
|
|
227
|
+
) -> None:
|
|
228
|
+
"""Emit the active exception message and optional traceback to ``stream``.
|
|
229
|
+
|
|
230
|
+
Why:
|
|
231
|
+
Offer a single choke point for rendering user-facing diagnostics so the
|
|
232
|
+
CLI can toggle between terse and verbose output via configuration.
|
|
233
|
+
Parameters:
|
|
234
|
+
trace_back: When ``None`` (default) reuse :data:`config.traceback`.
|
|
235
|
+
When ``True`` render a Rich traceback; otherwise print a truncated
|
|
236
|
+
red summary.
|
|
237
|
+
length_limit: Maximum length of the summary string when tracebacks are
|
|
238
|
+
suppressed.
|
|
239
|
+
stream: Target text stream; defaults to ``sys.stderr``.
|
|
240
|
+
Side Effects:
|
|
241
|
+
Flushes standard streams, inspects ``sys.exc_info()``, and prints via
|
|
242
|
+
Rich using the active colour configuration.
|
|
243
|
+
"""
|
|
244
|
+
|
|
245
|
+
flush_streams()
|
|
246
|
+
exc_info = _active_exception()
|
|
247
|
+
if exc_info is None:
|
|
248
|
+
return
|
|
249
|
+
|
|
250
|
+
target_stream = _target_stream(stream)
|
|
251
|
+
_emit_subprocess_output(exc_info, target_stream)
|
|
252
|
+
|
|
253
|
+
render_traceback = _resolve_traceback_choice(trace_back)
|
|
254
|
+
console = _console_for_tracebacks(target_stream)
|
|
255
|
+
|
|
256
|
+
_render_exception_view(console, exc_info, render_traceback, length_limit)
|
|
257
|
+
_finalise_console(console)
|
|
258
|
+
flush_streams()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _active_exception() -> BaseException | None:
|
|
262
|
+
"""Return the currently active exception from ``sys.exc_info``."""
|
|
263
|
+
return sys.exc_info()[1]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _target_stream(stream: TextIO | None) -> TextIO:
|
|
267
|
+
"""Resolve the diagnostics stream, defaulting to ``sys.stderr``."""
|
|
268
|
+
return stream or sys.stderr
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _resolve_traceback_choice(trace_back: bool | None) -> bool:
|
|
272
|
+
"""Decide whether to render a traceback based on explicit or global flags."""
|
|
273
|
+
return config.traceback if trace_back is None else trace_back
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _emit_subprocess_output(exc_info: BaseException, stream: TextIO) -> None:
|
|
277
|
+
"""Write any subprocess ``stdout``/``stderr`` captured on ``exc_info``."""
|
|
278
|
+
for attr in ("stdout", "stderr"):
|
|
279
|
+
_print_output(exc_info, attr, stream)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _console_for_tracebacks(stream: TextIO) -> Console:
|
|
283
|
+
"""Build a :class:`Console` configured for traceback rendering."""
|
|
284
|
+
force_terminal, color_system = _traceback_colour_preferences()
|
|
285
|
+
return _build_console(stream, force_terminal=force_terminal, color_system=color_system)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _render_exception_view(
|
|
289
|
+
console: Console,
|
|
290
|
+
exc_info: BaseException,
|
|
291
|
+
render_traceback: bool,
|
|
292
|
+
length_limit: int,
|
|
293
|
+
) -> None:
|
|
294
|
+
"""Render the chosen diagnostic view for ``exc_info``."""
|
|
295
|
+
if render_traceback:
|
|
296
|
+
_render_traceback(console, exc_info)
|
|
297
|
+
return
|
|
298
|
+
_render_summary(console, exc_info, length_limit)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _traceback_colour_preferences() -> tuple[bool | None, RichColorSystem | None]:
|
|
302
|
+
"""Determine whether tracebacks should force colour output."""
|
|
303
|
+
if config.traceback_force_color:
|
|
304
|
+
return True, "auto"
|
|
305
|
+
return None, None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _render_traceback(console: Console, exc_info: BaseException) -> None:
|
|
309
|
+
"""Render a Rich traceback for ``exc_info`` to ``console``."""
|
|
310
|
+
renderable = Traceback.from_exception(
|
|
311
|
+
type(exc_info),
|
|
312
|
+
exc_info,
|
|
313
|
+
exc_info.__traceback__,
|
|
314
|
+
show_locals=False,
|
|
315
|
+
)
|
|
316
|
+
console.print(renderable)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _render_summary(console: Console, exc_info: BaseException, length_limit: int) -> None:
|
|
320
|
+
"""Render a concise summary for ``exc_info`` with truncation support."""
|
|
321
|
+
message = Text(f"{type(exc_info).__name__}: {exc_info}", style="bold red")
|
|
322
|
+
summary = _truncate_message(message, length_limit)
|
|
323
|
+
console.print(summary)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _truncate_message(message: Text, length_limit: int) -> Text:
|
|
327
|
+
"""Return ``message`` truncated to ``length_limit`` characters when needed."""
|
|
328
|
+
if len(message.plain) <= length_limit:
|
|
329
|
+
return message
|
|
330
|
+
truncated = f"{message.plain[:length_limit]} ... [TRUNCATED at {length_limit} characters]"
|
|
331
|
+
return Text(truncated, style=message.style)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _finalise_console(console: Console) -> None:
|
|
335
|
+
"""Flush the console file handle to ensure output reaches the user."""
|
|
336
|
+
console.file.flush()
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def handle_cli_exception(
|
|
340
|
+
exc: BaseException,
|
|
341
|
+
*,
|
|
342
|
+
signal_specs: Sequence[SignalSpec] | None = None,
|
|
343
|
+
echo: _Echo | None = None,
|
|
344
|
+
) -> int:
|
|
345
|
+
"""Convert an exception raised by a CLI into a deterministic exit code.
|
|
346
|
+
|
|
347
|
+
Why:
|
|
348
|
+
Keep Click command bodies small by funnelling all error handling,
|
|
349
|
+
signalling, and traceback logic through one reusable helper.
|
|
350
|
+
Parameters:
|
|
351
|
+
exc: Exception propagated by the command execution.
|
|
352
|
+
signal_specs: Optional list of :class:`SignalSpec` definitions.
|
|
353
|
+
echo: Optional callable to replace :func:`click.echo` for message output.
|
|
354
|
+
Returns:
|
|
355
|
+
Integer exit code suitable for :func:`sys.exit`.
|
|
356
|
+
Side Effects:
|
|
357
|
+
May write to stderr, invoke :func:`print_exception_message`, and render
|
|
358
|
+
rich tracebacks when requested.
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
specs = _resolve_signal_specs(signal_specs)
|
|
362
|
+
echo_fn = _choose_echo(echo)
|
|
363
|
+
return _resolve_exit_code(exc, specs, echo_fn)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _choose_echo(echo: _Echo | None) -> _Echo:
|
|
367
|
+
"""Select the echo function, defaulting to click's standard helper."""
|
|
368
|
+
return echo if echo is not None else _default_echo
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _resolve_exit_code(
|
|
372
|
+
exc: BaseException,
|
|
373
|
+
specs: Sequence[SignalSpec],
|
|
374
|
+
echo: _Echo,
|
|
375
|
+
) -> int:
|
|
376
|
+
"""Walk the resolver chain until a numeric exit code emerges."""
|
|
377
|
+
for resolver in _exception_resolvers(specs, echo):
|
|
378
|
+
code = resolver(exc)
|
|
379
|
+
if code is not None:
|
|
380
|
+
return code
|
|
381
|
+
return _render_and_translate(exc)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _exception_resolvers(
|
|
385
|
+
specs: Sequence[SignalSpec],
|
|
386
|
+
echo: _Echo,
|
|
387
|
+
) -> Iterable[ExitResolver]:
|
|
388
|
+
"""Yield exit-code resolver callables in priority order."""
|
|
389
|
+
yield _signal_resolver(specs, echo)
|
|
390
|
+
yield _broken_pipe_exit
|
|
391
|
+
yield _click_exit_code
|
|
392
|
+
yield _system_exit_code
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _signal_resolver(
|
|
396
|
+
specs: Sequence[SignalSpec],
|
|
397
|
+
echo: _Echo,
|
|
398
|
+
) -> ExitResolver:
|
|
399
|
+
"""Wrap :func:`_signal_exit_code` with the captured context."""
|
|
400
|
+
|
|
401
|
+
def _resolver(exc: BaseException) -> int | None:
|
|
402
|
+
return _signal_exit_code(exc, specs, echo)
|
|
403
|
+
|
|
404
|
+
return _resolver
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _resolve_signal_specs(specs: Sequence[SignalSpec] | None) -> Sequence[SignalSpec]:
|
|
408
|
+
"""Resolve caller-provided signal specs, defaulting to standard ones."""
|
|
409
|
+
return specs if specs is not None else default_signal_specs()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _signal_exit_code(exc: BaseException, specs: Sequence[SignalSpec], echo: _Echo) -> int | None:
|
|
413
|
+
"""Return a signal exit code when ``exc`` matches one of ``specs``."""
|
|
414
|
+
for spec in specs:
|
|
415
|
+
if isinstance(exc, spec.exception):
|
|
416
|
+
echo(spec.message, err=True)
|
|
417
|
+
return spec.exit_code
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _broken_pipe_exit(exc: BaseException) -> int | None:
|
|
422
|
+
"""Return the configured broken-pipe exit code when applicable."""
|
|
423
|
+
if isinstance(exc, BrokenPipeError):
|
|
424
|
+
return int(config.broken_pipe_exit_code)
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _click_exit_code(exc: BaseException) -> int | None:
|
|
429
|
+
"""Let Click exceptions decide their own exit codes."""
|
|
430
|
+
if isinstance(exc, click.ClickException):
|
|
431
|
+
exc.show()
|
|
432
|
+
return exc.exit_code
|
|
433
|
+
return None
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _system_exit_code(exc: BaseException) -> int | None:
|
|
437
|
+
"""Extract the integer payload from ``SystemExit`` when present."""
|
|
438
|
+
if not isinstance(exc, SystemExit):
|
|
439
|
+
return None
|
|
440
|
+
return _safe_system_exit_code(exc)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _safe_system_exit_code(exc: SystemExit) -> int:
|
|
444
|
+
"""Read the ``SystemExit`` payload defensively, defaulting to failure.
|
|
445
|
+
|
|
446
|
+
Parameters:
|
|
447
|
+
exc: ``SystemExit`` raised by user code or Click internals.
|
|
448
|
+
Returns:
|
|
449
|
+
Integer payload when coercible; otherwise ``1``.
|
|
450
|
+
"""
|
|
451
|
+
with suppress(Exception):
|
|
452
|
+
return int(exc.code or 0)
|
|
453
|
+
return 1
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _render_and_translate(exc: BaseException) -> int:
|
|
457
|
+
"""Render the exception according to configuration, then resolve a code."""
|
|
458
|
+
print_exception_message(trace_back=config.traceback)
|
|
459
|
+
return get_system_exit_code(exc)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
@contextmanager
|
|
463
|
+
def cli_session(
|
|
464
|
+
*,
|
|
465
|
+
summary_limit: int = 500,
|
|
466
|
+
verbose_limit: int = 10_000,
|
|
467
|
+
overrides: Mapping[str, object] | None = None,
|
|
468
|
+
restore: bool = True,
|
|
469
|
+
) -> Iterator[
|
|
470
|
+
Callable[
|
|
471
|
+
[
|
|
472
|
+
ClickCommand,
|
|
473
|
+
],
|
|
474
|
+
int,
|
|
475
|
+
]
|
|
476
|
+
| Callable[..., int]
|
|
477
|
+
]:
|
|
478
|
+
"""Provide a managed execution context around :func:`run_cli`.
|
|
479
|
+
|
|
480
|
+
Why
|
|
481
|
+
Embedders often need to flip :data:`config.traceback` while ensuring
|
|
482
|
+
the previous state is restored even if command execution raises. This
|
|
483
|
+
helper centralises that lifecycle and reuses the standard exception
|
|
484
|
+
handler so callers no longer duplicate try/except scaffolding.
|
|
485
|
+
|
|
486
|
+
What
|
|
487
|
+
Snapshots the global configuration, applies ``overrides`` for the
|
|
488
|
+
duration of the session, and yields a callable that executes
|
|
489
|
+
:func:`run_cli` with a preconfigured exception handler honouring the
|
|
490
|
+
provided ``summary_limit``/``verbose_limit`` thresholds. When the
|
|
491
|
+
context exits, configuration reverts to its prior values regardless of
|
|
492
|
+
success or failure.
|
|
493
|
+
|
|
494
|
+
Parameters
|
|
495
|
+
----------
|
|
496
|
+
summary_limit:
|
|
497
|
+
Truncation budget used when tracebacks are disabled.
|
|
498
|
+
verbose_limit:
|
|
499
|
+
Character budget used when tracebacks are enabled.
|
|
500
|
+
overrides:
|
|
501
|
+
Optional mapping of configuration fields temporarily applied during
|
|
502
|
+
the session. When ``traceback`` is supplied and
|
|
503
|
+
``traceback_force_color`` is omitted, colour output is forced to align
|
|
504
|
+
with the verbose mode.
|
|
505
|
+
restore:
|
|
506
|
+
When ``True`` (default) configuration state is restored after the
|
|
507
|
+
session. Set to ``False`` to leave any overrides or runtime mutations
|
|
508
|
+
in place once the context exits.
|
|
509
|
+
|
|
510
|
+
Yields
|
|
511
|
+
------
|
|
512
|
+
Callable
|
|
513
|
+
Function that accepts a Click command and forwards optional ``run_cli``
|
|
514
|
+
keyword arguments, returning the resulting exit code.
|
|
515
|
+
"""
|
|
516
|
+
|
|
517
|
+
applied = _normalise_session_overrides(overrides)
|
|
518
|
+
manager = _session_config_manager(applied, restore)
|
|
519
|
+
|
|
520
|
+
with manager:
|
|
521
|
+
handler = _session_exception_handler(summary_limit, verbose_limit)
|
|
522
|
+
|
|
523
|
+
def _run(
|
|
524
|
+
command: ClickCommand,
|
|
525
|
+
*,
|
|
526
|
+
argv: Sequence[str] | None = None,
|
|
527
|
+
prog_name: str | None = None,
|
|
528
|
+
signal_specs: Sequence[SignalSpec] | None = None,
|
|
529
|
+
install_signals: bool = True,
|
|
530
|
+
exception_handler: Callable[[BaseException], int] | None = None,
|
|
531
|
+
signal_installer: Callable[[Sequence[SignalSpec] | None], Callable[[], None]] | None = None,
|
|
532
|
+
) -> int:
|
|
533
|
+
chosen_handler = exception_handler or handler
|
|
534
|
+
return run_cli(
|
|
535
|
+
command,
|
|
536
|
+
argv=argv,
|
|
537
|
+
prog_name=prog_name,
|
|
538
|
+
signal_specs=signal_specs,
|
|
539
|
+
install_signals=install_signals,
|
|
540
|
+
exception_handler=chosen_handler,
|
|
541
|
+
signal_installer=signal_installer,
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
yield _run
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
def _normalise_session_overrides(overrides: Mapping[str, object] | None) -> Mapping[str, object]:
|
|
548
|
+
"""Prepare configuration overrides, forcing colour when verbose tracebacks are enabled.
|
|
549
|
+
|
|
550
|
+
Returns a Mapping instead of dict to avoid unnecessary conversions.
|
|
551
|
+
Uses typed constants for field names to ensure type safety.
|
|
552
|
+
"""
|
|
553
|
+
if not overrides:
|
|
554
|
+
return {}
|
|
555
|
+
|
|
556
|
+
# If traceback is enabled but force_color is not set, add it
|
|
557
|
+
if _CONFIG_TRACEBACK in overrides and _CONFIG_TRACEBACK_FORCE_COLOR not in overrides:
|
|
558
|
+
# Need to create a new dict to add the extra field
|
|
559
|
+
result: dict[str, object] = {**overrides}
|
|
560
|
+
result[_CONFIG_TRACEBACK_FORCE_COLOR] = bool(overrides[_CONFIG_TRACEBACK])
|
|
561
|
+
return result
|
|
562
|
+
|
|
563
|
+
return overrides
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _session_config_manager(applied: Mapping[str, object], restore: bool) -> ContextManager[object]:
|
|
567
|
+
"""Return the context manager used to apply session overrides."""
|
|
568
|
+
if restore:
|
|
569
|
+
return config_overrides(**applied)
|
|
570
|
+
if not applied:
|
|
571
|
+
return nullcontext()
|
|
572
|
+
return _apply_overrides_without_restore(applied)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
@contextmanager
|
|
576
|
+
def _apply_overrides_without_restore(applied: Mapping[str, object]) -> Iterator[None]:
|
|
577
|
+
"""Apply overrides without restoring prior state on exit.
|
|
578
|
+
|
|
579
|
+
Safety Note:
|
|
580
|
+
The setattr calls here are type-safe because all field names in ``applied``
|
|
581
|
+
have been validated by ``_reject_unknown_fields`` in configuration.py
|
|
582
|
+
(line 114) before reaching this function. The config_overrides context
|
|
583
|
+
manager ensures only valid _Config field names can be used.
|
|
584
|
+
|
|
585
|
+
Parameters:
|
|
586
|
+
applied: Validated mapping of configuration field names to values.
|
|
587
|
+
"""
|
|
588
|
+
for name, value in applied.items():
|
|
589
|
+
setattr(config, name, value)
|
|
590
|
+
yield
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _session_exception_handler(summary_limit: int, verbose_limit: int) -> Callable[[BaseException], int]:
|
|
594
|
+
"""Build the exception handler used inside :func:`cli_session`."""
|
|
595
|
+
|
|
596
|
+
def _handler(exc: BaseException) -> int:
|
|
597
|
+
active = bool(config.traceback)
|
|
598
|
+
limit = verbose_limit if active else summary_limit
|
|
599
|
+
print_exception_message(trace_back=active, length_limit=limit)
|
|
600
|
+
return get_system_exit_code(exc)
|
|
601
|
+
|
|
602
|
+
return _handler
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def run_cli(
|
|
606
|
+
cli: ClickCommand,
|
|
607
|
+
argv: Sequence[str] | None = None,
|
|
608
|
+
*,
|
|
609
|
+
prog_name: str | None = None,
|
|
610
|
+
signal_specs: Sequence[SignalSpec] | None = None,
|
|
611
|
+
install_signals: bool = True,
|
|
612
|
+
exception_handler: Callable[[BaseException], int] | None = None,
|
|
613
|
+
signal_installer: Callable[[Sequence[SignalSpec] | None], Callable[[], None]] | None = None,
|
|
614
|
+
) -> int:
|
|
615
|
+
"""Execute a Click command with shared signal/error handling installed.
|
|
616
|
+
|
|
617
|
+
Why:
|
|
618
|
+
Guarantee consistent behaviour between console scripts and ``python -m``
|
|
619
|
+
while allowing advanced callers to customise exception handling or
|
|
620
|
+
signal installation.
|
|
621
|
+
Parameters:
|
|
622
|
+
cli: Click command or group to execute.
|
|
623
|
+
argv: Optional list of arguments (excluding program name).
|
|
624
|
+
prog_name: Override for Click's displayed program name.
|
|
625
|
+
signal_specs: Optional signal configuration overriding the defaults.
|
|
626
|
+
install_signals: When ``False`` skips handler registration (useful for
|
|
627
|
+
hosts that already manage signals).
|
|
628
|
+
exception_handler: Callable returning an exit code when exceptions
|
|
629
|
+
occur; defaults to :func:`handle_cli_exception`.
|
|
630
|
+
signal_installer: Callable responsible for installing signal handlers;
|
|
631
|
+
defaults to :func:`install_signal_handlers`.
|
|
632
|
+
Returns:
|
|
633
|
+
Integer exit code suitable for :func:`sys.exit`.
|
|
634
|
+
Side Effects:
|
|
635
|
+
May install process-wide signal handlers, execute the Click command, and
|
|
636
|
+
flush IO streams.
|
|
637
|
+
"""
|
|
638
|
+
|
|
639
|
+
specs = _resolve_signal_specs(signal_specs)
|
|
640
|
+
handler = _choose_exception_handler(exception_handler, specs)
|
|
641
|
+
restorer = _install_signal_handlers_when_requested(install_signals, signal_installer, specs)
|
|
642
|
+
|
|
643
|
+
try:
|
|
644
|
+
return _run_command_with_handler(cli, argv, prog_name, handler)
|
|
645
|
+
finally:
|
|
646
|
+
_finalise_cli_run(restorer)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _choose_exception_handler(
|
|
650
|
+
override: Callable[[BaseException], int] | None,
|
|
651
|
+
specs: Sequence[SignalSpec],
|
|
652
|
+
) -> Callable[[BaseException], int]:
|
|
653
|
+
"""Select the exception handler, defaulting to :func:`handle_cli_exception`."""
|
|
654
|
+
if override is not None:
|
|
655
|
+
return override
|
|
656
|
+
return _default_exception_handler(specs)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _default_exception_handler(specs: Sequence[SignalSpec]) -> Callable[[BaseException], int]:
|
|
660
|
+
"""Build the default exception handler bound to ``specs``."""
|
|
661
|
+
|
|
662
|
+
def _handler(exc: BaseException) -> int:
|
|
663
|
+
return handle_cli_exception(exc, signal_specs=specs)
|
|
664
|
+
|
|
665
|
+
return _handler
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _install_signal_handlers_when_requested(
|
|
669
|
+
install_signals: bool,
|
|
670
|
+
signal_installer: Callable[[Sequence[SignalSpec] | None], Callable[[], None]] | None,
|
|
671
|
+
specs: Sequence[SignalSpec],
|
|
672
|
+
) -> Callable[[], None] | None:
|
|
673
|
+
"""Install signal handlers when requested and return a restorer."""
|
|
674
|
+
if not install_signals:
|
|
675
|
+
return None
|
|
676
|
+
installer = signal_installer or install_signal_handlers
|
|
677
|
+
return installer(specs)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _run_command_with_handler(
|
|
681
|
+
cli: ClickCommand,
|
|
682
|
+
argv: Sequence[str] | None,
|
|
683
|
+
prog_name: str | None,
|
|
684
|
+
handler: Callable[[BaseException], int],
|
|
685
|
+
) -> int:
|
|
686
|
+
"""Invoke the Click command and delegate failures to ``handler``."""
|
|
687
|
+
try:
|
|
688
|
+
_invoke_command(cli, argv, prog_name)
|
|
689
|
+
except BaseException as exc: # noqa: BLE001 - single funnel for exit codes
|
|
690
|
+
return handler(exc)
|
|
691
|
+
return 0
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _finalise_cli_run(restorer: Callable[[], None] | None) -> None:
|
|
695
|
+
"""Restore signal handlers when needed and flush IO buffers."""
|
|
696
|
+
_restore_handlers_if_needed(restorer)
|
|
697
|
+
flush_streams()
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _invoke_command(cli: ClickCommand, argv: Sequence[str] | None, prog_name: str | None) -> None:
|
|
701
|
+
"""Invoke the Click command with ``standalone_mode`` disabled."""
|
|
702
|
+
cli.main(args=_normalised_args(argv), standalone_mode=False, prog_name=prog_name)
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def _normalised_args(argv: Sequence[str] | None) -> Sequence[str] | None:
|
|
706
|
+
"""Return ``argv`` as a mutable list when provided, otherwise ``None``."""
|
|
707
|
+
return list(argv) if argv is not None else None
|
|
708
|
+
|
|
709
|
+
|
|
710
|
+
def _restore_handlers_if_needed(restore: Callable[[], None] | None) -> None:
|
|
711
|
+
"""Invoke the signal restorer callback when one was provided."""
|
|
712
|
+
if restore is None:
|
|
713
|
+
return
|
|
714
|
+
restore()
|