lib-cli-exit-tools 2.3.3__tar.gz → 2.4.0__tar.gz
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-2.3.3 → lib_cli_exit_tools-2.4.0}/.gitignore +8 -4
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/CHANGELOG.md +19 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/Makefile +38 -4
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/PKG-INFO +40 -36
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/README.md +29 -25
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/pyproject.toml +11 -11
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/__init__conf__.py +1 -1
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/application/runner.py +71 -24
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/styling.py +5 -4
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_application_runner.py +177 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_cli.py +7 -13
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.codacy/cli-config.yaml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.codacy/cli.sh +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.codacy/codacy.yaml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.codacy/tools-configs/lizard.yaml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.codacy/tools-configs/trivy.yaml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.data_arch_violations.json +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.devcontainer/devcontainer.json +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.devcontainer/settings.json +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.env.example +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/actions/extract-metadata/action.yml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/dependabot.yml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/workflows/codeql.yml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/workflows/default_cicd_public.yml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/workflows/default_release_public.yml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.qlty/qlty.toml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.snyk +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/CONTRIBUTING.md +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/DEVELOPMENT.md +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/INSTALL.md +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/LICENSE +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/codecov.yml +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/docs/system-design/module_reference.md +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/notebooks/Quickstart.ipynb +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/__init__.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/__main__.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/adapters/__init__.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/adapters/signals.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/application/__init__.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/__init__.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/commands.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/group.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/typed_click.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/core/__init__.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/core/configuration.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/core/exit_codes.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/lib_cli_exit_tools.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/py.typed +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/conftest.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_adapters_signals.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_behaviors.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_core_configuration.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_core_exit_codes.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_metadata.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_module_entry.py +0 -0
- {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_signals_integration.py +0 -0
|
@@ -46,7 +46,8 @@ tmp_*
|
|
|
46
46
|
# *.py[cod]: catches .pyc, .pyo, .pyd
|
|
47
47
|
__pycache__/
|
|
48
48
|
*.py[cod]
|
|
49
|
-
|
|
49
|
+
# other compiled artifacts
|
|
50
|
+
*$py.class
|
|
50
51
|
|
|
51
52
|
# pytest, coverage, MyPy, Hypothesis
|
|
52
53
|
.pytest_cache/
|
|
@@ -72,12 +73,15 @@ coverage.xml
|
|
|
72
73
|
# source/package builds
|
|
73
74
|
build/
|
|
74
75
|
dist/
|
|
75
|
-
|
|
76
|
+
# metadata for pip installs
|
|
77
|
+
*.egg-info/
|
|
76
78
|
*.egg
|
|
77
79
|
*.whl
|
|
78
80
|
pip-wheel-metadata/
|
|
79
|
-
|
|
80
|
-
*.
|
|
81
|
+
# PyInstaller metadata
|
|
82
|
+
*.manifest
|
|
83
|
+
# PyInstaller spec files
|
|
84
|
+
*.spec
|
|
81
85
|
|
|
82
86
|
# Eggs, parts, wheels, sdist, etc.
|
|
83
87
|
develop-eggs/
|
|
@@ -4,6 +4,25 @@ All notable changes to this project are documented here. The format follows [Kee
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.4.0] 2026-10-05 20:48:53
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
- `cli_session()` now resolves exit codes the way `run_cli()`'s default handler does. Its handler went straight to a traceback summary plus `get_system_exit_code()`, skipping the resolver chain, so under a session a Click usage error (bad flag, unknown command) exited `1` and printed `NoSuchOption: ...` instead of exiting `2` with Click's usage message, a `ClickException` with its own `exit_code` lost it, and the `signal_specs` passed to the session runner were ignored. The session's `summary_limit`/`verbose_limit` still apply to an exception nothing else claims. CLIs built on `cli_session` now report a usage error as `2`, like Click and like `run_cli`.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `SessionRunner`, a `Protocol` typing the callable `cli_session()` yields. The previous annotation was a union whose first member took only the command, so a type checker rejected `run(cmd, argv=[...])` and callers had to `cast` the session.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- `README.md` is ASCII-only, and its `cli_session` section says how the session's handler resolves exit codes.
|
|
17
|
+
- Raised the dev tool floors.
|
|
18
|
+
|
|
19
|
+
## [2.3.4] 2026-07-29 15:20:59
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- `run_cli()` now returns the exit code a command chose with `ctx.exit(...)` instead of always reporting `0` on the non-exception path. Click is invoked with `standalone_mode=False`, which makes it *return* that code rather than raising it, and `_run_command_with_handler` discarded the value before returning a hardcoded `0`. Only the exception path could therefore produce a non-zero status, so a command that completed normally while reporting a negative *answer* - a host that did not respond, a search that found nothing, anything distinguishing "ran fine, result is no" from "failed" - silently exited `0`. `ClickCommand.main` is annotated `-> object` to match what Click actually returns. A command that simply completes still yields `0`, since Click returns `None` there.
|
|
23
|
+
|
|
24
|
+
No released behaviour changes for existing users: a sweep of every project in this tree found no other consumer calling `ctx.exit()` with a non-zero code, no test pinned the previous behaviour, and the documented `run_cli` contract in the README already described executing the command with `standalone_mode=False` without mentioning that its result was dropped.
|
|
25
|
+
|
|
7
26
|
## [2.3.3] 2026-07-24 16:09:16
|
|
8
27
|
|
|
9
28
|
### Fixed
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# BMK MAKEFILE 3.
|
|
1
|
+
# BMK MAKEFILE 3.18.0
|
|
2
2
|
# do not alter this file - it might be overwritten on new versions of BMK
|
|
3
3
|
# if You want to alter it, remove the first line # BMK MAKEFILE 1.0 - then it is a custom makefile and will not be overwritten
|
|
4
4
|
# bmk Makefile - thin wrapper using `uv tool upgrade` for persistent bmk
|
|
@@ -186,16 +186,50 @@ endif
|
|
|
186
186
|
# BMK_MIN floors the FALLBACK install. The upgrade path does not consult it: uv re-resolves
|
|
187
187
|
# the requirement recorded at install time, which is itself a `bmk>=...`, so it always lands
|
|
188
188
|
# on the newest release and clears the floor anyway.
|
|
189
|
-
BMK_MIN := 3.
|
|
189
|
+
BMK_MIN := 3.18.0
|
|
190
190
|
|
|
191
191
|
# Absent env, missing check, or damaged env - all three mean "do not upgrade, rebuild".
|
|
192
192
|
# `test -x` keeps a fresh machine quiet: there is no interpreter to run yet.
|
|
193
193
|
BMK_INTACT := $(if $(BMK_PY),test -x "$(BMK_PY)" && "$(BMK_PY)" -m bmk_selfcheck,false)
|
|
194
194
|
|
|
195
|
+
# The env this recipe mutates is shared by EVERY repo on the machine, so the mutation has to
|
|
196
|
+
# wait for the bmk processes running out of it. Every bmk started from that env holds a
|
|
197
|
+
# SHARED lock on <uv tool dir>/.bmk-tool.lock for its whole lifetime; these wrappers take
|
|
198
|
+
# the same lock EXCLUSIVE for the length of the upgrade. Without it, a make here deleted the
|
|
199
|
+
# site-packages out from under a bmk minutes into a test suite in an UNRELATED repo, and it
|
|
200
|
+
# surfaced as an ImportError inside bmk's OWN dependencies that cleared on a re-run - so it
|
|
201
|
+
# read as a flake rather than as the environment vanishing mid-gate.
|
|
202
|
+
#
|
|
203
|
+
# `bmk_toollock` is a second stdlib-only TOP-LEVEL module bmk ships, run on the tool env's
|
|
204
|
+
# own interpreter. That is not circular: a uv tool env's bin/python is a SYMLINK to a base
|
|
205
|
+
# interpreter OUTSIDE the tool dir, so the stdlib the guard runs on is not part of the tree
|
|
206
|
+
# uv is about to replace, and the guard is read and compiled before uv is ever spawned.
|
|
207
|
+
#
|
|
208
|
+
# NOT uv's own <uv tool dir>/.lock, which uv does honour: `uv tool upgrade` and `uv tool
|
|
209
|
+
# list` block on it too (measured), and the upgrade runs before every target - so a
|
|
210
|
+
# gate-lifetime shared lock there would make every make on the machine, and every unrelated
|
|
211
|
+
# `uv tool install`, wait for the longest-running gate.
|
|
212
|
+
#
|
|
213
|
+
# $(wildcard) rather than a shell test: on a machine with no tool env yet these expand to
|
|
214
|
+
# NOTHING and the install runs bare, which is exactly what a first install needs.
|
|
215
|
+
#
|
|
216
|
+
# The bounded wait is load-bearing in both directions. An unbounded one would serialise
|
|
217
|
+
# every repo's make behind the longest gate on the machine. A SKIPPED upgrade costs nothing
|
|
218
|
+
# (the next make picks it up), so the upgrade skips. The REPAIR may not skip: proceeding
|
|
219
|
+
# would leave a damaged env in place, so it fails and drops through to the retry.
|
|
220
|
+
#
|
|
221
|
+
# The LAST attempt is deliberately NEVER guarded, and a test pins that. The guard lives
|
|
222
|
+
# inside the env being repaired, so on the path where that env is absent or broken the guard
|
|
223
|
+
# may be broken too; an unguarded last resort is what stops a defective guard from making
|
|
224
|
+
# `make` unrunnable in every repo at once. If bmk_toollock ever falls out of the wheel, every
|
|
225
|
+
# make everywhere drops to a full reinstall - there is a packaging test for exactly that.
|
|
226
|
+
BMK_LOCK := $(if $(wildcard $(BMK_PY)),"$(BMK_PY)" -m bmk_toollock --exclusive --timeout 10 --on-timeout skip --,)
|
|
227
|
+
BMK_LOCK_REPAIR := $(if $(wildcard $(BMK_PY)),"$(BMK_PY)" -m bmk_toollock --exclusive --timeout 60 --on-timeout fail --,)
|
|
228
|
+
|
|
195
229
|
.PHONY: _ensure_bmk
|
|
196
230
|
_ensure_bmk:
|
|
197
|
-
@$(BMK_INTACT) && uv tool upgrade bmk \
|
|
198
|
-
|| uv tool install --reinstall --force "bmk>=$(BMK_MIN)" \
|
|
231
|
+
@$(BMK_INTACT) && $(BMK_LOCK) uv tool upgrade bmk \
|
|
232
|
+
|| $(BMK_LOCK_REPAIR) uv tool install --reinstall --force "bmk>=$(BMK_MIN)" \
|
|
199
233
|
|| uv tool install --reinstall --force "bmk>=$(BMK_MIN)"
|
|
200
234
|
|
|
201
235
|
# ──────────────────────────────────────────────────────────────
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: lib_cli_exit_tools
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.4.0
|
|
4
4
|
Summary: CLI exit handling helpers: clean signals, exit codes, and error printing
|
|
5
5
|
Project-URL: Homepage, https://github.com/bitranox/lib_cli_exit_tools
|
|
6
6
|
Project-URL: Repository, https://github.com/bitranox/lib_cli_exit_tools.git
|
|
@@ -21,23 +21,23 @@ Classifier: Programming Language :: Python :: 3.13
|
|
|
21
21
|
Classifier: Programming Language :: Python :: 3.14
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
|
-
Requires-Dist: rich-click>=1.9.
|
|
24
|
+
Requires-Dist: rich-click>=1.9.9
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: bandit>=1.9.4; extra == 'dev'
|
|
27
|
-
Requires-Dist: build>=1.
|
|
28
|
-
Requires-Dist: click>=8.
|
|
29
|
-
Requires-Dist: coverage[toml]>=7.
|
|
30
|
-
Requires-Dist: hypothesis>=6.
|
|
31
|
-
Requires-Dist: import-linter>=2.
|
|
27
|
+
Requires-Dist: build>=1.6.1; extra == 'dev'
|
|
28
|
+
Requires-Dist: click>=8.5.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: coverage[toml]>=7.16.2; extra == 'dev'
|
|
30
|
+
Requires-Dist: hypothesis>=6.168.4; extra == 'dev'
|
|
31
|
+
Requires-Dist: import-linter>=2.15; extra == 'dev'
|
|
32
32
|
Requires-Dist: pip-audit>=2.10.1; extra == 'dev'
|
|
33
|
-
Requires-Dist: pyright[nodejs]>=1.1.
|
|
33
|
+
Requires-Dist: pyright[nodejs]>=1.1.414; extra == 'dev'
|
|
34
34
|
Requires-Dist: pytest-asyncio>=1.4.0; extra == 'dev'
|
|
35
35
|
Requires-Dist: pytest-cov>=7.1.0; extra == 'dev'
|
|
36
36
|
Requires-Dist: pytest>=9.1.1; extra == 'dev'
|
|
37
37
|
Requires-Dist: rtoml>=0.13.0; extra == 'dev'
|
|
38
|
-
Requires-Dist: ruff>=0.16.
|
|
38
|
+
Requires-Dist: ruff>=0.16.10; extra == 'dev'
|
|
39
39
|
Requires-Dist: textual>=8.2.8; extra == 'dev'
|
|
40
|
-
Requires-Dist: twine>=
|
|
40
|
+
Requires-Dist: twine>=7.0.0; extra == 'dev'
|
|
41
41
|
Description-Content-Type: text/markdown
|
|
42
42
|
|
|
43
43
|
# lib_cli_exit_tools
|
|
@@ -63,15 +63,15 @@ Small helpers for robust CLI exit handling:
|
|
|
63
63
|
|
|
64
64
|
Building CLI applications that behave correctly in shell pipelines, CI/CD systems, and across platforms is surprisingly tricky. This library solves five common pain points:
|
|
65
65
|
|
|
66
|
-
1. **Correct Exit Codes**
|
|
66
|
+
1. **Correct Exit Codes** - Exceptions are automatically mapped to platform-appropriate exit codes (POSIX errno or BSD sysexits). `FileNotFoundError` returns `2`, `PermissionError` returns `13` on POSIX, and `KeyboardInterrupt` returns `130` - matching shell conventions without manual bookkeeping.
|
|
67
67
|
|
|
68
|
-
2. **Portable Signal Handling**
|
|
68
|
+
2. **Portable Signal Handling** - SIGINT (Ctrl+C), SIGTERM, and Windows SIGBREAK are translated into structured Python exceptions with deterministic exit codes. Your CLI behaves consistently whether terminated by a user, a process manager, or a CI runner.
|
|
69
69
|
|
|
70
|
-
3. **Clean Error Output**
|
|
70
|
+
3. **Clean Error Output** - Uncaught exceptions produce concise, coloured error messages by default. Toggle `--traceback` for full Rich-formatted stack traces during debugging - no code changes required.
|
|
71
71
|
|
|
72
|
-
4. **Pipeline-Friendly**
|
|
72
|
+
4. **Pipeline-Friendly** - `BrokenPipeError` is handled gracefully (exit 141 by default, matching `128 + SIGPIPE`), so piping output to `head` or other truncating tools doesn't produce noisy tracebacks.
|
|
73
73
|
|
|
74
|
-
5. **Zero Boilerplate**
|
|
74
|
+
5. **Zero Boilerplate** - Wrap any Click command with `run_cli()` and get signal handling, exit-code translation, and stream flushing in one call. Configuration is centralised and test-friendly via context managers.
|
|
75
75
|
|
|
76
76
|
## Install
|
|
77
77
|
|
|
@@ -101,11 +101,11 @@ The library installs three equivalent console scripts: `lib-cli-exit-tools` (pri
|
|
|
101
101
|
|
|
102
102
|
### Global Options
|
|
103
103
|
|
|
104
|
-
| Option
|
|
105
|
-
|
|
104
|
+
| Option | Default | Description |
|
|
105
|
+
|----------------------------------|---------|--------------------------------------|
|
|
106
106
|
| `--traceback` / `--no-traceback` | `False` | Show full Python traceback on errors |
|
|
107
|
-
| `--version`
|
|
108
|
-
| `-h`, `--help`
|
|
107
|
+
| `--version` | - | Show program version and exit |
|
|
108
|
+
| `-h`, `--help` | - | Show help message and exit |
|
|
109
109
|
|
|
110
110
|
### Commands
|
|
111
111
|
|
|
@@ -126,7 +126,7 @@ lib-cli-exit-tools --traceback fail # show full traceback
|
|
|
126
126
|
|
|
127
127
|
### Examples
|
|
128
128
|
|
|
129
|
-
The snippets below move from a minimal
|
|
129
|
+
The snippets below move from a minimal "hello world" through a production-ready
|
|
130
130
|
CLI that demonstrates configuration hooks and structured error handling.
|
|
131
131
|
|
|
132
132
|
#### 1. Minimal command (copy-paste ready)
|
|
@@ -223,7 +223,7 @@ def info(settings: Settings) -> None:
|
|
|
223
223
|
@cli.command()
|
|
224
224
|
@click.pass_obj
|
|
225
225
|
def fail(settings: Settings) -> None:
|
|
226
|
-
click.echo("About to fail
|
|
226
|
+
click.echo("About to fail...")
|
|
227
227
|
raise RuntimeError("intentional failure for diagnostics")
|
|
228
228
|
|
|
229
229
|
|
|
@@ -244,7 +244,7 @@ if __name__ == "__main__":
|
|
|
244
244
|
|
|
245
245
|
This version parses `--traceback` early (before Click sees it), then wires
|
|
246
246
|
configuration overrides, custom signal specs, and multiple commands into a
|
|
247
|
-
single composition point
|
|
247
|
+
single composition point - mirroring how a production CLI can layer policy logic
|
|
248
248
|
around Click while still delegating exit-code translation to `lib_cli_exit_tools`.
|
|
249
249
|
|
|
250
250
|
#### 4. Custom signal handlers
|
|
@@ -357,20 +357,20 @@ All configuration lives on the module-level `lib_cli_exit_tools.config` object.
|
|
|
357
357
|
from lib_cli_exit_tools import config
|
|
358
358
|
|
|
359
359
|
config.traceback = True # emit full tracebacks instead of short messages
|
|
360
|
-
config.exit_code_style = "sysexits" # emit BSD-style exit codes (EX_USAGE, EX_NOINPUT,
|
|
360
|
+
config.exit_code_style = "sysexits" # emit BSD-style exit codes (EX_USAGE, EX_NOINPUT, ...)
|
|
361
361
|
config.broken_pipe_exit_code = 0 # treat BrokenPipeError as a benign truncation
|
|
362
362
|
```
|
|
363
363
|
|
|
364
364
|
Field reference:
|
|
365
365
|
|
|
366
|
-
| Field
|
|
367
|
-
|
|
368
|
-
| `traceback`
|
|
369
|
-
| `exit_code_style`
|
|
370
|
-
| `broken_pipe_exit_code` | `int`
|
|
371
|
-
| `traceback_force_color` | `bool`
|
|
366
|
+
| Field | Type | Default | Description |
|
|
367
|
+
|-------------------------|---------------------------|-----------|--------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
368
|
+
| `traceback` | `bool` | `False` | When `True`, `handle_cli_exception` renders a full Rich traceback to stderr. The bundled CLI toggles this via `--traceback/--no-traceback`. |
|
|
369
|
+
| `exit_code_style` | `"errno"` \| `"sysexits"` | `"errno"` | Controls exit code mapping. `errno` returns POSIX/Windows-style codes; `sysexits` returns BSD-style semantic codes (EX_USAGE, EX_NOINPUT, etc.). |
|
|
370
|
+
| `broken_pipe_exit_code` | `int` | `141` | Exit status for `BrokenPipeError` (default mirrors `128 + SIGPIPE`). Set to `0` to treat truncation as success. |
|
|
371
|
+
| `traceback_force_color` | `bool` | `False` | Force Rich to emit ANSI-coloured tracebacks even when stderr is not a TTY. Useful for CI logs. |
|
|
372
372
|
|
|
373
|
-
Remember that `config` is module-level
|
|
373
|
+
Remember that `config` is module-level - if you call the library from multiple threads or embed it in another CLI, configure it once during bootstrap before handing control to user code. When you need temporary overrides (for tests or nested CLIs), wrap the change with the built-in context manager so state is restored automatically:
|
|
374
374
|
|
|
375
375
|
```python
|
|
376
376
|
from lib_cli_exit_tools import config_overrides, config
|
|
@@ -427,13 +427,13 @@ python -m scripts.test --coverage=off --verbose
|
|
|
427
427
|
The suite includes OS-aware cases (POSIX, Windows-specific signal handling), so
|
|
428
428
|
run it on each target platform you support to keep coverage consistent.
|
|
429
429
|
|
|
430
|
-
If your environment reports
|
|
430
|
+
If your environment reports "cannot execute" when running `pytest`, the auto-generated entry-point script likely points at a removed interpreter. Reinstall the dev extras or invoke tests with `python -m pytest` (for example, `python -m pytest tests/`).
|
|
431
431
|
|
|
432
432
|
When coverage uploads are skipped (no Codecov token), `make test` still writes `coverage.xml` and `codecov.xml` to the project root so you can inspect results locally or feed them into other tooling.
|
|
433
433
|
|
|
434
434
|
## Public API Reference
|
|
435
435
|
|
|
436
|
-
The package re-exports the helpers below via `lib_cli_exit_tools.__all__`. Import them directly with `from lib_cli_exit_tools import
|
|
436
|
+
The package re-exports the helpers below via `lib_cli_exit_tools.__all__`. Import them directly with `from lib_cli_exit_tools import ...`.
|
|
437
437
|
|
|
438
438
|
### `config`
|
|
439
439
|
Mutable dataclass-like singleton holding process-wide settings. Configure it during CLI startup.
|
|
@@ -457,8 +457,12 @@ Parameters:
|
|
|
457
457
|
|
|
458
458
|
### `cli_session(*, summary_limit=500, verbose_limit=10_000, overrides=None, restore=True)`
|
|
459
459
|
Context manager that snapshots `lib_cli_exit_tools.config`, optionally
|
|
460
|
-
applies temporary overrides, and yields a callable
|
|
461
|
-
`run_cli
|
|
460
|
+
applies temporary overrides, and yields a `SessionRunner`: a callable with
|
|
461
|
+
`run_cli`'s signature. Its exception handler resolves exit codes exactly like
|
|
462
|
+
`handle_cli_exception` - signals, broken pipes, Click exceptions and `SystemExit`
|
|
463
|
+
keep their own code and message, so a usage error exits `2` with Click's usage
|
|
464
|
+
text - and applies `summary_limit`/`verbose_limit` only to an exception none of
|
|
465
|
+
those claims.
|
|
462
466
|
|
|
463
467
|
Parameters:
|
|
464
468
|
- `summary_limit` (`int`, default `500`): Character budget when tracebacks are disabled.
|
|
@@ -466,7 +470,7 @@ Parameters:
|
|
|
466
470
|
- `overrides` (`Mapping[str, object] | None`, default `None`): Mapping of configuration field/value pairs applied during the session. When `traceback` is supplied and `traceback_force_color` is omitted, colour output is automatically forced.
|
|
467
471
|
- `restore` (`bool`, default `True`): When `True`, configuration state is restored after the session. Set to `False` to leave overrides in place once the context exits.
|
|
468
472
|
|
|
469
|
-
Use it to restore configuration automatically
|
|
473
|
+
Use it to restore configuration automatically - even when the wrapped command
|
|
470
474
|
raises:
|
|
471
475
|
|
|
472
476
|
```python
|
|
@@ -21,15 +21,15 @@ Small helpers for robust CLI exit handling:
|
|
|
21
21
|
|
|
22
22
|
Building CLI applications that behave correctly in shell pipelines, CI/CD systems, and across platforms is surprisingly tricky. This library solves five common pain points:
|
|
23
23
|
|
|
24
|
-
1. **Correct Exit Codes**
|
|
24
|
+
1. **Correct Exit Codes** - Exceptions are automatically mapped to platform-appropriate exit codes (POSIX errno or BSD sysexits). `FileNotFoundError` returns `2`, `PermissionError` returns `13` on POSIX, and `KeyboardInterrupt` returns `130` - matching shell conventions without manual bookkeeping.
|
|
25
25
|
|
|
26
|
-
2. **Portable Signal Handling**
|
|
26
|
+
2. **Portable Signal Handling** - SIGINT (Ctrl+C), SIGTERM, and Windows SIGBREAK are translated into structured Python exceptions with deterministic exit codes. Your CLI behaves consistently whether terminated by a user, a process manager, or a CI runner.
|
|
27
27
|
|
|
28
|
-
3. **Clean Error Output**
|
|
28
|
+
3. **Clean Error Output** - Uncaught exceptions produce concise, coloured error messages by default. Toggle `--traceback` for full Rich-formatted stack traces during debugging - no code changes required.
|
|
29
29
|
|
|
30
|
-
4. **Pipeline-Friendly**
|
|
30
|
+
4. **Pipeline-Friendly** - `BrokenPipeError` is handled gracefully (exit 141 by default, matching `128 + SIGPIPE`), so piping output to `head` or other truncating tools doesn't produce noisy tracebacks.
|
|
31
31
|
|
|
32
|
-
5. **Zero Boilerplate**
|
|
32
|
+
5. **Zero Boilerplate** - Wrap any Click command with `run_cli()` and get signal handling, exit-code translation, and stream flushing in one call. Configuration is centralised and test-friendly via context managers.
|
|
33
33
|
|
|
34
34
|
## Install
|
|
35
35
|
|
|
@@ -59,11 +59,11 @@ The library installs three equivalent console scripts: `lib-cli-exit-tools` (pri
|
|
|
59
59
|
|
|
60
60
|
### Global Options
|
|
61
61
|
|
|
62
|
-
| Option
|
|
63
|
-
|
|
62
|
+
| Option | Default | Description |
|
|
63
|
+
|----------------------------------|---------|--------------------------------------|
|
|
64
64
|
| `--traceback` / `--no-traceback` | `False` | Show full Python traceback on errors |
|
|
65
|
-
| `--version`
|
|
66
|
-
| `-h`, `--help`
|
|
65
|
+
| `--version` | - | Show program version and exit |
|
|
66
|
+
| `-h`, `--help` | - | Show help message and exit |
|
|
67
67
|
|
|
68
68
|
### Commands
|
|
69
69
|
|
|
@@ -84,7 +84,7 @@ lib-cli-exit-tools --traceback fail # show full traceback
|
|
|
84
84
|
|
|
85
85
|
### Examples
|
|
86
86
|
|
|
87
|
-
The snippets below move from a minimal
|
|
87
|
+
The snippets below move from a minimal "hello world" through a production-ready
|
|
88
88
|
CLI that demonstrates configuration hooks and structured error handling.
|
|
89
89
|
|
|
90
90
|
#### 1. Minimal command (copy-paste ready)
|
|
@@ -181,7 +181,7 @@ def info(settings: Settings) -> None:
|
|
|
181
181
|
@cli.command()
|
|
182
182
|
@click.pass_obj
|
|
183
183
|
def fail(settings: Settings) -> None:
|
|
184
|
-
click.echo("About to fail
|
|
184
|
+
click.echo("About to fail...")
|
|
185
185
|
raise RuntimeError("intentional failure for diagnostics")
|
|
186
186
|
|
|
187
187
|
|
|
@@ -202,7 +202,7 @@ if __name__ == "__main__":
|
|
|
202
202
|
|
|
203
203
|
This version parses `--traceback` early (before Click sees it), then wires
|
|
204
204
|
configuration overrides, custom signal specs, and multiple commands into a
|
|
205
|
-
single composition point
|
|
205
|
+
single composition point - mirroring how a production CLI can layer policy logic
|
|
206
206
|
around Click while still delegating exit-code translation to `lib_cli_exit_tools`.
|
|
207
207
|
|
|
208
208
|
#### 4. Custom signal handlers
|
|
@@ -315,20 +315,20 @@ All configuration lives on the module-level `lib_cli_exit_tools.config` object.
|
|
|
315
315
|
from lib_cli_exit_tools import config
|
|
316
316
|
|
|
317
317
|
config.traceback = True # emit full tracebacks instead of short messages
|
|
318
|
-
config.exit_code_style = "sysexits" # emit BSD-style exit codes (EX_USAGE, EX_NOINPUT,
|
|
318
|
+
config.exit_code_style = "sysexits" # emit BSD-style exit codes (EX_USAGE, EX_NOINPUT, ...)
|
|
319
319
|
config.broken_pipe_exit_code = 0 # treat BrokenPipeError as a benign truncation
|
|
320
320
|
```
|
|
321
321
|
|
|
322
322
|
Field reference:
|
|
323
323
|
|
|
324
|
-
| Field
|
|
325
|
-
|
|
326
|
-
| `traceback`
|
|
327
|
-
| `exit_code_style`
|
|
328
|
-
| `broken_pipe_exit_code` | `int`
|
|
329
|
-
| `traceback_force_color` | `bool`
|
|
324
|
+
| Field | Type | Default | Description |
|
|
325
|
+
|-------------------------|---------------------------|-----------|--------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
326
|
+
| `traceback` | `bool` | `False` | When `True`, `handle_cli_exception` renders a full Rich traceback to stderr. The bundled CLI toggles this via `--traceback/--no-traceback`. |
|
|
327
|
+
| `exit_code_style` | `"errno"` \| `"sysexits"` | `"errno"` | Controls exit code mapping. `errno` returns POSIX/Windows-style codes; `sysexits` returns BSD-style semantic codes (EX_USAGE, EX_NOINPUT, etc.). |
|
|
328
|
+
| `broken_pipe_exit_code` | `int` | `141` | Exit status for `BrokenPipeError` (default mirrors `128 + SIGPIPE`). Set to `0` to treat truncation as success. |
|
|
329
|
+
| `traceback_force_color` | `bool` | `False` | Force Rich to emit ANSI-coloured tracebacks even when stderr is not a TTY. Useful for CI logs. |
|
|
330
330
|
|
|
331
|
-
Remember that `config` is module-level
|
|
331
|
+
Remember that `config` is module-level - if you call the library from multiple threads or embed it in another CLI, configure it once during bootstrap before handing control to user code. When you need temporary overrides (for tests or nested CLIs), wrap the change with the built-in context manager so state is restored automatically:
|
|
332
332
|
|
|
333
333
|
```python
|
|
334
334
|
from lib_cli_exit_tools import config_overrides, config
|
|
@@ -385,13 +385,13 @@ python -m scripts.test --coverage=off --verbose
|
|
|
385
385
|
The suite includes OS-aware cases (POSIX, Windows-specific signal handling), so
|
|
386
386
|
run it on each target platform you support to keep coverage consistent.
|
|
387
387
|
|
|
388
|
-
If your environment reports
|
|
388
|
+
If your environment reports "cannot execute" when running `pytest`, the auto-generated entry-point script likely points at a removed interpreter. Reinstall the dev extras or invoke tests with `python -m pytest` (for example, `python -m pytest tests/`).
|
|
389
389
|
|
|
390
390
|
When coverage uploads are skipped (no Codecov token), `make test` still writes `coverage.xml` and `codecov.xml` to the project root so you can inspect results locally or feed them into other tooling.
|
|
391
391
|
|
|
392
392
|
## Public API Reference
|
|
393
393
|
|
|
394
|
-
The package re-exports the helpers below via `lib_cli_exit_tools.__all__`. Import them directly with `from lib_cli_exit_tools import
|
|
394
|
+
The package re-exports the helpers below via `lib_cli_exit_tools.__all__`. Import them directly with `from lib_cli_exit_tools import ...`.
|
|
395
395
|
|
|
396
396
|
### `config`
|
|
397
397
|
Mutable dataclass-like singleton holding process-wide settings. Configure it during CLI startup.
|
|
@@ -415,8 +415,12 @@ Parameters:
|
|
|
415
415
|
|
|
416
416
|
### `cli_session(*, summary_limit=500, verbose_limit=10_000, overrides=None, restore=True)`
|
|
417
417
|
Context manager that snapshots `lib_cli_exit_tools.config`, optionally
|
|
418
|
-
applies temporary overrides, and yields a callable
|
|
419
|
-
`run_cli
|
|
418
|
+
applies temporary overrides, and yields a `SessionRunner`: a callable with
|
|
419
|
+
`run_cli`'s signature. Its exception handler resolves exit codes exactly like
|
|
420
|
+
`handle_cli_exception` - signals, broken pipes, Click exceptions and `SystemExit`
|
|
421
|
+
keep their own code and message, so a usage error exits `2` with Click's usage
|
|
422
|
+
text - and applies `summary_limit`/`verbose_limit` only to an exception none of
|
|
423
|
+
those claims.
|
|
420
424
|
|
|
421
425
|
Parameters:
|
|
422
426
|
- `summary_limit` (`int`, default `500`): Character budget when tracebacks are disabled.
|
|
@@ -424,7 +428,7 @@ Parameters:
|
|
|
424
428
|
- `overrides` (`Mapping[str, object] | None`, default `None`): Mapping of configuration field/value pairs applied during the session. When `traceback` is supplied and `traceback_force_color` is omitted, colour output is automatically forced.
|
|
425
429
|
- `restore` (`bool`, default `True`): When `True`, configuration state is restored after the session. Set to `False` to leave overrides in place once the context exits.
|
|
426
430
|
|
|
427
|
-
Use it to restore configuration automatically
|
|
431
|
+
Use it to restore configuration automatically - even when the wrapped command
|
|
428
432
|
raises:
|
|
429
433
|
|
|
430
434
|
```python
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "lib_cli_exit_tools"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.4.0"
|
|
4
4
|
description = "CLI exit handling helpers: clean signals, exit codes, and error printing"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.10"
|
|
7
|
-
dependencies = ["rich-click>=1.9.
|
|
7
|
+
dependencies = ["rich-click>=1.9.9"]
|
|
8
8
|
license = { text = "MIT" }
|
|
9
9
|
authors = [{ name = "bitranox", email = "bitranox@gmail.com" }]
|
|
10
10
|
keywords = ["cli", "exit", "signals", "errors", "click"]
|
|
@@ -40,7 +40,7 @@ Issues = "https://github.com/bitranox/lib_cli_exit_tools/issues"
|
|
|
40
40
|
dev = [
|
|
41
41
|
# 1.5.1 was yanked upstream (unintended breaking changes), so 1.5.0 is the newest usable
|
|
42
42
|
# release; a >=1.5.1 floor has ZERO candidates and makes this extra unresolvable.
|
|
43
|
-
"build>=1.
|
|
43
|
+
"build>=1.6.1",
|
|
44
44
|
# codecov-cli is disabled, not dropped: it requires click<8.3.0, which drags click back to
|
|
45
45
|
# 8.2.1 - the version with CVE-2026-7246 (command injection in click.edit(), fixed in 8.3.3).
|
|
46
46
|
# Nothing else here caps click, so with this line active the audit gate goes red on every
|
|
@@ -49,20 +49,20 @@ dev = [
|
|
|
49
49
|
# Re-enable once codecov-cli relaxes its click<8.3.0 pin.
|
|
50
50
|
# "codecov-cli>=11.2.8",
|
|
51
51
|
# Transitive CVE pins
|
|
52
|
-
"click>=8.
|
|
53
|
-
"coverage[toml]>=7.
|
|
54
|
-
"hypothesis>=6.
|
|
55
|
-
"import-linter>=2.
|
|
52
|
+
"click>=8.5.0", # CVE-2026-7246
|
|
53
|
+
"coverage[toml]>=7.16.2",
|
|
54
|
+
"hypothesis>=6.168.4",
|
|
55
|
+
"import-linter>=2.15",
|
|
56
56
|
"bandit>=1.9.4",
|
|
57
57
|
"pip-audit>=2.10.1",
|
|
58
|
-
"pyright[nodejs]>=1.1.
|
|
58
|
+
"pyright[nodejs]>=1.1.414",
|
|
59
59
|
"pytest>=9.1.1",
|
|
60
60
|
"pytest-asyncio>=1.4.0",
|
|
61
61
|
"pytest-cov>=7.1.0",
|
|
62
|
-
"ruff>=0.16.
|
|
62
|
+
"ruff>=0.16.10",
|
|
63
63
|
"textual>=8.2.8",
|
|
64
64
|
"rtoml>=0.13.0",
|
|
65
|
-
"twine>=
|
|
65
|
+
"twine>=7.0.0"
|
|
66
66
|
]
|
|
67
67
|
|
|
68
68
|
[project.scripts]
|
|
@@ -72,7 +72,7 @@ cli-exit-tools = "lib_cli_exit_tools.cli:main"
|
|
|
72
72
|
lib-cli-exit-tools = "lib_cli_exit_tools.cli:main"
|
|
73
73
|
|
|
74
74
|
[build-system]
|
|
75
|
-
requires = ["hatchling>=1.
|
|
75
|
+
requires = ["hatchling>=1.32.4"]
|
|
76
76
|
build-backend = "hatchling.build"
|
|
77
77
|
|
|
78
78
|
[tool.hatch.build.targets.wheel]
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/__init__conf__.py
RENAMED
|
@@ -26,7 +26,7 @@ name = "lib_cli_exit_tools"
|
|
|
26
26
|
#: Human-readable summary shown in CLI help output.
|
|
27
27
|
title = "CLI exit handling helpers: clean signals, exit codes, and error printing"
|
|
28
28
|
#: Current release version pulled from ``pyproject.toml`` by automation.
|
|
29
|
-
version = "2.
|
|
29
|
+
version = "2.4.0"
|
|
30
30
|
#: Repository homepage presented to users.
|
|
31
31
|
homepage = "https://github.com/bitranox/lib_cli_exit_tools"
|
|
32
32
|
#: Author attribution surfaced in CLI output.
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/application/runner.py
RENAMED
|
@@ -60,11 +60,14 @@ class ClickCommand(Protocol):
|
|
|
60
60
|
complete_var: str | None = ...,
|
|
61
61
|
standalone_mode: bool = ..., # noqa: FBT001 - mirrors Click's actual positional main() signature
|
|
62
62
|
**_: object,
|
|
63
|
-
) ->
|
|
63
|
+
) -> object:
|
|
64
|
+
"""Return Click's own result: ctx.exit()'s code, or None on success."""
|
|
65
|
+
...
|
|
64
66
|
|
|
65
67
|
|
|
66
68
|
__all__ = [
|
|
67
69
|
"SessionOverrides",
|
|
70
|
+
"SessionRunner",
|
|
68
71
|
"cli_session",
|
|
69
72
|
"flush_streams",
|
|
70
73
|
"handle_cli_exception",
|
|
@@ -73,6 +76,22 @@ __all__ = [
|
|
|
73
76
|
]
|
|
74
77
|
|
|
75
78
|
|
|
79
|
+
class SessionRunner(Protocol):
|
|
80
|
+
"""The callable :func:`cli_session` yields: :func:`run_cli` with the session's handler."""
|
|
81
|
+
|
|
82
|
+
def __call__( # pragma: no cover - structural typing
|
|
83
|
+
self,
|
|
84
|
+
command: ClickCommand,
|
|
85
|
+
*,
|
|
86
|
+
argv: Sequence[str] | None = None,
|
|
87
|
+
prog_name: str | None = None,
|
|
88
|
+
signal_specs: Sequence[SignalSpec] | None = None,
|
|
89
|
+
install_signals: bool = True,
|
|
90
|
+
exception_handler: Callable[[BaseException], int] | None = None,
|
|
91
|
+
signal_installer: Callable[[Sequence[SignalSpec] | None], Callable[[], None]] | None = None,
|
|
92
|
+
) -> int: ...
|
|
93
|
+
|
|
94
|
+
|
|
76
95
|
class _Echo(Protocol):
|
|
77
96
|
"""Protocol describing the echo interface expected by error handlers."""
|
|
78
97
|
|
|
@@ -358,7 +377,7 @@ def handle_cli_exception(
|
|
|
358
377
|
|
|
359
378
|
specs = _resolve_signal_specs(signal_specs)
|
|
360
379
|
echo_fn = _choose_echo(echo)
|
|
361
|
-
return _resolve_exit_code(exc, specs, echo_fn)
|
|
380
|
+
return _resolve_exit_code(exc, specs, echo_fn, fallback=_render_and_translate)
|
|
362
381
|
|
|
363
382
|
|
|
364
383
|
def _choose_echo(echo: _Echo | None) -> _Echo:
|
|
@@ -370,13 +389,20 @@ def _resolve_exit_code(
|
|
|
370
389
|
exc: BaseException,
|
|
371
390
|
specs: Sequence[SignalSpec],
|
|
372
391
|
echo: _Echo,
|
|
392
|
+
*,
|
|
393
|
+
fallback: Callable[[BaseException], int],
|
|
373
394
|
) -> int:
|
|
374
|
-
"""Walk the resolver chain
|
|
395
|
+
"""Walk the resolver chain; hand an exception none of them claims to ``fallback``.
|
|
396
|
+
|
|
397
|
+
Signals, broken pipes, Click exceptions and ``SystemExit`` carry their own
|
|
398
|
+
exit code and message, so only an otherwise unhandled exception reaches
|
|
399
|
+
``fallback``, which renders it and maps it to a code.
|
|
400
|
+
"""
|
|
375
401
|
for resolver in _exception_resolvers(specs, echo):
|
|
376
402
|
code = resolver(exc)
|
|
377
403
|
if code is not None:
|
|
378
404
|
return code
|
|
379
|
-
return
|
|
405
|
+
return fallback(exc)
|
|
380
406
|
|
|
381
407
|
|
|
382
408
|
def _exception_resolvers(
|
|
@@ -464,15 +490,7 @@ def cli_session(
|
|
|
464
490
|
verbose_limit: int = 10_000,
|
|
465
491
|
overrides: SessionOverrides | None = None,
|
|
466
492
|
restore: bool = True,
|
|
467
|
-
) -> Generator[
|
|
468
|
-
Callable[
|
|
469
|
-
[
|
|
470
|
-
ClickCommand,
|
|
471
|
-
],
|
|
472
|
-
int,
|
|
473
|
-
]
|
|
474
|
-
| Callable[..., int]
|
|
475
|
-
]:
|
|
493
|
+
) -> Generator[SessionRunner]:
|
|
476
494
|
"""Provide a managed execution context around :func:`run_cli`.
|
|
477
495
|
|
|
478
496
|
Why
|
|
@@ -507,7 +525,7 @@ def cli_session(
|
|
|
507
525
|
|
|
508
526
|
Yields
|
|
509
527
|
------
|
|
510
|
-
|
|
528
|
+
SessionRunner
|
|
511
529
|
Function that accepts a Click command and forwards optional ``run_cli``
|
|
512
530
|
keyword arguments, returning the resulting exit code.
|
|
513
531
|
"""
|
|
@@ -516,7 +534,6 @@ def cli_session(
|
|
|
516
534
|
manager = _session_config_manager(applied, restore=restore)
|
|
517
535
|
|
|
518
536
|
with manager:
|
|
519
|
-
handler = _session_exception_handler(summary_limit, verbose_limit)
|
|
520
537
|
|
|
521
538
|
def _run(
|
|
522
539
|
command: ClickCommand,
|
|
@@ -528,7 +545,11 @@ def cli_session(
|
|
|
528
545
|
exception_handler: Callable[[BaseException], int] | None = None,
|
|
529
546
|
signal_installer: Callable[[Sequence[SignalSpec] | None], Callable[[], None]] | None = None,
|
|
530
547
|
) -> int:
|
|
531
|
-
chosen_handler = exception_handler or
|
|
548
|
+
chosen_handler = exception_handler or _session_exception_handler(
|
|
549
|
+
summary_limit=summary_limit,
|
|
550
|
+
verbose_limit=verbose_limit,
|
|
551
|
+
signal_specs=signal_specs,
|
|
552
|
+
)
|
|
532
553
|
return run_cli(
|
|
533
554
|
command,
|
|
534
555
|
argv=argv,
|
|
@@ -585,15 +606,30 @@ def _apply_overrides_without_restore(applied: SessionOverrides) -> Generator[Non
|
|
|
585
606
|
yield
|
|
586
607
|
|
|
587
608
|
|
|
588
|
-
def _session_exception_handler(
|
|
589
|
-
|
|
609
|
+
def _session_exception_handler(
|
|
610
|
+
*,
|
|
611
|
+
summary_limit: int,
|
|
612
|
+
verbose_limit: int,
|
|
613
|
+
signal_specs: Sequence[SignalSpec] | None,
|
|
614
|
+
) -> Callable[[BaseException], int]:
|
|
615
|
+
"""Build the exception handler used inside :func:`cli_session`.
|
|
590
616
|
|
|
591
|
-
|
|
617
|
+
It runs the same resolver chain as :func:`handle_cli_exception`, so a Click
|
|
618
|
+
usage error exits with Click's own code and message under a session too;
|
|
619
|
+
the session's character budgets apply only to the exception nothing else
|
|
620
|
+
claims.
|
|
621
|
+
"""
|
|
622
|
+
specs = _resolve_signal_specs(signal_specs)
|
|
623
|
+
|
|
624
|
+
def _render_with_session_limits(exc: BaseException) -> int:
|
|
592
625
|
active = bool(config.traceback)
|
|
593
626
|
limit = verbose_limit if active else summary_limit
|
|
594
627
|
print_exception_message(trace_back=active, length_limit=limit)
|
|
595
628
|
return get_system_exit_code(exc)
|
|
596
629
|
|
|
630
|
+
def _handler(exc: BaseException) -> int:
|
|
631
|
+
return _resolve_exit_code(exc, specs, _default_echo, fallback=_render_with_session_limits)
|
|
632
|
+
|
|
597
633
|
return _handler
|
|
598
634
|
|
|
599
635
|
|
|
@@ -681,10 +717,15 @@ def _run_command_with_handler(
|
|
|
681
717
|
) -> int:
|
|
682
718
|
"""Invoke the Click command and delegate failures to ``handler``."""
|
|
683
719
|
try:
|
|
684
|
-
_invoke_command(cli, argv, prog_name)
|
|
720
|
+
outcome = _invoke_command(cli, argv, prog_name)
|
|
685
721
|
except BaseException as exc: # single funnel for exit codes; handler translates every exception type
|
|
686
722
|
return handler(exc)
|
|
687
|
-
return
|
|
723
|
+
# Click is invoked with standalone_mode=False, which makes it *return*
|
|
724
|
+
# ctx.exit()'s code rather than raising it. That is how a command reports a
|
|
725
|
+
# negative answer - nothing found, host did not respond - as distinct from
|
|
726
|
+
# an error. Discarding this value would collapse every such outcome to
|
|
727
|
+
# success; None is Click's plain-success result and stays 0.
|
|
728
|
+
return outcome if isinstance(outcome, int) else 0
|
|
688
729
|
|
|
689
730
|
|
|
690
731
|
def _finalise_cli_run(restorer: Callable[[], None] | None) -> None:
|
|
@@ -693,9 +734,15 @@ def _finalise_cli_run(restorer: Callable[[], None] | None) -> None:
|
|
|
693
734
|
flush_streams()
|
|
694
735
|
|
|
695
736
|
|
|
696
|
-
def _invoke_command(cli: ClickCommand, argv: Sequence[str] | None, prog_name: str | None) ->
|
|
697
|
-
"""Invoke the Click command with ``standalone_mode`` disabled.
|
|
698
|
-
|
|
737
|
+
def _invoke_command(cli: ClickCommand, argv: Sequence[str] | None, prog_name: str | None) -> object:
|
|
738
|
+
"""Invoke the Click command with ``standalone_mode`` disabled.
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
Whatever Click returns: the code passed to ``ctx.exit()``, or ``None``
|
|
742
|
+
when the command simply completed.
|
|
743
|
+
"""
|
|
744
|
+
|
|
745
|
+
return cli.main(args=_normalised_args(argv), standalone_mode=False, prog_name=prog_name)
|
|
699
746
|
|
|
700
747
|
|
|
701
748
|
def _normalised_args(argv: Sequence[str] | None) -> Sequence[str] | None:
|
|
@@ -15,10 +15,10 @@ System Integration:
|
|
|
15
15
|
|
|
16
16
|
from __future__ import annotations
|
|
17
17
|
|
|
18
|
+
import sys
|
|
18
19
|
from contextlib import contextmanager
|
|
19
20
|
from typing import TYPE_CHECKING, Any, TypedDict
|
|
20
21
|
|
|
21
|
-
import rich_click as click
|
|
22
22
|
from rich_click import rich_click as rich_config
|
|
23
23
|
|
|
24
24
|
if TYPE_CHECKING:
|
|
@@ -104,9 +104,10 @@ def _temporary_rich_click_configuration() -> Generator[None]: # pyright: ignore
|
|
|
104
104
|
"""Apply plain-output safeguards and restore rich-click globals afterwards."""
|
|
105
105
|
|
|
106
106
|
snapshot = _snapshot_rich_click_options()
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
107
|
+
# sys.stdout/sys.stderr, not click's resolution of them: rich renders through
|
|
108
|
+
# its own Console on these streams, and click 8.5.0 deprecated get_text_stream
|
|
109
|
+
# (removed in 9.0) with no supported replacement.
|
|
110
|
+
if _needs_plain_output(sys.stdout) and _needs_plain_output(sys.stderr):
|
|
110
111
|
_prefer_ascii_layout()
|
|
111
112
|
try:
|
|
112
113
|
yield
|
|
@@ -417,6 +417,90 @@ def test_session_config_manager_with_no_overrides_returns_null_context() -> None
|
|
|
417
417
|
pass # Should not raise
|
|
418
418
|
|
|
419
419
|
|
|
420
|
+
class _ExitThree(click.ClickException):
|
|
421
|
+
"""A Click exception that chooses its own exit code."""
|
|
422
|
+
|
|
423
|
+
exit_code = 3
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class _HangupError(Exception):
|
|
427
|
+
"""A caller-defined signal exception for signal-spec tests."""
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
@click.command("ok")
|
|
431
|
+
def _ok_command() -> None:
|
|
432
|
+
"""Succeed."""
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
@click.command("chosen")
|
|
436
|
+
def _chosen_command() -> None:
|
|
437
|
+
"""Fail with a Click exception that sets exit_code 3."""
|
|
438
|
+
raise _ExitThree("chosen failure")
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
@click.command("boom")
|
|
442
|
+
def _boom_command() -> None:
|
|
443
|
+
"""Fail with an ordinary exception."""
|
|
444
|
+
raise RuntimeError("boom")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
@click.command("hangup")
|
|
448
|
+
def _hangup_command() -> None:
|
|
449
|
+
"""Raise the caller-defined signal exception."""
|
|
450
|
+
raise _HangupError
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _session_probe_cli() -> click.Group:
|
|
454
|
+
"""Build a real Click group whose commands cover every exit-code path."""
|
|
455
|
+
return click.Group("probe", commands=[_ok_command, _chosen_command, _boom_command, _hangup_command])
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@pytest.mark.os_agnostic
|
|
459
|
+
def test_a_usage_error_under_cli_session_exits_two_with_clicks_usage_message(reset_config: None, capsys: pytest.CaptureFixture[str]) -> None:
|
|
460
|
+
with runner.cli_session() as execute:
|
|
461
|
+
code = execute(_session_probe_cli(), argv=["--bad-flag"], install_signals=False)
|
|
462
|
+
|
|
463
|
+
err = capsys.readouterr().err
|
|
464
|
+
assert code == 2
|
|
465
|
+
assert "Usage:" in err
|
|
466
|
+
assert "No such option" in err
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
@pytest.mark.os_agnostic
|
|
470
|
+
@pytest.mark.parametrize(
|
|
471
|
+
"argv",
|
|
472
|
+
[["ok"], ["--bad-flag"], ["no-such-command"], ["ok", "--bad-flag"], ["chosen"], ["boom"]],
|
|
473
|
+
ids=["success", "bad-flag", "unknown-command", "bad-subcommand-flag", "click-chosen-code", "plain-exception"],
|
|
474
|
+
)
|
|
475
|
+
def test_cli_session_exits_with_the_code_run_cli_gives(reset_config: None, argv: list[str]) -> None:
|
|
476
|
+
expected = runner.run_cli(_session_probe_cli(), argv=argv, install_signals=False)
|
|
477
|
+
|
|
478
|
+
with runner.cli_session() as execute:
|
|
479
|
+
code = execute(_session_probe_cli(), argv=argv, install_signals=False)
|
|
480
|
+
|
|
481
|
+
assert code == expected
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
@pytest.mark.os_agnostic
|
|
485
|
+
def test_cli_session_honours_the_signal_specs_its_caller_passes(reset_config: None, capsys: pytest.CaptureFixture[str]) -> None:
|
|
486
|
+
specs = [SignalSpec(signum=0, exception=_HangupError, message="hung up", exit_code=129)]
|
|
487
|
+
|
|
488
|
+
with runner.cli_session() as execute:
|
|
489
|
+
code = execute(_session_probe_cli(), argv=["hangup"], signal_specs=specs, install_signals=False)
|
|
490
|
+
|
|
491
|
+
assert code == 129
|
|
492
|
+
assert "hung up" in capsys.readouterr().err
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
@pytest.mark.os_agnostic
|
|
496
|
+
def test_cli_session_still_truncates_an_unhandled_exception_at_its_summary_limit(reset_config: None, capsys: pytest.CaptureFixture[str]) -> None:
|
|
497
|
+
with runner.cli_session(summary_limit=5) as execute:
|
|
498
|
+
code = execute(_session_probe_cli(), argv=["boom"], install_signals=False)
|
|
499
|
+
|
|
500
|
+
assert code != 0
|
|
501
|
+
assert "[TRUNCATED at 5 characters]" in capsys.readouterr().err
|
|
502
|
+
|
|
503
|
+
|
|
420
504
|
# =============================================================================
|
|
421
505
|
# Run CLI
|
|
422
506
|
# =============================================================================
|
|
@@ -476,3 +560,96 @@ def test_run_cli_skips_signal_installation_when_disabled(monkeypatch: pytest.Mon
|
|
|
476
560
|
runner.run_cli(DummyCommand(lambda: None), install_signals=False)
|
|
477
561
|
|
|
478
562
|
assert called == []
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
# =============================================================================
|
|
566
|
+
# Exit codes a command chose deliberately
|
|
567
|
+
# =============================================================================
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _no_signal_handlers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
571
|
+
"""Keep these tests from touching process-wide signal state."""
|
|
572
|
+
|
|
573
|
+
def install(_specs: Sequence[SignalSpec]) -> Callable[[], None]:
|
|
574
|
+
def restore() -> None:
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
return restore
|
|
578
|
+
|
|
579
|
+
monkeypatch.setattr(runner, "install_signal_handlers", install)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _command_exiting_with(code: int) -> click.Command:
|
|
583
|
+
"""Return a real Click command that deliberately exits with ``code``.
|
|
584
|
+
|
|
585
|
+
A real command rather than the stub above, because the stub cannot
|
|
586
|
+
reproduce Click's return-value behaviour and that is exactly what is
|
|
587
|
+
under test here.
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
@click.pass_context
|
|
591
|
+
def body(ctx: click.Context) -> None:
|
|
592
|
+
ctx.exit(code)
|
|
593
|
+
|
|
594
|
+
return click.command()(body)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _group_with_subcommand_exiting_with(code: int) -> click.Group:
|
|
598
|
+
"""Return a group whose subcommand exits with ``code``."""
|
|
599
|
+
|
|
600
|
+
@click.group()
|
|
601
|
+
def app() -> None: ...
|
|
602
|
+
|
|
603
|
+
@click.pass_context
|
|
604
|
+
def body(ctx: click.Context) -> None:
|
|
605
|
+
ctx.exit(code)
|
|
606
|
+
|
|
607
|
+
app.add_command(click.command("check")(body))
|
|
608
|
+
return app
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
@pytest.mark.os_agnostic
|
|
612
|
+
def test_run_cli_propagates_a_deliberate_non_zero_exit(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
613
|
+
"""A command that calls ctx.exit(1) must exit 1, not 0.
|
|
614
|
+
|
|
615
|
+
``ctx.exit`` is how a Click command reports a negative *answer* - a host
|
|
616
|
+
that did not respond, a search that found nothing - as distinct from an
|
|
617
|
+
error. With ``standalone_mode=False`` Click returns that code rather than
|
|
618
|
+
raising it, so it only reaches the caller if the return value is passed on.
|
|
619
|
+
Dropping it collapses every such outcome into success.
|
|
620
|
+
"""
|
|
621
|
+
|
|
622
|
+
_no_signal_handlers(monkeypatch)
|
|
623
|
+
|
|
624
|
+
assert runner.run_cli(_command_exiting_with(1), [], prog_name="t") == 1
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
@pytest.mark.os_agnostic
|
|
628
|
+
@pytest.mark.parametrize("code", [1, 2, 3, 42])
|
|
629
|
+
def test_run_cli_propagates_every_deliberate_exit_code(monkeypatch: pytest.MonkeyPatch, code: int) -> None:
|
|
630
|
+
"""Whatever code the command chose is the code the caller receives."""
|
|
631
|
+
|
|
632
|
+
_no_signal_handlers(monkeypatch)
|
|
633
|
+
|
|
634
|
+
assert runner.run_cli(_command_exiting_with(code), [], prog_name="t") == code
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@pytest.mark.os_agnostic
|
|
638
|
+
def test_run_cli_still_returns_zero_for_a_command_that_just_returns(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
639
|
+
"""Click returns None on a plain success, which must still mean zero."""
|
|
640
|
+
|
|
641
|
+
_no_signal_handlers(monkeypatch)
|
|
642
|
+
|
|
643
|
+
def body() -> None:
|
|
644
|
+
click.echo("done")
|
|
645
|
+
|
|
646
|
+
assert runner.run_cli(click.command()(body), [], prog_name="t") == 0
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@pytest.mark.os_agnostic
|
|
650
|
+
def test_run_cli_propagates_a_deliberate_exit_from_a_subcommand(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
651
|
+
"""The same holds one level down, which is where real CLIs exit from."""
|
|
652
|
+
|
|
653
|
+
_no_signal_handlers(monkeypatch)
|
|
654
|
+
|
|
655
|
+
assert runner.run_cli(_group_with_subcommand_exiting_with(1), ["check"], prog_name="t") == 1
|
|
@@ -10,10 +10,10 @@ Each test verifies exactly one CLI behavior:
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import sys
|
|
13
14
|
from typing import TYPE_CHECKING, Any
|
|
14
15
|
|
|
15
16
|
import pytest
|
|
16
|
-
import rich_click as click
|
|
17
17
|
from rich_click import rich_click as rich_config
|
|
18
18
|
|
|
19
19
|
import lib_cli_exit_tools
|
|
@@ -260,10 +260,8 @@ def test_ascii_streams_disable_force_terminal(monkeypatch: pytest.MonkeyPatch) -
|
|
|
260
260
|
|
|
261
261
|
original = _snapshot_rich_click_options()
|
|
262
262
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
monkeypatch.setattr(click, "get_text_stream", _get_dummy_stream)
|
|
263
|
+
monkeypatch.setattr(sys, "stdout", DummyStream())
|
|
264
|
+
monkeypatch.setattr(sys, "stderr", DummyStream())
|
|
267
265
|
|
|
268
266
|
with _temporary_rich_click_configuration():
|
|
269
267
|
assert rich_config.FORCE_TERMINAL is False
|
|
@@ -282,10 +280,8 @@ def test_ascii_streams_disable_color_system(monkeypatch: pytest.MonkeyPatch) ->
|
|
|
282
280
|
|
|
283
281
|
original = _snapshot_rich_click_options()
|
|
284
282
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
monkeypatch.setattr(click, "get_text_stream", _get_dummy_stream)
|
|
283
|
+
monkeypatch.setattr(sys, "stdout", DummyStream())
|
|
284
|
+
monkeypatch.setattr(sys, "stderr", DummyStream())
|
|
289
285
|
|
|
290
286
|
with _temporary_rich_click_configuration():
|
|
291
287
|
assert rich_config.COLOR_SYSTEM is None
|
|
@@ -304,10 +300,8 @@ def test_utf8_tty_streams_preserve_color_system(monkeypatch: pytest.MonkeyPatch)
|
|
|
304
300
|
|
|
305
301
|
original = _snapshot_rich_click_options()
|
|
306
302
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
monkeypatch.setattr(click, "get_text_stream", _get_fancy_stream)
|
|
303
|
+
monkeypatch.setattr(sys, "stdout", FancyStream())
|
|
304
|
+
monkeypatch.setattr(sys, "stderr", FancyStream())
|
|
311
305
|
|
|
312
306
|
with _temporary_rich_click_configuration():
|
|
313
307
|
assert original.get("COLOR_SYSTEM") == rich_config.COLOR_SYSTEM
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/actions/extract-metadata/action.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/workflows/default_cicd_public.yml
RENAMED
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/workflows/default_release_public.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/docs/system-design/module_reference.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/adapters/__init__.py
RENAMED
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/adapters/signals.py
RENAMED
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/application/__init__.py
RENAMED
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/__init__.py
RENAMED
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/commands.py
RENAMED
|
File without changes
|
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/typed_click.py
RENAMED
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/core/__init__.py
RENAMED
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/core/configuration.py
RENAMED
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/core/exit_codes.py
RENAMED
|
File without changes
|
{lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/lib_cli_exit_tools.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|