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,420 @@
1
+ """Exit-code translation helpers for lib_cli_exit_tools.
2
+
3
+ Purpose:
4
+ Provide deterministic mappings from Python exceptions to operating-system
5
+ exit codes, honouring both POSIX/Windows errno semantics and BSD sysexits
6
+ conventions.
7
+ Contents:
8
+ * :func:`get_system_exit_code` – primary mapping entry point.
9
+ * :func:`_sysexits_mapping` – internal helper for sysexits mode.
10
+ System Integration:
11
+ Used by application orchestration and CLI adapters to convert unhandled
12
+ exceptions into numeric exit statuses while respecting
13
+ :data:`lib_cli_exit_tools.core.configuration.config` toggles.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import os
19
+ import subprocess # nosec B404 - imported for CalledProcessError type inspection
20
+ from typing import Callable, Iterable, Mapping
21
+
22
+ from .configuration import config
23
+
24
+ __all__ = ["get_system_exit_code"]
25
+
26
+ Resolver = Callable[[BaseException], int | None]
27
+
28
+
29
+ def _is_posix_platform() -> bool:
30
+ """Check if running on a POSIX platform.
31
+
32
+ Why:
33
+ Centralize platform detection to avoid string literal comparisons.
34
+ What:
35
+ Return True if os.name indicates a POSIX system.
36
+ Parameters:
37
+ None.
38
+ Returns:
39
+ Boolean indicating POSIX platform.
40
+ Side Effects:
41
+ None.
42
+ """
43
+ return os.name == "posix"
44
+
45
+
46
+ def get_system_exit_code(exc: BaseException) -> int:
47
+ """Why:
48
+ Ensure all uncaught exceptions translate into deterministic exit codes.
49
+ What:
50
+ Walk the resolver pipeline and return the first concrete exit code,
51
+ defaulting to ``1`` when nothing matches.
52
+ Parameters:
53
+ exc: Exception raised by application or adapter code.
54
+ Returns:
55
+ Integer exit code derived from OS conventions or configuration.
56
+ Side Effects:
57
+ None.
58
+ """
59
+
60
+ code = _first_resolved_code(exc)
61
+ return 1 if code is None else code
62
+
63
+
64
+ def _first_resolved_code(exc: BaseException) -> int | None:
65
+ """Return the first exit code produced by the resolver chain."""
66
+ for resolver in _exit_resolvers():
67
+ code = resolver(exc)
68
+ if code is not None:
69
+ return code
70
+ return None
71
+
72
+
73
+ def _exit_resolvers() -> Iterable[Resolver]:
74
+ """Why:
75
+ Encapsulate precedence so behaviour stays consistent across callers.
76
+ What:
77
+ Yield resolver callables ordered from most specific to most general.
78
+ Parameters:
79
+ None.
80
+ Returns:
81
+ Iterable of resolver callables.
82
+ Side Effects:
83
+ None.
84
+ """
85
+
86
+ return (
87
+ _code_from_called_process_error,
88
+ _code_from_keyboard_interrupt,
89
+ _code_from_winerror_attribute,
90
+ _code_from_broken_pipe,
91
+ _code_from_errno,
92
+ _code_from_system_exit,
93
+ _code_from_sysexits_mode,
94
+ _code_from_platform_mapping,
95
+ )
96
+
97
+
98
+ def _code_from_called_process_error(exc: BaseException) -> int | None:
99
+ """Why:
100
+ Preserve exit statuses produced by failing subprocesses.
101
+ What:
102
+ Inspect ``exc`` for a numeric ``returncode`` attribute and propagate it.
103
+ Parameters:
104
+ exc: Exception raised by :mod:`subprocess` helpers.
105
+ Returns:
106
+ Numeric return code or ``None`` when the value is unusable.
107
+ Side Effects:
108
+ None.
109
+ """
110
+ if not isinstance(exc, subprocess.CalledProcessError):
111
+ return None
112
+ return _safe_int(getattr(exc, "returncode", None)) or 1
113
+
114
+
115
+ def _code_from_keyboard_interrupt(exc: BaseException) -> int | None:
116
+ """Why:
117
+ Align with shell conventions for Ctrl+C interrupts.
118
+ What:
119
+ Return ``130`` when ``exc`` is ``KeyboardInterrupt``.
120
+ Parameters:
121
+ exc: Exception object raised by Python.
122
+ Returns:
123
+ ``130`` or ``None`` when not applicable.
124
+ Side Effects:
125
+ None.
126
+ """
127
+ if isinstance(exc, KeyboardInterrupt):
128
+ return 130
129
+ return None
130
+
131
+
132
+ def _code_from_winerror_attribute(exc: BaseException) -> int | None:
133
+ """Why:
134
+ Windows APIs expose failure reasons via ``winerror`` rather than ``errno``.
135
+ What:
136
+ Parse ``exc.winerror`` into an integer when present.
137
+ Parameters:
138
+ exc: Exception potentially exposing a ``winerror`` attribute.
139
+ Returns:
140
+ Integer winerror or ``None`` when unavailable.
141
+ Side Effects:
142
+ None.
143
+ """
144
+ if not hasattr(exc, "winerror"):
145
+ return None
146
+ return _safe_int(getattr(exc, "winerror"))
147
+
148
+
149
+ def _code_from_broken_pipe(exc: BaseException) -> int | None:
150
+ """Why:
151
+ Honour the configurable exit code for truncated pipelines.
152
+ What:
153
+ Return :data:`config.broken_pipe_exit_code` when ``exc`` is a
154
+ ``BrokenPipeError``.
155
+ Parameters:
156
+ exc: Exception under evaluation.
157
+ Returns:
158
+ Configured exit code or ``None`` when the exception is unrelated.
159
+ Side Effects:
160
+ None.
161
+ """
162
+ if isinstance(exc, BrokenPipeError):
163
+ return int(config.broken_pipe_exit_code)
164
+ return None
165
+
166
+
167
+ def _code_from_errno(exc: BaseException) -> int | None:
168
+ """Why:
169
+ Preserve errno semantics for standard filesystem and OS errors.
170
+ What:
171
+ Convert the ``errno`` attribute into an integer when present.
172
+ Parameters:
173
+ exc: Exception potentially carrying an ``errno`` attribute.
174
+ Returns:
175
+ Parsed integer errno or ``None`` when unavailable.
176
+ Side Effects:
177
+ None.
178
+ """
179
+ if not isinstance(exc, OSError):
180
+ return None
181
+ return _safe_int(getattr(exc, "errno", None))
182
+
183
+
184
+ def _code_from_system_exit(exc: BaseException) -> int | None:
185
+ """Why:
186
+ ``sys.exit`` payloads should be honoured when safe.
187
+ What:
188
+ Validate and return the embedded ``SystemExit.code`` value.
189
+ Parameters:
190
+ exc: Exception propagated by ``sys.exit``.
191
+ Returns:
192
+ Integer payload, ``0`` for ``None`` payloads, or ``1`` on failure.
193
+ Side Effects:
194
+ None.
195
+ """
196
+ if not isinstance(exc, SystemExit):
197
+ return None
198
+
199
+ code = getattr(exc, "code", None)
200
+ if isinstance(code, int):
201
+ return code
202
+ if code is None:
203
+ return 0
204
+ candidate = _safe_int(str(code))
205
+ return 1 if candidate is None else candidate
206
+
207
+
208
+ def _code_from_sysexits_mode(exc: BaseException) -> int | None:
209
+ """Why:
210
+ Honour the optional sysexits configuration for shell-centric workflows.
211
+ What:
212
+ Delegate to the sysexits resolver pipeline when the mode is enabled.
213
+ Parameters:
214
+ exc: Exception under evaluation.
215
+ Returns:
216
+ Sysexits-derived integer or ``None`` when mode is disabled.
217
+ Side Effects:
218
+ None.
219
+ """
220
+ if config.exit_code_style != "sysexits":
221
+ return None
222
+ return _sysexits_resolved_code(exc)
223
+
224
+
225
+ def _code_from_platform_mapping(exc: BaseException) -> int | None:
226
+ """Why:
227
+ Provide sensible defaults for common exceptions when specific resolvers fail.
228
+ What:
229
+ Consult the platform table for an exit code matching ``exc``.
230
+ Parameters:
231
+ exc: Exception under evaluation.
232
+ Returns:
233
+ Integer exit code or ``None``.
234
+ Side Effects:
235
+ None.
236
+ """
237
+ for exc_type, code in _platform_exception_map().items():
238
+ if isinstance(exc, exc_type):
239
+ return code
240
+ return None
241
+
242
+
243
+ def _platform_exception_map() -> Mapping[type[BaseException], int]:
244
+ """Why:
245
+ Different platforms use distinct numeric codes for the same errors.
246
+ What:
247
+ Return either the POSIX or Windows mapping dictionary based on
248
+ :data:`os.name`.
249
+ Parameters:
250
+ None.
251
+ Returns:
252
+ Dictionary mapping exception types to integer exit codes.
253
+ Side Effects:
254
+ None.
255
+ """
256
+ if _is_posix_platform():
257
+ return _posix_exception_map()
258
+ return _windows_exception_map()
259
+
260
+
261
+ def _posix_exception_map() -> Mapping[type[BaseException], int]:
262
+ """Why:
263
+ Keep POSIX-specific errno defaults centralised and documented.
264
+ What:
265
+ Provide a dictionary mapping high-level exceptions to POSIX exit codes.
266
+ Parameters:
267
+ None.
268
+ Returns:
269
+ Dictionary of exception types and exit codes.
270
+ Side Effects:
271
+ None.
272
+ """
273
+ return {
274
+ FileNotFoundError: 2,
275
+ PermissionError: 13,
276
+ FileExistsError: 17,
277
+ IsADirectoryError: 21,
278
+ NotADirectoryError: 20,
279
+ TimeoutError: 110,
280
+ TypeError: 22,
281
+ ValueError: 22,
282
+ RuntimeError: 1,
283
+ }
284
+
285
+
286
+ def _windows_exception_map() -> Mapping[type[BaseException], int]:
287
+ """Why:
288
+ Keep Windows-specific winerror defaults centralised and documented.
289
+ What:
290
+ Provide a dictionary mapping high-level exceptions to Windows exit codes.
291
+ Parameters:
292
+ None.
293
+ Returns:
294
+ Dictionary of exception types and exit codes.
295
+ Side Effects:
296
+ None.
297
+ """
298
+ return {
299
+ FileNotFoundError: 2,
300
+ PermissionError: 5,
301
+ FileExistsError: 80,
302
+ IsADirectoryError: 267,
303
+ NotADirectoryError: 267,
304
+ TimeoutError: 1460,
305
+ TypeError: 87,
306
+ ValueError: 87,
307
+ RuntimeError: 1,
308
+ }
309
+
310
+
311
+ def _safe_int(value: object | None) -> int | None:
312
+ """Attempt to coerce ``value`` into an integer, returning ``None`` on failure.
313
+
314
+ Why:
315
+ ``errno`` and ``winerror`` fields may be ``None`` or non-numeric
316
+ objects; this helper converts safely without raising.
317
+ Parameters:
318
+ value: Object expected to represent an integer.
319
+ Returns:
320
+ Parsed integer or ``None`` when conversion fails.
321
+ Side Effects:
322
+ None.
323
+ """
324
+ try:
325
+ if value is None:
326
+ return None
327
+ return int(value) # type: ignore[arg-type]
328
+ except Exception:
329
+ return None
330
+
331
+
332
+ def _sysexits_resolved_code(exc: BaseException) -> int:
333
+ """Return the sysexits-friendly code for ``exc``."""
334
+ for resolver in _sysexits_resolvers():
335
+ code = resolver(exc)
336
+ if code is not None:
337
+ return code
338
+ return 1
339
+
340
+
341
+ def _sysexits_resolvers() -> Iterable[Resolver]:
342
+ """Yield sysexits-specific resolver callables."""
343
+ return (
344
+ _sysexits_from_system_exit,
345
+ _sysexits_from_keyboard_interrupt,
346
+ _sysexits_from_called_process_error,
347
+ _sysexits_from_broken_pipe,
348
+ _sysexits_from_usage_errors,
349
+ _sysexits_from_missing_resource,
350
+ _sysexits_from_permission_denied,
351
+ _sysexits_from_io_errors,
352
+ _sysexits_default,
353
+ )
354
+
355
+
356
+ def _sysexits_from_system_exit(exc: BaseException) -> int | None:
357
+ """Translate ``SystemExit`` payloads in sysexits mode."""
358
+ if not isinstance(exc, SystemExit):
359
+ return None
360
+ try:
361
+ return int(exc.code or 0) # type: ignore[attr-defined]
362
+ except Exception:
363
+ return 1
364
+
365
+
366
+ def _sysexits_from_keyboard_interrupt(exc: BaseException) -> int | None:
367
+ """Return the sysexits code for Ctrl+C interrupts."""
368
+ if isinstance(exc, KeyboardInterrupt):
369
+ return 130
370
+ return None
371
+
372
+
373
+ def _sysexits_from_called_process_error(exc: BaseException) -> int | None:
374
+ """Reuse subprocess return codes when available."""
375
+ if not isinstance(exc, subprocess.CalledProcessError):
376
+ return None
377
+ try:
378
+ return int(exc.returncode)
379
+ except Exception:
380
+ return 1
381
+
382
+
383
+ def _sysexits_from_broken_pipe(exc: BaseException) -> int | None:
384
+ """Forward the configured broken-pipe code."""
385
+ if isinstance(exc, BrokenPipeError):
386
+ return int(config.broken_pipe_exit_code)
387
+ return None
388
+
389
+
390
+ def _sysexits_from_usage_errors(exc: BaseException) -> int | None:
391
+ """Return the usage-error sysexits code for common argument mistakes."""
392
+ if isinstance(exc, (TypeError, ValueError)):
393
+ return 64
394
+ return None
395
+
396
+
397
+ def _sysexits_from_missing_resource(exc: BaseException) -> int | None:
398
+ """Map missing resources to ``EX_NOINPUT``."""
399
+ if isinstance(exc, FileNotFoundError):
400
+ return 66
401
+ return None
402
+
403
+
404
+ def _sysexits_from_permission_denied(exc: BaseException) -> int | None:
405
+ """Translate permission failures to ``EX_NOPERM``."""
406
+ if isinstance(exc, PermissionError):
407
+ return 77
408
+ return None
409
+
410
+
411
+ def _sysexits_from_io_errors(exc: BaseException) -> int | None:
412
+ """Capture generic IO failures under ``EX_IOERR``."""
413
+ if isinstance(exc, (OSError, IOError)):
414
+ return 74
415
+ return None
416
+
417
+
418
+ def _sysexits_default(exc: BaseException) -> int | None:
419
+ """Fallback resolver yielding the generic failure code."""
420
+ return 1
@@ -0,0 +1,83 @@
1
+ """Facade aggregating the exit-tooling helpers exposed by lib_cli_exit_tools.
2
+
3
+ Purpose:
4
+ Provide a stable import location for configuration, signal handling, exit
5
+ code translation, and CLI orchestration primitives after the refactor into
6
+ layered modules.
7
+ Contents:
8
+ * ``config`` from :mod:`lib_cli_exit_tools.core.configuration`.
9
+ * ``config_overrides`` and ``reset_config`` helpers to manage configuration
10
+ state safely during temporary tweaks.
11
+ * ``get_system_exit_code`` from :mod:`lib_cli_exit_tools.core.exit_codes`.
12
+ * ``handle_cli_exception`` and ``run_cli`` from
13
+ :mod:`lib_cli_exit_tools.application.runner`.
14
+ * :func:`i_should_fail` defined here for intentionally exercising error paths.
15
+ * Signal helpers from :mod:`lib_cli_exit_tools.adapters.signals`.
16
+ System Integration:
17
+ The CLI adapter (:mod:`lib_cli_exit_tools.cli`) and external consumers
18
+ continue importing from this facade to avoid knowledge of the new package
19
+ structure.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from .adapters.signals import (
25
+ CliSignalError,
26
+ SigBreakInterrupt,
27
+ SigIntInterrupt,
28
+ SigTermInterrupt,
29
+ SignalSpec,
30
+ default_signal_specs,
31
+ install_signal_handlers,
32
+ )
33
+ from .application.runner import (
34
+ cli_session,
35
+ flush_streams,
36
+ handle_cli_exception,
37
+ print_exception_message,
38
+ run_cli,
39
+ )
40
+ from .core.configuration import config, config_overrides, reset_config
41
+ from .core.exit_codes import get_system_exit_code
42
+
43
+ __all__ = [
44
+ "config",
45
+ "get_system_exit_code",
46
+ "print_exception_message",
47
+ "flush_streams",
48
+ "SignalSpec",
49
+ "CliSignalError",
50
+ "SigIntInterrupt",
51
+ "SigTermInterrupt",
52
+ "SigBreakInterrupt",
53
+ "default_signal_specs",
54
+ "install_signal_handlers",
55
+ "handle_cli_exception",
56
+ "i_should_fail",
57
+ "cli_session",
58
+ "run_cli",
59
+ "config_overrides",
60
+ "reset_config",
61
+ ]
62
+
63
+ PUBLIC_API = tuple(__all__)
64
+
65
+
66
+ def i_should_fail() -> None:
67
+ """Raise :class:`RuntimeError` to exercise error-handling flows.
68
+
69
+ Why:
70
+ Provide a deterministic failure path so engineers can verify traceback
71
+ toggles, exit-code translation, and structured error reporting during
72
+ manual or automated testing.
73
+ Raises:
74
+ RuntimeError: Always raised with a stable message for assertion-friendly
75
+ diagnostics.
76
+ Examples:
77
+ >>> i_should_fail()
78
+ Traceback (most recent call last):
79
+ ...
80
+ RuntimeError: i should fail
81
+ """
82
+
83
+ raise RuntimeError("i should fail")
File without changes