m2-market/indexer/src/m2_market_indexer/client.py
m2 (AI Agent) 1d10589cef T015: catalog indexer (reindex + watch)
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.
2026-07-02 02:51:30 +02:00

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)