brutalsystems-realtime-client 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .ruff_cache/
5
+ .pytest_cache/
6
+ dist/
7
+ *.egg-info/
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: brutalsystems-realtime-client
3
+ Version: 0.1.0
4
+ Summary: Python SDK for the realtime service — WebSocket subscriber + publisher and a generic JWT minter. `pip install brutalsystems-realtime-client`, then `import realtime_client`.
5
+ Requires-Python: >=3.13
6
+ Requires-Dist: brutalsystems-realtime-core==0.1.0
7
+ Requires-Dist: httpx>=0.27
8
+ Requires-Dist: websockets>=13
@@ -0,0 +1,17 @@
1
+ [project]
2
+ name = "brutalsystems-realtime-client"
3
+ version = "0.1.0"
4
+ description = "Python SDK for the realtime service — WebSocket subscriber + publisher and a generic JWT minter. `pip install brutalsystems-realtime-client`, then `import realtime_client`."
5
+ requires-python = ">=3.13"
6
+ dependencies = [
7
+ "brutalsystems-realtime-core==0.1.0",
8
+ "websockets>=13",
9
+ "httpx>=0.27",
10
+ ]
11
+
12
+ [build-system]
13
+ requires = ["hatchling"]
14
+ build-backend = "hatchling.build"
15
+
16
+ [tool.hatch.build.targets.wheel]
17
+ packages = ["realtime_client"]
@@ -0,0 +1,5 @@
1
+ """Python SDK for the realtime service."""
2
+ from realtime_client.publisher import RealtimePublisher, rest_publish
3
+ from realtime_client.subscriber import RealtimeSubscriber
4
+
5
+ __all__ = ["RealtimeSubscriber", "RealtimePublisher", "rest_publish"]
@@ -0,0 +1,140 @@
1
+ # python/packages/realtime-client/realtime_client/publisher.py
2
+ """Realtime publisher.
3
+
4
+ Best-effort background publisher ported from an internal best-effort publisher
5
+ — KEEP the bounded queue with drop-oldest, the worker
6
+ task, lazy reconnect with token re-mint, and the ping_interval keepalive.
7
+ publish() never blocks the caller. `rest_publish` is a thin typed helper for
8
+ the REST endpoint — a stand-in until openapi-python-client codegen lands."""
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import logging
14
+ from collections.abc import Awaitable, Callable
15
+ from typing import Any
16
+
17
+ import httpx
18
+
19
+ from realtime_core import bearer_subprotocol, publish_frame
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # websockets keepalive: ping every 20s so a dead socket surfaces promptly.
24
+ _PING_INTERVAL = 20
25
+
26
+
27
+ async def _default_connect(url: str, subprotocols: list[str]) -> Any:
28
+ import websockets
29
+
30
+ return await websockets.connect(
31
+ url, subprotocols=subprotocols or None, ping_interval=_PING_INTERVAL,
32
+ )
33
+
34
+
35
+ class RealtimePublisher:
36
+ def __init__(
37
+ self, *, url: str, token_provider: Callable[[], str] | None = None,
38
+ max_queue_size: int = 1000,
39
+ _connect: Callable[[str, list[str]], Awaitable[Any]] = _default_connect,
40
+ ) -> None:
41
+ self._url = url
42
+ self._token_provider = token_provider
43
+ self._connect = _connect
44
+ self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=max_queue_size)
45
+ self._worker_task: asyncio.Task[Any] | None = None
46
+ self._stop = asyncio.Event()
47
+ self._ws: Any | None = None
48
+
49
+ async def __aenter__(self) -> RealtimePublisher:
50
+ await self.start()
51
+ return self
52
+
53
+ async def __aexit__(self, *exc: Any) -> None:
54
+ await self.stop()
55
+
56
+ async def start(self) -> None:
57
+ self._stop.clear()
58
+ self._worker_task = asyncio.create_task(self._worker())
59
+
60
+ async def stop(self) -> None:
61
+ self._stop.set()
62
+ if self._worker_task is not None:
63
+ await self._worker_task
64
+ self._worker_task = None
65
+ if self._ws is not None:
66
+ try:
67
+ await self._ws.close()
68
+ except Exception:
69
+ pass
70
+ self._ws = None
71
+
72
+ async def publish(self, channel: str, data: dict[str, Any], *, scope: str | None = None) -> None:
73
+ """Non-blocking enqueue. On a full queue, drop the OLDEST event."""
74
+ frame = publish_frame(channel, data, scope=scope)
75
+ try:
76
+ self._queue.put_nowait(frame)
77
+ except asyncio.QueueFull:
78
+ try:
79
+ self._queue.get_nowait() # drop oldest
80
+ except asyncio.QueueEmpty:
81
+ pass
82
+ try:
83
+ self._queue.put_nowait(frame)
84
+ except asyncio.QueueFull:
85
+ logger.warning("realtime publish queue full; dropped event")
86
+
87
+ async def publish_event(
88
+ self, channel: str, event: str, payload: dict[str, Any], *, scope: str | None = None,
89
+ ) -> None:
90
+ """Convenience matching the prior internal publisher signature."""
91
+ await self.publish(channel, {"event": event, "payload": payload}, scope=scope)
92
+
93
+ async def _worker(self) -> None:
94
+ while not self._stop.is_set():
95
+ try:
96
+ frame = await asyncio.wait_for(self._queue.get(), timeout=0.5)
97
+ except TimeoutError:
98
+ continue
99
+ try:
100
+ await self._send_one(frame)
101
+ except Exception as exc:
102
+ # Best-effort: log + drop the socket; the next event reconnects
103
+ # (and re-mints the token) via _ensure_connected.
104
+ logger.warning("realtime publish failed: %s", exc)
105
+ if self._ws is not None:
106
+ try:
107
+ await self._ws.close()
108
+ except Exception:
109
+ pass
110
+ self._ws = None
111
+
112
+ async def _ensure_connected(self) -> None:
113
+ if self._ws is not None:
114
+ return
115
+ subprotocols: list[str] = []
116
+ if self._token_provider is not None:
117
+ subprotocols = [bearer_subprotocol(self._token_provider())]
118
+ self._ws = await self._connect(self._url, subprotocols)
119
+
120
+ async def _send_one(self, frame: dict[str, Any]) -> None:
121
+ await self._ensure_connected()
122
+ assert self._ws is not None
123
+ await self._ws.send(json.dumps(frame))
124
+
125
+
126
+ async def rest_publish(
127
+ base_url: str, channel: str, data: dict[str, Any], *,
128
+ token: str, client: httpx.AsyncClient | None = None,
129
+ ) -> dict[str, Any]:
130
+ url = f"{base_url}/api/v1/channels/{channel}/messages"
131
+ headers = {"Authorization": f"Bearer {token}"}
132
+ owns = client is None
133
+ client = client or httpx.AsyncClient()
134
+ try:
135
+ resp = await client.post(url, json={"data": data}, headers=headers)
136
+ resp.raise_for_status()
137
+ return resp.json()
138
+ finally:
139
+ if owns:
140
+ await client.aclose()
@@ -0,0 +1,146 @@
1
+ """Realtime WebSocket subscriber.
2
+
3
+ Single WS connection multiplexed across subscribe() calls; each subscribe()
4
+ returns an async iterator filtered to its channel(s). Ported from an internal
5
+ realtime client — KEEP the reconnect/backoff/resubscribe
6
+ behavior and the eager subscribe() semantics. `_connect` is injectable for
7
+ tests; the env-var names in from_env are generic (not service-specific)."""
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ import json
12
+ import os
13
+ from collections.abc import AsyncIterator, Awaitable, Callable
14
+ from typing import Any
15
+
16
+ import websockets
17
+
18
+ from realtime_core import (
19
+ EventFrame,
20
+ TokenMinter,
21
+ bearer_subprotocol,
22
+ parse_inbound,
23
+ subscribe_frame,
24
+ )
25
+
26
+ _BACKOFF_SECONDS = (0.1, 0.5, 1.0, 2.0, 5.0)
27
+
28
+
29
+ async def _default_connect(url: str, subprotocols: list[str]) -> Any:
30
+ return await websockets.connect(url, subprotocols=subprotocols)
31
+
32
+
33
+ class RealtimeSubscriber:
34
+ def __init__(
35
+ self, *, url: str, token_provider: Callable[[], str],
36
+ _connect: Callable[[str, list[str]], Awaitable[Any]] = _default_connect,
37
+ ) -> None:
38
+ self._url = url
39
+ self._token_provider = token_provider
40
+ self._connect = _connect
41
+ self._ws: Any | None = None
42
+ self._queues: dict[str, asyncio.Queue[EventFrame]] = {}
43
+ self._reader_task: asyncio.Task[Any] | None = None
44
+
45
+ @classmethod
46
+ def from_env(cls, *, owner_service: str, tenant_id: str = "_org") -> RealtimeSubscriber:
47
+ """Build a TokenMinter from generic env vars (REALTIME_URL,
48
+ REALTIME_JWT_ISSUER, REALTIME_JWT_PRIVATE_KEY). The names are generic
49
+ on purpose — a public SDK must not hardcode a single consumer's
50
+ identity; each consumer wires its own env (or passes an explicit
51
+ token_provider)."""
52
+ url = os.environ.get("REALTIME_URL")
53
+ issuer = os.environ.get("REALTIME_JWT_ISSUER")
54
+ private_key = os.environ.get("REALTIME_JWT_PRIVATE_KEY")
55
+ missing = [n for n, v in
56
+ (("REALTIME_URL", url), ("REALTIME_JWT_ISSUER", issuer),
57
+ ("REALTIME_JWT_PRIVATE_KEY", private_key)) if not v]
58
+ if missing:
59
+ raise RuntimeError(f"missing env vars for from_env: {', '.join(missing)}")
60
+ minter = TokenMinter(
61
+ private_key=private_key, issuer=issuer,
62
+ subject=owner_service, tenant_id=tenant_id,
63
+ )
64
+ return cls(url=url, token_provider=minter)
65
+
66
+ async def __aenter__(self) -> RealtimeSubscriber:
67
+ token = self._token_provider()
68
+ self._ws = await self._connect(self._url, [bearer_subprotocol(token)])
69
+ self._reader_task = asyncio.create_task(self._reader())
70
+ return self
71
+
72
+ async def __aexit__(self, *exc: Any) -> None:
73
+ if self._reader_task is not None:
74
+ self._reader_task.cancel()
75
+ try:
76
+ await self._reader_task
77
+ except BaseException:
78
+ pass
79
+ if self._ws is not None:
80
+ await self._ws.close()
81
+
82
+ async def _reader(self) -> None:
83
+ backoff_idx = 0
84
+ while True:
85
+ try:
86
+ assert self._ws is not None
87
+ async for raw in self._ws:
88
+ msg = json.loads(raw)
89
+ evt = parse_inbound(msg)
90
+ if evt is None:
91
+ continue
92
+ q = self._queues.get(evt.channel)
93
+ if q is not None:
94
+ await q.put(evt)
95
+ # remote closed cleanly — fall through to reconnect
96
+ except asyncio.CancelledError:
97
+ raise
98
+ except Exception:
99
+ pass
100
+
101
+ # reconnect path (ported verbatim)
102
+ delay = _BACKOFF_SECONDS[min(backoff_idx, len(_BACKOFF_SECONDS) - 1)]
103
+ backoff_idx += 1
104
+ if backoff_idx > 10:
105
+ return
106
+ await asyncio.sleep(delay)
107
+ new_ws = None
108
+ try:
109
+ token = self._token_provider()
110
+ new_ws = await self._connect(self._url, [bearer_subprotocol(token)])
111
+ for ch in list(self._queues.keys()):
112
+ await new_ws.send(json.dumps(subscribe_frame(ch)))
113
+ self._ws = new_ws
114
+ backoff_idx = 0
115
+ except Exception:
116
+ if new_ws is not None:
117
+ try:
118
+ await new_ws.close()
119
+ except BaseException:
120
+ pass
121
+ continue
122
+
123
+ async def subscribe(self, channels: list[str]) -> AsyncIterator[EventFrame]:
124
+ """Register `channels` eagerly and return an async iterator over events.
125
+
126
+ EAGER: the queue is installed and the subscribe frame is sent before
127
+ this returns, so a caller can subscribe, trigger work, then iterate
128
+ without losing an event published in between. Must NOT be a lazy
129
+ generator (a generator body runs only on first __anext__, deferring
130
+ registration)."""
131
+ assert self._ws is not None
132
+ queue: asyncio.Queue[EventFrame] = asyncio.Queue()
133
+ for ch in channels:
134
+ self._queues[ch] = queue
135
+ await self._ws.send(json.dumps(subscribe_frame(ch)))
136
+ return self._drain(channels, queue)
137
+
138
+ async def _drain(
139
+ self, channels: list[str], queue: asyncio.Queue[EventFrame],
140
+ ) -> AsyncIterator[EventFrame]:
141
+ try:
142
+ while True:
143
+ yield await queue.get()
144
+ finally:
145
+ for ch in channels:
146
+ self._queues.pop(ch, None)