graphrefly 0.1.0__py3-none-any.whl
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.
- graphrefly/__init__.py +160 -0
- graphrefly/compat/__init__.py +18 -0
- graphrefly/compat/async_utils.py +228 -0
- graphrefly/compat/asyncio_runner.py +89 -0
- graphrefly/compat/trio_runner.py +81 -0
- graphrefly/core/__init__.py +142 -0
- graphrefly/core/clock.py +20 -0
- graphrefly/core/dynamic_node.py +749 -0
- graphrefly/core/guard.py +277 -0
- graphrefly/core/meta.py +149 -0
- graphrefly/core/node.py +963 -0
- graphrefly/core/protocol.py +460 -0
- graphrefly/core/runner.py +107 -0
- graphrefly/core/subgraph_locks.py +296 -0
- graphrefly/core/sugar.py +138 -0
- graphrefly/core/versioning.py +193 -0
- graphrefly/extra/__init__.py +313 -0
- graphrefly/extra/adapters.py +2149 -0
- graphrefly/extra/backoff.py +287 -0
- graphrefly/extra/backpressure.py +113 -0
- graphrefly/extra/checkpoint.py +307 -0
- graphrefly/extra/composite.py +303 -0
- graphrefly/extra/cron.py +133 -0
- graphrefly/extra/data_structures.py +707 -0
- graphrefly/extra/resilience.py +727 -0
- graphrefly/extra/sources.py +766 -0
- graphrefly/extra/tier1.py +1067 -0
- graphrefly/extra/tier2.py +1802 -0
- graphrefly/graph/__init__.py +31 -0
- graphrefly/graph/graph.py +2249 -0
- graphrefly/integrations/__init__.py +1 -0
- graphrefly/integrations/fastapi.py +767 -0
- graphrefly/patterns/__init__.py +5 -0
- graphrefly/patterns/ai.py +2132 -0
- graphrefly/patterns/cqrs.py +515 -0
- graphrefly/patterns/memory.py +639 -0
- graphrefly/patterns/messaging.py +553 -0
- graphrefly/patterns/orchestration.py +536 -0
- graphrefly/patterns/reactive_layout/__init__.py +81 -0
- graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
- graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
- graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
- graphrefly/py.typed +1 -0
- graphrefly-0.1.0.dist-info/METADATA +253 -0
- graphrefly-0.1.0.dist-info/RECORD +47 -0
- graphrefly-0.1.0.dist-info/WHEEL +4 -0
- graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,767 @@
|
|
|
1
|
+
"""FastAPI integration for GraphReFly (roadmap §5.1).
|
|
2
|
+
|
|
3
|
+
Composable utilities for wiring GraphReFly reactive graphs into FastAPI
|
|
4
|
+
applications. All bridges are **reactive** (subscribe-based, no polling).
|
|
5
|
+
|
|
6
|
+
Quick start::
|
|
7
|
+
|
|
8
|
+
from fastapi import FastAPI, Depends
|
|
9
|
+
from graphrefly import Graph, state
|
|
10
|
+
from graphrefly.integrations.fastapi import (
|
|
11
|
+
graphrefly_lifespan,
|
|
12
|
+
graphrefly_router,
|
|
13
|
+
get_graph,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
g = Graph("app")
|
|
17
|
+
g.add("counter", state(0))
|
|
18
|
+
|
|
19
|
+
app = FastAPI(lifespan=graphrefly_lifespan(g))
|
|
20
|
+
app.include_router(graphrefly_router(g, prefix="/graph"))
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import json
|
|
27
|
+
import queue
|
|
28
|
+
import threading
|
|
29
|
+
from contextlib import asynccontextmanager, suppress
|
|
30
|
+
from typing import TYPE_CHECKING, Any
|
|
31
|
+
|
|
32
|
+
from fastapi import APIRouter, Depends
|
|
33
|
+
from fastapi.responses import JSONResponse
|
|
34
|
+
from starlette.requests import Request # noqa: TC002 — runtime introspection by FastAPI
|
|
35
|
+
from starlette.responses import StreamingResponse
|
|
36
|
+
|
|
37
|
+
from graphrefly.compat.async_utils import to_async_iter
|
|
38
|
+
from graphrefly.compat.asyncio_runner import AsyncioRunner
|
|
39
|
+
from graphrefly.core.protocol import MessageType
|
|
40
|
+
from graphrefly.core.runner import set_default_runner
|
|
41
|
+
from graphrefly.extra.backpressure import (
|
|
42
|
+
WatermarkOptions,
|
|
43
|
+
create_watermark_controller,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
from collections.abc import AsyncIterator, Callable, Sequence
|
|
48
|
+
|
|
49
|
+
from graphrefly.core.node import Node
|
|
50
|
+
from graphrefly.core.protocol import Messages
|
|
51
|
+
from graphrefly.extra.backpressure import WatermarkController
|
|
52
|
+
from graphrefly.graph.graph import Graph, GraphObserveSource
|
|
53
|
+
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
# 1. Lifespan
|
|
56
|
+
# ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
_APP_STATE_KEY = "graphrefly"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def graphrefly_lifespan(
|
|
62
|
+
*graphs: Graph | dict[str, Graph],
|
|
63
|
+
destroy_on_shutdown: bool = True,
|
|
64
|
+
) -> Callable[..., Any]:
|
|
65
|
+
"""Return an async context manager suitable for ``FastAPI(lifespan=...)``.
|
|
66
|
+
|
|
67
|
+
Accepts a single :class:`Graph`, multiple graphs (positional — keyed as
|
|
68
|
+
``"0"``, ``"1"``, …), or a ``dict[str, Graph]`` mapping.
|
|
69
|
+
|
|
70
|
+
On startup the :class:`~graphrefly.compat.AsyncioRunner` is configured as
|
|
71
|
+
the default runner so async sources (``from_awaitable``, ``from_async_iter``)
|
|
72
|
+
work out of the box.
|
|
73
|
+
|
|
74
|
+
On shutdown, each registered graph is destroyed (``graph.destroy()``)
|
|
75
|
+
unless *destroy_on_shutdown* is ``False``.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
@asynccontextmanager
|
|
79
|
+
async def _lifespan(app: Any) -> AsyncIterator[None]:
|
|
80
|
+
runner = AsyncioRunner.from_running()
|
|
81
|
+
set_default_runner(runner)
|
|
82
|
+
|
|
83
|
+
registry = _normalize_graphs(graphs)
|
|
84
|
+
app.state.graphrefly = registry
|
|
85
|
+
|
|
86
|
+
yield
|
|
87
|
+
|
|
88
|
+
set_default_runner(None)
|
|
89
|
+
if destroy_on_shutdown:
|
|
90
|
+
for g in registry.values():
|
|
91
|
+
with suppress(Exception):
|
|
92
|
+
g.destroy()
|
|
93
|
+
|
|
94
|
+
return _lifespan
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _normalize_graphs(
|
|
98
|
+
graphs: tuple[Any, ...],
|
|
99
|
+
) -> dict[str, Any]:
|
|
100
|
+
if not graphs:
|
|
101
|
+
raise TypeError(
|
|
102
|
+
"graphrefly_lifespan() requires at least one Graph or "
|
|
103
|
+
'dict argument, e.g. graphrefly_lifespan(my_graph) or '
|
|
104
|
+
'graphrefly_lifespan({"main": g1}).'
|
|
105
|
+
)
|
|
106
|
+
if len(graphs) == 1 and isinstance(graphs[0], dict):
|
|
107
|
+
return dict(graphs[0])
|
|
108
|
+
if len(graphs) == 1:
|
|
109
|
+
return {"default": graphs[0]}
|
|
110
|
+
# Multiple args: require explicit dict keys.
|
|
111
|
+
result: dict[str, Any] = {}
|
|
112
|
+
positional_count = 0
|
|
113
|
+
for g in graphs:
|
|
114
|
+
if isinstance(g, dict):
|
|
115
|
+
for key in g:
|
|
116
|
+
if key in result:
|
|
117
|
+
raise TypeError(
|
|
118
|
+
f"Duplicate graph key {key!r} across multiple dict arguments. "
|
|
119
|
+
"Merge your dicts before passing to graphrefly_lifespan()."
|
|
120
|
+
)
|
|
121
|
+
result.update(g)
|
|
122
|
+
else:
|
|
123
|
+
positional_count += 1
|
|
124
|
+
if positional_count > 0:
|
|
125
|
+
noun = "argument" if positional_count == 1 else "arguments"
|
|
126
|
+
raise TypeError(
|
|
127
|
+
f"{positional_count} positional Graph {noun} cannot be auto-named. "
|
|
128
|
+
"Pass a dict mapping explicit names to Graph instances instead, e.g. "
|
|
129
|
+
'graphrefly_lifespan({"main": g1, "analytics": g2}).'
|
|
130
|
+
)
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# 2. Dependency injection
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_graph(name: str = "default") -> Any:
|
|
140
|
+
"""FastAPI dependency that resolves a :class:`Graph` from the lifespan registry.
|
|
141
|
+
|
|
142
|
+
Usage::
|
|
143
|
+
|
|
144
|
+
from fastapi import Depends
|
|
145
|
+
|
|
146
|
+
@app.get("/describe")
|
|
147
|
+
async def describe(graph = Depends(get_graph())):
|
|
148
|
+
return graph.describe()
|
|
149
|
+
|
|
150
|
+
@app.get("/other")
|
|
151
|
+
async def other(graph = Depends(get_graph("analytics"))):
|
|
152
|
+
return graph.describe()
|
|
153
|
+
"""
|
|
154
|
+
return _GetGraphDep(name)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class _GetGraphDep:
|
|
158
|
+
"""Callable dependency — FastAPI introspects ``__call__`` for injection."""
|
|
159
|
+
|
|
160
|
+
__slots__ = ("_name",)
|
|
161
|
+
|
|
162
|
+
def __init__(self, name: str) -> None:
|
|
163
|
+
self._name = name
|
|
164
|
+
|
|
165
|
+
def __call__(self, request: Request) -> Any:
|
|
166
|
+
registry: dict[str, Any] = getattr(request.app.state, _APP_STATE_KEY, {})
|
|
167
|
+
if self._name not in registry:
|
|
168
|
+
raise KeyError(
|
|
169
|
+
f"Graph {self._name!r} not found in lifespan registry. "
|
|
170
|
+
f"Available: {sorted(registry)}"
|
|
171
|
+
)
|
|
172
|
+
return registry[self._name]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# ---------------------------------------------------------------------------
|
|
176
|
+
# 3. SSE response
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def sse_response(
|
|
181
|
+
source: Node[Any],
|
|
182
|
+
*,
|
|
183
|
+
serialize: Callable[[Any], str] | None = None,
|
|
184
|
+
data_event: str = "data",
|
|
185
|
+
error_event: str = "error",
|
|
186
|
+
complete_event: str = "complete",
|
|
187
|
+
include_resolved: bool = False,
|
|
188
|
+
keepalive_s: float | None = None,
|
|
189
|
+
) -> Any:
|
|
190
|
+
"""Return a Starlette ``StreamingResponse`` streaming SSE from *source*.
|
|
191
|
+
|
|
192
|
+
Uses the existing :func:`~graphrefly.extra.sources.to_sse` sync iterator
|
|
193
|
+
internally. Starlette runs sync iterators in a threadpool, so the event
|
|
194
|
+
loop is never blocked.
|
|
195
|
+
"""
|
|
196
|
+
from graphrefly.extra.adapters import to_sse
|
|
197
|
+
|
|
198
|
+
return StreamingResponse(
|
|
199
|
+
to_sse(
|
|
200
|
+
source,
|
|
201
|
+
serialize=serialize,
|
|
202
|
+
data_event=data_event,
|
|
203
|
+
error_event=error_event,
|
|
204
|
+
complete_event=complete_event,
|
|
205
|
+
include_resolved=include_resolved,
|
|
206
|
+
keepalive_s=keepalive_s,
|
|
207
|
+
),
|
|
208
|
+
media_type="text/event-stream",
|
|
209
|
+
headers={
|
|
210
|
+
"Cache-Control": "no-cache",
|
|
211
|
+
"Connection": "keep-alive",
|
|
212
|
+
"X-Accel-Buffering": "no",
|
|
213
|
+
},
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# 4. WebSocket handler
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
async def ws_handler(
|
|
223
|
+
websocket: Any,
|
|
224
|
+
*,
|
|
225
|
+
source: Node[Any] | None = None,
|
|
226
|
+
sink: Node[Any] | None = None,
|
|
227
|
+
serialize: Callable[[Any], str] | None = None,
|
|
228
|
+
deserialize: Callable[[str], Any] | None = None,
|
|
229
|
+
actor: dict[str, Any] | None = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Bidirectional bridge between a FastAPI ``WebSocket`` and GraphReFly nodes.
|
|
232
|
+
|
|
233
|
+
* **source → client**: subscribes to *source* via :func:`to_async_iter` and
|
|
234
|
+
sends each DATA payload as a JSON text frame.
|
|
235
|
+
* **client → sink**: receives JSON text frames and calls
|
|
236
|
+
``sink.down([(DATA, payload)])`` (with *actor* when provided).
|
|
237
|
+
|
|
238
|
+
Either *source* or *sink* (or both) may be ``None`` for one-way bridges.
|
|
239
|
+
The handler runs until the WebSocket closes or the source completes.
|
|
240
|
+
"""
|
|
241
|
+
await websocket.accept()
|
|
242
|
+
|
|
243
|
+
_ser = serialize or _default_serialize
|
|
244
|
+
_deser = deserialize or json.loads
|
|
245
|
+
|
|
246
|
+
async def _send_loop() -> None:
|
|
247
|
+
if source is None:
|
|
248
|
+
return
|
|
249
|
+
async for value in to_async_iter(source):
|
|
250
|
+
await websocket.send_text(_ser(value))
|
|
251
|
+
|
|
252
|
+
async def _recv_loop() -> None:
|
|
253
|
+
if sink is None:
|
|
254
|
+
# Still consume frames so the connection stays alive.
|
|
255
|
+
try:
|
|
256
|
+
while True:
|
|
257
|
+
await websocket.receive_text()
|
|
258
|
+
except Exception:
|
|
259
|
+
return
|
|
260
|
+
return
|
|
261
|
+
try:
|
|
262
|
+
while True:
|
|
263
|
+
raw = await websocket.receive_text()
|
|
264
|
+
payload = _deser(raw)
|
|
265
|
+
if actor is not None:
|
|
266
|
+
sink.down(
|
|
267
|
+
[(MessageType.DATA, payload)],
|
|
268
|
+
actor=actor,
|
|
269
|
+
)
|
|
270
|
+
else:
|
|
271
|
+
sink.down([(MessageType.DATA, payload)])
|
|
272
|
+
except Exception:
|
|
273
|
+
return
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
tasks = []
|
|
277
|
+
if source is not None:
|
|
278
|
+
tasks.append(asyncio.create_task(_send_loop()))
|
|
279
|
+
tasks.append(asyncio.create_task(_recv_loop()))
|
|
280
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
281
|
+
for t in pending:
|
|
282
|
+
t.cancel()
|
|
283
|
+
with suppress(asyncio.CancelledError):
|
|
284
|
+
await t
|
|
285
|
+
# Propagate errors from done tasks.
|
|
286
|
+
for t in done:
|
|
287
|
+
if t.exception() is not None:
|
|
288
|
+
raise t.exception() # type: ignore[misc]
|
|
289
|
+
finally:
|
|
290
|
+
with suppress(Exception):
|
|
291
|
+
await websocket.close()
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# 5. ObserveGateway — graph.observe() → WebSocket (multi-path subscription)
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
class ObserveGateway:
|
|
300
|
+
"""Manages per-client WebSocket subscriptions to graph nodes via ``observe()``.
|
|
301
|
+
|
|
302
|
+
A standalone helper that can be wired into any WebSocket endpoint. Each
|
|
303
|
+
client can subscribe/unsubscribe to individual node paths. Actor-scoped
|
|
304
|
+
observation respects node guards.
|
|
305
|
+
|
|
306
|
+
When *high_water_mark* is set, each per-client subscription creates a
|
|
307
|
+
:class:`WatermarkController`. The client sends
|
|
308
|
+
``{"type": "ack", "path": "...", "count": N}`` to drain.
|
|
309
|
+
|
|
310
|
+
Example::
|
|
311
|
+
|
|
312
|
+
gw = ObserveGateway(graph, high_water_mark=64)
|
|
313
|
+
|
|
314
|
+
@app.websocket("/observe")
|
|
315
|
+
async def observe(ws: WebSocket):
|
|
316
|
+
await ws.accept()
|
|
317
|
+
gw.handle_connection(ws)
|
|
318
|
+
try:
|
|
319
|
+
while True:
|
|
320
|
+
raw = await ws.receive_text()
|
|
321
|
+
gw.handle_message(ws, raw)
|
|
322
|
+
except WebSocketDisconnect:
|
|
323
|
+
gw.handle_disconnect(ws)
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
def __init__(
|
|
327
|
+
self,
|
|
328
|
+
graph: Graph,
|
|
329
|
+
*,
|
|
330
|
+
extract_actor: Callable[..., Any] | None = None,
|
|
331
|
+
high_water_mark: int | None = None,
|
|
332
|
+
low_water_mark: int | None = None,
|
|
333
|
+
) -> None:
|
|
334
|
+
self._graph = graph
|
|
335
|
+
self._extract_actor = extract_actor or (lambda _client: None)
|
|
336
|
+
self._high_water_mark = high_water_mark
|
|
337
|
+
self._low_water_mark = low_water_mark
|
|
338
|
+
# client → { path → { unsub, wm? } }
|
|
339
|
+
self._clients: dict[Any, dict[str, dict[str, Any]]] = {}
|
|
340
|
+
|
|
341
|
+
def handle_connection(self, client: Any) -> None:
|
|
342
|
+
"""Register a new client. Call from ``on_connect``."""
|
|
343
|
+
if client not in self._clients:
|
|
344
|
+
self._clients[client] = {}
|
|
345
|
+
|
|
346
|
+
def handle_disconnect(self, client: Any) -> None:
|
|
347
|
+
"""Unregister a client and dispose all subscriptions."""
|
|
348
|
+
subs = self._clients.pop(client, None)
|
|
349
|
+
if subs is None:
|
|
350
|
+
return
|
|
351
|
+
for entry in subs.values():
|
|
352
|
+
wm = entry.get("wm")
|
|
353
|
+
if wm is not None:
|
|
354
|
+
wm.dispose()
|
|
355
|
+
entry["unsub"]()
|
|
356
|
+
|
|
357
|
+
def handle_message(
|
|
358
|
+
self,
|
|
359
|
+
client: Any,
|
|
360
|
+
raw: Any,
|
|
361
|
+
*,
|
|
362
|
+
send: Callable[..., None] | None = None,
|
|
363
|
+
) -> None:
|
|
364
|
+
"""Handle an incoming client message (subscribe/unsubscribe/ack)."""
|
|
365
|
+
sender = send or self._default_send(client)
|
|
366
|
+
try:
|
|
367
|
+
cmd = json.loads(raw) if isinstance(raw, str) else raw
|
|
368
|
+
except Exception:
|
|
369
|
+
sender({"type": "err", "message": "invalid command"})
|
|
370
|
+
return
|
|
371
|
+
|
|
372
|
+
cmd_type = cmd.get("type")
|
|
373
|
+
try:
|
|
374
|
+
if cmd_type == "subscribe":
|
|
375
|
+
self._subscribe(client, cmd["path"], sender)
|
|
376
|
+
elif cmd_type == "unsubscribe":
|
|
377
|
+
self._unsubscribe(client, cmd["path"], sender)
|
|
378
|
+
elif cmd_type == "ack":
|
|
379
|
+
self._ack(client, cmd["path"], cmd.get("count", 1))
|
|
380
|
+
else:
|
|
381
|
+
sender({"type": "err", "message": f"unknown command type: {cmd_type}"})
|
|
382
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
383
|
+
sender({"type": "err", "message": f"bad command: {exc}"})
|
|
384
|
+
|
|
385
|
+
def subscription_count(self, client: Any) -> int:
|
|
386
|
+
"""Number of active subscriptions for a client."""
|
|
387
|
+
subs = self._clients.get(client)
|
|
388
|
+
return len(subs) if subs is not None else 0
|
|
389
|
+
|
|
390
|
+
def destroy(self) -> None:
|
|
391
|
+
"""Dispose all clients and subscriptions."""
|
|
392
|
+
for client in list(self._clients):
|
|
393
|
+
self.handle_disconnect(client)
|
|
394
|
+
|
|
395
|
+
# -------------------------------------------------------------------
|
|
396
|
+
# Internal
|
|
397
|
+
# -------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def _subscribe(self, client: Any, path: str, send: Callable[..., None]) -> None:
|
|
400
|
+
subs = self._clients.get(client)
|
|
401
|
+
if subs is None:
|
|
402
|
+
subs = {}
|
|
403
|
+
self._clients[client] = subs
|
|
404
|
+
if path in subs:
|
|
405
|
+
send({"type": "subscribed", "path": path})
|
|
406
|
+
return
|
|
407
|
+
|
|
408
|
+
actor = self._extract_actor(client)
|
|
409
|
+
try:
|
|
410
|
+
handle = (
|
|
411
|
+
self._graph.observe(path, actor=actor)
|
|
412
|
+
if actor is not None
|
|
413
|
+
else self._graph.observe(path)
|
|
414
|
+
)
|
|
415
|
+
except Exception as exc:
|
|
416
|
+
send({"type": "err", "message": str(exc)})
|
|
417
|
+
return
|
|
418
|
+
|
|
419
|
+
wm: WatermarkController | None = None
|
|
420
|
+
if self._high_water_mark is not None:
|
|
421
|
+
effective_low = (
|
|
422
|
+
self._low_water_mark
|
|
423
|
+
if self._low_water_mark is not None
|
|
424
|
+
else self._high_water_mark // 2
|
|
425
|
+
)
|
|
426
|
+
wm = create_watermark_controller(
|
|
427
|
+
handle.up,
|
|
428
|
+
WatermarkOptions(
|
|
429
|
+
high_water_mark=self._high_water_mark,
|
|
430
|
+
low_water_mark=effective_low,
|
|
431
|
+
),
|
|
432
|
+
)
|
|
433
|
+
|
|
434
|
+
unsub_ref: list[Any] = [None]
|
|
435
|
+
|
|
436
|
+
def cleanup() -> None:
|
|
437
|
+
if wm is not None:
|
|
438
|
+
wm.dispose()
|
|
439
|
+
if unsub_ref[0] is not None:
|
|
440
|
+
unsub_ref[0]()
|
|
441
|
+
subs.pop(path, None)
|
|
442
|
+
|
|
443
|
+
def sink(msgs: Messages) -> None:
|
|
444
|
+
for msg in msgs:
|
|
445
|
+
t = msg[0]
|
|
446
|
+
if t is MessageType.DATA:
|
|
447
|
+
if wm is not None:
|
|
448
|
+
wm.on_enqueue()
|
|
449
|
+
value = msg[1] if len(msg) > 1 else None
|
|
450
|
+
_try_send(send, {"type": "data", "path": path, "value": value})
|
|
451
|
+
elif t is MessageType.ERROR:
|
|
452
|
+
payload = msg[1] if len(msg) > 1 else None
|
|
453
|
+
err_msg = str(payload) if payload is not None else "unknown error"
|
|
454
|
+
_try_send(send, {"type": "error", "path": path, "error": err_msg})
|
|
455
|
+
cleanup()
|
|
456
|
+
return
|
|
457
|
+
elif t is MessageType.COMPLETE or t is MessageType.TEARDOWN:
|
|
458
|
+
_try_send(send, {"type": "complete", "path": path})
|
|
459
|
+
cleanup()
|
|
460
|
+
return
|
|
461
|
+
|
|
462
|
+
unsub_ref[0] = handle.subscribe(sink)
|
|
463
|
+
subs[path] = {"unsub": unsub_ref[0], "wm": wm}
|
|
464
|
+
send({"type": "subscribed", "path": path})
|
|
465
|
+
|
|
466
|
+
def _unsubscribe(self, client: Any, path: str, send: Callable[..., None]) -> None:
|
|
467
|
+
subs = self._clients.get(client)
|
|
468
|
+
entry = subs.pop(path, None) if subs else None
|
|
469
|
+
if entry is not None:
|
|
470
|
+
wm = entry.get("wm")
|
|
471
|
+
if wm is not None:
|
|
472
|
+
wm.dispose()
|
|
473
|
+
entry["unsub"]()
|
|
474
|
+
send({"type": "unsubscribed", "path": path})
|
|
475
|
+
|
|
476
|
+
def _ack(self, client: Any, path: str, count: int) -> None:
|
|
477
|
+
subs = self._clients.get(client)
|
|
478
|
+
if subs is None:
|
|
479
|
+
return
|
|
480
|
+
entry = subs.get(path)
|
|
481
|
+
if entry is None:
|
|
482
|
+
return
|
|
483
|
+
wm = entry.get("wm")
|
|
484
|
+
if wm is None:
|
|
485
|
+
return
|
|
486
|
+
n = min(max(0, int(count)), 1024)
|
|
487
|
+
for _ in range(n):
|
|
488
|
+
wm.on_dequeue()
|
|
489
|
+
|
|
490
|
+
@staticmethod
|
|
491
|
+
def _default_send(client: Any) -> Callable[..., None]:
|
|
492
|
+
def send(msg: Any) -> None:
|
|
493
|
+
_try_send(lambda m: client.send_text(json.dumps(m)), msg)
|
|
494
|
+
|
|
495
|
+
return send
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _try_send(send: Callable[..., None], msg: Any) -> None:
|
|
499
|
+
with suppress(Exception):
|
|
500
|
+
send(msg)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
# ---------------------------------------------------------------------------
|
|
504
|
+
# 6. Router factory
|
|
505
|
+
# ---------------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def graphrefly_router(
|
|
509
|
+
graph: Any,
|
|
510
|
+
*,
|
|
511
|
+
prefix: str = "",
|
|
512
|
+
actor_resolver: Any | None = None,
|
|
513
|
+
tags: Sequence[str] | None = None,
|
|
514
|
+
) -> Any:
|
|
515
|
+
"""Pre-built :class:`~fastapi.APIRouter` exposing a graph's HTTP API.
|
|
516
|
+
|
|
517
|
+
Endpoints:
|
|
518
|
+
|
|
519
|
+
- ``GET {prefix}/describe`` — :meth:`Graph.describe`
|
|
520
|
+
- ``GET {prefix}/nodes/{name}`` — :meth:`Graph.get`
|
|
521
|
+
- ``PUT {prefix}/nodes/{name}`` — :meth:`Graph.set`
|
|
522
|
+
- ``GET {prefix}/observe`` — SSE of :meth:`Graph.observe` (graph-wide)
|
|
523
|
+
- ``GET {prefix}/observe/{name}`` — SSE of :meth:`Graph.observe` (single node)
|
|
524
|
+
- ``GET {prefix}/snapshot`` — :meth:`Graph.snapshot`
|
|
525
|
+
|
|
526
|
+
Parameters
|
|
527
|
+
----------
|
|
528
|
+
graph:
|
|
529
|
+
A :class:`Graph` instance.
|
|
530
|
+
prefix:
|
|
531
|
+
URL prefix for all endpoints.
|
|
532
|
+
actor_resolver:
|
|
533
|
+
Optional FastAPI dependency that returns an :class:`Actor` dict.
|
|
534
|
+
When provided, it is injected into write/signal/observe endpoints.
|
|
535
|
+
tags:
|
|
536
|
+
FastAPI tags for OpenAPI grouping.
|
|
537
|
+
"""
|
|
538
|
+
router = APIRouter(prefix=prefix, tags=list(tags or ["graphrefly"]))
|
|
539
|
+
|
|
540
|
+
def _resolve_actor(request: Request) -> Any:
|
|
541
|
+
if actor_resolver is not None:
|
|
542
|
+
return actor_resolver(request)
|
|
543
|
+
return None
|
|
544
|
+
|
|
545
|
+
@router.get("/describe")
|
|
546
|
+
async def describe(actor: Any = Depends(_resolve_actor)) -> Any: # noqa: B008
|
|
547
|
+
if actor is not None:
|
|
548
|
+
return graph.describe(actor=actor)
|
|
549
|
+
return graph.describe()
|
|
550
|
+
|
|
551
|
+
@router.get("/nodes/{name:path}")
|
|
552
|
+
async def get_node(name: str) -> Any:
|
|
553
|
+
try:
|
|
554
|
+
value = graph.get(name)
|
|
555
|
+
except KeyError:
|
|
556
|
+
return JSONResponse({"error": f"node {name!r} not found"}, status_code=404)
|
|
557
|
+
return {"name": name, "value": value}
|
|
558
|
+
|
|
559
|
+
@router.put("/nodes/{name:path}")
|
|
560
|
+
async def set_node(
|
|
561
|
+
name: str,
|
|
562
|
+
request: Request,
|
|
563
|
+
actor: Any = Depends(_resolve_actor), # noqa: B008
|
|
564
|
+
) -> Any:
|
|
565
|
+
try:
|
|
566
|
+
body = await request.json()
|
|
567
|
+
except Exception:
|
|
568
|
+
return JSONResponse({"error": "invalid JSON body"}, status_code=400)
|
|
569
|
+
|
|
570
|
+
if isinstance(body, dict):
|
|
571
|
+
if "value" not in body:
|
|
572
|
+
return JSONResponse({"error": "body must contain a 'value' key"}, status_code=400)
|
|
573
|
+
value = body["value"]
|
|
574
|
+
else:
|
|
575
|
+
value = body
|
|
576
|
+
|
|
577
|
+
try:
|
|
578
|
+
if actor is not None:
|
|
579
|
+
graph.set(name, value, actor=actor)
|
|
580
|
+
else:
|
|
581
|
+
graph.set(name, value)
|
|
582
|
+
except KeyError:
|
|
583
|
+
return JSONResponse({"error": f"node {name!r} not found"}, status_code=404)
|
|
584
|
+
return {"ok": True}
|
|
585
|
+
|
|
586
|
+
@router.get("/observe")
|
|
587
|
+
async def observe_all(actor: Any = Depends(_resolve_actor)) -> Any: # noqa: B008
|
|
588
|
+
obs = graph.observe(actor=actor) if actor is not None else graph.observe()
|
|
589
|
+
return _observe_sse_response(obs, graph_wide=True)
|
|
590
|
+
|
|
591
|
+
@router.get("/observe/{name:path}")
|
|
592
|
+
async def observe_node(
|
|
593
|
+
name: str,
|
|
594
|
+
actor: Any = Depends(_resolve_actor), # noqa: B008
|
|
595
|
+
) -> Any:
|
|
596
|
+
try:
|
|
597
|
+
obs = graph.observe(name, actor=actor) if actor is not None else graph.observe(name)
|
|
598
|
+
except KeyError:
|
|
599
|
+
return JSONResponse({"error": f"node {name!r} not found"}, status_code=404)
|
|
600
|
+
return _observe_sse_response(obs, graph_wide=False)
|
|
601
|
+
|
|
602
|
+
@router.get("/snapshot")
|
|
603
|
+
async def snapshot() -> Any:
|
|
604
|
+
return graph.snapshot()
|
|
605
|
+
|
|
606
|
+
return router
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
# ---------------------------------------------------------------------------
|
|
610
|
+
# Observe SSE helpers (graph-wide uses path-prefixed events)
|
|
611
|
+
# ---------------------------------------------------------------------------
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _observe_sse_response(
|
|
615
|
+
obs: GraphObserveSource,
|
|
616
|
+
*,
|
|
617
|
+
graph_wide: bool,
|
|
618
|
+
high_water_mark: int | None = None,
|
|
619
|
+
low_water_mark: int | None = None,
|
|
620
|
+
) -> Any:
|
|
621
|
+
"""Build a StreamingResponse for a :class:`GraphObserveSource`.
|
|
622
|
+
|
|
623
|
+
When *high_water_mark* is set, a :class:`WatermarkController` sends
|
|
624
|
+
PAUSE upstream when the queue depth exceeds the threshold and RESUME
|
|
625
|
+
when the consumer drains below *low_water_mark* (default:
|
|
626
|
+
``high_water_mark // 2``).
|
|
627
|
+
"""
|
|
628
|
+
from graphrefly.extra.adapters import sse_frame
|
|
629
|
+
|
|
630
|
+
q: queue.Queue[str | tuple[str, bool] | None] = queue.Queue()
|
|
631
|
+
done = threading.Event()
|
|
632
|
+
|
|
633
|
+
wm: WatermarkController | None = None
|
|
634
|
+
if high_water_mark is not None:
|
|
635
|
+
effective_low = low_water_mark if low_water_mark is not None else high_water_mark // 2
|
|
636
|
+
wm = create_watermark_controller(
|
|
637
|
+
obs.up,
|
|
638
|
+
WatermarkOptions(high_water_mark=high_water_mark, low_water_mark=effective_low),
|
|
639
|
+
)
|
|
640
|
+
|
|
641
|
+
# DATA frames are enqueued as ``(sse_string, True)`` tuples so the
|
|
642
|
+
# consumer can call ``wm.on_dequeue()`` only for items that had a
|
|
643
|
+
# matching ``wm.on_enqueue()``. Non-DATA frames are plain strings.
|
|
644
|
+
|
|
645
|
+
if graph_wide:
|
|
646
|
+
|
|
647
|
+
def sink(path: str, msgs: Messages) -> None:
|
|
648
|
+
if done.is_set():
|
|
649
|
+
return
|
|
650
|
+
for msg in msgs:
|
|
651
|
+
t = msg[0]
|
|
652
|
+
if t is MessageType.DATA:
|
|
653
|
+
payload = msg[1] if len(msg) > 1 else None
|
|
654
|
+
frame = sse_frame(path, _json_encode(payload))
|
|
655
|
+
if wm is not None:
|
|
656
|
+
wm.on_enqueue()
|
|
657
|
+
q.put((frame, True))
|
|
658
|
+
else:
|
|
659
|
+
q.put(frame)
|
|
660
|
+
elif t is MessageType.ERROR:
|
|
661
|
+
payload = msg[1] if len(msg) > 1 else None
|
|
662
|
+
q.put(sse_frame(f"error:{path}", _json_encode(payload)))
|
|
663
|
+
elif t is MessageType.COMPLETE:
|
|
664
|
+
q.put(sse_frame(f"complete:{path}"))
|
|
665
|
+
elif t is MessageType.TEARDOWN:
|
|
666
|
+
done.set()
|
|
667
|
+
q.put(None)
|
|
668
|
+
return
|
|
669
|
+
else:
|
|
670
|
+
|
|
671
|
+
def sink(msgs: Messages) -> None: # type: ignore[misc]
|
|
672
|
+
if done.is_set():
|
|
673
|
+
return
|
|
674
|
+
for msg in msgs:
|
|
675
|
+
t = msg[0]
|
|
676
|
+
if t is MessageType.DATA:
|
|
677
|
+
payload = msg[1] if len(msg) > 1 else None
|
|
678
|
+
frame = sse_frame("data", _json_encode(payload))
|
|
679
|
+
if wm is not None:
|
|
680
|
+
wm.on_enqueue()
|
|
681
|
+
q.put((frame, True))
|
|
682
|
+
else:
|
|
683
|
+
q.put(frame)
|
|
684
|
+
elif t is MessageType.ERROR:
|
|
685
|
+
payload = msg[1] if len(msg) > 1 else None
|
|
686
|
+
q.put(sse_frame("error", _json_encode(payload)))
|
|
687
|
+
done.set()
|
|
688
|
+
q.put(None)
|
|
689
|
+
return
|
|
690
|
+
elif t is MessageType.COMPLETE:
|
|
691
|
+
q.put(sse_frame("complete"))
|
|
692
|
+
done.set()
|
|
693
|
+
q.put(None)
|
|
694
|
+
return
|
|
695
|
+
elif t is MessageType.TEARDOWN:
|
|
696
|
+
done.set()
|
|
697
|
+
q.put(None)
|
|
698
|
+
return
|
|
699
|
+
|
|
700
|
+
unsub = obs.subscribe(sink)
|
|
701
|
+
|
|
702
|
+
def _iter() -> Any:
|
|
703
|
+
try:
|
|
704
|
+
while not done.is_set():
|
|
705
|
+
try:
|
|
706
|
+
chunk = q.get(timeout=30)
|
|
707
|
+
except queue.Empty:
|
|
708
|
+
yield ": keepalive\n\n"
|
|
709
|
+
continue
|
|
710
|
+
if chunk is None:
|
|
711
|
+
break
|
|
712
|
+
if isinstance(chunk, tuple):
|
|
713
|
+
yield chunk[0]
|
|
714
|
+
if wm is not None:
|
|
715
|
+
wm.on_dequeue()
|
|
716
|
+
else:
|
|
717
|
+
yield chunk
|
|
718
|
+
finally:
|
|
719
|
+
done.set()
|
|
720
|
+
unsub()
|
|
721
|
+
if wm is not None:
|
|
722
|
+
wm.dispose()
|
|
723
|
+
|
|
724
|
+
return StreamingResponse(
|
|
725
|
+
_iter(),
|
|
726
|
+
media_type="text/event-stream",
|
|
727
|
+
headers={
|
|
728
|
+
"Cache-Control": "no-cache",
|
|
729
|
+
"Connection": "keep-alive",
|
|
730
|
+
"X-Accel-Buffering": "no",
|
|
731
|
+
},
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
# ---------------------------------------------------------------------------
|
|
736
|
+
# Helpers
|
|
737
|
+
# ---------------------------------------------------------------------------
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
def _default_serialize(value: Any) -> str:
|
|
741
|
+
if isinstance(value, str):
|
|
742
|
+
return value
|
|
743
|
+
try:
|
|
744
|
+
return json.dumps(value)
|
|
745
|
+
except TypeError:
|
|
746
|
+
return str(value)
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _json_encode(value: Any) -> str:
|
|
750
|
+
if isinstance(value, str):
|
|
751
|
+
return value
|
|
752
|
+
if isinstance(value, BaseException):
|
|
753
|
+
return str(value)
|
|
754
|
+
try:
|
|
755
|
+
return json.dumps(value)
|
|
756
|
+
except TypeError:
|
|
757
|
+
return str(value)
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
__all__ = [
|
|
761
|
+
"ObserveGateway",
|
|
762
|
+
"get_graph",
|
|
763
|
+
"graphrefly_lifespan",
|
|
764
|
+
"graphrefly_router",
|
|
765
|
+
"sse_response",
|
|
766
|
+
"ws_handler",
|
|
767
|
+
]
|