|
| 1 | +"""HTTP admin client for branched streams (M5 P1, Experimental). |
| 2 | +
|
| 3 | +Wraps the broker's ``/api/v1/branches/*`` admin API. Sibling to |
| 4 | +:class:`streamline_sdk.branches.BranchedTopic`, which handles read-side |
| 5 | +wire-name parsing. |
| 6 | +
|
| 7 | +Example: |
| 8 | + client = BranchAdminClient("http://localhost:9094") |
| 9 | + await client.create("orders", "exp-a", parent=None) |
| 10 | + branches = await client.list() |
| 11 | + await client.append("orders/exp-a", role="user", text="hi") |
| 12 | + await client.delete("orders/exp-a") |
| 13 | +""" |
| 14 | + |
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import asyncio |
| 18 | +import json |
| 19 | +from dataclasses import dataclass, field |
| 20 | +from typing import Any, Optional, Sequence |
| 21 | + |
| 22 | +try: |
| 23 | + import aiohttp |
| 24 | + |
| 25 | + _HAS_AIOHTTP = True |
| 26 | +except ImportError: # pragma: no cover - import guard |
| 27 | + _HAS_AIOHTTP = False |
| 28 | + |
| 29 | +from .exceptions import StreamlineError |
| 30 | + |
| 31 | + |
| 32 | +class BranchAdminError(StreamlineError): |
| 33 | + """Raised on branch admin API failures.""" |
| 34 | + |
| 35 | + |
| 36 | +@dataclass |
| 37 | +class BranchView: |
| 38 | + """A branch as returned by the admin API.""" |
| 39 | + |
| 40 | + id: str |
| 41 | + parent: Optional[str] = None |
| 42 | + created_at_ms: int = 0 |
| 43 | + message_count: int = 0 |
| 44 | + metadata: dict[str, Any] = field(default_factory=dict) |
| 45 | + |
| 46 | + @classmethod |
| 47 | + def from_json(cls, data: dict[str, Any]) -> "BranchView": |
| 48 | + return cls( |
| 49 | + id=data["id"], |
| 50 | + parent=data.get("parent"), |
| 51 | + created_at_ms=int(data.get("created_at_ms", 0)), |
| 52 | + message_count=int(data.get("message_count", 0)), |
| 53 | + metadata=dict(data.get("metadata", {})), |
| 54 | + ) |
| 55 | + |
| 56 | + |
| 57 | +@dataclass |
| 58 | +class BranchMessage: |
| 59 | + """A message within a branch.""" |
| 60 | + |
| 61 | + role: str |
| 62 | + text: str |
| 63 | + timestamp_ms: int = 0 |
| 64 | + |
| 65 | + def to_json(self) -> dict[str, Any]: |
| 66 | + out: dict[str, Any] = {"role": self.role, "text": self.text} |
| 67 | + if self.timestamp_ms: |
| 68 | + out["timestamp_ms"] = self.timestamp_ms |
| 69 | + return out |
| 70 | + |
| 71 | + @classmethod |
| 72 | + def from_json(cls, data: dict[str, Any]) -> "BranchMessage": |
| 73 | + return cls( |
| 74 | + role=str(data.get("role", "")), |
| 75 | + text=str(data.get("text", "")), |
| 76 | + timestamp_ms=int(data.get("timestamp_ms", 0)), |
| 77 | + ) |
| 78 | + |
| 79 | + |
| 80 | +class BranchAdminClient: |
| 81 | + """Async client for the broker's branch admin API. |
| 82 | +
|
| 83 | + Args: |
| 84 | + http_url: HTTP base URL (default: ``http://localhost:9094``). |
| 85 | + timeout: Per-request timeout in seconds. |
| 86 | + """ |
| 87 | + |
| 88 | + def __init__( |
| 89 | + self, http_url: str = "http://localhost:9094", timeout: float = 10.0 |
| 90 | + ) -> None: |
| 91 | + self.http_url = http_url.rstrip("/") |
| 92 | + self.timeout = timeout |
| 93 | + |
| 94 | + async def create( |
| 95 | + self, |
| 96 | + topic: str, |
| 97 | + name: str, |
| 98 | + *, |
| 99 | + parent: Optional[str] = None, |
| 100 | + metadata: Optional[dict[str, Any]] = None, |
| 101 | + ) -> BranchView: |
| 102 | + """Create a new branch ``<topic>/<name>``.""" |
| 103 | + body: dict[str, Any] = {"topic": topic, "name": name} |
| 104 | + if parent is not None: |
| 105 | + body["parent"] = parent |
| 106 | + if metadata: |
| 107 | + body["metadata"] = metadata |
| 108 | + data = await self._request("POST", "/api/v1/branches", json_body=body) |
| 109 | + return BranchView.from_json(data) |
| 110 | + |
| 111 | + async def list(self) -> list[BranchView]: |
| 112 | + """List all known branches across topics.""" |
| 113 | + data = await self._request("GET", "/api/v1/branches") |
| 114 | + items = data if isinstance(data, list) else data.get("items", []) |
| 115 | + return [BranchView.from_json(b) for b in items] |
| 116 | + |
| 117 | + async def get(self, branch_id: str) -> BranchView: |
| 118 | + """Get a single branch by id (``<topic>/<name>``).""" |
| 119 | + data = await self._request( |
| 120 | + "GET", f"/api/v1/branches/{branch_id}" |
| 121 | + ) |
| 122 | + return BranchView.from_json(data) |
| 123 | + |
| 124 | + async def delete(self, branch_id: str) -> None: |
| 125 | + """Delete a branch.""" |
| 126 | + await self._request("DELETE", f"/api/v1/branches/{branch_id}") |
| 127 | + |
| 128 | + async def append( |
| 129 | + self, |
| 130 | + branch_id: str, |
| 131 | + role: str, |
| 132 | + text: str, |
| 133 | + *, |
| 134 | + timestamp_ms: int = 0, |
| 135 | + ) -> None: |
| 136 | + """Append a single message to a branch.""" |
| 137 | + msg = BranchMessage(role=role, text=text, timestamp_ms=timestamp_ms) |
| 138 | + await self._request( |
| 139 | + "POST", |
| 140 | + f"/api/v1/branches/{branch_id}/messages", |
| 141 | + json_body=msg.to_json(), |
| 142 | + ) |
| 143 | + |
| 144 | + async def messages(self, branch_id: str) -> list[BranchMessage]: |
| 145 | + """Read all messages on a branch.""" |
| 146 | + data = await self._request( |
| 147 | + "GET", f"/api/v1/branches/{branch_id}/messages" |
| 148 | + ) |
| 149 | + items = data if isinstance(data, list) else data.get("messages", []) |
| 150 | + return [BranchMessage.from_json(m) for m in items] |
| 151 | + |
| 152 | + # ------------------------------------------------------------------ |
| 153 | + async def _request( |
| 154 | + self, |
| 155 | + method: str, |
| 156 | + path: str, |
| 157 | + *, |
| 158 | + json_body: Optional[dict[str, Any]] = None, |
| 159 | + ) -> Any: |
| 160 | + url = f"{self.http_url}{path}" |
| 161 | + |
| 162 | + if _HAS_AIOHTTP: |
| 163 | + timeout = aiohttp.ClientTimeout(total=self.timeout) |
| 164 | + async with aiohttp.ClientSession(timeout=timeout) as session: |
| 165 | + async with session.request( |
| 166 | + method, url, json=json_body |
| 167 | + ) as resp: |
| 168 | + body_text = await resp.text() |
| 169 | + self._check(resp.status, body_text, method, path) |
| 170 | + if not body_text: |
| 171 | + return {} |
| 172 | + try: |
| 173 | + return json.loads(body_text) |
| 174 | + except json.JSONDecodeError: |
| 175 | + return body_text |
| 176 | + else: # urllib fallback so the SDK stays importable without aiohttp |
| 177 | + import urllib.request |
| 178 | + import urllib.error |
| 179 | + |
| 180 | + data: Optional[bytes] = None |
| 181 | + headers = {} |
| 182 | + if json_body is not None: |
| 183 | + data = json.dumps(json_body).encode("utf-8") |
| 184 | + headers["Content-Type"] = "application/json" |
| 185 | + req = urllib.request.Request( |
| 186 | + url, data=data, headers=headers, method=method |
| 187 | + ) |
| 188 | + |
| 189 | + def _sync() -> Any: |
| 190 | + try: |
| 191 | + with urllib.request.urlopen(req, timeout=self.timeout) as resp: |
| 192 | + body = resp.read().decode("utf-8") |
| 193 | + self._check(resp.status, body, method, path) |
| 194 | + return json.loads(body) if body else {} |
| 195 | + except urllib.error.HTTPError as e: |
| 196 | + body = e.read().decode("utf-8", errors="replace") |
| 197 | + self._check(e.code, body, method, path) |
| 198 | + return {} |
| 199 | + |
| 200 | + return await asyncio.to_thread(_sync) |
| 201 | + |
| 202 | + @staticmethod |
| 203 | + def _check(status: int, body: str, method: str, path: str) -> None: |
| 204 | + if 200 <= status < 300: |
| 205 | + return |
| 206 | + raise BranchAdminError( |
| 207 | + f"{method} {path} -> HTTP {status}: {body[:512]}" |
| 208 | + ) |
| 209 | + |
| 210 | + |
| 211 | +__all__ = [ |
| 212 | + "BranchAdminClient", |
| 213 | + "BranchAdminError", |
| 214 | + "BranchMessage", |
| 215 | + "BranchView", |
| 216 | +] |
0 commit comments