gira-cli 1.0.0__py3-none-any.whl
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.
gira_cli/__init__.py
ADDED
gira_cli/installer.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import importlib.metadata
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import stat
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tarfile
|
|
12
|
+
import tempfile
|
|
13
|
+
import urllib.request
|
|
14
|
+
import zipfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import BinaryIO, Dict, Optional
|
|
17
|
+
|
|
18
|
+
REPO = "StatPan/gira"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def package_version() -> str:
|
|
22
|
+
return importlib.metadata.version("gira-cli")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def release_version() -> str:
|
|
26
|
+
env_version = os.environ.get("GIRA_VERSION", "").strip()
|
|
27
|
+
if env_version:
|
|
28
|
+
return env_version
|
|
29
|
+
version = package_version()
|
|
30
|
+
if "dev" in version:
|
|
31
|
+
return ""
|
|
32
|
+
return version if version.startswith("v") else f"v{version}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_platform(system: Optional[str] = None, machine: Optional[str] = None) -> Dict[str, str]:
|
|
36
|
+
system = (system or platform.system()).lower()
|
|
37
|
+
machine = (machine or platform.machine()).lower()
|
|
38
|
+
os_map = {
|
|
39
|
+
"linux": "linux",
|
|
40
|
+
"darwin": "darwin",
|
|
41
|
+
"windows": "windows",
|
|
42
|
+
}
|
|
43
|
+
arch_map = {
|
|
44
|
+
"x86_64": "amd64",
|
|
45
|
+
"amd64": "amd64",
|
|
46
|
+
"arm64": "arm64",
|
|
47
|
+
"aarch64": "arm64",
|
|
48
|
+
}
|
|
49
|
+
goos = os_map.get(system)
|
|
50
|
+
goarch = arch_map.get(machine)
|
|
51
|
+
if not goos:
|
|
52
|
+
raise RuntimeError(f"unsupported OS: {system}")
|
|
53
|
+
if not goarch:
|
|
54
|
+
raise RuntimeError(f"unsupported architecture: {machine}")
|
|
55
|
+
if goos == "windows" and goarch != "amd64":
|
|
56
|
+
raise RuntimeError(f"unsupported Windows architecture: {machine}")
|
|
57
|
+
return {
|
|
58
|
+
"goos": goos,
|
|
59
|
+
"goarch": goarch,
|
|
60
|
+
"extension": "zip" if goos == "windows" else "tar.gz",
|
|
61
|
+
"binary": "gira.exe" if goos == "windows" else "gira",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def archive_name(version: str, info: Dict[str, str]) -> str:
|
|
66
|
+
return f"gira_{version}_{info['goos']}_{info['goarch']}.{info['extension']}"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def base_url(version: str) -> str:
|
|
70
|
+
return os.environ.get("GIRA_BASE_URL", "").strip() or f"https://github.com/{REPO}/releases/download/{version}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def cache_dir(version: str, info: Dict[str, str]) -> Path:
|
|
74
|
+
override = os.environ.get("GIRA_PYPI_CACHE_DIR", "").strip()
|
|
75
|
+
root = Path(override).expanduser() if override else Path.home() / ".cache" / "gira-cli"
|
|
76
|
+
return root / version / f"{info['goos']}_{info['goarch']}"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def binary_checksum_path(target: Path) -> Path:
|
|
80
|
+
return target.with_name(f"{target.name}.sha256")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def file_checksum(path: Path) -> str:
|
|
84
|
+
digest = hashlib.sha256()
|
|
85
|
+
with path.open("rb") as source:
|
|
86
|
+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
87
|
+
digest.update(chunk)
|
|
88
|
+
return digest.hexdigest()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def cached_binary_valid(target: Path) -> bool:
|
|
92
|
+
marker = binary_checksum_path(target)
|
|
93
|
+
if not target.is_file() or not marker.is_file():
|
|
94
|
+
return False
|
|
95
|
+
if os.name != "nt" and not os.access(target, os.X_OK):
|
|
96
|
+
return False
|
|
97
|
+
return marker.read_text().strip() == file_checksum(target)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def read_url(url: str) -> bytes:
|
|
101
|
+
if url.startswith("file://"):
|
|
102
|
+
return Path(urllib.request.url2pathname(url[7:])).read_bytes()
|
|
103
|
+
with urllib.request.urlopen(url) as response:
|
|
104
|
+
return response.read()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def download_file(url: str, target: Path) -> None:
|
|
108
|
+
target.write_bytes(read_url(url))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def checksum_for(checksums: str, archive: str) -> str:
|
|
112
|
+
for line in checksums.splitlines():
|
|
113
|
+
parts = line.strip().split()
|
|
114
|
+
if len(parts) >= 2 and parts[1] in {archive, f"*{archive}"}:
|
|
115
|
+
return parts[0]
|
|
116
|
+
raise RuntimeError(f"checksum asset does not include {archive}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def verify_checksum(path: Path, expected: str) -> None:
|
|
120
|
+
actual = file_checksum(path)
|
|
121
|
+
if actual != expected:
|
|
122
|
+
raise RuntimeError(f"checksum mismatch for {path.name}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def validate_archive_member(name: str) -> Path:
|
|
126
|
+
if not name or "\x00" in name or "\\" in name or ":" in name:
|
|
127
|
+
raise RuntimeError(f"unsafe archive member path: {name}")
|
|
128
|
+
path = Path(name)
|
|
129
|
+
if path.is_absolute() or ".." in path.parts:
|
|
130
|
+
raise RuntimeError(f"unsafe archive member path: {name}")
|
|
131
|
+
return path
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def copy_member(source: BinaryIO, target: Path) -> None:
|
|
135
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
with target.open("wb") as output:
|
|
137
|
+
shutil.copyfileobj(source, output)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def extract_archive(path: Path, extension: str, output_dir: Path) -> None:
|
|
141
|
+
if extension == "zip":
|
|
142
|
+
with zipfile.ZipFile(path) as archive:
|
|
143
|
+
for member in archive.infolist():
|
|
144
|
+
target = output_dir / validate_archive_member(member.filename)
|
|
145
|
+
if member.is_dir():
|
|
146
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
continue
|
|
148
|
+
with archive.open(member) as source:
|
|
149
|
+
copy_member(source, target)
|
|
150
|
+
return
|
|
151
|
+
with tarfile.open(path, "r:gz") as archive:
|
|
152
|
+
for member in archive.getmembers():
|
|
153
|
+
target = output_dir / validate_archive_member(member.name)
|
|
154
|
+
if member.isdir():
|
|
155
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
156
|
+
continue
|
|
157
|
+
if not member.isfile():
|
|
158
|
+
raise RuntimeError(f"unsupported archive member type: {member.name}")
|
|
159
|
+
source = archive.extractfile(member)
|
|
160
|
+
if source is None:
|
|
161
|
+
raise RuntimeError(f"unable to extract archive member: {member.name}")
|
|
162
|
+
with source:
|
|
163
|
+
copy_member(source, target)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def install_cached_binary(source: Path, target: Path) -> None:
|
|
167
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
checksum = file_checksum(source)
|
|
169
|
+
with tempfile.NamedTemporaryFile(prefix=f".{target.name}.", dir=target.parent, delete=False) as tmp:
|
|
170
|
+
tmp_path = Path(tmp.name)
|
|
171
|
+
marker_tmp_path: Optional[Path] = None
|
|
172
|
+
try:
|
|
173
|
+
shutil.copy2(source, tmp_path)
|
|
174
|
+
tmp_path.chmod(tmp_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
175
|
+
os.replace(tmp_path, target)
|
|
176
|
+
|
|
177
|
+
marker = binary_checksum_path(target)
|
|
178
|
+
with tempfile.NamedTemporaryFile(
|
|
179
|
+
mode="w", prefix=f".{marker.name}.", dir=target.parent, delete=False
|
|
180
|
+
) as marker_tmp:
|
|
181
|
+
marker_tmp.write(f"{checksum}\n")
|
|
182
|
+
marker_tmp_path = Path(marker_tmp.name)
|
|
183
|
+
os.replace(marker_tmp_path, marker)
|
|
184
|
+
finally:
|
|
185
|
+
tmp_path.unlink(missing_ok=True)
|
|
186
|
+
if marker_tmp_path is not None:
|
|
187
|
+
marker_tmp_path.unlink(missing_ok=True)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def ensure_binary() -> Path:
|
|
191
|
+
version = release_version()
|
|
192
|
+
if not version:
|
|
193
|
+
raise RuntimeError("gira-cli development package cannot resolve a release version; set GIRA_VERSION")
|
|
194
|
+
info = resolve_platform()
|
|
195
|
+
target_dir = cache_dir(version, info)
|
|
196
|
+
target = target_dir / info["binary"]
|
|
197
|
+
if cached_binary_valid(target):
|
|
198
|
+
return target
|
|
199
|
+
|
|
200
|
+
archive = archive_name(version, info)
|
|
201
|
+
root = base_url(version)
|
|
202
|
+
with tempfile.TemporaryDirectory(prefix="gira-pypi-") as tmp:
|
|
203
|
+
tmpdir = Path(tmp)
|
|
204
|
+
archive_path = tmpdir / archive
|
|
205
|
+
checksums_path = tmpdir / "checksums.txt"
|
|
206
|
+
download_file(f"{root}/{archive}", archive_path)
|
|
207
|
+
download_file(f"{root}/checksums.txt", checksums_path)
|
|
208
|
+
expected = checksum_for(checksums_path.read_text(), archive)
|
|
209
|
+
verify_checksum(archive_path, expected)
|
|
210
|
+
extract_archive(archive_path, info["extension"], tmpdir)
|
|
211
|
+
extracted = tmpdir / f"gira_{version}_{info['goos']}_{info['goarch']}" / info["binary"]
|
|
212
|
+
if not extracted.exists():
|
|
213
|
+
raise RuntimeError(f"release archive did not contain {info['binary']}")
|
|
214
|
+
install_cached_binary(extracted, target)
|
|
215
|
+
return target
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main() -> int:
|
|
219
|
+
try:
|
|
220
|
+
binary = ensure_binary()
|
|
221
|
+
except Exception as exc:
|
|
222
|
+
print(f"gira pip wrapper: {exc}", file=sys.stderr)
|
|
223
|
+
return 1
|
|
224
|
+
completed = subprocess.run([str(binary), *sys.argv[1:]], check=False)
|
|
225
|
+
return int(completed.returncode)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
if __name__ == "__main__":
|
|
229
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gira-cli
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Jira-style project flow on GitHub, distributed as the official Go-built gira binary.
|
|
5
|
+
Author: StatPan
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Environment :: Console
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# gira-cli
|
|
15
|
+
|
|
16
|
+
PyPI distribution wrapper for the official Go-built `gira` binary.
|
|
17
|
+
|
|
18
|
+
This package does not reimplement Gira in Python. The `gira` console command downloads the matching GitHub Release archive on first run, verifies `checksums.txt`, caches the native binary, and then executes it.
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install gira-cli
|
|
22
|
+
gira version
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The PyPI package name is `gira-cli` because the `gira` package name is already occupied on PyPI. The installed command is still `gira`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
gira_cli/__init__.py,sha256=DcFXAJ6glywmbz0T05Hyfh_xhB7syoZP170iJV4UZF0,48
|
|
2
|
+
gira_cli/installer.py,sha256=NYJzNwyRQUwChE2G0OQq_iuJyICh81D2j399GCZC7cY,7901
|
|
3
|
+
gira_cli-1.0.0.dist-info/METADATA,sha256=y2MGOLN8XYpHrxOpPrdp2BU4n5rXceDTUgFJ0SPRYLM,932
|
|
4
|
+
gira_cli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
5
|
+
gira_cli-1.0.0.dist-info/entry_points.txt,sha256=3Hm1vcjmCaeBailXFa76KM0uli4mwTz--tWuvtKKZxI,39
|
|
6
|
+
gira_cli-1.0.0.dist-info/top_level.txt,sha256=WEcNSHunWxPTWGuGh-D9H-HBsT2YnlliNyVHOJSuO9M,9
|
|
7
|
+
gira_cli-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gira_cli
|