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.
- lib_cli_exit_tools/__init__.py +15 -0
- lib_cli_exit_tools/__init__conf__.py +108 -0
- lib_cli_exit_tools/__main__.py +4 -0
- lib_cli_exit_tools/cli.py +138 -0
- lib_cli_exit_tools/lib_cli_exit_tools.py +353 -0
- lib_cli_exit_tools/py.typed +0 -0
- lib_cli_exit_tools-1.0.0.dist-info/METADATA +267 -0
- lib_cli_exit_tools-1.0.0.dist-info/RECORD +11 -0
- lib_cli_exit_tools-1.0.0.dist-info/WHEEL +4 -0
- lib_cli_exit_tools-1.0.0.dist-info/entry_points.txt +4 -0
- lib_cli_exit_tools-1.0.0.dist-info/licenses/LICENSE +22 -0
|
@@ -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,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
|
+
[](https://github.com/bitranox/lib_cli_exit_tools/actions/workflows/ci.yml)
|
|
34
|
+
[](https://github.com/bitranox/lib_cli_exit_tools/actions/workflows/codeql.yml)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
[](https://mybinder.org/v2/gh/bitranox/lib_cli_exit_tools/HEAD?labpath=notebooks%2FQuickstart.ipynb)
|
|
37
|
+
[](https://pypi.org/project/lib_cli_exit_tools/)
|
|
38
|
+
[](https://pypi.org/project/lib_cli_exit_tools/)
|
|
39
|
+
[](https://docs.astral.sh/ruff/)
|
|
40
|
+
[](https://codecov.io/gh/bitranox/lib_cli_exit_tools)
|
|
41
|
+
[](https://qlty.sh/gh/bitranox/projects/lib_cli_exit_tools)
|
|
42
|
+
[](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,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
|
+
|