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.
- brutalsystems_realtime_client-0.1.0/.gitignore +7 -0
- brutalsystems_realtime_client-0.1.0/PKG-INFO +8 -0
- brutalsystems_realtime_client-0.1.0/pyproject.toml +17 -0
- brutalsystems_realtime_client-0.1.0/realtime_client/__init__.py +5 -0
- brutalsystems_realtime_client-0.1.0/realtime_client/publisher.py +140 -0
- brutalsystems_realtime_client-0.1.0/realtime_client/subscriber.py +146 -0
|
@@ -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,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)
|