lib-cli-exit-tools 2.3.4__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.4 → lib_cli_exit_tools-2.4.0}/.gitignore +8 -4
  2. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/CHANGELOG.md +12 -0
  3. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/Makefile +38 -4
  4. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/PKG-INFO +39 -35
  5. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/README.md +29 -25
  6. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/pyproject.toml +10 -10
  7. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/__init__conf__.py +1 -1
  8. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/application/runner.py +52 -18
  9. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/styling.py +5 -4
  10. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/tests/test_application_runner.py +84 -0
  11. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/tests/test_cli.py +7 -13
  12. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.codacy/cli-config.yaml +0 -0
  13. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.codacy/cli.sh +0 -0
  14. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.codacy/codacy.yaml +0 -0
  15. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.codacy/tools-configs/lizard.yaml +0 -0
  16. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.codacy/tools-configs/trivy.yaml +0 -0
  17. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.data_arch_violations.json +0 -0
  18. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.devcontainer/devcontainer.json +0 -0
  19. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.devcontainer/settings.json +0 -0
  20. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.env.example +0 -0
  21. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.github/actions/extract-metadata/action.yml +0 -0
  22. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.github/dependabot.yml +0 -0
  23. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.github/workflows/codeql.yml +0 -0
  24. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.github/workflows/default_cicd_public.yml +0 -0
  25. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.github/workflows/default_release_public.yml +0 -0
  26. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.qlty/qlty.toml +0 -0
  27. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/.snyk +0 -0
  28. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/CONTRIBUTING.md +0 -0
  29. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/DEVELOPMENT.md +0 -0
  30. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/INSTALL.md +0 -0
  31. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/LICENSE +0 -0
  32. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/codecov.yml +0 -0
  33. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/docs/system-design/module_reference.md +0 -0
  34. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/notebooks/Quickstart.ipynb +0 -0
  35. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/__init__.py +0 -0
  36. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/__main__.py +0 -0
  37. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/adapters/__init__.py +0 -0
  38. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/adapters/signals.py +0 -0
  39. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/application/__init__.py +0 -0
  40. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/__init__.py +0 -0
  41. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/commands.py +0 -0
  42. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/cli/group.py +0 -0
  43. {lib_cli_exit_tools-2.3.4 → 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.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/core/__init__.py +0 -0
  45. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/core/configuration.py +0 -0
  46. {lib_cli_exit_tools-2.3.4 → 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.4 → 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.4 → lib_cli_exit_tools-2.4.0}/src/lib_cli_exit_tools/py.typed +0 -0
  49. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/tests/conftest.py +0 -0
  50. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/tests/test_adapters_signals.py +0 -0
  51. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/tests/test_behaviors.py +0 -0
  52. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/tests/test_core_configuration.py +0 -0
  53. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/tests/test_core_exit_codes.py +0 -0
  54. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/tests/test_metadata.py +0 -0
  55. {lib_cli_exit_tools-2.3.4 → lib_cli_exit_tools-2.4.0}/tests/test_module_entry.py +0 -0
  56. {lib_cli_exit_tools-2.3.4 → 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,18 @@ 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
+
7
19
  ## [2.3.4] 2026-07-29 15:20:59
8
20
 
9
21
  ### Fixed
@@ -1,4 +1,4 @@
1
- # BMK MAKEFILE 3.13.2
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.2
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.4
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,21 +21,21 @@ 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.163.0; 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
40
  Requires-Dist: twine>=7.0.0; extra == 'dev'
41
41
  Description-Content-Type: text/markdown
@@ -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.4"
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,17 +49,17 @@ 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.163.0",
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
65
  "twine>=7.0.0"
@@ -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.4"
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.
@@ -67,6 +67,7 @@ class ClickCommand(Protocol):
67
67
 
68
68
  __all__ = [
69
69
  "SessionOverrides",
70
+ "SessionRunner",
70
71
  "cli_session",
71
72
  "flush_streams",
72
73
  "handle_cli_exception",
@@ -75,6 +76,22 @@ __all__ = [
75
76
  ]
76
77
 
77
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
+
78
95
  class _Echo(Protocol):
79
96
  """Protocol describing the echo interface expected by error handlers."""
80
97
 
@@ -360,7 +377,7 @@ def handle_cli_exception(
360
377
 
361
378
  specs = _resolve_signal_specs(signal_specs)
362
379
  echo_fn = _choose_echo(echo)
363
- return _resolve_exit_code(exc, specs, echo_fn)
380
+ return _resolve_exit_code(exc, specs, echo_fn, fallback=_render_and_translate)
364
381
 
365
382
 
366
383
  def _choose_echo(echo: _Echo | None) -> _Echo:
@@ -372,13 +389,20 @@ def _resolve_exit_code(
372
389
  exc: BaseException,
373
390
  specs: Sequence[SignalSpec],
374
391
  echo: _Echo,
392
+ *,
393
+ fallback: Callable[[BaseException], int],
375
394
  ) -> int:
376
- """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
+ """
377
401
  for resolver in _exception_resolvers(specs, echo):
378
402
  code = resolver(exc)
379
403
  if code is not None:
380
404
  return code
381
- return _render_and_translate(exc)
405
+ return fallback(exc)
382
406
 
383
407
 
384
408
  def _exception_resolvers(
@@ -466,15 +490,7 @@ def cli_session(
466
490
  verbose_limit: int = 10_000,
467
491
  overrides: SessionOverrides | None = None,
468
492
  restore: bool = True,
469
- ) -> Generator[
470
- Callable[
471
- [
472
- ClickCommand,
473
- ],
474
- int,
475
- ]
476
- | Callable[..., int]
477
- ]:
493
+ ) -> Generator[SessionRunner]:
478
494
  """Provide a managed execution context around :func:`run_cli`.
479
495
 
480
496
  Why
@@ -509,7 +525,7 @@ def cli_session(
509
525
 
510
526
  Yields
511
527
  ------
512
- Callable
528
+ SessionRunner
513
529
  Function that accepts a Click command and forwards optional ``run_cli``
514
530
  keyword arguments, returning the resulting exit code.
515
531
  """
@@ -518,7 +534,6 @@ def cli_session(
518
534
  manager = _session_config_manager(applied, restore=restore)
519
535
 
520
536
  with manager:
521
- handler = _session_exception_handler(summary_limit, verbose_limit)
522
537
 
523
538
  def _run(
524
539
  command: ClickCommand,
@@ -530,7 +545,11 @@ def cli_session(
530
545
  exception_handler: Callable[[BaseException], int] | None = None,
531
546
  signal_installer: Callable[[Sequence[SignalSpec] | None], Callable[[], None]] | None = None,
532
547
  ) -> int:
533
- 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
+ )
534
553
  return run_cli(
535
554
  command,
536
555
  argv=argv,
@@ -587,15 +606,30 @@ def _apply_overrides_without_restore(applied: SessionOverrides) -> Generator[Non
587
606
  yield
588
607
 
589
608
 
590
- def _session_exception_handler(summary_limit: int, verbose_limit: int) -> Callable[[BaseException], int]:
591
- """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`.
592
616
 
593
- 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:
594
625
  active = bool(config.traceback)
595
626
  limit = verbose_limit if active else summary_limit
596
627
  print_exception_message(trace_back=active, length_limit=limit)
597
628
  return get_system_exit_code(exc)
598
629
 
630
+ def _handler(exc: BaseException) -> int:
631
+ return _resolve_exit_code(exc, specs, _default_echo, fallback=_render_with_session_limits)
632
+
599
633
  return _handler
600
634
 
601
635
 
@@ -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
  # =============================================================================
@@ -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