Skip to content

Commit 1f1e8b7

Browse files
committed
feat(branches): add BranchedTopic with wire-protocol mapping and admin support
1 parent c8305c5 commit 1f1e8b7

2 files changed

Lines changed: 263 additions & 0 deletions

File tree

‎streamline_sdk/branches.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Branched-streams support for the Python SDK (M5 P1, Experimental).
2+
3+
Subscribe to a branched view by passing ``branch="<name>"`` to ``Consumer``.
4+
5+
Internally this maps to the wire-protocol topic ``<topic>@branch=<name>``
6+
which the broker routes through the COW reader.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import re
12+
from dataclasses import dataclass
13+
from typing import Optional
14+
15+
_BRANCH_NAME_RE = re.compile(r"^[a-z0-9-]+$")
16+
17+
18+
class BranchError(ValueError):
19+
"""Raised for invalid branch names or operations."""
20+
21+
22+
@dataclass(frozen=True)
23+
class BranchedTopic:
24+
"""A topic name + branch view."""
25+
26+
topic: str
27+
branch: Optional[str] = None
28+
29+
def __post_init__(self) -> None:
30+
if self.branch is not None and not _BRANCH_NAME_RE.match(self.branch):
31+
raise BranchError(
32+
f"branch name must match [a-z0-9-]+: got {self.branch!r}"
33+
)
34+
35+
def wire_name(self) -> str:
36+
"""The on-the-wire topic name: ``logs`` or ``logs@branch=exp-a``."""
37+
if self.branch is None:
38+
return self.topic
39+
return f"{self.topic}@branch={self.branch}"
40+
41+
42+
def parse_wire_name(s: str) -> BranchedTopic:
43+
"""Inverse of :meth:`BranchedTopic.wire_name`."""
44+
if "@branch=" in s:
45+
topic, branch = s.split("@branch=", 1)
46+
return BranchedTopic(topic, branch)
47+
return BranchedTopic(s)

‎streamline_sdk/branches_admin.py‎

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
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

Comments
 (0)