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.
@@ -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()