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.
Files changed (56) hide show
  1. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.gitignore +8 -4
  2. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/CHANGELOG.md +19 -0
  3. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/Makefile +38 -4
  4. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/PKG-INFO +40 -36
  5. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/README.md +29 -25
  6. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/pyproject.toml +11 -11
  7. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/__init__conf__.py +1 -1
  8. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/application/runner.py +71 -24
  9. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/styling.py +5 -4
  10. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_application_runner.py +177 -0
  11. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_cli.py +7 -13
  12. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.codacy/cli-config.yaml +0 -0
  13. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.codacy/cli.sh +0 -0
  14. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.codacy/codacy.yaml +0 -0
  15. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.codacy/tools-configs/lizard.yaml +0 -0
  16. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.codacy/tools-configs/trivy.yaml +0 -0
  17. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.data_arch_violations.json +0 -0
  18. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.devcontainer/devcontainer.json +0 -0
  19. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.devcontainer/settings.json +0 -0
  20. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.env.example +0 -0
  21. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/actions/extract-metadata/action.yml +0 -0
  22. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/dependabot.yml +0 -0
  23. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/workflows/codeql.yml +0 -0
  24. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/workflows/default_cicd_public.yml +0 -0
  25. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.github/workflows/default_release_public.yml +0 -0
  26. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.qlty/qlty.toml +0 -0
  27. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/.snyk +0 -0
  28. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/CONTRIBUTING.md +0 -0
  29. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/DEVELOPMENT.md +0 -0
  30. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/INSTALL.md +0 -0
  31. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/LICENSE +0 -0
  32. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/codecov.yml +0 -0
  33. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/docs/system-design/module_reference.md +0 -0
  34. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/notebooks/Quickstart.ipynb +0 -0
  35. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/__init__.py +0 -0
  36. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/__main__.py +0 -0
  37. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/adapters/__init__.py +0 -0
  38. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/adapters/signals.py +0 -0
  39. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/application/__init__.py +0 -0
  40. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/__init__.py +0 -0
  41. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/commands.py +0 -0
  42. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/group.py +0 -0
  43. {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
  44. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/core/__init__.py +0 -0
  45. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/core/configuration.py +0 -0
  46. {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
  47. {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
  48. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/py.typed +0 -0
  49. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/conftest.py +0 -0
  50. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_adapters_signals.py +0 -0
  51. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_behaviors.py +0 -0
  52. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_core_configuration.py +0 -0
  53. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_core_exit_codes.py +0 -0
  54. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_metadata.py +0 -0
  55. {lib_cli_exit_tools-2.3.3 → lib_cli_exit_tools-2.4.0}/tests/test_module_entry.py +0 -0
  56. {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
- *$py.class # other compiled artifacts
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
- *.egg-info/ # metadata for pip installs
76
+ # metadata for pip installs
77
+ *.egg-info/
76
78
  *.egg
77
79
  *.whl
78
80
  pip-wheel-metadata/
79
- *.manifest # PyInstaller metadata
80
- *.spec # PyInstaller spec files
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.13.0
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.13.0
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: lib_cli_exit_tools
3
- Version: 2.3.3
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.8
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.5.0; extra == 'dev'
28
- Requires-Dist: click>=8.4.2; extra == 'dev'
29
- Requires-Dist: coverage[toml]>=7.15.2; extra == 'dev'
30
- Requires-Dist: hypothesis>=6.161.2; extra == 'dev'
31
- Requires-Dist: import-linter>=2.13; extra == 'dev'
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.411; extra == 'dev'
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.0; extra == 'dev'
38
+ Requires-Dist: ruff>=0.16.10; extra == 'dev'
39
39
  Requires-Dist: textual>=8.2.8; extra == 'dev'
40
- Requires-Dist: twine>=6.2.0; extra == 'dev'
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** — 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.
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** — 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.
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** — Uncaught exceptions produce concise, coloured error messages by default. Toggle `--traceback` for full Rich-formatted stack traces during debugging — no code changes required.
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** — `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.
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** — 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.
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 | Default | Description |
105
- |--------|---------|-------------|
104
+ | Option | Default | Description |
105
+ |----------------------------------|---------|--------------------------------------|
106
106
  | `--traceback` / `--no-traceback` | `False` | Show full Python traceback on errors |
107
- | `--version` | — | Show program version and exit |
108
- | `-h`, `--help` | — | Show help message and exit |
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 “hello world” through a production-ready
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—mirroring how a production CLI can layer policy logic
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 | 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. |
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—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:
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 “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/`).
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 compatible with
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—even when the wrapped command
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** — 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.
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** — 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.
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** — Uncaught exceptions produce concise, coloured error messages by default. Toggle `--traceback` for full Rich-formatted stack traces during debugging — no code changes required.
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** — `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.
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** — 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.
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 | Default | Description |
63
- |--------|---------|-------------|
62
+ | Option | Default | Description |
63
+ |----------------------------------|---------|--------------------------------------|
64
64
  | `--traceback` / `--no-traceback` | `False` | Show full Python traceback on errors |
65
- | `--version` | — | Show program version and exit |
66
- | `-h`, `--help` | — | Show help message and exit |
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 “hello world” through a production-ready
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—mirroring how a production CLI can layer policy logic
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 | 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. |
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—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:
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 “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/`).
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 compatible with
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—even when the wrapped command
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.3"
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.8"]
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.5.0",
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.4.2", # CVE-2026-7246
53
- "coverage[toml]>=7.15.2",
54
- "hypothesis>=6.161.2",
55
- "import-linter>=2.13",
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.411",
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.0",
62
+ "ruff>=0.16.10",
63
63
  "textual>=8.2.8",
64
64
  "rtoml>=0.13.0",
65
- "twine>=6.2.0"
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.31.0"]
75
+ requires = ["hatchling>=1.32.4"]
76
76
  build-backend = "hatchling.build"
77
77
 
78
78
  [tool.hatch.build.targets.wheel]
@@ -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.3.3"
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.
@@ -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
- ) -> None: ...
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 until a numeric exit code emerges."""
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 _render_and_translate(exc)
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
- Callable
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 handler
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(summary_limit: int, verbose_limit: int) -> Callable[[BaseException], int]:
589
- """Build the exception handler used inside :func:`cli_session`."""
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
- def _handler(exc: BaseException) -> int:
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 0
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) -> None:
697
- """Invoke the Click command with ``standalone_mode`` disabled."""
698
- cli.main(args=_normalised_args(argv), standalone_mode=False, prog_name=prog_name)
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
- stdout_stream = click.get_text_stream("stdout")
108
- stderr_stream = click.get_text_stream("stderr")
109
- if _needs_plain_output(stdout_stream) and _needs_plain_output(stderr_stream):
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
- def _get_dummy_stream(name: str) -> DummyStream:
264
- return DummyStream()
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
- def _get_dummy_stream(name: str) -> DummyStream:
286
- return DummyStream()
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
- def _get_fancy_stream(name: str) -> FancyStream:
308
- return FancyStream()
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