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.
Files changed (47) hide show
  1. graphrefly/__init__.py +160 -0
  2. graphrefly/compat/__init__.py +18 -0
  3. graphrefly/compat/async_utils.py +228 -0
  4. graphrefly/compat/asyncio_runner.py +89 -0
  5. graphrefly/compat/trio_runner.py +81 -0
  6. graphrefly/core/__init__.py +142 -0
  7. graphrefly/core/clock.py +20 -0
  8. graphrefly/core/dynamic_node.py +749 -0
  9. graphrefly/core/guard.py +277 -0
  10. graphrefly/core/meta.py +149 -0
  11. graphrefly/core/node.py +963 -0
  12. graphrefly/core/protocol.py +460 -0
  13. graphrefly/core/runner.py +107 -0
  14. graphrefly/core/subgraph_locks.py +296 -0
  15. graphrefly/core/sugar.py +138 -0
  16. graphrefly/core/versioning.py +193 -0
  17. graphrefly/extra/__init__.py +313 -0
  18. graphrefly/extra/adapters.py +2149 -0
  19. graphrefly/extra/backoff.py +287 -0
  20. graphrefly/extra/backpressure.py +113 -0
  21. graphrefly/extra/checkpoint.py +307 -0
  22. graphrefly/extra/composite.py +303 -0
  23. graphrefly/extra/cron.py +133 -0
  24. graphrefly/extra/data_structures.py +707 -0
  25. graphrefly/extra/resilience.py +727 -0
  26. graphrefly/extra/sources.py +766 -0
  27. graphrefly/extra/tier1.py +1067 -0
  28. graphrefly/extra/tier2.py +1802 -0
  29. graphrefly/graph/__init__.py +31 -0
  30. graphrefly/graph/graph.py +2249 -0
  31. graphrefly/integrations/__init__.py +1 -0
  32. graphrefly/integrations/fastapi.py +767 -0
  33. graphrefly/patterns/__init__.py +5 -0
  34. graphrefly/patterns/ai.py +2132 -0
  35. graphrefly/patterns/cqrs.py +515 -0
  36. graphrefly/patterns/memory.py +639 -0
  37. graphrefly/patterns/messaging.py +553 -0
  38. graphrefly/patterns/orchestration.py +536 -0
  39. graphrefly/patterns/reactive_layout/__init__.py +81 -0
  40. graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
  41. graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
  42. graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
  43. graphrefly/py.typed +1 -0
  44. graphrefly-0.1.0.dist-info/METADATA +253 -0
  45. graphrefly-0.1.0.dist-info/RECORD +47 -0
  46. graphrefly-0.1.0.dist-info/WHEEL +4 -0
  47. 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
+ ]