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
@@ -0,0 +1,3 @@
1
+ from .installer import main
2
+
3
+ __all__ = ["main"]
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ gira = gira_cli:main
@@ -0,0 +1 @@
1
+ gira_cli