merge: cli-registry-client (US1 wave 1)
This commit is contained in:
commit
a14afc0244
1 changed files with 98 additions and 0 deletions
98
cli/src/m2_market/registry.py
Normal file
98
cli/src/m2_market/registry.py
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
"""Forgejo-backed registry client (contracts/registry-layout.md).
|
||||||
|
|
||||||
|
Listings live at ``listings/<listing_id>/{listing,solution}.json`` on the
|
||||||
|
registry repo's default branch, read via the Forgejo contents API. Solution
|
||||||
|
bundles attach as repo releases tagged ``listing.json.install_ref``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryError(RuntimeError):
|
||||||
|
"""Raised when the registry returns an unexpected response."""
|
||||||
|
|
||||||
|
|
||||||
|
class RegistryClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: str,
|
||||||
|
token: str,
|
||||||
|
repo: str = "m2/market-registry",
|
||||||
|
auth_user: str = "m2",
|
||||||
|
) -> None:
|
||||||
|
owner, _, name = repo.partition("/")
|
||||||
|
self._owner = owner
|
||||||
|
self._repo = name
|
||||||
|
self._client = httpx.Client(
|
||||||
|
base_url=base_url.rstrip("/"),
|
||||||
|
auth=(auth_user, token),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __enter__(self) -> "RegistryClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *exc_info: object) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._client.close()
|
||||||
|
|
||||||
|
def _repo_api(self, path: str) -> str:
|
||||||
|
return f"/api/v1/repos/{self._owner}/{self._repo}{path}"
|
||||||
|
|
||||||
|
def _get_json_contents(self, repo_path: str) -> dict:
|
||||||
|
resp = self._client.get(self._repo_api(f"/contents/{repo_path}"))
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
content = base64.b64decode(data["content"])
|
||||||
|
return json.loads(content)
|
||||||
|
|
||||||
|
def get_listing(self, listing_id: str) -> dict:
|
||||||
|
return self._get_json_contents(f"listings/{listing_id}/listing.json")
|
||||||
|
|
||||||
|
def get_solution(self, listing_id: str) -> dict:
|
||||||
|
return self._get_json_contents(f"listings/{listing_id}/solution.json")
|
||||||
|
|
||||||
|
def download_bundle(self, install_ref: str, dest_dir: str | Path) -> Path:
|
||||||
|
resp = self._client.get(self._repo_api(f"/releases/tags/{install_ref}"))
|
||||||
|
resp.raise_for_status()
|
||||||
|
release = resp.json()
|
||||||
|
assets = release.get("assets") or []
|
||||||
|
bundle = next((a for a in assets if a["name"].endswith(".tar.gz")), None)
|
||||||
|
if bundle is None:
|
||||||
|
raise RegistryError(
|
||||||
|
f"release {install_ref!r} has no .tar.gz bundle asset"
|
||||||
|
)
|
||||||
|
|
||||||
|
dest_dir = Path(dest_dir)
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest_path = dest_dir / bundle["name"]
|
||||||
|
download_url = bundle.get("browser_download_url") or bundle["url"]
|
||||||
|
|
||||||
|
with self._client.stream("GET", download_url) as stream:
|
||||||
|
stream.raise_for_status()
|
||||||
|
with dest_path.open("wb") as fh:
|
||||||
|
for chunk in stream.iter_bytes():
|
||||||
|
fh.write(chunk)
|
||||||
|
|
||||||
|
return dest_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def verify_bundle(path: str | Path, content_hash: str) -> bool:
|
||||||
|
algo, _, expected = content_hash.partition(":")
|
||||||
|
if algo != "sha256" or not expected:
|
||||||
|
raise ValueError(f"unsupported content_hash format: {content_hash!r}")
|
||||||
|
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
with Path(path).open("rb") as fh:
|
||||||
|
for chunk in iter(lambda: fh.read(65536), b""):
|
||||||
|
digest.update(chunk)
|
||||||
|
|
||||||
|
return digest.hexdigest() == expected
|
||||||
Loading…
Reference in a new issue