lib-cli-exit-tools 1.0.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.
@@ -0,0 +1,15 @@
1
+ from __future__ import annotations
2
+
3
+ from .lib_cli_exit_tools import (
4
+ config,
5
+ get_system_exit_code,
6
+ print_exception_message,
7
+ flush_streams,
8
+ )
9
+
10
+ __all__ = [
11
+ "config",
12
+ "get_system_exit_code",
13
+ "print_exception_message",
14
+ "flush_streams",
15
+ ]
@@ -0,0 +1,108 @@
1
+ """Runtime metadata facade loaded from installed package metadata.
2
+
3
+ Reads values from the installed distribution's metadata via `importlib.metadata`
4
+ so that `pyproject.toml` remains the single source of truth.
5
+
6
+ Falls back to defaults when not installed (e.g., running from a working tree
7
+ without an editable install).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib import metadata as _im
13
+ from typing import Any, Protocol, runtime_checkable
14
+
15
+ _DIST_NAME = "lib_cli_exit_tools"
16
+
17
+
18
+ @runtime_checkable
19
+ class _MetaMapping(Protocol):
20
+ """Minimal protocol for package metadata across Python versions.
21
+
22
+ On Python < 3.12, `importlib.metadata.metadata()` returns an
23
+ `email.message.Message` which supports `.get(key, default)`.
24
+ On Python >= 3.12, it returns `PackageMetadata`, which also supports `.get`.
25
+ We type to this protocol to keep Pyright happy on 3.10.
26
+ """
27
+
28
+ def get(self, __key: str, __default: object = ...) -> object: ...
29
+
30
+
31
+ def _get_str(m: _MetaMapping, key: str, default: str = "") -> str:
32
+ """Return metadata value as string or a default if missing/not a string."""
33
+ v = m.get(key, default)
34
+ return v if isinstance(v, str) else default
35
+
36
+
37
+ def _meta() -> Any | None:
38
+ try:
39
+ return _im.metadata(_DIST_NAME)
40
+ except _im.PackageNotFoundError:
41
+ return None
42
+
43
+
44
+ def _version() -> str:
45
+ try:
46
+ return _im.version(_DIST_NAME)
47
+ except _im.PackageNotFoundError:
48
+ return "0.0.0.dev0"
49
+
50
+
51
+ def _home_page(m: Any | None) -> str:
52
+ if not m:
53
+ return "https://github.com/bitranox/lib_cli_exit_tools"
54
+ # cast to protocol for typing purposes
55
+ mm: _MetaMapping = m # type: ignore[assignment]
56
+ hp = _get_str(mm, "Home-page") or _get_str(mm, "Homepage")
57
+ return hp or "https://github.com/bitranox/lib_cli_exit_tools"
58
+
59
+
60
+ def _author(m: Any | None) -> tuple[str, str]:
61
+ if not m:
62
+ return ("bitranox", "bitranox@gmail.com")
63
+ mm: _MetaMapping = m # type: ignore[assignment]
64
+ return (_get_str(mm, "Author", ""), _get_str(mm, "Author-email", ""))
65
+
66
+
67
+ def _summary(m: Any | None) -> str:
68
+ if not m:
69
+ return "Functions to exit a CLI application properly"
70
+ mm: _MetaMapping = m # type: ignore[assignment]
71
+ return _get_str(mm, "Summary", "Functions to exit a CLI application properly")
72
+
73
+
74
+ def _shell_command() -> str:
75
+ # Discover console script name mapping to our CLI main, fallback to dist name
76
+ eps = _im.entry_points(group="console_scripts")
77
+ target = "lib_cli_exit_tools.cli:main"
78
+ for ep in list(eps):
79
+ if ep.value == target:
80
+ return ep.name
81
+ return _DIST_NAME
82
+
83
+
84
+ # Public values (resolve metadata once)
85
+ _m = _meta()
86
+ name = _DIST_NAME
87
+ title = _summary(_m)
88
+ version = _version()
89
+ homepage = _home_page(_m)
90
+ author, author_email = _author(_m)
91
+ shell_command = _shell_command()
92
+
93
+
94
+ def print_info() -> None:
95
+ """Print resolved metadata in a compact, aligned block."""
96
+ fields = [
97
+ ("name", name),
98
+ ("title", title),
99
+ ("version", version),
100
+ ("homepage", homepage),
101
+ ("author", author),
102
+ ("author_email", author_email),
103
+ ("shell_command", shell_command),
104
+ ]
105
+ pad = max(len(k) for k, _ in fields)
106
+ lines = [f"Info for {name}:", ""]
107
+ lines += [f" {k.ljust(pad)} = {v}" for k, v in fields]
108
+ print("\n".join(lines))
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import platform
4
+ import signal
5
+ from typing import Optional, List
6
+ from types import FrameType
7
+
8
+ import click
9
+
10
+ from . import __init__conf__
11
+ from . import lib_cli_exit_tools as tools
12
+
13
+ is_windows = platform.system().lower() == "windows"
14
+ is_posix = not is_windows
15
+
16
+
17
+ class SigIntError(Exception):
18
+ """Raised on SIGINT (Ctrl+C)."""
19
+
20
+
21
+ class SigTermError(Exception):
22
+ """Raised on SIGTERM (POSIX) or SIGBREAK (Windows)."""
23
+
24
+
25
+ class SigBreakError(Exception):
26
+ """Raised on SIGBREAK (Windows Ctrl+Break)."""
27
+
28
+
29
+ def _sigint_handler(signo: int, frame: FrameType | None) -> None:
30
+ raise SigIntError()
31
+
32
+
33
+ def _sigterm_handler(signo: int, frame: FrameType | None) -> None:
34
+ raise SigTermError()
35
+
36
+
37
+ def _install_signal_handlers() -> None:
38
+ """Install minimal, portable signal handlers.
39
+
40
+ * Always handle SIGINT.
41
+ * On POSIX, also handle SIGTERM.
42
+ * On Windows, handle SIGBREAK (Ctrl+Break) as a best-effort replacement for SIGTERM.
43
+ """
44
+ signal.signal(signal.SIGINT, _sigint_handler)
45
+ if is_posix:
46
+ signal.signal(signal.SIGTERM, _sigterm_handler)
47
+ else:
48
+ # SIGBREAK exists on Windows
49
+ try:
50
+
51
+ def _sigbreak_handler(signo: int, frame: FrameType | None) -> None:
52
+ raise SigBreakError()
53
+
54
+ signal.signal(signal.SIGBREAK, _sigbreak_handler) # type: ignore[attr-defined]
55
+ except Exception:
56
+ pass
57
+
58
+
59
+ CLICK_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) # noqa: C408
60
+
61
+
62
+ @click.group(help=__init__conf__.title, context_settings=CLICK_CONTEXT_SETTINGS)
63
+ @click.version_option(
64
+ version=__init__conf__.version,
65
+ prog_name=__init__conf__.shell_command,
66
+ message=f"{__init__conf__.shell_command} version {__init__conf__.version}",
67
+ )
68
+ @click.option(
69
+ "--traceback/--no-traceback",
70
+ is_flag=True,
71
+ default=False,
72
+ help="Show full Python traceback on errors",
73
+ )
74
+ @click.pass_context
75
+ def cli(ctx: click.Context, traceback: bool) -> None:
76
+ """Root CLI group. Stores global opts in context & tools.config."""
77
+ ctx.ensure_object(dict)
78
+ ctx.obj["traceback"] = traceback
79
+ tools.config.traceback = traceback
80
+
81
+
82
+ @cli.command("info", context_settings=CLICK_CONTEXT_SETTINGS)
83
+ def cli_info() -> None:
84
+ """Print project information."""
85
+ __init__conf__.print_info()
86
+
87
+
88
+ def _handle_exception(e: BaseException) -> int:
89
+ """Centralized exception → exit-code + printing.
90
+
91
+ Respects tools.config.traceback and uses tools.print_exception_message.
92
+ Returns an integer once (single return style).
93
+ """
94
+ result: int | None = None
95
+ if isinstance(e, (SigIntError, KeyboardInterrupt)):
96
+ click.echo("Aborted (SIGINT).", err=True)
97
+ result = 130 # 128 + SIGINT(2)
98
+ elif isinstance(e, SigTermError):
99
+ click.echo("Terminated (SIGTERM/SIGBREAK).", err=True)
100
+ result = 143 # 128 + SIGTERM(15)
101
+ elif isinstance(e, SigBreakError): # precise mapping for Windows Ctrl+Break
102
+ click.echo("Terminated (SIGBREAK).", err=True)
103
+ result = 149 # 128 + SIGBREAK(21)
104
+ elif isinstance(e, BrokenPipeError):
105
+ # Broken pipe: stay quiet and exit with configured code
106
+ result = int(tools.config.broken_pipe_exit_code)
107
+ elif isinstance(e, click.ClickException):
108
+ e.show()
109
+ result = e.exit_code
110
+ elif isinstance(e, SystemExit):
111
+ try:
112
+ result = int(e.code or 0)
113
+ except Exception:
114
+ result = 1
115
+ else:
116
+ # Unexpected exception: print nicely or with traceback depending on config
117
+ if tools.config.traceback:
118
+ # Re-raise so Python prints full traceback (useful in CI/dev)
119
+ raise
120
+ tools.print_exception_message()
121
+ result = tools.get_system_exit_code(e)
122
+ return int(result)
123
+
124
+
125
+ def main(argv: Optional[List[str]] = None) -> int:
126
+ """Entrypoint returning an int exit code.
127
+
128
+ Use via `python -m lib_cli_exit_tools` or console script.
129
+ """
130
+ _install_signal_handlers()
131
+ try:
132
+ # Do not let Click exit the process; we want to convert to int code
133
+ cli.main(args=argv, standalone_mode=False, prog_name=__init__conf__.shell_command)
134
+ return 0
135
+ except BaseException as e: # noqa: BLE001 - we want a single exit funnel
136
+ return _handle_exception(e)
137
+ finally:
138
+ tools.flush_streams()
@@ -0,0 +1,353 @@
1
+ """Core helpers for clean CLI exits and error printing.
2
+
3
+ Exposed API:
4
+ - config: runtime options (traceback toggle, exit code style, broken pipe behavior)
5
+ - get_system_exit_code(exc): map exceptions → OS-appropriate exit codes
6
+ - print_exception_message(...): print concise error, optional traceback, stdout/stderr if present
7
+ - flush_streams(): flush stdout/stderr safely
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import subprocess
14
+ import sys
15
+ import traceback
16
+ from dataclasses import dataclass
17
+ from typing import Any, Literal, Optional, TextIO
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class _Config:
22
+ """Runtime configuration for library behavior."""
23
+
24
+ traceback: bool = False
25
+ # errno → numeric OS codes (default) | sysexits (64–78 semantics)
26
+ exit_code_style: Literal["errno", "sysexits"] = "errno"
27
+ # Exit code to use for BrokenPipeError (common in pipelines). Typical choices:
28
+ # 141 (128+SIGPIPE), 0 (treat as benign truncation), or 32 (EPIPE).
29
+ broken_pipe_exit_code: int = 141
30
+
31
+
32
+ config = _Config()
33
+
34
+
35
+ def get_system_exit_code(exc: BaseException) -> int:
36
+ """
37
+ Return the exit code for linux or Windows os, based on the exception.
38
+ If, on windows, the winerror code is passed with the Exception, we return that winerror code.
39
+
40
+
41
+ Parameter
42
+ ---------
43
+ exc
44
+ the exception to analyze
45
+
46
+
47
+ Result
48
+ ------
49
+ exit_code
50
+ as integer
51
+
52
+
53
+ Examples
54
+ --------
55
+
56
+ >>> try:
57
+ ... raise RuntimeError()
58
+ ... except RuntimeError as my_exc:
59
+ ... get_system_exit_code(my_exc)
60
+ 1
61
+ >>> try:
62
+ ... raise RuntimeError()
63
+ ... except RuntimeError as my_exc:
64
+ ... setattr(my_exc, 'winerror', 42)
65
+ ... get_system_exit_code(my_exc)
66
+ 42
67
+ >>> try:
68
+ ... raise RuntimeError()
69
+ ... except RuntimeError as my_exc:
70
+ ... setattr(my_exc, 'winerror', None)
71
+ ... get_system_exit_code(my_exc)
72
+ 1
73
+ >>> try:
74
+ ... exit(99)
75
+ ... except SystemExit as my_exc:
76
+ ... get_system_exit_code(my_exc)
77
+ 99
78
+
79
+ """
80
+
81
+ # Prefer precise sources first: explicit returncodes, signals, winerror/errno
82
+ # subprocess returncode
83
+ if isinstance(exc, subprocess.CalledProcessError):
84
+ try:
85
+ return int(exc.returncode)
86
+ except Exception:
87
+ return 1
88
+
89
+ # KeyboardInterrupt should map to 130 regardless of platform
90
+ if isinstance(exc, KeyboardInterrupt):
91
+ return 130
92
+
93
+ # Windows-specific error code passthrough
94
+ if hasattr(exc, "winerror"):
95
+ try:
96
+ return int(getattr(exc, "winerror")) # type: ignore[arg-type]
97
+ except (AttributeError, TypeError, ValueError):
98
+ pass
99
+
100
+ # Broken pipe (common when piping to head/less): don't be noisy
101
+ if isinstance(exc, BrokenPipeError):
102
+ return int(config.broken_pipe_exit_code)
103
+
104
+ # If an errno is present (OSError family), prefer it
105
+ if isinstance(exc, OSError) and getattr(exc, "errno", None) is not None:
106
+ try:
107
+ return int(exc.errno) # type: ignore[arg-type]
108
+ except Exception:
109
+ pass
110
+
111
+ # Fallback mappings (ordered from specific to general)
112
+ # POSIX references: errno.h; Windows: common Win32/WSA equivalents when errno/winerror absent
113
+ posix_exceptions = {
114
+ FileNotFoundError: 2, # ENOENT
115
+ PermissionError: 13, # EACCES
116
+ FileExistsError: 17, # EEXIST
117
+ IsADirectoryError: 21, # EISDIR
118
+ NotADirectoryError: 20, # ENOTDIR
119
+ TimeoutError: 110, # ETIMEDOUT
120
+ TypeError: 22, # EINVAL
121
+ ValueError: 22, # EINVAL
122
+ RuntimeError: 1,
123
+ }
124
+ windows_exceptions = {
125
+ FileNotFoundError: 2, # ERROR_FILE_NOT_FOUND
126
+ PermissionError: 5, # ERROR_ACCESS_DENIED
127
+ FileExistsError: 80, # ERROR_FILE_EXISTS
128
+ IsADirectoryError: 267, # ERROR_DIRECTORY
129
+ NotADirectoryError: 267, # ERROR_DIRECTORY
130
+ TimeoutError: 1460, # ERROR_TIMEOUT
131
+ TypeError: 87, # ERROR_INVALID_PARAMETER
132
+ ValueError: 87, # map to invalid parameter
133
+ RuntimeError: 1,
134
+ }
135
+
136
+ # Handle SystemExit robustly
137
+ if isinstance(exc, SystemExit):
138
+ code = getattr(exc, "code", None)
139
+ if isinstance(code, int):
140
+ return code
141
+ if code is None:
142
+ return 0
143
+ try:
144
+ return int(str(code))
145
+ except Exception:
146
+ return 1
147
+
148
+ # At this point, if a sysexits mapping is requested, apply it
149
+ if config.exit_code_style == "sysexits":
150
+ return _sysexits_mapping(exc)
151
+
152
+ if os.name == "posix":
153
+ exceptions = posix_exceptions
154
+ else:
155
+ exceptions = windows_exceptions
156
+
157
+ # Handle all other Exceptions
158
+ for exception in exceptions:
159
+ if isinstance(exc, exception):
160
+ return exceptions[exception]
161
+
162
+ # this should never happen
163
+ return 1
164
+
165
+
166
+ def _sysexits_mapping(exc: BaseException) -> int:
167
+ """Map common exceptions to sysexits(3) style codes.
168
+
169
+ EX_USAGE(64), EX_NOINPUT(66), EX_NOPERM(77), EX_IOERR(74)
170
+ Defaults to 1 if no good fit is found.
171
+ """
172
+ result: int
173
+ if isinstance(exc, SystemExit):
174
+ try:
175
+ result = int(exc.code) # type: ignore[attr-defined]
176
+ except Exception:
177
+ result = 1
178
+ elif isinstance(exc, KeyboardInterrupt):
179
+ result = 130
180
+ elif isinstance(exc, subprocess.CalledProcessError):
181
+ try:
182
+ result = int(exc.returncode)
183
+ except Exception:
184
+ result = 1
185
+ elif isinstance(exc, BrokenPipeError):
186
+ # Broken pipe often considered benign; keep configured behavior
187
+ result = int(config.broken_pipe_exit_code)
188
+ elif isinstance(exc, (TypeError, ValueError)):
189
+ result = 64 # EX_USAGE
190
+ elif isinstance(exc, FileNotFoundError):
191
+ result = 66 # EX_NOINPUT
192
+ elif isinstance(exc, PermissionError):
193
+ result = 77 # EX_NOPERM
194
+ elif isinstance(exc, (OSError, IOError)):
195
+ result = 74 # EX_IOERR
196
+ else:
197
+ result = 1
198
+ return result
199
+
200
+
201
+ def print_exception_message(trace_back: bool = config.traceback, length_limit: int = 500, stream: Optional[TextIO] = None) -> None:
202
+ """
203
+ Prints the Exception Message to stderr. If trace_back is True, it also prints the traceback information.
204
+ If the exception has stdout, stderr attributes (like subprocess.CalledProcessError), those will also be printed.
205
+
206
+ Parameters
207
+ ----------
208
+ trace_back : bool, optional
209
+ Whether to print traceback information. Default is False.
210
+ length_limit : int, optional
211
+ Maximum length of the exception message to be printed. Default is 500.
212
+ stream : Optional[TextIO], optional
213
+ The stream to print to. Default is sys.stderr.
214
+
215
+ Examples
216
+ --------
217
+
218
+ >>> # test with exc_info = None
219
+ >>> print_exception_message()
220
+
221
+ >>> # test with exc_info
222
+ >>> try:
223
+ ... raise FileNotFoundError('unknown_command_test1')
224
+ ... except Exception: # noqa
225
+ ... print_exception_message(True, length_limit=15, stream=sys.stdout)
226
+ ... print_exception_message(False, stream=sys.stdout)
227
+ ... print_exception_message(True, stream=sys.stdout)
228
+ Traceback Info...
229
+
230
+ >>> # test with subprocess to get stdout, stderr
231
+ >>> import subprocess
232
+ >>> try:
233
+ ... discard=subprocess.run('unknown_command_test2_this_should_fail', shell=True, check=True)
234
+ ... except subprocess.CalledProcessError:
235
+ ... print_exception_message(False, stream=sys.stdout)
236
+ ... print_exception_message(True, stream=sys.stdout)
237
+ ... print_exception_message(True, stream=sys.stdout)
238
+ CalledProcessError...
239
+
240
+ """
241
+ flush_streams()
242
+
243
+ if stream is None:
244
+ stream = sys.stderr
245
+
246
+ exc_info = sys.exc_info()[1] # Get the current exception
247
+ if exc_info is not None:
248
+ exc_info_type = type(exc_info).__name__
249
+ exc_info_msg = f"{exc_info_type}: {str(exc_info)}"
250
+
251
+ # Print traceback if trace_back is True
252
+ if trace_back:
253
+ exc_info_msg = f"Traceback Information:\n{traceback.format_exc()}"
254
+
255
+ # If message exceeds length limit, truncate it
256
+ if len(exc_info_msg) > length_limit:
257
+ exc_info_msg = f"{exc_info_msg[:length_limit]} ...[TRUNCATED at {length_limit} characters]"
258
+
259
+ # Print stdout/stderr if they exist in the exception
260
+ _print_output(exc_info, "stdout", stream)
261
+ _print_output(exc_info, "stderr", stream)
262
+
263
+ # Print the exception message
264
+ print(exc_info_msg, file=stream)
265
+ flush_streams()
266
+
267
+
268
+ def _print_output(exc_info: Any, attr: str, stream: Optional[TextIO] = None) -> None:
269
+ """
270
+ Helper function to print an attribute (stdout, stderr) of the exc_info if it exists.
271
+
272
+ Parameters
273
+ ----------
274
+ exc_info : Any
275
+ The exception object that may contain stdout or stderr.
276
+ attr : str
277
+ The attribute name ('stdout' or 'stderr').
278
+ stream : Optional[TextIO]
279
+ The stream to print to. Default is sys.stderr.
280
+
281
+ >>> class ExcInfo(object):
282
+ ... pass
283
+
284
+ >>> my_exc_info = ExcInfo()
285
+
286
+ >>> # test no stdout attribute
287
+ >>> _print_output(my_exc_info, 'stdout')
288
+
289
+ >>> # test stdout=None
290
+ >>> my_exc_info.stdout=None
291
+ >>> _print_output(my_exc_info, 'stdout')
292
+
293
+ >>> # test stdout
294
+ >>> my_exc_info.stdout=b'test'
295
+ >>> _print_output(my_exc_info, 'stdout', stream=sys.stdout)
296
+ STDOUT: test
297
+
298
+ >>> my_exc_info = ExcInfo()
299
+
300
+ >>> # test no stderr attribute
301
+ >>> _print_output(my_exc_info, 'stderr')
302
+
303
+ >>> # test stderr=None
304
+ >>> my_exc_info.stderr=None
305
+ >>> _print_output(my_exc_info, 'stderr')
306
+
307
+ >>> # test stderr
308
+ >>> my_exc_info.stderr=b'test'
309
+ >>> _print_output(my_exc_info, 'stderr', stream=sys.stdout)
310
+ STDERR: test
311
+
312
+ """
313
+ if stream is None:
314
+ stream = sys.stderr
315
+
316
+ if hasattr(exc_info, attr):
317
+ output = getattr(exc_info, attr)
318
+ if output is None:
319
+ return
320
+ text: Optional[str] = None
321
+ if isinstance(output, bytes):
322
+ try:
323
+ text = output.decode("utf-8", errors="replace")
324
+ except Exception:
325
+ text = None
326
+ elif isinstance(output, str):
327
+ text = output
328
+ if text is not None:
329
+ print(f"{attr.upper()}: {text}", file=stream)
330
+
331
+
332
+ def flush_streams() -> None:
333
+ """
334
+ flush the streams - make sure the output is written early,
335
+ otherwise the output might be printed even after another CLI
336
+ command is launched
337
+
338
+
339
+ Examples
340
+ --------
341
+
342
+
343
+ >>> flush_streams()
344
+
345
+ """
346
+ try:
347
+ sys.stdout.flush()
348
+ except Exception:
349
+ pass
350
+ try:
351
+ sys.stderr.flush()
352
+ except Exception:
353
+ pass
File without changes
@@ -0,0 +1,267 @@
1
+ Metadata-Version: 2.4
2
+ Name: lib_cli_exit_tools
3
+ Version: 1.0.0
4
+ Summary: CLI exit handling helpers: clean signals, exit codes, and error printing
5
+ Project-URL: Homepage, https://github.com/bitranox/lib_cli_exit_tools
6
+ Project-URL: Repository, https://github.com/bitranox/lib_cli_exit_tools.git
7
+ Project-URL: Issues, https://github.com/bitranox/lib_cli_exit_tools/issues
8
+ Author-email: bitranox <bitranox@gmail.com>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: cli,click,errors,exit,signals
12
+ Classifier: Environment :: Console
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: click>=8.1
20
+ Provides-Extra: dev
21
+ Requires-Dist: build>=1.2; extra == 'dev'
22
+ Requires-Dist: codecov>=2.1.13; extra == 'dev'
23
+ Requires-Dist: pyright>=1.1; extra == 'dev'
24
+ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.2; extra == 'dev'
26
+ Requires-Dist: ruff>=0.5; extra == 'dev'
27
+ Requires-Dist: twine>=5.1; extra == 'dev'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # lib_cli_exit_tools
31
+
32
+ <!-- Badges -->
33
+ [![CI](https://github.com/bitranox/lib_cli_exit_tools/actions/workflows/ci.yml/badge.svg)](https://github.com/bitranox/lib_cli_exit_tools/actions/workflows/ci.yml)
34
+ [![CodeQL](https://github.com/bitranox/lib_cli_exit_tools/actions/workflows/codeql.yml/badge.svg)](https://github.com/bitranox/lib_cli_exit_tools/actions/workflows/codeql.yml)
35
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
36
+ [![Jupyter](https://img.shields.io/badge/Jupyter-Launch-orange?logo=jupyter)](https://mybinder.org/v2/gh/bitranox/lib_cli_exit_tools/HEAD?labpath=notebooks%2FQuickstart.ipynb)
37
+ [![PyPI](https://img.shields.io/pypi/v/lib_cli_exit_tools.svg)](https://pypi.org/project/lib_cli_exit_tools/)
38
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/lib_cli_exit_tools.svg)](https://pypi.org/project/lib_cli_exit_tools/)
39
+ [![Code Style: Ruff](https://img.shields.io/badge/Code%20Style-Ruff-46A3FF?logo=ruff&labelColor=000)](https://docs.astral.sh/ruff/)
40
+ [![codecov](https://codecov.io/gh/bitranox/lib_cli_exit_tools/graph/badge.svg?token=z1D8JSjWEH)](https://codecov.io/gh/bitranox/lib_cli_exit_tools)
41
+ [![Maintainability](https://qlty.sh/gh/bitranox/projects/lib_cli_exit_tools/maintainability.svg)](https://qlty.sh/gh/bitranox/projects/lib_cli_exit_tools)
42
+ [![Known Vulnerabilities](https://snyk.io/test/github/bitranox/lib_cli_exit_tools/badge.svg)](https://snyk.io/test/github/bitranox/lib_cli_exit_tools)
43
+
44
+ Small helpers for robust CLI exit handling:
45
+ - Portable signal handling (SIGINT, SIGTERM/SIGBREAK)
46
+ - Consistent exception → exit code mapping
47
+ - Concise error printing with optional traceback and subprocess stdout/stderr capture
48
+
49
+ ## Install
50
+
51
+ Pick one of the options below. All methods register the `lib_cli_exit_tools` and `cli-exit-tools` commands on your PATH.
52
+
53
+ ### 1) Standard virtualenv (pip)
54
+
55
+ ```bash
56
+ python -m venv .venv
57
+ source .venv/bin/activate # Windows: .venv\\Scripts\\activate
58
+ pip install -e .[dev] # dev install
59
+ # or for runtime only:
60
+ pip install .
61
+ ```
62
+
63
+ ### 2) Per-user (no venv)
64
+
65
+ ```bash
66
+ pip install --user .
67
+ ```
68
+
69
+ Note: respects PEP 668; avoid on system Python if “externally managed”. Ensure `~/.local/bin` (POSIX) is on PATH.
70
+
71
+ ### 3) pipx (isolated, recommended for end users)
72
+
73
+ ```bash
74
+ pipx install .
75
+ pipx upgrade lib_cli_exit_tools
76
+ # From Git tag/commit:
77
+ pipx install "git+https://github.com/bitranox/lib_cli_exit_tools@v0.1.0"
78
+ ```
79
+
80
+ ### 4) uv (fast installer/runner)
81
+
82
+ ```bash
83
+ uv pip install -e .[dev]
84
+ uv tool install .
85
+ uvx lib_cli_exit_tools --help
86
+ ```
87
+
88
+ ### 5) From artifacts
89
+
90
+ ```bash
91
+ python -m build
92
+ pip install dist/lib_cli_exit_tools-*.whl
93
+ pip install dist/lib_cli_exit_tools-*.tar.gz # sdist
94
+ ```
95
+
96
+ ### 6) Poetry / PDM (project-managed envs)
97
+
98
+ ```bash
99
+ # Poetry
100
+ poetry add lib_cli_exit_tools # as dependency
101
+ poetry install # for local dev
102
+
103
+ # PDM
104
+ pdm add lib_cli_exit_tools
105
+ pdm install
106
+ ```
107
+
108
+ ### 7) From Git via pip (CI-friendly)
109
+
110
+ ```bash
111
+ pip install "git+https://github.com/bitranox/lib_cli_exit_tools@v0.1.0#egg=lib_cli_exit_tools"
112
+ ```
113
+
114
+ ### 8) Conda/mamba (optional)
115
+
116
+ ```bash
117
+ mamba create -n cli-exit python=3.12 pip
118
+ mamba activate cli-exit
119
+ pip install .
120
+ ```
121
+
122
+ ### 9) System package managers (optional distribution)
123
+
124
+ - Homebrew formula (macOS): `brew install lib_cli_exit_tools` (if published)
125
+ - Nix: flake/package for reproducible installs
126
+ - Deb/RPM via `fpm` for OS-native packages
127
+
128
+ ### Make Targets
129
+
130
+ | Target | Description |
131
+ |-------------------|--------------------------------------------------------------------------------------------|
132
+ | `help` | Show help |
133
+ | `install` | Install package editable |
134
+ | `dev` | Install package with dev extras |
135
+ | `test` | Lint, type-check, run tests with coverage, upload to Codecov |
136
+ | `run` | Run module CLI (requires dev install or src on PYTHONPATH) |
137
+ | `version-current` | Print current version from pyproject.toml |
138
+ | `bump` | Bump version: VERSION=X.Y.Z or PART=major |minor|patch (default: patch); updates pyproject.toml and CHANGELOG.md |
139
+ | `bump-patch` | Bump patch version (X.Y.Z -> X.Y.(Z+1)) |
140
+ | `bump-minor` | Bump minor version (X.Y.Z -> X.(Y+1).0) |
141
+ | `bump-major` | Bump major version ((X+1).0.0) |
142
+ | `clean` | Remove caches, build artifacts, and coverage |
143
+ | `push` | Commit all changes once and push to GitHub (no CI monitoring) |
144
+ | `build` | Build wheel/sdist and attempt conda, brew, and nix builds (auto-installs tools if missing) |
145
+
146
+ #### Target Details
147
+
148
+ - `test`: single entry point for local CI — runs ruff lint + format check, pyright, pytest (including doctests) with coverage (enabled by default), and uploads coverage to Codecov if configured (reads `.env`).
149
+ - Auto‑bootstrap: `make test` will try to install dev tools (`pip install -e .[dev]`) if `ruff`/`pyright`/`pytest` are missing. Set `SKIP_BOOTSTRAP=1` to skip this behavior.
150
+ - `build`: convenient builder — creates Python wheel/sdist, then attempts Conda, Homebrew, and Nix builds. It auto‑installs missing tools (Miniforge, Homebrew, Nix) when needed.
151
+ - `install`/`dev`/`user-install`: common install flows for editable or per‑user installs.
152
+ - `version-current`: prints current version from `pyproject.toml`.
153
+ - `bump`: updates `pyproject.toml` version and inserts a new section in `CHANGELOG.md`. Use `VERSION=X.Y.Z make bump` or `make bump-minor`/`bump-major`/`bump-patch`.
154
+ - `pipx-*` and `uv-*`: isolated CLI installations for end users and fast developer tooling.
155
+ - `which-cmd`/`verify-install`: quick diagnostics to ensure the command is on PATH.
156
+
157
+ ## Usage
158
+
159
+ Console script:
160
+
161
+ ```bash
162
+ # After install (pip/pipx/uv tool)
163
+ lib_cli_exit_tools --help
164
+ cli-exit-tools --help # alias
165
+ lib_cli_exit_tools info
166
+ ```
167
+
168
+ Library:
169
+
170
+ ```python
171
+ import lib_cli_exit_tools
172
+
173
+ lib_cli_exit_tools.config.traceback = False # show short messages
174
+ try:
175
+ raise FileNotFoundError("missing.txt")
176
+ except Exception as e:
177
+ code = lib_cli_exit_tools.get_system_exit_code(e) # 2 on POSIX
178
+ lib_cli_exit_tools.print_exception_message() # prints: FileNotFoundError: missing.txt
179
+ raise SystemExit(code)
180
+ ```
181
+
182
+ Command names registered on install
183
+ - lib_cli_exit_tools (default)
184
+ - cli-exit-tools (alias)
185
+ - python -m lib_cli_exit_tools (module entry)
186
+
187
+ If you installed with --user or in a venv, make sure the corresponding bin directory is on PATH:
188
+ - Linux/macOS venv: .venv/bin
189
+ - Linux/macOS user: ~/.local/bin
190
+ - Windows venv: .venv\Scripts
191
+ - Windows user: %APPDATA%\Python\PythonXY\Scripts
192
+
193
+ ## Exit Codes
194
+
195
+ - SIGINT → 130, SIGTERM → 143 (POSIX), SIGBREAK → 149 (Windows)
196
+ - SystemExit(n) → n
197
+ - Common exceptions map to POSIX/Windows codes (FileNotFoundError, PermissionError, ValueError, etc.)
198
+
199
+ ### Broken pipe behavior
200
+ - Default: exit 141 quietly (128+SIGPIPE), no noisy error output.
201
+ - Configure: `config.broken_pipe_exit_code = 0` to treat as benign truncation, or `32` (EPIPE).
202
+
203
+ ### Sysexits mode (optional)
204
+ - Set `config.exit_code_style = "sysexits"` to map ValueError/TypeError → EX_USAGE(64),
205
+ FileNotFoundError → EX_NOINPUT(66), PermissionError → EX_NOPERM(77), generic OSError → EX_IOERR(74).
206
+
207
+ ## Development
208
+
209
+ ```bash
210
+ make test # ruff + pyright + pytest + coverage (default ON)
211
+ SKIP_BOOTSTRAP=1 make test # skip auto-install of dev deps
212
+ COVERAGE=off make test # disable coverage locally
213
+ COVERAGE=on make test # force coverage and generate coverage.xml/codecov.xml
214
+ ```
215
+
216
+ ### Packaging sync (Conda/Brew/Nix)
217
+
218
+ - `make test` and `make push` automatically align the packaging skeletons in `packaging/` with the current `pyproject.toml`:
219
+ - Conda: updates `{% set version = "X.Y.Z" %}` and both `python >=X.Y` constraints to match `requires-python`.
220
+ - Homebrew: updates the source URL tag to `vX.Y.Z` and sets `depends_on "python@X.Y"` to match `requires-python`.
221
+ - Nix: updates the package `version`, example `rev = "vX.Y.Z"`, and switches `pkgs.pythonXYZPackages` / `pkgs.pythonXYZ` to match the minimum Python version from `requires-python`.
222
+
223
+ - To run just the sync without bumping versions: `python tools/bump_version.py --sync-packaging`.
224
+
225
+ - On release tags (`v*.*.*`), CI validates that packaging files are consistent with `pyproject.toml` and will fail if they drift.
226
+
227
+ ## Versioning & Metadata
228
+
229
+ - Single source of truth for package metadata is `pyproject.toml` (`[project]`).
230
+ - The library reads its own installed metadata at runtime via `importlib.metadata` (see `src/lib_cli_exit_tools/__init__conf__.py`).
231
+ - Do not duplicate the version in code; bump only `pyproject.toml` and update `CHANGELOG.md`.
232
+ - Console script name is discovered from entry points; defaults to `lib_cli_exit_tools`.
233
+
234
+ ## Packaging Skeletons
235
+
236
+ Starter files for package managers live under `packaging/`:
237
+
238
+ - Conda: `packaging/conda/recipe/meta.yaml` (update version + sha256)
239
+ - Homebrew: `packaging/brew/Formula/lib-cli-exit-tools.rb` (fill sha256 and vendored resources)
240
+ - Nix: `packaging/nix/flake.nix` (use working tree or pin to GitHub rev with sha256)
241
+
242
+ These are templates; fill placeholders (e.g., sha256) before publishing. Version and Python constraints are auto-synced from `pyproject.toml` by `make test`/`make push` and during version bumps.
243
+
244
+ ## CI & Publishing
245
+
246
+ GitHub Actions workflows are included:
247
+
248
+ - `.github/workflows/ci.yml` — lint/type/test, build wheel/sdist, verify pipx and uv installs, Nix and Conda builds (CI-only; no local install required).
249
+ - `.github/workflows/release.yml` — on tags `v*.*.*`, builds artifacts and publishes to PyPI when `PYPI_API_TOKEN` secret is set.
250
+
251
+ To publish a release:
252
+ 1. Bump `pyproject.toml` version and update `CHANGELOG.md`.
253
+ 2. Tag the commit (`git tag v0.1.1 && git push --tags`).
254
+ 3. Ensure `PYPI_API_TOKEN` secret is configured in the repo.
255
+ 4. Release workflow uploads wheel/sdist to PyPI.
256
+
257
+ Conda/Homebrew/Nix: use files in `packaging/` to submit to their ecosystems. CI also attempts builds to validate recipes, but does not publish automatically.
258
+
259
+ ### Local Codecov uploads
260
+
261
+ - `make test` (with coverage enabled) generates `coverage.xml` and `codecov.xml`, then attempts to upload via the Codecov CLI or the bash uploader.
262
+ - For private repos, set `CODECOV_TOKEN` (see `.env.example`) or export it in your shell.
263
+ - For public repos, a token is typically not required.
264
+
265
+ ## License
266
+
267
+ MIT
@@ -0,0 +1,11 @@
1
+ lib_cli_exit_tools/__init__.py,sha256=bmWORhmUzMCxixZyY6kLItgZKNshEO7dW0lzcKdQv88,267
2
+ lib_cli_exit_tools/__init__conf__.py,sha256=0yzJsfOvRnei1f8VuQXeAkQrNNm-OP-RW41jD_RE9oY,3311
3
+ lib_cli_exit_tools/__main__.py,sha256=MHKZ_ae3fSLGTLUUMOx15fWdeOnJSHhq-zslRP5F5Lc,79
4
+ lib_cli_exit_tools/cli.py,sha256=E6oN628_Ej43vgTwjCsepUTyeER_6l21G2O6ve-rAiM,4322
5
+ lib_cli_exit_tools/lib_cli_exit_tools.py,sha256=XAm3v4tGZViaF7uzG3rgVo_RnZn8JvkP8yZ18qJRRE4,10659
6
+ lib_cli_exit_tools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ lib_cli_exit_tools-1.0.0.dist-info/METADATA,sha256=GIp020qyvgnjvjTQZVtILVSlYe4dEWo_FxNT9O0ZC7I,12173
8
+ lib_cli_exit_tools-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
9
+ lib_cli_exit_tools-1.0.0.dist-info/entry_points.txt,sha256=8R-_3UT0KcQ7HQ7idDx6rlG77GhHb0E2N5QnN41dj-M,161
10
+ lib_cli_exit_tools-1.0.0.dist-info/licenses/LICENSE,sha256=6s9SL2InRrwynPjJYiER7ONHBshbGj_yC5EBzfo7l9I,1072
11
+ lib_cli_exit_tools-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,4 @@
1
+ [console_scripts]
2
+ cli-exit-tools = lib_cli_exit_tools.cli:main
3
+ lib-cli-exit-tools = lib_cli_exit_tools.cli:main
4
+ lib_cli_exit_tools = lib_cli_exit_tools.cli:main
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Robert Nowotny
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+