Registry (Forgejo) + memory-api clients under m2_market_indexer.client, with memory-api paths/creds as env-overridable constants (config.py). reindex.py drops market:catalog and rebuilds from published listings on main, skipping schema-invalid ones. watch.py polls the registry tip commit against a cached state.json and upserts changed/new published listings, deletes delisted ones.
175 lines
5.6 KiB
Python
175 lines
5.6 KiB
Python
"""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)
|