ohbin 0.2.2__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.
ohbin/_engine.py ADDED
@@ -0,0 +1,256 @@
1
+ """Download + SHA256-verify + cache a release binary on first use.
2
+
3
+ Cache layout (`$XDG_CACHE_HOME` or `~/.cache`):
4
+
5
+ ohbin/<tool>/<version>/<binary> ← extracted, chmod +x binary
6
+ ohbin/<tool>/<version>/.lock ← flock during install
7
+
8
+ Concurrent invocations on the same host coordinate via flock — first one
9
+ downloads, the rest wait and reuse the same binary. POSIX-only (fcntl); a
10
+ Windows path isn't shipped until someone needs it.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import fcntl
16
+ import hashlib
17
+ import http.client
18
+ import os
19
+ import shutil
20
+ import tarfile
21
+ import tempfile
22
+ import urllib.error
23
+ import urllib.request
24
+ import zipfile
25
+ from contextlib import contextmanager
26
+ from pathlib import Path
27
+ from typing import IO, TYPE_CHECKING
28
+ from urllib.parse import unquote, urlparse
29
+
30
+ from ohbin._crypto import decrypt_binary
31
+ from ohbin._errors import OhbinError
32
+ from ohbin._retry import retry, retryable_http_status
33
+
34
+ if TYPE_CHECKING:
35
+ from collections.abc import Iterator
36
+
37
+ _DOWNLOAD_TIMEOUT = 60 # seconds per read/connect attempt
38
+
39
+
40
+ class ChecksumMismatchError(OhbinError):
41
+ """Raised when a downloaded asset's SHA256 doesn't match the pinned value."""
42
+
43
+
44
+ class BinaryNotFoundError(OhbinError):
45
+ """Raised when the expected binary isn't present inside a downloaded archive."""
46
+
47
+
48
+ class MissingPasswordError(OhbinError):
49
+ """Raised when an encrypted tool is run without a password from --password or pyproject."""
50
+
51
+
52
+ class _TransientDownloadError(RuntimeError):
53
+ """Internal: a retryable download failure (network / timeout / truncated stream)."""
54
+
55
+
56
+ def cache_root(tool: str, version: str) -> Path:
57
+ xdg = os.environ.get("XDG_CACHE_HOME")
58
+ base = Path(xdg) if xdg else Path.home() / ".cache"
59
+ return base / "ohbin" / tool / version
60
+
61
+
62
+ @contextmanager
63
+ def _exclusive_lock(lock_path: Path) -> Iterator[None]:
64
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
65
+ fd = os.open(str(lock_path), os.O_CREAT | os.O_RDWR, 0o644)
66
+ try:
67
+ fcntl.flock(fd, fcntl.LOCK_EX)
68
+ yield
69
+ finally:
70
+ fcntl.flock(fd, fcntl.LOCK_UN)
71
+ os.close(fd)
72
+
73
+
74
+ def _sha256_of(stream: IO[bytes]) -> str:
75
+ h = hashlib.sha256()
76
+ for chunk in iter(lambda: stream.read(1 << 20), b""):
77
+ h.update(chunk)
78
+ return h.hexdigest()
79
+
80
+
81
+ def _download_once(url: str, dest: Path) -> None:
82
+ # Release URLs redirect to a signed CDN host; urllib follows redirects by
83
+ # default. Stream to disk so we don't buffer the archive in RAM.
84
+ request = urllib.request.Request(url, headers={"User-Agent": "ohbin"})
85
+ try:
86
+ with (
87
+ urllib.request.urlopen(request, timeout=_DOWNLOAD_TIMEOUT) as response,
88
+ dest.open("wb") as out,
89
+ ):
90
+ shutil.copyfileobj(response, out)
91
+ except urllib.error.HTTPError as exc: # check first — subclass of URLError/OSError
92
+ if retryable_http_status(exc.code):
93
+ raise _TransientDownloadError(f"download {url} → HTTP {exc.code}") from exc
94
+ raise
95
+ except (OSError, http.client.IncompleteRead) as exc:
96
+ # URLError, ConnectionError, timeout, or a truncated/reset stream.
97
+ raise _TransientDownloadError(f"download {url} failed: {exc}") from exc
98
+
99
+
100
+ def _download(url: str, dest: Path) -> None:
101
+ retry(lambda: _download_once(url, dest), op="download", retry_on=(_TransientDownloadError,))
102
+
103
+
104
+ def sha256_of_url(url: str) -> str:
105
+ """Download a URL to a temp file and return its SHA256 (used by `add`)."""
106
+ fd, name = tempfile.mkstemp(prefix="ohbin-")
107
+ os.close(fd)
108
+ tmp = Path(name)
109
+ try:
110
+ _download(url, tmp)
111
+ with tmp.open("rb") as fh:
112
+ return _sha256_of(fh)
113
+ finally:
114
+ tmp.unlink(missing_ok=True)
115
+
116
+
117
+ def text_of_url(url: str) -> str:
118
+ """Download a URL to a temp file and return its decoded text.
119
+
120
+ Used to read a gist file (e.g. the `ohbin.json` index) from its `raw_url` when
121
+ GitHub truncates the inline `content` returned by the gists API.
122
+ """
123
+ fd, name = tempfile.mkstemp(prefix="ohbin-")
124
+ os.close(fd)
125
+ tmp = Path(name)
126
+ try:
127
+ _download(url, tmp)
128
+ return tmp.read_text()
129
+ finally:
130
+ tmp.unlink(missing_ok=True)
131
+
132
+
133
+ def _verify(path: Path, expected_sha256: str) -> None:
134
+ with path.open("rb") as fh:
135
+ actual = _sha256_of(fh)
136
+ if actual != expected_sha256:
137
+ msg = f"checksum mismatch for {path.name}: expected {expected_sha256}, got {actual}"
138
+ raise ChecksumMismatchError(msg)
139
+
140
+
141
+ def _url_filename(url: str) -> str:
142
+ name = Path(unquote(urlparse(url).path)).name
143
+ return name or "download"
144
+
145
+
146
+ def _finalize(staged: Path, dest: Path) -> None:
147
+ staged.chmod(0o755)
148
+ staged.replace(dest) # atomic within the same filesystem
149
+
150
+
151
+ def _extract_tar(archive: Path, binary: str, dest: Path) -> None:
152
+ with tarfile.open(archive, "r:*") as tf: # r:* auto-detects gz/xz/bz2/plain
153
+ member = next(
154
+ (m for m in tf.getmembers() if m.isfile() and Path(m.name).name == binary),
155
+ None,
156
+ )
157
+ if member is None:
158
+ msg = f"binary {binary!r} not found in archive {archive.name}"
159
+ raise BinaryNotFoundError(msg)
160
+ with tempfile.TemporaryDirectory(dir=str(dest.parent)) as staging:
161
+ tf.extract(member, path=staging, filter="data")
162
+ _finalize(Path(staging) / member.name, dest)
163
+
164
+
165
+ def _extract_zip(archive: Path, binary: str, dest: Path) -> None:
166
+ with zipfile.ZipFile(archive) as zf:
167
+ member = next(
168
+ (n for n in zf.namelist() if not n.endswith("/") and Path(n).name == binary),
169
+ None,
170
+ )
171
+ if member is None:
172
+ msg = f"binary {binary!r} not found in archive {archive.name}"
173
+ raise BinaryNotFoundError(msg)
174
+ with tempfile.TemporaryDirectory(dir=str(dest.parent)) as staging:
175
+ zf.extract(member, path=staging)
176
+ _finalize(Path(staging) / member, dest)
177
+
178
+
179
+ def _place_raw(archive: Path, dest: Path) -> None:
180
+ # Asset is the bare executable (no archive wrapper).
181
+ with tempfile.TemporaryDirectory(dir=str(dest.parent)) as staging:
182
+ staged = Path(staging) / dest.name
183
+ shutil.copyfile(archive, staged)
184
+ _finalize(staged, dest)
185
+
186
+
187
+ def _decrypt_into(archive: Path, dest: Path, *, password: str | None, binary_sha256: str | None) -> None:
188
+ # The downloaded file is base64 text (gzip+AES blob); decrypt to the raw binary.
189
+ if not password:
190
+ msg = "encrypted tool requires a password (pass --password or set 'password' in pyproject)"
191
+ raise MissingPasswordError(msg)
192
+ binary = decrypt_binary(archive.read_text(), password)
193
+ if binary_sha256 is not None:
194
+ actual = hashlib.sha256(binary).hexdigest()
195
+ if actual != binary_sha256:
196
+ msg = (
197
+ f"decrypted binary checksum mismatch: expected {binary_sha256}, got {actual} "
198
+ "(wrong password or corrupt blob)"
199
+ )
200
+ raise ChecksumMismatchError(msg)
201
+ with tempfile.TemporaryDirectory(dir=str(dest.parent)) as staging:
202
+ staged = Path(staging) / dest.name
203
+ staged.write_bytes(binary)
204
+ _finalize(staged, dest)
205
+
206
+
207
+ def _extract(archive: Path, binary: str, dest: Path) -> None:
208
+ name = archive.name.lower()
209
+ if name.endswith((".tar.gz", ".tgz", ".tar.xz", ".tar.bz2", ".tar")):
210
+ _extract_tar(archive, binary, dest)
211
+ elif name.endswith(".zip"):
212
+ _extract_zip(archive, binary, dest)
213
+ else:
214
+ _place_raw(archive, dest)
215
+
216
+
217
+ def ensure_from(
218
+ *,
219
+ tool: str,
220
+ version: str,
221
+ binary: str,
222
+ url: str,
223
+ sha256: str,
224
+ encrypted: bool = False,
225
+ password: str | None = None,
226
+ binary_sha256: str | None = None,
227
+ ) -> Path:
228
+ """Return the cached binary path, downloading + verifying it on first use.
229
+
230
+ For encrypted tools the downloaded file is a base64 gzip+AES blob: `sha256`
231
+ verifies the ciphertext (tamper-evidence on the gist), then it's decrypted with
232
+ `password` and the plaintext checked against `binary_sha256`. The decrypted
233
+ binary is cached like any other — first run decrypts, the rest reuse it.
234
+ """
235
+ cache = cache_root(tool, version)
236
+ target = cache / binary
237
+ if target.is_file() and os.access(target, os.X_OK):
238
+ return target
239
+
240
+ with _exclusive_lock(cache / ".lock"):
241
+ # Another process may have finished while we waited on the lock.
242
+ if target.is_file() and os.access(target, os.X_OK):
243
+ return target
244
+
245
+ archive = cache / _url_filename(url)
246
+ try:
247
+ _download(url, archive)
248
+ _verify(archive, sha256)
249
+ if encrypted:
250
+ _decrypt_into(archive, target, password=password, binary_sha256=binary_sha256)
251
+ else:
252
+ _extract(archive, binary, target)
253
+ finally:
254
+ archive.unlink(missing_ok=True)
255
+
256
+ return target
ohbin/_errors.py ADDED
@@ -0,0 +1,12 @@
1
+ """Common base for all expected, user-facing ohbin failures.
2
+
3
+ The CLI catches `OhbinError` and prints `error: <message>` without a stack trace;
4
+ anything else propagates as a real (unexpected) crash. Library callers can likewise
5
+ `except ohbin.OhbinError` to handle every anticipated failure in one place.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+
11
+ class OhbinError(RuntimeError):
12
+ """Base class for anticipated ohbin failures (bad manifest, checksum, decrypt, …)."""
ohbin/_gist.py ADDED
@@ -0,0 +1,234 @@
1
+ """`ohbin publish-gist` / `add-gist`: ship encrypted private binaries through gists.
2
+
3
+ A gist holds one base64'd `gzip+AES` blob per platform (`<name>-<platform>.enc.b64`)
4
+ plus an `ohbin.json` index that maps each platform to its file and the *decrypted*
5
+ binary's SHA256. `publish-gist` produces and uploads them; `add-gist` reads the index,
6
+ resolves each file's immutable raw URL, pins the ciphertext SHA, and writes the tool
7
+ into pyproject. Gists are secret (link-gated), the binary is encrypted, and the
8
+ password lives elsewhere — a leaked link alone is useless.
9
+
10
+ All GitHub access goes through the `gh` CLI (`gh api`), reusing the user's auth.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import shutil
18
+ import subprocess
19
+ from pathlib import Path
20
+ from typing import TypedDict
21
+ from urllib.parse import urlparse
22
+
23
+ from ohbin._add import local_pyproject, write_tool
24
+ from ohbin._crypto import encrypt_binary
25
+ from ohbin._engine import sha256_of_url, text_of_url
26
+ from ohbin._errors import OhbinError
27
+ from ohbin._platform import current_platform
28
+ from ohbin._types import AssetEntry, ToolConfig
29
+
30
+ _OHBIN_INDEX = "ohbin.json"
31
+ _INDEX_VERSION = 1
32
+
33
+
34
+ class GistAssetInfo(TypedDict):
35
+ """Per-platform entry in a gist's `ohbin.json` index."""
36
+
37
+ file: str
38
+ binary_sha256: str
39
+
40
+
41
+ class GistMeta(TypedDict):
42
+ """The `ohbin.json` index stored alongside the encrypted blobs in a gist."""
43
+
44
+ ohbin_gist: int
45
+ name: str
46
+ binary: str
47
+ encrypted: bool
48
+ assets: dict[str, GistAssetInfo]
49
+
50
+
51
+ class GistError(OhbinError):
52
+ """Raised when a gist can't be published or resolved (gh failure, malformed index)."""
53
+
54
+
55
+ def _require_gh() -> str:
56
+ path = shutil.which("gh")
57
+ if path is None:
58
+ msg = "gh CLI not found on PATH — required to publish/resolve gists"
59
+ raise GistError(msg)
60
+ return path
61
+
62
+
63
+ def _gh_api(method: str, path: str, body: dict[str, object] | None = None) -> dict[str, object]:
64
+ cmd = [_require_gh(), "api", "-X", method, path]
65
+ payload: bytes | None = None
66
+ if body is not None:
67
+ cmd += ["--input", "-"]
68
+ payload = json.dumps(body).encode()
69
+ proc = subprocess.run(cmd, input=payload, capture_output=True, check=False)
70
+ if proc.returncode != 0:
71
+ detail = proc.stderr.decode(errors="replace").strip()
72
+ msg = f"gh api {method} {path} failed: {detail or 'unknown error'}"
73
+ raise GistError(msg)
74
+ parsed: dict[str, object] = json.loads(proc.stdout)
75
+ return parsed
76
+
77
+
78
+ def _parse_gist_id(ref: str) -> str:
79
+ ref = ref.strip()
80
+ if "/" not in ref and ":" not in ref:
81
+ return ref # already a bare id
82
+ parts = [p for p in urlparse(ref).path.split("/") if p]
83
+ if "raw" in parts: # gist raw URL: .../<user>/<id>/raw/<sha>/<file>
84
+ parts = parts[: parts.index("raw")]
85
+ if not parts:
86
+ msg = f"cannot parse a gist id from {ref!r}"
87
+ raise GistError(msg)
88
+ return parts[-1]
89
+
90
+
91
+ def _file_entry(files: dict[str, object], name: str) -> dict[str, object]:
92
+ entry = files.get(name)
93
+ if not isinstance(entry, dict):
94
+ msg = f"gist is missing file {name!r}"
95
+ raise GistError(msg)
96
+ return entry
97
+
98
+
99
+ def _load_index(files: dict[str, object]) -> GistMeta:
100
+ entry = _file_entry(files, _OHBIN_INDEX)
101
+ raw = entry.get("content")
102
+ # GitHub truncates the inline `content` of *every* file in the gists API once
103
+ # any single file exceeds ~1MB — so a tiny index sitting next to a multi-MB
104
+ # encrypted blob comes back empty (`truncated: true`, `content: ""`). Fall back
105
+ # to the file's raw_url, which always serves the complete bytes.
106
+ if entry.get("truncated") or not isinstance(raw, str) or not raw:
107
+ raw_url = entry.get("raw_url")
108
+ if not isinstance(raw_url, str):
109
+ msg = f"{_OHBIN_INDEX} content is truncated and has no raw_url to fall back to"
110
+ raise GistError(msg)
111
+ raw = text_of_url(raw_url)
112
+ meta: GistMeta = json.loads(raw)
113
+ return meta
114
+
115
+
116
+ def publish_gist(
117
+ *,
118
+ name: str,
119
+ binary: str,
120
+ binary_path: Path,
121
+ password: str,
122
+ platform_key: str | None,
123
+ gist_ref: str | None,
124
+ description: str,
125
+ ) -> str:
126
+ """Encrypt `binary_path` for one platform and POST/PATCH it into a secret gist.
127
+
128
+ With `gist_ref` the blob is added to an existing gist (another platform for the
129
+ same tool); otherwise a fresh secret gist is created. Returns the gist's html_url.
130
+ """
131
+ plat = platform_key or current_platform().key
132
+ data = binary_path.read_bytes()
133
+ blob = encrypt_binary(data, password)
134
+ fname = f"{name}-{plat}.enc.b64"
135
+
136
+ if gist_ref is not None:
137
+ gid = _parse_gist_id(gist_ref)
138
+ existing = _gh_api("GET", f"gists/{gid}")
139
+ files = existing.get("files")
140
+ meta = _load_index(files) if isinstance(files, dict) else _new_index(name, binary)
141
+ else:
142
+ meta = _new_index(name, binary)
143
+
144
+ meta["assets"][plat] = {"file": fname, "binary_sha256": hashlib.sha256(data).hexdigest()}
145
+ body: dict[str, object] = {
146
+ "public": False,
147
+ "files": {
148
+ fname: {"content": blob},
149
+ _OHBIN_INDEX: {"content": json.dumps(meta, indent=2, sort_keys=True)},
150
+ },
151
+ }
152
+ if gist_ref is not None:
153
+ result = _gh_api("PATCH", f"gists/{_parse_gist_id(gist_ref)}", body)
154
+ else:
155
+ body["description"] = description
156
+ result = _gh_api("POST", "gists", body)
157
+
158
+ html_url = result.get("html_url")
159
+ if not isinstance(html_url, str):
160
+ msg = "gist created but GitHub returned no html_url"
161
+ raise GistError(msg)
162
+ return html_url
163
+
164
+
165
+ def _new_index(name: str, binary: str) -> GistMeta:
166
+ return {
167
+ "ohbin_gist": _INDEX_VERSION,
168
+ "name": name,
169
+ "binary": binary,
170
+ "encrypted": True,
171
+ "assets": {},
172
+ }
173
+
174
+
175
+ def _snapshot(gist: dict[str, object], gid: str) -> str:
176
+ """Short id for the gist's current revision — used as the cache-busting version."""
177
+ history = gist.get("history")
178
+ if isinstance(history, list) and history:
179
+ first = history[0]
180
+ if isinstance(first, dict):
181
+ version = first.get("version")
182
+ if isinstance(version, str):
183
+ return version[:12]
184
+ return gid[:12]
185
+
186
+
187
+ def resolve_gist(gist_ref: str, *, password: str | None = None) -> tuple[str, ToolConfig]:
188
+ """Read a gist's `ohbin.json` index and build a ToolConfig (pins ciphertext SHAs)."""
189
+ gid = _parse_gist_id(gist_ref)
190
+ gist = _gh_api("GET", f"gists/{gid}")
191
+ files = gist.get("files")
192
+ if not isinstance(files, dict):
193
+ msg = f"gist {gid} has no files"
194
+ raise GistError(msg)
195
+ meta = _load_index(files)
196
+
197
+ assets: dict[str, AssetEntry] = {}
198
+ for plat, info in meta["assets"].items():
199
+ raw_url = _file_entry(files, info["file"]).get("raw_url")
200
+ if not isinstance(raw_url, str):
201
+ msg = f"gist file {info['file']!r} has no raw_url"
202
+ raise GistError(msg)
203
+ # Hash the bytes GitHub actually serves (immune to any newline normalisation).
204
+ assets[plat] = AssetEntry(
205
+ url=raw_url,
206
+ sha256=sha256_of_url(raw_url),
207
+ binary_sha256=info["binary_sha256"],
208
+ )
209
+
210
+ cfg = ToolConfig(
211
+ repo=f"gist:{gid}",
212
+ version=f"gist-{_snapshot(gist, gid)}",
213
+ binary=meta["binary"],
214
+ assets=assets,
215
+ encrypted=True,
216
+ )
217
+ if password is not None:
218
+ cfg["password"] = password
219
+ return meta["name"], cfg
220
+
221
+
222
+ def add_gist_tool(
223
+ *,
224
+ gist_ref: str,
225
+ name: str | None,
226
+ password: str | None,
227
+ pyproject: Path | None,
228
+ ) -> tuple[str, Path]:
229
+ """Resolve a gist and write its (encrypted) tool entry into pyproject."""
230
+ resolved_name, cfg = resolve_gist(gist_ref, password=password)
231
+ cmd_name = name or resolved_name
232
+ target = local_pyproject(pyproject)
233
+ write_tool(target, cmd_name, cfg)
234
+ return cmd_name, target
ohbin/_github.py ADDED
@@ -0,0 +1,125 @@
1
+ """Resolve a GitHub release + its assets — via the `gh` CLI when present, else the REST API.
2
+
3
+ Failures are split two ways: a clean **404** means "this tag doesn't exist"
4
+ (we try `v`-prefixed then bare, then give up — never retried), while anything
5
+ else — network error, timeout, rate-limit, 5xx, a non-404 `gh` failure — is
6
+ **transient** and retried with backoff. Conflating the two is exactly what made
7
+ a flaky network look like "release not found".
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import shutil
15
+ import subprocess
16
+ import urllib.error
17
+ import urllib.request
18
+ from typing import Any, NamedTuple
19
+
20
+ from ohbin._errors import OhbinError
21
+ from ohbin._retry import retry, retryable_http_status
22
+
23
+ _API = "https://api.github.com"
24
+ _TIMEOUT = 30 # seconds per attempt
25
+
26
+
27
+ class ReleaseNotFoundError(OhbinError):
28
+ """Raised when no release matches the given repo/version (a real 404)."""
29
+
30
+
31
+ class _TransientError(RuntimeError):
32
+ """Internal: a retryable GitHub fetch failure (network / timeout / 5xx / rate-limit / gh error)."""
33
+
34
+
35
+ class Asset(NamedTuple):
36
+ name: str
37
+ url: str
38
+ digest: str | None # GitHub asset digest like "sha256:..." (may be absent)
39
+
40
+
41
+ class Release(NamedTuple):
42
+ tag: str
43
+ assets: list[Asset]
44
+
45
+
46
+ def _candidate_paths(repo: str, version: str | None) -> list[str]:
47
+ if version is None:
48
+ return [f"repos/{repo}/releases/latest"]
49
+ # Accept both `v1.2.3` and `1.2.3` tag conventions.
50
+ return [
51
+ f"repos/{repo}/releases/tags/v{version}",
52
+ f"repos/{repo}/releases/tags/{version}",
53
+ ]
54
+
55
+
56
+ def _fetch_gh_one(path: str) -> dict[str, Any] | None:
57
+ """Return parsed JSON, None on a clean 404, or raise `_TransientError` otherwise."""
58
+ try:
59
+ proc = subprocess.run( # fixed argv, `gh` resolved from PATH
60
+ ["gh", "api", path],
61
+ capture_output=True,
62
+ text=True,
63
+ check=False,
64
+ timeout=_TIMEOUT,
65
+ )
66
+ except subprocess.TimeoutExpired as exc:
67
+ raise _TransientError(f"gh api {path} timed out after {_TIMEOUT}s") from exc
68
+ if proc.returncode == 0:
69
+ parsed: dict[str, Any] = json.loads(proc.stdout)
70
+ return parsed
71
+ stderr = proc.stderr.strip()
72
+ if "http 404" in stderr.lower() or "not found" in stderr.lower():
73
+ return None
74
+ raise _TransientError(f"gh api {path} failed: {stderr or 'unknown error'}")
75
+
76
+
77
+ def _fetch_http_one(path: str) -> dict[str, Any] | None:
78
+ headers = {
79
+ "Accept": "application/vnd.github+json",
80
+ "User-Agent": "ohbin",
81
+ "X-GitHub-Api-Version": "2022-11-28",
82
+ }
83
+ token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
84
+ if token:
85
+ headers["Authorization"] = f"Bearer {token}"
86
+ request = urllib.request.Request(f"{_API}/{path}", headers=headers) # fixed https host
87
+ try:
88
+ with urllib.request.urlopen(request, timeout=_TIMEOUT) as response:
89
+ parsed: dict[str, Any] = json.loads(response.read())
90
+ return parsed
91
+ except urllib.error.HTTPError as exc:
92
+ if exc.code == 404:
93
+ return None
94
+ if retryable_http_status(exc.code):
95
+ raise _TransientError(f"GET {path} → HTTP {exc.code}") from exc
96
+ raise
97
+ except urllib.error.URLError as exc: # network / DNS / timeout
98
+ raise _TransientError(f"GET {path} failed: {exc.reason}") from exc
99
+
100
+
101
+ def _fetch_raw(repo: str, version: str | None) -> dict[str, Any]:
102
+ fetch_one = _fetch_gh_one if shutil.which("gh") else _fetch_http_one
103
+ candidates = _candidate_paths(repo, version)
104
+
105
+ def attempt() -> dict[str, Any] | None:
106
+ for path in candidates:
107
+ data = fetch_one(path)
108
+ if data is not None:
109
+ return data
110
+ return None # every candidate was a clean 404 → genuinely not found
111
+
112
+ raw = retry(attempt, op=f"fetch release for {repo}", retry_on=(_TransientError,))
113
+ if raw is None:
114
+ msg = f"could not resolve a release for {repo} (tried: {', '.join(candidates)})"
115
+ raise ReleaseNotFoundError(msg)
116
+ return raw
117
+
118
+
119
+ def fetch_release(repo: str, version: str | None) -> Release:
120
+ raw = _fetch_raw(repo, version)
121
+ assets = [
122
+ Asset(name=str(a["name"]), url=str(a["browser_download_url"]), digest=a.get("digest"))
123
+ for a in raw.get("assets", [])
124
+ ]
125
+ return Release(tag=str(raw["tag_name"]), assets=assets)