lib-cli-exit-tools 1.0.2__tar.gz → 1.1.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 (64) hide show
  1. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/CHANGELOG.md +11 -3
  2. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/CONTRIBUTING.md +1 -1
  3. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/Makefile +3 -10
  4. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/PKG-INFO +36 -7
  5. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/README.md +34 -6
  6. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/codecov.yml +1 -1
  7. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/packaging/README.md +2 -2
  8. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/packaging/brew/Formula/lib-cli-exit-tools.rb +1 -1
  9. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/packaging/conda/recipe/meta.yaml +1 -1
  10. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/packaging/nix/flake.nix +2 -2
  11. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/pyproject.toml +2 -2
  12. lib_cli_exit_tools-1.1.0/qlty.toml +2 -0
  13. lib_cli_exit_tools-1.1.0/scripts/_utils.py +255 -0
  14. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/build.py +4 -2
  15. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/bump.py +1 -1
  16. {lib_cli_exit_tools-1.0.2/tools → lib_cli_exit_tools-1.1.0/scripts}/bump_version.py +39 -30
  17. lib_cli_exit_tools-1.0.2/scripts/menu_tui.py → lib_cli_exit_tools-1.1.0/scripts/menu.py +50 -59
  18. lib_cli_exit_tools-1.1.0/scripts/run_cli.py +26 -0
  19. lib_cli_exit_tools-1.1.0/scripts/test.py +129 -0
  20. lib_cli_exit_tools-1.1.0/src/lib_cli_exit_tools/__init__.py +33 -0
  21. lib_cli_exit_tools-1.1.0/src/lib_cli_exit_tools/__main__.py +4 -0
  22. lib_cli_exit_tools-1.1.0/src/lib_cli_exit_tools/cli.py +45 -0
  23. lib_cli_exit_tools-1.1.0/src/lib_cli_exit_tools/lib_cli_exit_tools.py +372 -0
  24. lib_cli_exit_tools-1.1.0/tests/test_cli.py +85 -0
  25. lib_cli_exit_tools-1.1.0/tests/test_cli_extra.py +32 -0
  26. lib_cli_exit_tools-1.1.0/tests/test_extend_cli.py +111 -0
  27. lib_cli_exit_tools-1.0.2/docs/textual/README.md +0 -109
  28. lib_cli_exit_tools-1.0.2/docs/textual/snippets/actions_bindings.py +0 -24
  29. lib_cli_exit_tools-1.0.2/docs/textual/snippets/listview_basic.py +0 -20
  30. lib_cli_exit_tools-1.0.2/docs/textual/snippets/mount_await.py +0 -21
  31. lib_cli_exit_tools-1.0.2/docs/textual/snippets/richlog_stream.py +0 -56
  32. lib_cli_exit_tools-1.0.2/scripts/_utils.py +0 -125
  33. lib_cli_exit_tools-1.0.2/scripts/menu.py +0 -310
  34. lib_cli_exit_tools-1.0.2/scripts/menu_cli.py +0 -136
  35. lib_cli_exit_tools-1.0.2/scripts/run_cli.py +0 -17
  36. lib_cli_exit_tools-1.0.2/scripts/test.py +0 -83
  37. lib_cli_exit_tools-1.0.2/src/lib_cli_exit_tools/__init__.py +0 -15
  38. lib_cli_exit_tools-1.0.2/src/lib_cli_exit_tools/__main__.py +0 -4
  39. lib_cli_exit_tools-1.0.2/src/lib_cli_exit_tools/cli.py +0 -138
  40. lib_cli_exit_tools-1.0.2/src/lib_cli_exit_tools/lib_cli_exit_tools.py +0 -353
  41. lib_cli_exit_tools-1.0.2/tests/test_cli.py +0 -49
  42. lib_cli_exit_tools-1.0.2/tests/test_cli_extra.py +0 -54
  43. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/.env.example +0 -0
  44. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/.github/workflows/ci.yml +0 -0
  45. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/.github/workflows/codeql.yml +0 -0
  46. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/.github/workflows/release.yml +0 -0
  47. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/.gitignore +0 -0
  48. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/AGENTS.md +0 -0
  49. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/LICENSE +0 -0
  50. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/notebooks/Quickstart.ipynb +0 -0
  51. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/__init__.py +0 -0
  52. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/bump_major.py +0 -0
  53. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/bump_minor.py +0 -0
  54. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/bump_patch.py +0 -0
  55. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/clean.py +0 -0
  56. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/dev.py +0 -0
  57. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/install.py +0 -0
  58. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/push.py +0 -0
  59. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/release.py +0 -0
  60. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/scripts/version_current.py +0 -0
  61. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/src/lib_cli_exit_tools/__init__conf__.py +0 -0
  62. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/src/lib_cli_exit_tools/py.typed +0 -0
  63. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/tests/test_exit_tools.py +0 -0
  64. {lib_cli_exit_tools-1.0.2 → lib_cli_exit_tools-1.1.0}/tests/test_lib_extra.py +0 -0
@@ -1,12 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.1.0] - 2025-09-16
4
+
5
+ - introduced lib_cli_exit_tools.run_cli helper to reduce boilerplate code. see cli.py as example
6
+
7
+ ## [1.0.3] - 2025-09-16
8
+
9
+ - added make menu
10
+
3
11
  ## [1.0.2] - 2025-09-15
4
12
 
5
- - _Describe changes here._
13
+ - minor internals
6
14
 
7
15
  ## [1.0.1] - 2025-09-15
8
16
 
9
- - _Describe changes here._
17
+ - minor internals
10
18
 
11
19
  ## [1.0.0] - 2025-09-15
12
20
 
@@ -16,7 +24,7 @@
16
24
 
17
25
  ### Added
18
26
  - CI job to execute Jupyter notebook on Python 3.10 (installs ipykernel, normalizes notebook IDs) and a tag‑time packaging‑consistency check.
19
- - Packaging auto‑sync: `make test`, `make push`, and the bump script now align Conda/Brew/Nix files to `pyproject.toml` version, `requires-python`, and runtime deps. Homebrew resources are updated (URL + sha256) using PyPI metadata. A sync‑only mode is available via `python tools/bump_version.py --sync-packaging`.
27
+ - Packaging auto‑sync: `make test`, `make push`, and the bump script now align Conda/Brew/Nix files to `pyproject.toml` version, `requires-python`, and runtime deps. Homebrew resources are updated (URL + sha256) using PyPI metadata. A sync‑only mode is available via `python scripts/bump_version.py --sync-packaging`.
20
28
  - Tests covering SystemExit variants, tolerant output printing, English CLI messages, and Windows mapping via monkeypatched `os.name`.
21
29
 
22
30
  ### Changed
@@ -28,7 +28,7 @@ Thanks for helping improve lib_cli_exit_tools! This guide keeps changes small, s
28
28
  - Homebrew: ensures a `resource "<dep>"` for each dep; if pinned, uses that version; otherwise pins to the latest PyPI release, updating `url` + `sha256`.
29
29
  - Nix: updates the package version, `pkgs.pythonXYZPackages`/`pkgs.pythonXYZ` to match the min Python, and `propagatedBuildInputs` to include all deps as `pypkgs.<name>`.
30
30
  - Network note: Homebrew resource updates fetch PyPI metadata; if offline, those updates may be skipped.
31
- - If packaging drifts, run: `python tools/bump_version.py --sync-packaging`.
31
+ - If packaging drifts, run: `python scripts/bump_version.py --sync-packaging`.
32
32
  - CI enforces consistency on tags via a `packaging-consistency` job.
33
33
 
34
34
  ## 5) Tests & Style
@@ -1,6 +1,5 @@
1
1
  SHELL := /bin/bash
2
2
  .ONESHELL:
3
-
4
3
  # Config
5
4
  PY ?= python3
6
5
  PIP ?= pip
@@ -18,7 +17,7 @@ FAIL_UNDER ?= 80
18
17
  # - off : never run coverage
19
18
  COVERAGE ?= on
20
19
 
21
- .PHONY: help install dev test run clean build push release version-current bump bump-patch bump-minor bump-major menu menu-cli menu-tui _bootstrap-dev
20
+ .PHONY: help install dev test run clean build push release version-current bump bump-patch bump-minor bump-major menu _bootstrap-dev
22
21
 
23
22
  help: ## Show help
24
23
  @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
@@ -95,11 +94,5 @@ build: ## Build wheel/sdist and attempt conda, brew, and nix builds (auto-instal
95
94
  release: ## Create and push tag vX.Y.Z from pyproject, create GitHub release (if gh present), then sync packaging
96
95
  $(PY) scripts/release.py --remote=$(REMOTE)
97
96
 
98
- menu: ## Interactive menu (CLI prompts by default)
99
- $(PY) -u scripts/menu_cli.py < /dev/tty > /dev/tty 2>&1
100
-
101
- menu-cli: ## Force simple CLI prompt menu (no TUI)
102
- $(PY) -u scripts/menu_cli.py < /dev/tty > /dev/tty 2>&1
103
-
104
- menu-tui: ## Force Textual TUI menu (may not work on limited terminals)
105
- $(PY) -u scripts/menu_tui.py < /dev/tty > /dev/tty 2>&1
97
+ menu: ## Interactive TUI menu
98
+ $(PY) -u scripts/menu.py < /dev/tty > /dev/tty 2>&1
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lib_cli_exit_tools
3
- Version: 1.0.2
3
+ Version: 1.1.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
@@ -25,6 +25,7 @@ Requires-Dist: pytest-cov>=5.0; extra == 'dev'
25
25
  Requires-Dist: pytest>=8.2; extra == 'dev'
26
26
  Requires-Dist: ruff>=0.5; extra == 'dev'
27
27
  Requires-Dist: textual>=0.50; extra == 'dev'
28
+ Requires-Dist: tomli>=2.0; extra == 'dev'
28
29
  Requires-Dist: twine>=5.1; extra == 'dev'
29
30
  Description-Content-Type: text/markdown
30
31
 
@@ -144,8 +145,6 @@ pip install .
144
145
  | `push` | Commit all changes once and push to GitHub (no CI monitoring) |
145
146
  | `build` | Build wheel/sdist and attempt conda, brew, and nix builds (auto-installs tools if missing) |
146
147
  | `menu` | Interactive TUI to run targets and edit parameters (requires dev dep: textual) |
147
- | `menu-cli` | Force simple prompt menu (no TUI) |
148
- | `menu-tui` | Force Textual TUI (may not work in limited terminals) |
149
148
 
150
149
  #### Target Parameters (env vars)
151
150
 
@@ -162,6 +161,7 @@ pip install .
162
161
  - `test`
163
162
  - `COVERAGE=on|auto|off` (default: `on`) — controls pytest coverage run and Codecov upload
164
163
  - `SKIP_BOOTSTRAP=1` — skip auto-install of dev tools if missing
164
+ - `TEST_VERBOSE=1` — echo each command executed by the test harness
165
165
  - Also respects `CODECOV_TOKEN` when needed for uploads
166
166
 
167
167
  - `run`
@@ -195,7 +195,7 @@ pip install .
195
195
 
196
196
  ### Interactive Menu (Textual)
197
197
 
198
- `make menu` launches a colorful terminal UI (powered by `textual`) to browse targets, edit parameters, and run them with live output. If the terminal doesn’t support a full TUI or `textual` can’t load, it falls back to a simple prompt menu.
198
+ `make menu` launches a colorful terminal UI (powered by `textual`) to browse targets, edit parameters, and run them with live output.
199
199
 
200
200
  Install dev extras if you haven’t:
201
201
 
@@ -207,8 +207,6 @@ Run the menu:
207
207
 
208
208
  ```bash
209
209
  make menu
210
- make menu-cli # always use simple prompt menu
211
- make menu-tui # force TUI (if your terminal supports it)
212
210
  ```
213
211
 
214
212
  #### Target Details
@@ -233,6 +231,37 @@ cli-exit-tools --help # alias
233
231
  lib_cli_exit_tools info
234
232
  ```
235
233
 
234
+ ### Embed in your own CLI
235
+
236
+ The `run_cli` helper wraps any Click command with lib_cli_exit_tools’ signal and error handling.
237
+
238
+ ```python
239
+ from __future__ import annotations
240
+
241
+ import click
242
+
243
+ from lib_cli_exit_tools import run_cli
244
+
245
+
246
+ @click.command()
247
+ def hello() -> None:
248
+ """Minimal Click command with automatic signal-aware exit handling."""
249
+
250
+ click.echo("Hello from lib_cli_exit_tools!")
251
+
252
+
253
+ if __name__ == "__main__":
254
+ # run_cli handles SIGINT/SIGTERM/SIGBREAK, Click exceptions, and converts
255
+ # any other exception into an appropriate exit code.
256
+ raise SystemExit(run_cli(hello))
257
+ ```
258
+
259
+ Run it with:
260
+
261
+ ```bash
262
+ python hello.py
263
+ ```
264
+
236
265
  Library:
237
266
 
238
267
  ```python
@@ -288,7 +317,7 @@ COVERAGE=on make test # force coverage and generate coverage.xml/codecov.
288
317
  - Homebrew: updates the source URL tag to `vX.Y.Z` and sets `depends_on "python@X.Y"` to match `requires-python`.
289
318
  - Nix: updates the package `version`, example `rev = "vX.Y.Z"`, and switches `pkgs.pythonXYZPackages` / `pkgs.pythonXYZ` to match the minimum Python version from `requires-python`.
290
319
 
291
- - To run just the sync without bumping versions: `python tools/bump_version.py --sync-packaging`.
320
+ - To run just the sync without bumping versions: `python scripts/bump_version.py --sync-packaging`.
292
321
 
293
322
  - On release tags (`v*.*.*`), CI validates that packaging files are consistent with `pyproject.toml` and will fail if they drift.
294
323
 
@@ -114,8 +114,6 @@ pip install .
114
114
  | `push` | Commit all changes once and push to GitHub (no CI monitoring) |
115
115
  | `build` | Build wheel/sdist and attempt conda, brew, and nix builds (auto-installs tools if missing) |
116
116
  | `menu` | Interactive TUI to run targets and edit parameters (requires dev dep: textual) |
117
- | `menu-cli` | Force simple prompt menu (no TUI) |
118
- | `menu-tui` | Force Textual TUI (may not work in limited terminals) |
119
117
 
120
118
  #### Target Parameters (env vars)
121
119
 
@@ -132,6 +130,7 @@ pip install .
132
130
  - `test`
133
131
  - `COVERAGE=on|auto|off` (default: `on`) — controls pytest coverage run and Codecov upload
134
132
  - `SKIP_BOOTSTRAP=1` — skip auto-install of dev tools if missing
133
+ - `TEST_VERBOSE=1` — echo each command executed by the test harness
135
134
  - Also respects `CODECOV_TOKEN` when needed for uploads
136
135
 
137
136
  - `run`
@@ -165,7 +164,7 @@ pip install .
165
164
 
166
165
  ### Interactive Menu (Textual)
167
166
 
168
- `make menu` launches a colorful terminal UI (powered by `textual`) to browse targets, edit parameters, and run them with live output. If the terminal doesn’t support a full TUI or `textual` can’t load, it falls back to a simple prompt menu.
167
+ `make menu` launches a colorful terminal UI (powered by `textual`) to browse targets, edit parameters, and run them with live output.
169
168
 
170
169
  Install dev extras if you haven’t:
171
170
 
@@ -177,8 +176,6 @@ Run the menu:
177
176
 
178
177
  ```bash
179
178
  make menu
180
- make menu-cli # always use simple prompt menu
181
- make menu-tui # force TUI (if your terminal supports it)
182
179
  ```
183
180
 
184
181
  #### Target Details
@@ -203,6 +200,37 @@ cli-exit-tools --help # alias
203
200
  lib_cli_exit_tools info
204
201
  ```
205
202
 
203
+ ### Embed in your own CLI
204
+
205
+ The `run_cli` helper wraps any Click command with lib_cli_exit_tools’ signal and error handling.
206
+
207
+ ```python
208
+ from __future__ import annotations
209
+
210
+ import click
211
+
212
+ from lib_cli_exit_tools import run_cli
213
+
214
+
215
+ @click.command()
216
+ def hello() -> None:
217
+ """Minimal Click command with automatic signal-aware exit handling."""
218
+
219
+ click.echo("Hello from lib_cli_exit_tools!")
220
+
221
+
222
+ if __name__ == "__main__":
223
+ # run_cli handles SIGINT/SIGTERM/SIGBREAK, Click exceptions, and converts
224
+ # any other exception into an appropriate exit code.
225
+ raise SystemExit(run_cli(hello))
226
+ ```
227
+
228
+ Run it with:
229
+
230
+ ```bash
231
+ python hello.py
232
+ ```
233
+
206
234
  Library:
207
235
 
208
236
  ```python
@@ -258,7 +286,7 @@ COVERAGE=on make test # force coverage and generate coverage.xml/codecov.
258
286
  - Homebrew: updates the source URL tag to `vX.Y.Z` and sets `depends_on "python@X.Y"` to match `requires-python`.
259
287
  - Nix: updates the package `version`, example `rev = "vX.Y.Z"`, and switches `pkgs.pythonXYZPackages` / `pkgs.pythonXYZ` to match the minimum Python version from `requires-python`.
260
288
 
261
- - To run just the sync without bumping versions: `python tools/bump_version.py --sync-packaging`.
289
+ - To run just the sync without bumping versions: `python scripts/bump_version.py --sync-packaging`.
262
290
 
263
291
  - On release tags (`v*.*.*`), CI validates that packaging files are consistent with `pyproject.toml` and will fail if they drift.
264
292
 
@@ -20,9 +20,9 @@ ignore:
20
20
  - "tests/**"
21
21
  - "packaging/**"
22
22
  - "**/__init__conf__.py"
23
+ - "scripts/**"
23
24
 
24
25
  comment:
25
26
  layout: "reach, diff, flags, files"
26
27
  behavior: default
27
28
  require_changes: false
28
-
@@ -9,13 +9,13 @@ This directory contains packaging files and an automated sync flow so they stay
9
9
  - Or semantic part: `make bump PART=patch|minor|major`
10
10
  - To only synchronize packaging to the current `pyproject.toml` without changing versions:
11
11
  ```bash
12
- python3 tools/bump_version.py --sync-packaging
12
+ python3 scripts/bump_version.py --sync-packaging
13
13
  ```
14
14
  - `make test` runs the same sync step up-front to keep packaging up to date.
15
15
 
16
16
  ## What auto-sync updates
17
17
 
18
- The script `tools/bump_version.py` updates the following when network access is available:
18
+ The script `scripts/bump_version.py` updates the following when network access is available:
19
19
 
20
20
  1) Conda recipe (`packaging/conda/recipe/meta.yaml`)
21
21
  - Sets Jinja version to the current `pyproject.toml` version.
@@ -3,7 +3,7 @@ class LibCliExitTools < Formula
3
3
 
4
4
  desc "CLI exit handling helpers: clean signals, exit codes, and error printing"
5
5
  homepage "https://github.com/bitranox/lib_cli_exit_tools"
6
- url "https://github.com/bitranox/lib_cli_exit_tools/archive/refs/tags/v1.0.2.tar.gz"
6
+ url "https://github.com/bitranox/lib_cli_exit_tools/archive/refs/tags/v1.1.0.tar.gz"
7
7
  k3cc316e7d0b09dd79e617cb3e91b14ee414b7e6695b108a326d9d3ccd08dd9"
8
8
  license "MIT"
9
9
 
@@ -1,5 +1,5 @@
1
1
  {% set name = "lib_cli_exit_tools" %}
2
- {% set version = "1.0.2" %}
2
+ {% set version = "1.1.0" %}
3
3
 
4
4
  package:
5
5
  name: {{ name|lower }}
@@ -35,7 +35,7 @@
35
35
  {
36
36
  packages.default = pypkgs.buildPythonPackage {
37
37
  pname = "lib_cli_exit_tools";
38
- version = "1.0.2";
38
+ version = "1.1.0";
39
39
  pyproject = true;
40
40
  # Build from the repository root (two levels up from packaging/nix)
41
41
  src = ../..;
@@ -43,7 +43,7 @@
43
43
  # src = pkgs.fetchFromGitHub {
44
44
  # owner = "bitranox";
45
45
  # repo = "lib_cli_exit_tools";
46
- # rev = "v1.0.2";
46
+ # rev = "v1.1.0";
47
47
  # sha256 = "<fill-me>";
48
48
  # };
49
49
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "lib_cli_exit_tools"
3
- version = "1.0.2"
3
+ version = "1.1.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"
@@ -32,6 +32,7 @@ dev = [
32
32
  "twine>=5.1",
33
33
  "codecov>=2.1.13",
34
34
  "textual>=0.50",
35
+ "tomli>=2.0",
35
36
  ]
36
37
 
37
38
  [project.scripts]
@@ -62,7 +63,6 @@ target-version = "py313"
62
63
  typeCheckingMode = "strict"
63
64
  exclude = [
64
65
  "scripts/menu.py",
65
- "docs",
66
66
  ]
67
67
 
68
68
  [tool.pytest.ini_options]
@@ -0,0 +1,2 @@
1
+ [analysis]
2
+ ignore = ["scripts/"]
@@ -0,0 +1,255 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shlex
5
+ import subprocess
6
+ import sys
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from subprocess import CompletedProcess
10
+ from typing import Any, Mapping, Sequence
11
+ from urllib.parse import urlparse
12
+
13
+ try: # Python 3.11+
14
+ import tomllib # type: ignore[attr-defined]
15
+ except ModuleNotFoundError: # pragma: no cover - fallback for older interpreters
16
+ try:
17
+ import tomli as tomllib # type: ignore
18
+ except ModuleNotFoundError: # pragma: no cover - pyproject parsing will degrade gracefully
19
+ tomllib = None # type: ignore
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class RunResult:
24
+ code: int
25
+ out: str
26
+ err: str
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class ProjectMetadata:
31
+ name: str
32
+ slug: str
33
+ repo_url: str
34
+ repo_host: str
35
+ repo_owner: str
36
+ repo_name: str
37
+ homepage: str
38
+ import_package: str
39
+ coverage_source: str
40
+
41
+ @property
42
+ def brew_formula_path(self) -> str:
43
+ return f"packaging/brew/Formula/{self.slug}.rb"
44
+
45
+ def github_tarball_url(self, version: str) -> str:
46
+ if self.repo_host == "github.com" and self.repo_owner and self.repo_name:
47
+ return f"https://github.com/{self.repo_owner}/{self.repo_name}/archive/refs/tags/v{version}.tar.gz"
48
+ return ""
49
+
50
+
51
+ _PYPROJECT_DATA_CACHE: dict[Path, dict[str, Any]] = {}
52
+ _METADATA_CACHE: dict[Path, ProjectMetadata] = {}
53
+
54
+
55
+ def run(
56
+ cmd: Sequence[str] | str,
57
+ *,
58
+ check: bool = True,
59
+ capture: bool = True,
60
+ cwd: str | None = None,
61
+ env: Mapping[str, str] | None = None,
62
+ dry_run: bool = False,
63
+ ) -> RunResult:
64
+ if isinstance(cmd, str):
65
+ display = cmd
66
+ shell = True
67
+ args: Sequence[str] | str = cmd
68
+ else:
69
+ display = " ".join(shlex.quote(p) for p in cmd)
70
+ shell = False
71
+ args = list(cmd)
72
+ if dry_run:
73
+ print(f"[dry-run] {display}")
74
+ return RunResult(0, "", "")
75
+ proc: CompletedProcess[str] = subprocess.run(
76
+ args,
77
+ shell=shell,
78
+ cwd=cwd,
79
+ env=env,
80
+ text=True,
81
+ capture_output=capture,
82
+ )
83
+ if check and proc.returncode != 0:
84
+ raise SystemExit(proc.returncode)
85
+ return RunResult(int(proc.returncode or 0), proc.stdout or "", proc.stderr or "")
86
+
87
+
88
+ def cmd_exists(name: str) -> bool:
89
+ return subprocess.call(["bash", "-lc", f"command -v {shlex.quote(name)} >/dev/null 2>&1"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) == 0
90
+
91
+
92
+ def _normalize_slug(value: str) -> str:
93
+ slug = re.sub(r"[^A-Za-z0-9]+", "-", value).strip("-").lower()
94
+ return slug or value.replace("_", "-").lower()
95
+
96
+
97
+ def _load_pyproject(pyproject: Path) -> dict[str, Any]:
98
+ path = pyproject.resolve()
99
+ cached = _PYPROJECT_DATA_CACHE.get(path)
100
+ if cached is not None:
101
+ return cached
102
+ text = path.read_text(encoding="utf-8")
103
+ data: dict[str, Any] = {}
104
+ if tomllib is not None:
105
+ try:
106
+ data = tomllib.loads(text)
107
+ except Exception:
108
+ data = {}
109
+ _PYPROJECT_DATA_CACHE[path] = data
110
+ return data
111
+
112
+
113
+ def _derive_import_package(data: dict[str, Any], fallback: str) -> str:
114
+ try:
115
+ packages = data.get("tool", {}).get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {}).get("packages", [])
116
+ if isinstance(packages, list) and packages:
117
+ first = packages[0]
118
+ if isinstance(first, str) and first:
119
+ return Path(first).name
120
+ except AttributeError:
121
+ pass
122
+ project_scripts = data.get("project", {}).get("scripts", {})
123
+ if isinstance(project_scripts, dict):
124
+ for value in project_scripts.values():
125
+ if isinstance(value, str) and ":" in value:
126
+ module = value.split(":", 1)[0]
127
+ return module.split(".", 1)[0]
128
+ return fallback.replace("-", "_")
129
+
130
+
131
+ def _derive_coverage_source(data: dict[str, Any], fallback: str) -> str:
132
+ try:
133
+ sources = data.get("tool", {}).get("coverage", {}).get("run", {}).get("source", [])
134
+ if isinstance(sources, list) and sources:
135
+ first = sources[0]
136
+ if isinstance(first, str) and first:
137
+ return first
138
+ except AttributeError:
139
+ pass
140
+ return fallback
141
+
142
+
143
+ def get_project_metadata(pyproject: Path = Path("pyproject.toml")) -> ProjectMetadata:
144
+ path = pyproject.resolve()
145
+ cached = _METADATA_CACHE.get(path)
146
+ if cached is not None:
147
+ return cached
148
+
149
+ data = _load_pyproject(pyproject)
150
+ project = data.get("project", {}) if isinstance(data, dict) else {}
151
+ name = str(project.get("name") or pyproject.stem).strip() or "project"
152
+ slug = _normalize_slug(name)
153
+
154
+ urls = project.get("urls", {}) if isinstance(project, dict) else {}
155
+ repo_url = str(urls.get("Repository") or "")
156
+ homepage = str(urls.get("Homepage") or project.get("homepage") or "")
157
+ repo_host = repo_owner = repo_name = ""
158
+ if repo_url:
159
+ parsed = urlparse(repo_url)
160
+ repo_host = parsed.netloc.lower()
161
+ repo_path = parsed.path.strip("/")
162
+ if repo_path.endswith(".git"):
163
+ repo_path = repo_path[:-4]
164
+ parts = [p for p in repo_path.split("/") if p]
165
+ if len(parts) >= 2:
166
+ repo_owner, repo_name = parts[0], parts[1]
167
+
168
+ import_package = _derive_import_package(data, name)
169
+ coverage_source = _derive_coverage_source(data, import_package)
170
+
171
+ meta = ProjectMetadata(
172
+ name=name,
173
+ slug=slug,
174
+ repo_url=repo_url,
175
+ repo_host=repo_host,
176
+ repo_owner=repo_owner,
177
+ repo_name=repo_name,
178
+ homepage=homepage,
179
+ import_package=import_package,
180
+ coverage_source=coverage_source,
181
+ )
182
+ _METADATA_CACHE[path] = meta
183
+ return meta
184
+
185
+
186
+ def read_version_from_pyproject(pyproject: Path = Path("pyproject.toml")) -> str:
187
+ data = _load_pyproject(pyproject)
188
+ project = data.get("project", {}) if isinstance(data, dict) else {}
189
+ version = str(project.get("version") or "").strip()
190
+ if version:
191
+ return version
192
+ text = pyproject.read_text(encoding="utf-8")
193
+ match = re.search(r'(?m)^version\s*=\s*"([0-9]+(?:\.[0-9]+){2})"', text)
194
+ return match.group(1) if match else ""
195
+
196
+
197
+ def ensure_clean_git_tree() -> None:
198
+ dirty = subprocess.call(["bash", "-lc", "! git diff --quiet || ! git diff --cached --quiet"], stdout=subprocess.DEVNULL)
199
+ if dirty == 0:
200
+ print("[release] Working tree not clean. Commit or stash changes first.", file=sys.stderr)
201
+ raise SystemExit(1)
202
+
203
+
204
+ def git_branch() -> str:
205
+ return run(["git", "rev-parse", "--abbrev-ref", "HEAD"], capture=True).out.strip()
206
+
207
+
208
+ def git_delete_tag(name: str, *, remote: str | None = None) -> None:
209
+ run(["git", "tag", "-d", name], check=False, capture=True)
210
+ if remote:
211
+ run(["git", "push", remote, f":refs/tags/{name}"], check=False)
212
+
213
+
214
+ def git_tag_exists(name: str) -> bool:
215
+ return subprocess.call(["bash", "-lc", f"git rev-parse -q --verify {shlex.quote('refs/tags/' + name)} >/dev/null"], stdout=subprocess.DEVNULL) == 0
216
+
217
+
218
+ def git_create_annotated_tag(name: str, message: str) -> None:
219
+ run(["git", "tag", "-a", name, "-m", message])
220
+
221
+
222
+ def git_push(remote: str, ref: str) -> None:
223
+ run(["git", "push", remote, ref])
224
+
225
+
226
+ def gh_available() -> bool:
227
+ return cmd_exists("gh")
228
+
229
+
230
+ def gh_release_exists(tag: str) -> bool:
231
+ return subprocess.call(["bash", "-lc", f"gh release view {shlex.quote(tag)} >/dev/null 2>&1"], stdout=subprocess.DEVNULL) == 0
232
+
233
+
234
+ def gh_release_create(tag: str, title: str, body: str) -> None:
235
+ run(["gh", "release", "create", tag, "-t", title, "-n", body], check=False)
236
+
237
+
238
+ def gh_release_edit(tag: str, title: str, body: str) -> None:
239
+ run(["gh", "release", "edit", tag, "-t", title, "-n", body], check=False)
240
+
241
+
242
+ def sync_packaging() -> None:
243
+ run([sys.executable, "scripts/bump_version.py", "--sync-packaging"], check=False)
244
+
245
+
246
+ def bootstrap_dev() -> None:
247
+ if not (cmd_exists("ruff") and cmd_exists("pyright")):
248
+ print("[bootstrap] Installing dev dependencies via 'pip install -e .[dev]'")
249
+ run([sys.executable, "-m", "pip", "install", "-e", ".[dev]"])
250
+ try:
251
+ from importlib import import_module
252
+
253
+ import_module("sqlite3")
254
+ except Exception:
255
+ run([sys.executable, "-m", "pip", "install", "pysqlite3-binary"], check=False)
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import click
4
4
  import sys
5
5
 
6
- from ._utils import cmd_exists, run
6
+ from ._utils import cmd_exists, get_project_metadata, run
7
7
 
8
8
 
9
9
  @click.command(help="Build wheel/sdist, optionally attempt conda/brew/nix builds if tools present")
@@ -14,6 +14,8 @@ def main(conda: bool, brew: bool, nix: bool) -> None:
14
14
  click.echo("[1/4] Building wheel/sdist via python -m build")
15
15
  run([sys.executable, "-m", "build"]) # requires build in dev deps
16
16
 
17
+ project = get_project_metadata()
18
+
17
19
  click.echo("[2/4] Attempting conda-build")
18
20
  if conda and cmd_exists("conda"):
19
21
  run(["bash", "-lc", "CONDA_USE_LOCAL=1 conda build packaging/conda/recipe"], check=False)
@@ -22,7 +24,7 @@ def main(conda: bool, brew: bool, nix: bool) -> None:
22
24
 
23
25
  click.echo("[3/4] Attempting Homebrew build/install from local formula")
24
26
  if brew and cmd_exists("brew"):
25
- run(["bash", "-lc", "brew install --build-from-source packaging/brew/Formula/lib-cli-exit-tools.rb"], check=False)
27
+ run(["bash", "-lc", f"brew install --build-from-source {project.brew_formula_path}"], check=False)
26
28
  else:
27
29
  click.echo("[brew] skipping: Homebrew not available or disabled")
28
30
 
@@ -14,7 +14,7 @@ from scripts._utils import run # noqa: E402
14
14
  @click.option("--pyproject", type=click.Path(path_type=Path), default=Path("pyproject.toml"))
15
15
  @click.option("--changelog", type=click.Path(path_type=Path), default=Path("CHANGELOG.md"))
16
16
  def main(version_: str | None, part: str | None, pyproject: Path, changelog: Path) -> None:
17
- args = [sys.executable, "tools/bump_version.py"]
17
+ args = [sys.executable, "scripts/bump_version.py"]
18
18
  if version_:
19
19
  args += ["--version", version_]
20
20
  else: