"""Registry (Forgejo) + market:catalog (memory-api) clients. Shared by reindex.py and watch.py. Registry read side mirrors cli/src/m2_market/registry.py's contents-API approach (contracts/registry-layout.md); this module adds the listing-directory-listing and commit-polling calls the indexer needs that the CLI client doesn't. Memory-api paths are configurable constants (config.py), not hardcoded, per contracts/catalog-index.md. """ from __future__ import annotations import base64 import json 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, ref: str = "main", *, auth_user: str = "m2", timeout: float = 15.0, transport: httpx.BaseTransport | None = None, ) -> None: owner, _, name = repo.partition("/") self._owner = owner self._repo = name self.ref = ref self._client = httpx.Client( base_url=base_url.rstrip("/"), auth=(auth_user, token), timeout=timeout, transport=transport, ) def close(self) -> None: self._client.close() def __enter__(self) -> RegistryClient: return self def __exit__(self, *exc_info: object) -> None: self.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}"), params={"ref": self.ref} ) resp.raise_for_status() data = resp.json() content = base64.b64decode(data["content"]) return json.loads(content) def list_listing_ids(self) -> list[str]: """Directory names under listings/ on `self.ref` (one per listing).""" resp = self._client.get( self._repo_api("/contents/listings"), params={"ref": self.ref} ) if resp.status_code == 404: return [] resp.raise_for_status() entries = resp.json() return sorted(e["name"] for e in entries if e.get("type") == "dir") 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 | None: """solution.json is optional (used for `intent`); None if absent.""" try: return self._get_json_contents(f"listings/{listing_id}/solution.json") except httpx.HTTPStatusError as exc: if exc.response.status_code == 404: return None raise def get_main_commit_sha(self) -> str: resp = self._client.get(self._repo_api(f"/branches/{self.ref}")) resp.raise_for_status() data = resp.json() try: return data["commit"]["id"] except KeyError as exc: raise RegistryError(f"unexpected branch response shape: {data!r}") from exc class MemoryError_(RuntimeError): """Raised when memory-api returns an unexpected response.""" class MemoryClient: """Writer for the `market:catalog` partition (contracts/catalog-index.md). ONLY m2-market-indexer writes here (reindex + watch); everything else is a reader via CatalogClient's `/search`. """ def __init__( self, base_url: str, api_key: str, partition: str, *, partition_path: str, document_path: str, timeout: float = 15.0, transport: httpx.BaseTransport | None = None, ) -> None: self.partition = partition self.partition_path = partition_path self.document_path = document_path self._client = httpx.Client( base_url=base_url.rstrip("/"), headers={"X-API-Key": api_key}, timeout=timeout, transport=transport, ) def close(self) -> None: self._client.close() def __enter__(self) -> MemoryClient: return self def __exit__(self, *exc_info: object) -> None: self.close() def _partition_url(self) -> str: return self.partition_path.format(partition=self.partition) def _document_url(self, doc_id: str) -> str: return self.document_path.format(partition=self.partition, doc_id=doc_id) def drop_partition(self) -> None: """Delete the partition wholesale; a later upsert recreates it.""" resp = self._client.delete(self._partition_url()) if resp.status_code not in (200, 204, 404): resp.raise_for_status() def upsert_document(self, doc_id: str, text: str, payload: dict) -> None: resp = self._client.put( self._document_url(doc_id), json={"text": text, "payload": payload}, ) resp.raise_for_status() def delete_document(self, doc_id: str) -> None: resp = self._client.delete(self._document_url(doc_id)) if resp.status_code not in (200, 204, 404): resp.raise_for_status() def build_embedded_text(listing: dict, solution: dict | None) -> str: """name + summary + keywords + intent(solution.json, if present) + evidence_summary.""" parts = [ listing.get("name", ""), listing.get("summary", ""), " ".join(listing.get("keywords", []) or []), ] if solution and solution.get("intent"): parts.append(solution["intent"]) parts.append(listing.get("evidence_summary", "")) return "\n".join(p for p in parts if p)