lib-cli-exit-tools 1.0.3__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.
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/CHANGELOG.md +4 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/PKG-INFO +33 -1
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/README.md +31 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/packaging/brew/Formula/lib-cli-exit-tools.rb +1 -1
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/packaging/conda/recipe/meta.yaml +1 -1
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/packaging/nix/flake.nix +2 -2
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/pyproject.toml +2 -1
- lib_cli_exit_tools-1.1.0/scripts/_utils.py +255 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/build.py +4 -2
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/bump_version.py +38 -29
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/menu.py +9 -1
- lib_cli_exit_tools-1.1.0/scripts/run_cli.py +26 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/test.py +41 -8
- lib_cli_exit_tools-1.1.0/src/lib_cli_exit_tools/__init__.py +33 -0
- lib_cli_exit_tools-1.1.0/src/lib_cli_exit_tools/__main__.py +4 -0
- lib_cli_exit_tools-1.1.0/src/lib_cli_exit_tools/cli.py +45 -0
- lib_cli_exit_tools-1.1.0/src/lib_cli_exit_tools/lib_cli_exit_tools.py +372 -0
- lib_cli_exit_tools-1.1.0/tests/test_cli.py +85 -0
- lib_cli_exit_tools-1.1.0/tests/test_cli_extra.py +32 -0
- lib_cli_exit_tools-1.1.0/tests/test_extend_cli.py +111 -0
- lib_cli_exit_tools-1.0.3/scripts/_utils.py +0 -125
- lib_cli_exit_tools-1.0.3/scripts/run_cli.py +0 -17
- lib_cli_exit_tools-1.0.3/src/lib_cli_exit_tools/__init__.py +0 -15
- lib_cli_exit_tools-1.0.3/src/lib_cli_exit_tools/__main__.py +0 -4
- lib_cli_exit_tools-1.0.3/src/lib_cli_exit_tools/cli.py +0 -138
- lib_cli_exit_tools-1.0.3/src/lib_cli_exit_tools/lib_cli_exit_tools.py +0 -353
- lib_cli_exit_tools-1.0.3/tests/test_cli.py +0 -49
- lib_cli_exit_tools-1.0.3/tests/test_cli_extra.py +0 -54
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/.env.example +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/.github/workflows/ci.yml +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/.github/workflows/codeql.yml +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/.github/workflows/release.yml +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/.gitignore +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/AGENTS.md +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/CONTRIBUTING.md +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/LICENSE +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/Makefile +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/codecov.yml +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/notebooks/Quickstart.ipynb +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/packaging/README.md +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/qlty.toml +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/__init__.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/bump.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/bump_major.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/bump_minor.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/bump_patch.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/clean.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/dev.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/install.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/push.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/release.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/scripts/version_current.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/src/lib_cli_exit_tools/__init__conf__.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/src/lib_cli_exit_tools/py.typed +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/tests/test_exit_tools.py +0 -0
- {lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/tests/test_lib_extra.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lib_cli_exit_tools
|
|
3
|
-
Version: 1.0
|
|
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
|
|
|
@@ -230,6 +231,37 @@ cli-exit-tools --help # alias
|
|
|
230
231
|
lib_cli_exit_tools info
|
|
231
232
|
```
|
|
232
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
|
+
|
|
233
265
|
Library:
|
|
234
266
|
|
|
235
267
|
```python
|
|
@@ -200,6 +200,37 @@ cli-exit-tools --help # alias
|
|
|
200
200
|
lib_cli_exit_tools info
|
|
201
201
|
```
|
|
202
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
|
+
|
|
203
234
|
Library:
|
|
204
235
|
|
|
205
236
|
```python
|
{lib_cli_exit_tools-1.0.3 → lib_cli_exit_tools-1.1.0}/packaging/brew/Formula/lib-cli-exit-tools.rb
RENAMED
|
@@ -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.
|
|
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
|
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
{
|
|
36
36
|
packages.default = pypkgs.buildPythonPackage {
|
|
37
37
|
pname = "lib_cli_exit_tools";
|
|
38
|
-
version = "1.0
|
|
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
|
|
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
|
|
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]
|
|
@@ -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
|
|
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
|
|
|
@@ -8,6 +8,10 @@ from typing import Any, Dict, Tuple, Optional, cast
|
|
|
8
8
|
import json
|
|
9
9
|
import urllib.request
|
|
10
10
|
import hashlib
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
14
|
+
from scripts._utils import get_project_metadata # noqa: E402
|
|
11
15
|
|
|
12
16
|
# Optional TOML parser for Python < 3.11 support during type checking
|
|
13
17
|
try: # pragma: no cover - import resolution
|
|
@@ -18,6 +22,8 @@ except Exception: # pragma: no cover - best-effort fallback
|
|
|
18
22
|
except Exception: # noqa: PLC1901
|
|
19
23
|
_tomllib = None # type: ignore[assignment]
|
|
20
24
|
|
|
25
|
+
PROJECT_META = get_project_metadata()
|
|
26
|
+
|
|
21
27
|
|
|
22
28
|
def parse_args() -> argparse.Namespace:
|
|
23
29
|
p = argparse.ArgumentParser(description="Bump version in pyproject.toml and CHANGELOG.md")
|
|
@@ -221,19 +227,20 @@ def _update_conda_recipe(version: str, path: Path) -> None:
|
|
|
221
227
|
text = new_text
|
|
222
228
|
changed = True
|
|
223
229
|
# Attempt to set sha256 for remote source tarball (used when COND A_USE_LOCAL != 1)
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
230
|
+
tar_url = PROJECT_META.github_tarball_url(version)
|
|
231
|
+
if tar_url:
|
|
232
|
+
try:
|
|
233
|
+
with urllib.request.urlopen(tar_url, timeout=10) as resp:
|
|
234
|
+
data = resp.read()
|
|
235
|
+
sha = hashlib.sha256(data).hexdigest()
|
|
236
|
+
text_sha = re.sub(r'(sha256:\s*")([^"]*)(")', rf"\1{sha}\3", text)
|
|
237
|
+
if text_sha != text:
|
|
238
|
+
text = text_sha
|
|
239
|
+
changed = True
|
|
240
|
+
print(f"[bump] conda recipe: sha256 updated for v{version}")
|
|
241
|
+
except Exception:
|
|
242
|
+
# Network may be unavailable; leave sha256 as-is
|
|
243
|
+
pass
|
|
237
244
|
|
|
238
245
|
if changed:
|
|
239
246
|
path.write_text(text, encoding="utf-8")
|
|
@@ -248,18 +255,19 @@ def _brew_set_source_tag(text: str, version: str) -> tuple[str, bool]:
|
|
|
248
255
|
changed = True
|
|
249
256
|
text = new_text
|
|
250
257
|
# Try to fetch tarball and compute sha256; replace first sha256 occurrence (main formula)
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
258
|
+
tar_url = PROJECT_META.github_tarball_url(version)
|
|
259
|
+
if tar_url:
|
|
260
|
+
try:
|
|
261
|
+
with urllib.request.urlopen(tar_url, timeout=10) as resp:
|
|
262
|
+
data = resp.read()
|
|
263
|
+
sha = hashlib.sha256(data).hexdigest()
|
|
264
|
+
pattern = r'(^\s*sha256\s+")([^"]+)(")'
|
|
265
|
+
updated = re.sub(pattern, rf"\1{sha}\3", text, count=1, flags=re.M)
|
|
266
|
+
if updated != text:
|
|
267
|
+
text = updated
|
|
268
|
+
changed = True
|
|
269
|
+
except Exception:
|
|
270
|
+
pass
|
|
263
271
|
return text, changed
|
|
264
272
|
|
|
265
273
|
|
|
@@ -338,12 +346,13 @@ def _update_nix_flake(version: str, path: Path) -> None:
|
|
|
338
346
|
return
|
|
339
347
|
|
|
340
348
|
# Replace version = "X.Y.Z";
|
|
341
|
-
# Update package block version only for pname
|
|
349
|
+
# Update package block version only for the configured pname from pyproject
|
|
342
350
|
def repl_pkg_block(m: re.Match[str]) -> str:
|
|
343
351
|
return f"{m.group(1)}{version}{m.group(3)}"
|
|
344
352
|
|
|
353
|
+
name_pattern = re.escape(PROJECT_META.name)
|
|
345
354
|
t1 = re.sub(
|
|
346
|
-
|
|
355
|
+
rf"(pname\s*=\s*\"{name_pattern}\";\s*[^}}]*?\bversion\s*=\s*\")(\d+\.\d+\.\d+)(\";)",
|
|
347
356
|
repl_pkg_block,
|
|
348
357
|
text,
|
|
349
358
|
flags=re.S,
|
|
@@ -398,7 +407,7 @@ def main() -> int:
|
|
|
398
407
|
raise SystemExit("version not found in pyproject.toml")
|
|
399
408
|
target = m.group(1)
|
|
400
409
|
_update_conda_recipe(target, Path("packaging/conda/recipe/meta.yaml"))
|
|
401
|
-
_update_brew_formula(target, Path(
|
|
410
|
+
_update_brew_formula(target, Path(PROJECT_META.brew_formula_path))
|
|
402
411
|
_update_nix_flake(target, Path("packaging/nix/flake.nix"))
|
|
403
412
|
return 0
|
|
404
413
|
|
|
@@ -429,7 +438,7 @@ def main() -> int:
|
|
|
429
438
|
|
|
430
439
|
# Also bump packaging skeletons if present
|
|
431
440
|
_update_conda_recipe(target, Path("packaging/conda/recipe/meta.yaml"))
|
|
432
|
-
_update_brew_formula(target, Path(
|
|
441
|
+
_update_brew_formula(target, Path(PROJECT_META.brew_formula_path))
|
|
433
442
|
_update_nix_flake(target, Path("packaging/nix/flake.nix"))
|
|
434
443
|
return 0
|
|
435
444
|
|
|
@@ -4,12 +4,20 @@ import asyncio
|
|
|
4
4
|
import os
|
|
5
5
|
import signal
|
|
6
6
|
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
from typing import TYPE_CHECKING, Callable, Dict, Optional
|
|
8
9
|
|
|
9
10
|
import contextlib
|
|
10
11
|
|
|
11
12
|
from rich.text import Text
|
|
12
13
|
|
|
14
|
+
import sys
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
17
|
+
from scripts._utils import get_project_metadata # noqa: E402
|
|
18
|
+
|
|
19
|
+
PROJECT = get_project_metadata()
|
|
20
|
+
|
|
13
21
|
if TYPE_CHECKING:
|
|
14
22
|
from textual import events
|
|
15
23
|
from textual.app import App, ComposeResult
|
|
@@ -170,7 +178,7 @@ class MenuScreen(Screen[None]):
|
|
|
170
178
|
def compose(self) -> ComposeResult: # type: ignore[override]
|
|
171
179
|
with Container(id="backdrop"):
|
|
172
180
|
with Vertical(id="window", classes="whiptail-window"):
|
|
173
|
-
yield Static("
|
|
181
|
+
yield Static(f"{PROJECT.name} — Make Targets", id="title")
|
|
174
182
|
self._description = Static("Select a target and press Enter.", id="description")
|
|
175
183
|
yield self._description
|
|
176
184
|
yield self._build_list()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
import sys
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
9
|
+
from scripts._utils import get_project_metadata # noqa: E402
|
|
10
|
+
|
|
11
|
+
PROJECT = get_project_metadata()
|
|
12
|
+
PACKAGE = PROJECT.import_package
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command(help=f"Run {PROJECT.name} CLI (passes additional args)")
|
|
16
|
+
@click.argument("args", nargs=-1)
|
|
17
|
+
def main(args: tuple[str, ...]) -> None:
|
|
18
|
+
import_module(f"{PACKAGE}.__main__")
|
|
19
|
+
cli_main = import_module(f"{PACKAGE}.cli").main
|
|
20
|
+
|
|
21
|
+
code = cli_main(list(args) if args else ["--help"]) # returns int
|
|
22
|
+
raise SystemExit(int(code))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|