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,766 @@
|
|
|
1
|
+
"""Sources, sinks, and multicast helpers from :func:`~graphrefly.core.node.node` (roadmap §2.3).
|
|
2
|
+
|
|
3
|
+
Cold sync sources use a no-deps producer (same pattern as :func:`~graphrefly.extra.tier2.interval`).
|
|
4
|
+
Multicast helpers are thin dependency wires so one upstream subscription is shared across all
|
|
5
|
+
downstream sinks of the returned node (ref-counted disconnect when the last sink unsubscribes).
|
|
6
|
+
|
|
7
|
+
Protocol/system/ingest adapters (HTTP, WebSocket, SSE, MCP, git, fs, Kafka, etc.) have moved to
|
|
8
|
+
:mod:`graphrefly.extra.adapters`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import threading
|
|
15
|
+
from collections.abc import AsyncIterable, Awaitable, Callable, Iterable
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from graphrefly.core.node import Node, NodeActions, node
|
|
20
|
+
from graphrefly.core.protocol import Messages, MessageType
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _msg_val(m: tuple[Any, ...]) -> Any:
|
|
24
|
+
assert len(m) >= 2
|
|
25
|
+
return m[1]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# --- static sources -----------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def of(*values: Any) -> Node[Any]:
|
|
32
|
+
"""Emit each argument as ``DATA`` in order, then ``COMPLETE`` on subscribe.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
*values: Values to emit sequentially as ``DATA`` messages.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
A cold :class:`~graphrefly.core.node.Node` that completes after emitting all values.
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
```python
|
|
42
|
+
from graphrefly.extra import of
|
|
43
|
+
from graphrefly.extra.sources import first_value_from
|
|
44
|
+
n = of(1, 2, 3)
|
|
45
|
+
assert first_value_from(n) == 1
|
|
46
|
+
```
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
50
|
+
try:
|
|
51
|
+
for v in values:
|
|
52
|
+
actions.emit(v)
|
|
53
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
54
|
+
except BaseException as err:
|
|
55
|
+
actions.down([(MessageType.ERROR, err)])
|
|
56
|
+
return lambda: None
|
|
57
|
+
|
|
58
|
+
return node(start, describe_kind="of", complete_when_deps_complete=False)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def empty() -> Node[Any]:
|
|
62
|
+
"""Emit ``COMPLETE`` immediately when the first sink subscribes.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A :class:`~graphrefly.core.node.Node` that completes with no ``DATA``.
|
|
66
|
+
|
|
67
|
+
Example:
|
|
68
|
+
```python
|
|
69
|
+
from graphrefly.extra import empty
|
|
70
|
+
from graphrefly.extra.sources import to_list
|
|
71
|
+
assert to_list(empty()) == []
|
|
72
|
+
```
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
76
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
77
|
+
return lambda: None
|
|
78
|
+
|
|
79
|
+
return node(start, describe_kind="empty", complete_when_deps_complete=False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def never() -> Node[Any]:
|
|
83
|
+
"""Create a source that never emits any messages.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
A :class:`~graphrefly.core.node.Node` whose producer is a no-op
|
|
87
|
+
(no ``DATA``, no ``COMPLETE``).
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
```python
|
|
91
|
+
from graphrefly.extra import never
|
|
92
|
+
n = never()
|
|
93
|
+
# n.get() is None; no DATA will ever arrive
|
|
94
|
+
```
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
def start(_deps: list[Any], _actions: NodeActions) -> Callable[[], None]:
|
|
98
|
+
return lambda: None
|
|
99
|
+
|
|
100
|
+
return node(start, describe_kind="never", complete_when_deps_complete=False)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def throw_error(error: BaseException | Any) -> Node[Any]:
|
|
104
|
+
"""Emit a single ``ERROR`` message when the first sink subscribes.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
error: The exception or value to send as the ``ERROR`` payload.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
A :class:`~graphrefly.core.node.Node` that immediately errors on subscribe.
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
```python
|
|
114
|
+
from graphrefly.extra import throw_error
|
|
115
|
+
n = throw_error(ValueError("bad"))
|
|
116
|
+
try:
|
|
117
|
+
from graphrefly.extra.sources import first_value_from
|
|
118
|
+
first_value_from(n)
|
|
119
|
+
except ValueError:
|
|
120
|
+
pass
|
|
121
|
+
```
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
125
|
+
actions.down([(MessageType.ERROR, error)])
|
|
126
|
+
return lambda: None
|
|
127
|
+
|
|
128
|
+
return node(start, describe_kind="throw_error", complete_when_deps_complete=False)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# --- iterable / timer / cron --------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def from_iter(iterable: Iterable[Any]) -> Node[Any]:
|
|
135
|
+
"""Drain a synchronous iterable on subscribe, emitting one ``DATA`` per item then ``COMPLETE``.
|
|
136
|
+
|
|
137
|
+
If iteration raises an exception, the producer emits ``ERROR`` and stops.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
iterable: Any synchronous iterable (list, generator, etc.).
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
A cold :class:`~graphrefly.core.node.Node` that completes after the iterable is drained.
|
|
144
|
+
|
|
145
|
+
Example:
|
|
146
|
+
```python
|
|
147
|
+
from graphrefly.extra import from_iter
|
|
148
|
+
from graphrefly.extra.sources import to_list
|
|
149
|
+
assert to_list(from_iter([1, 2, 3])) == [1, 2, 3]
|
|
150
|
+
```
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
154
|
+
try:
|
|
155
|
+
for item in iterable:
|
|
156
|
+
actions.emit(item)
|
|
157
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
158
|
+
except BaseException as err:
|
|
159
|
+
actions.down([(MessageType.ERROR, err)])
|
|
160
|
+
return lambda: None
|
|
161
|
+
|
|
162
|
+
return node(start, describe_kind="from_iter", complete_when_deps_complete=False)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def from_timer(
|
|
166
|
+
delay: float,
|
|
167
|
+
period: float | None = None,
|
|
168
|
+
*,
|
|
169
|
+
first: int = 0,
|
|
170
|
+
) -> Node[Any]:
|
|
171
|
+
"""Emit a value after a delay, then optionally tick at a fixed period (like Rx ``timer``).
|
|
172
|
+
|
|
173
|
+
If *period* is ``None``, emit *first* once then ``COMPLETE``. If *period* is
|
|
174
|
+
set, emit *first*, *first+1*, *first+2*, ... every *period* seconds. Timer
|
|
175
|
+
threads are daemonized and cancelled on unsubscribe.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
delay: Seconds to wait before the first emission (must be >= 0).
|
|
179
|
+
period: Optional repeat interval in seconds (``None`` = one-shot).
|
|
180
|
+
first: Integer value for the first emission (default ``0``).
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
A :class:`~graphrefly.core.node.Node` that emits on a timer thread.
|
|
184
|
+
|
|
185
|
+
Example:
|
|
186
|
+
```python
|
|
187
|
+
from graphrefly.extra import from_timer
|
|
188
|
+
from graphrefly.extra.sources import first_value_from
|
|
189
|
+
n = from_timer(0.001)
|
|
190
|
+
assert first_value_from(n) == 0
|
|
191
|
+
```
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
if delay < 0 or (period is not None and period < 0):
|
|
195
|
+
msg = "delay and period must be non-negative"
|
|
196
|
+
raise ValueError(msg)
|
|
197
|
+
|
|
198
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
199
|
+
timer: list[threading.Timer | None] = [None]
|
|
200
|
+
stopped = [False]
|
|
201
|
+
n = [first]
|
|
202
|
+
per = period
|
|
203
|
+
|
|
204
|
+
def cancel() -> None:
|
|
205
|
+
if timer[0] is not None:
|
|
206
|
+
timer[0].cancel()
|
|
207
|
+
timer[0] = None
|
|
208
|
+
|
|
209
|
+
def tick_repeat() -> None:
|
|
210
|
+
if stopped[0]:
|
|
211
|
+
return
|
|
212
|
+
assert per is not None
|
|
213
|
+
actions.emit(n[0])
|
|
214
|
+
n[0] += 1
|
|
215
|
+
tt = threading.Timer(per, tick_repeat)
|
|
216
|
+
tt.daemon = True
|
|
217
|
+
tt.start()
|
|
218
|
+
timer[0] = tt
|
|
219
|
+
|
|
220
|
+
def after_delay() -> None:
|
|
221
|
+
if stopped[0]:
|
|
222
|
+
return
|
|
223
|
+
if period is None:
|
|
224
|
+
actions.emit(n[0])
|
|
225
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
226
|
+
timer[0] = None
|
|
227
|
+
return
|
|
228
|
+
tick_repeat()
|
|
229
|
+
|
|
230
|
+
tt0 = threading.Timer(delay, after_delay)
|
|
231
|
+
tt0.daemon = True
|
|
232
|
+
tt0.start()
|
|
233
|
+
timer[0] = tt0
|
|
234
|
+
|
|
235
|
+
def cleanup() -> None:
|
|
236
|
+
stopped[0] = True
|
|
237
|
+
cancel()
|
|
238
|
+
|
|
239
|
+
return cleanup
|
|
240
|
+
|
|
241
|
+
return node(start, describe_kind="from_timer", complete_when_deps_complete=False)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def from_cron(expr: str, *, tick_s: float = 60.0) -> Node[Any]:
|
|
245
|
+
"""Fire on each wall-clock minute matching a 5-field cron expression.
|
|
246
|
+
|
|
247
|
+
Emits wall-clock nanosecond timestamp on each match.
|
|
248
|
+
Uses a built-in cron parser (no external dependencies).
|
|
249
|
+
"""
|
|
250
|
+
from graphrefly.core.clock import wall_clock_ns as _wall_clock_ns
|
|
251
|
+
from graphrefly.extra.cron import CronSchedule, matches_cron, parse_cron
|
|
252
|
+
|
|
253
|
+
schedule: CronSchedule = parse_cron(expr)
|
|
254
|
+
|
|
255
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
256
|
+
timer: list[threading.Timer | None] = [None]
|
|
257
|
+
stopped = [False]
|
|
258
|
+
last_fired_key = [-1]
|
|
259
|
+
|
|
260
|
+
def check() -> None:
|
|
261
|
+
if stopped[0]:
|
|
262
|
+
return
|
|
263
|
+
now = datetime.fromtimestamp(_wall_clock_ns() / 1_000_000_000)
|
|
264
|
+
key = (
|
|
265
|
+
now.year * 100_000_000
|
|
266
|
+
+ now.month * 1_000_000
|
|
267
|
+
+ now.day * 10_000
|
|
268
|
+
+ now.hour * 100
|
|
269
|
+
+ now.minute
|
|
270
|
+
)
|
|
271
|
+
if key != last_fired_key[0] and matches_cron(schedule, now):
|
|
272
|
+
last_fired_key[0] = key
|
|
273
|
+
actions.emit(_wall_clock_ns())
|
|
274
|
+
# Schedule next check
|
|
275
|
+
if not stopped[0]:
|
|
276
|
+
t = threading.Timer(tick_s, check)
|
|
277
|
+
t.daemon = True
|
|
278
|
+
t.start()
|
|
279
|
+
timer[0] = t
|
|
280
|
+
|
|
281
|
+
check()
|
|
282
|
+
|
|
283
|
+
def cleanup() -> None:
|
|
284
|
+
stopped[0] = True
|
|
285
|
+
if timer[0] is not None:
|
|
286
|
+
timer[0].cancel()
|
|
287
|
+
timer[0] = None
|
|
288
|
+
|
|
289
|
+
return cleanup
|
|
290
|
+
|
|
291
|
+
return node(start, describe_kind="from_cron", complete_when_deps_complete=False)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# --- async bridges ------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def from_awaitable(
|
|
298
|
+
awaitable: Awaitable[Any],
|
|
299
|
+
*,
|
|
300
|
+
runner: Any | None = None,
|
|
301
|
+
) -> Node[Any]:
|
|
302
|
+
"""Resolve an awaitable via a :class:`~graphrefly.core.runner.Runner`.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
awaitable: The awaitable/coroutine to resolve.
|
|
306
|
+
runner: Optional :class:`~graphrefly.core.runner.Runner`. When ``None``,
|
|
307
|
+
uses the thread-local default runner (see
|
|
308
|
+
:func:`~graphrefly.core.runner.set_default_runner`).
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
A :class:`~graphrefly.core.node.Node` that emits one ``DATA`` then
|
|
312
|
+
``COMPLETE``, or ``ERROR`` on failure.
|
|
313
|
+
"""
|
|
314
|
+
from graphrefly.core.runner import resolve_runner
|
|
315
|
+
|
|
316
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
317
|
+
stopped = [False]
|
|
318
|
+
|
|
319
|
+
async def arun() -> Any:
|
|
320
|
+
return await awaitable
|
|
321
|
+
|
|
322
|
+
def on_result(v: Any) -> None:
|
|
323
|
+
if not stopped[0]:
|
|
324
|
+
actions.emit(v)
|
|
325
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
326
|
+
|
|
327
|
+
def on_error(err: BaseException) -> None:
|
|
328
|
+
if not stopped[0]:
|
|
329
|
+
actions.down([(MessageType.ERROR, err)])
|
|
330
|
+
|
|
331
|
+
cancel = resolve_runner(runner).schedule(arun(), on_result, on_error)
|
|
332
|
+
|
|
333
|
+
def cleanup() -> None:
|
|
334
|
+
stopped[0] = True
|
|
335
|
+
cancel()
|
|
336
|
+
|
|
337
|
+
return cleanup
|
|
338
|
+
|
|
339
|
+
return node(start, describe_kind="from_awaitable", complete_when_deps_complete=False)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def from_async_iter(
|
|
343
|
+
aiterable: AsyncIterable[Any],
|
|
344
|
+
*,
|
|
345
|
+
runner: Any | None = None,
|
|
346
|
+
) -> Node[Any]:
|
|
347
|
+
"""Iterate an async iterable via a :class:`~graphrefly.core.runner.Runner`.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
aiterable: The async iterable to drain.
|
|
351
|
+
runner: Optional :class:`~graphrefly.core.runner.Runner`. When ``None``,
|
|
352
|
+
uses the thread-local default runner.
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
A :class:`~graphrefly.core.node.Node` that emits ``DATA`` per item,
|
|
356
|
+
then ``COMPLETE``, or ``ERROR`` on failure.
|
|
357
|
+
"""
|
|
358
|
+
from graphrefly.core.runner import resolve_runner
|
|
359
|
+
|
|
360
|
+
def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
|
|
361
|
+
stopped = [False]
|
|
362
|
+
|
|
363
|
+
async def arun() -> None:
|
|
364
|
+
async for item in aiterable:
|
|
365
|
+
if stopped[0]:
|
|
366
|
+
return
|
|
367
|
+
actions.emit(item)
|
|
368
|
+
|
|
369
|
+
def on_result(_: Any) -> None:
|
|
370
|
+
if not stopped[0]:
|
|
371
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
372
|
+
|
|
373
|
+
def on_error(err: BaseException) -> None:
|
|
374
|
+
if not stopped[0]:
|
|
375
|
+
actions.down([(MessageType.ERROR, err)])
|
|
376
|
+
|
|
377
|
+
cancel = resolve_runner(runner).schedule(arun(), on_result, on_error)
|
|
378
|
+
|
|
379
|
+
def cleanup() -> None:
|
|
380
|
+
stopped[0] = True
|
|
381
|
+
cancel()
|
|
382
|
+
|
|
383
|
+
return cleanup
|
|
384
|
+
|
|
385
|
+
return node(start, describe_kind="from_async_iter", complete_when_deps_complete=False)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def from_any(value: Any, *, runner: Any | None = None) -> Node[Any]:
|
|
389
|
+
"""Coerce a value into a :class:`~graphrefly.core.node.Node` using the best matching source.
|
|
390
|
+
|
|
391
|
+
Dispatch rules:
|
|
392
|
+
|
|
393
|
+
- Existing :class:`~graphrefly.core.node.Node` -> returned as-is.
|
|
394
|
+
- :class:`collections.abc.AsyncIterable` / async iterator -> :func:`from_async_iter`.
|
|
395
|
+
- Awaitable / :class:`asyncio.Future` / coroutine -> :func:`from_awaitable`.
|
|
396
|
+
- Otherwise tries ``iter(value)``; if that fails uses :func:`of`.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
value: Any value to coerce.
|
|
400
|
+
runner: Optional :class:`~graphrefly.core.runner.Runner` forwarded to
|
|
401
|
+
:func:`from_awaitable` / :func:`from_async_iter` when applicable.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
A :class:`~graphrefly.core.node.Node` wrapping *value*.
|
|
405
|
+
|
|
406
|
+
Example:
|
|
407
|
+
```python
|
|
408
|
+
from graphrefly.extra.sources import from_any
|
|
409
|
+
n = from_any([1, 2, 3])
|
|
410
|
+
from graphrefly.extra.sources import to_list
|
|
411
|
+
assert to_list(n) == [1, 2, 3]
|
|
412
|
+
```
|
|
413
|
+
"""
|
|
414
|
+
if isinstance(value, Node):
|
|
415
|
+
return value
|
|
416
|
+
if isinstance(value, AsyncIterable):
|
|
417
|
+
return from_async_iter(value, runner=runner)
|
|
418
|
+
if isinstance(value, Awaitable) or asyncio.isfuture(value) or asyncio.iscoroutine(value):
|
|
419
|
+
return from_awaitable(value, runner=runner)
|
|
420
|
+
try:
|
|
421
|
+
it = iter(value)
|
|
422
|
+
except TypeError:
|
|
423
|
+
return of(value)
|
|
424
|
+
return from_iter(it)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# --- sinks --------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def for_each(
|
|
431
|
+
source: Node[Any],
|
|
432
|
+
fn: Callable[[Any], None],
|
|
433
|
+
*,
|
|
434
|
+
on_error: Callable[[BaseException], None] | None = None,
|
|
435
|
+
) -> Callable[[], None]:
|
|
436
|
+
"""Subscribe to *source* and invoke ``fn(value)`` for each ``DATA`` message.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
source: The node to subscribe to.
|
|
440
|
+
fn: Callback invoked with each ``DATA`` payload.
|
|
441
|
+
on_error: Optional callback invoked when an ``ERROR`` is received. If
|
|
442
|
+
omitted, the error is re-raised from inside the sink.
|
|
443
|
+
|
|
444
|
+
Returns:
|
|
445
|
+
An unsubscribe callable; call it to detach.
|
|
446
|
+
|
|
447
|
+
Example:
|
|
448
|
+
```python
|
|
449
|
+
from graphrefly import state
|
|
450
|
+
from graphrefly.extra.sources import for_each
|
|
451
|
+
x = state(0)
|
|
452
|
+
log = []
|
|
453
|
+
unsub = for_each(x, log.append)
|
|
454
|
+
x.down([("DATA", 7)])
|
|
455
|
+
unsub()
|
|
456
|
+
assert log == [7]
|
|
457
|
+
```
|
|
458
|
+
"""
|
|
459
|
+
|
|
460
|
+
def sink(msgs: Messages) -> None:
|
|
461
|
+
for m in msgs:
|
|
462
|
+
t = m[0]
|
|
463
|
+
if t is MessageType.DATA:
|
|
464
|
+
fn(_msg_val(m))
|
|
465
|
+
elif t is MessageType.ERROR:
|
|
466
|
+
err = _msg_val(m)
|
|
467
|
+
if on_error is not None:
|
|
468
|
+
if isinstance(err, BaseException):
|
|
469
|
+
on_error(err)
|
|
470
|
+
else:
|
|
471
|
+
on_error(RuntimeError(str(err)))
|
|
472
|
+
else:
|
|
473
|
+
if isinstance(err, BaseException):
|
|
474
|
+
raise err
|
|
475
|
+
msg = str(err)
|
|
476
|
+
raise RuntimeError(msg)
|
|
477
|
+
|
|
478
|
+
return source.subscribe(sink)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def to_list(
|
|
482
|
+
source: Node[Any],
|
|
483
|
+
*,
|
|
484
|
+
timeout: float | None = None,
|
|
485
|
+
) -> list[Any]:
|
|
486
|
+
"""Block until ``COMPLETE`` or ``ERROR``, collecting all ``DATA`` payloads in order.
|
|
487
|
+
|
|
488
|
+
Args:
|
|
489
|
+
source: The node to collect from.
|
|
490
|
+
timeout: Optional timeout in seconds; raises :exc:`TimeoutError` if
|
|
491
|
+
``COMPLETE`` does not arrive in time.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
A list of ``DATA`` payloads in emission order.
|
|
495
|
+
|
|
496
|
+
Example:
|
|
497
|
+
```python
|
|
498
|
+
from graphrefly.extra import of
|
|
499
|
+
from graphrefly.extra.sources import to_list
|
|
500
|
+
assert to_list(of(1, 2, 3)) == [1, 2, 3]
|
|
501
|
+
```
|
|
502
|
+
"""
|
|
503
|
+
out: list[Any] = []
|
|
504
|
+
done = threading.Event()
|
|
505
|
+
err_box: list[BaseException | Any | None] = [None]
|
|
506
|
+
|
|
507
|
+
def sink(msgs: Messages) -> None:
|
|
508
|
+
for m in msgs:
|
|
509
|
+
t = m[0]
|
|
510
|
+
if t is MessageType.DATA:
|
|
511
|
+
out.append(_msg_val(m))
|
|
512
|
+
elif t is MessageType.ERROR:
|
|
513
|
+
err_box[0] = _msg_val(m)
|
|
514
|
+
done.set()
|
|
515
|
+
elif t is MessageType.COMPLETE:
|
|
516
|
+
done.set()
|
|
517
|
+
|
|
518
|
+
unsub = source.subscribe(sink)
|
|
519
|
+
try:
|
|
520
|
+
if timeout is None:
|
|
521
|
+
done.wait()
|
|
522
|
+
elif not done.wait(timeout):
|
|
523
|
+
msg = "to_list timed out"
|
|
524
|
+
raise TimeoutError(msg)
|
|
525
|
+
finally:
|
|
526
|
+
unsub()
|
|
527
|
+
|
|
528
|
+
err = err_box[0]
|
|
529
|
+
if err is not None:
|
|
530
|
+
if isinstance(err, BaseException):
|
|
531
|
+
raise err
|
|
532
|
+
raise RuntimeError(str(err))
|
|
533
|
+
return out
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def first_value_from(
|
|
537
|
+
source: Node[Any],
|
|
538
|
+
*,
|
|
539
|
+
timeout: float | None = None,
|
|
540
|
+
) -> Any:
|
|
541
|
+
"""Block until the first ``DATA`` value or a terminal ``ERROR`` arrives.
|
|
542
|
+
|
|
543
|
+
On ``COMPLETE`` without prior ``DATA``, raises :exc:`StopIteration`. With
|
|
544
|
+
*timeout*, raises :exc:`TimeoutError` if no terminal message arrives in time.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
source: The node to await the first value from.
|
|
548
|
+
timeout: Optional timeout in seconds.
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
The first ``DATA`` payload received.
|
|
552
|
+
|
|
553
|
+
Notes:
|
|
554
|
+
Python exposes this as a synchronous blocking call. The TypeScript equivalent
|
|
555
|
+
``firstValueFrom`` returns a ``Promise``; both provide the same escape-hatch
|
|
556
|
+
semantics with implementation differences due to language concurrency models.
|
|
557
|
+
|
|
558
|
+
Example:
|
|
559
|
+
```python
|
|
560
|
+
from graphrefly.extra import of
|
|
561
|
+
from graphrefly.extra.sources import first_value_from
|
|
562
|
+
assert first_value_from(of(42)) == 42
|
|
563
|
+
```
|
|
564
|
+
"""
|
|
565
|
+
got: list[Any | None] = [None]
|
|
566
|
+
err_box: list[BaseException | Any | None] = [None]
|
|
567
|
+
complete_without_data = [False]
|
|
568
|
+
done = threading.Event()
|
|
569
|
+
|
|
570
|
+
def sink(msgs: Messages) -> None:
|
|
571
|
+
for m in msgs:
|
|
572
|
+
t = m[0]
|
|
573
|
+
if t is MessageType.DATA:
|
|
574
|
+
if got[0] is None:
|
|
575
|
+
got[0] = _msg_val(m)
|
|
576
|
+
done.set()
|
|
577
|
+
elif t is MessageType.ERROR:
|
|
578
|
+
err_box[0] = _msg_val(m)
|
|
579
|
+
done.set()
|
|
580
|
+
elif t is MessageType.COMPLETE:
|
|
581
|
+
if got[0] is None:
|
|
582
|
+
complete_without_data[0] = True
|
|
583
|
+
done.set()
|
|
584
|
+
|
|
585
|
+
unsub = source.subscribe(sink)
|
|
586
|
+
try:
|
|
587
|
+
if timeout is None:
|
|
588
|
+
done.wait()
|
|
589
|
+
elif not done.wait(timeout):
|
|
590
|
+
msg = "first_value_from timed out"
|
|
591
|
+
raise TimeoutError(msg)
|
|
592
|
+
finally:
|
|
593
|
+
unsub()
|
|
594
|
+
|
|
595
|
+
err = err_box[0]
|
|
596
|
+
if err is not None:
|
|
597
|
+
if isinstance(err, BaseException):
|
|
598
|
+
raise err
|
|
599
|
+
raise RuntimeError(str(err))
|
|
600
|
+
if complete_without_data[0] and got[0] is None:
|
|
601
|
+
raise StopIteration
|
|
602
|
+
return got[0]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# --- multicast ----------------------------------------------------------------
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def share[T](source: Node[T]) -> Node[T]:
|
|
609
|
+
"""Share one upstream subscription across all downstream sinks (ref-counted).
|
|
610
|
+
|
|
611
|
+
Args:
|
|
612
|
+
source: The upstream node to multicast.
|
|
613
|
+
|
|
614
|
+
Returns:
|
|
615
|
+
A new :class:`~graphrefly.core.node.Node` that connects to *source* once
|
|
616
|
+
and ref-counts downstream subscriptions.
|
|
617
|
+
|
|
618
|
+
Example:
|
|
619
|
+
```python
|
|
620
|
+
from graphrefly import state
|
|
621
|
+
from graphrefly.extra.sources import share, for_each
|
|
622
|
+
x = state(0)
|
|
623
|
+
s = share(x)
|
|
624
|
+
log = []
|
|
625
|
+
unsub = for_each(s, log.append)
|
|
626
|
+
x.down([("DATA", 1)])
|
|
627
|
+
unsub()
|
|
628
|
+
```
|
|
629
|
+
"""
|
|
630
|
+
return node([source], describe_kind="share", initial=source.get())
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def cached[T](source: Node[T]) -> Node[T]:
|
|
634
|
+
"""Alias of :func:`share` with ``describe_kind='cached'`` (hot wire).
|
|
635
|
+
|
|
636
|
+
Late joiners observe new ``DATA`` from the shared upstream; use
|
|
637
|
+
:meth:`~graphrefly.core.node.Node.get` after subscribe for the latest cached value on the
|
|
638
|
+
returned node.
|
|
639
|
+
"""
|
|
640
|
+
return node([source], describe_kind="cached", initial=source.get())
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
class _ReplayNode[T](Node[T]):
|
|
644
|
+
"""Thin subclass that intercepts subscribe to replay buffered DATA to late joiners."""
|
|
645
|
+
|
|
646
|
+
__slots__ = ("_replay_buf", "_replay_buf_size")
|
|
647
|
+
|
|
648
|
+
def __init__(
|
|
649
|
+
self,
|
|
650
|
+
deps: list[Any],
|
|
651
|
+
fn: Any,
|
|
652
|
+
opts: dict[str, Any],
|
|
653
|
+
buf: list[Any],
|
|
654
|
+
buf_size: int,
|
|
655
|
+
) -> None:
|
|
656
|
+
super().__init__(deps, fn, opts)
|
|
657
|
+
self._replay_buf = buf
|
|
658
|
+
self._replay_buf_size = buf_size
|
|
659
|
+
|
|
660
|
+
def subscribe(
|
|
661
|
+
self,
|
|
662
|
+
sink: Callable[[Messages], None],
|
|
663
|
+
hints: Any = None,
|
|
664
|
+
*,
|
|
665
|
+
actor: Any = None,
|
|
666
|
+
) -> Callable[[], None]:
|
|
667
|
+
# Replay buffered values before connecting live stream
|
|
668
|
+
for v in list(self._replay_buf):
|
|
669
|
+
sink([(MessageType.DATA, v)])
|
|
670
|
+
return super().subscribe(sink, hints, actor=actor)
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def replay[T](source: Node[T], buffer_size: int = 1) -> Node[T]:
|
|
674
|
+
"""Multicast with late-subscriber replay of the last *buffer_size* ``DATA`` payloads.
|
|
675
|
+
|
|
676
|
+
Args:
|
|
677
|
+
source: The upstream node to multicast.
|
|
678
|
+
buffer_size: Number of ``DATA`` payloads to buffer for late joiners (>= 1).
|
|
679
|
+
|
|
680
|
+
Returns:
|
|
681
|
+
A :class:`~graphrefly.core.node.Node` that replays buffered values to
|
|
682
|
+
each new subscriber before connecting the live stream.
|
|
683
|
+
|
|
684
|
+
Example:
|
|
685
|
+
```python
|
|
686
|
+
from graphrefly import state
|
|
687
|
+
from graphrefly.extra.sources import replay, for_each
|
|
688
|
+
x = state(0)
|
|
689
|
+
r = replay(x, buffer_size=2)
|
|
690
|
+
x.down([("DATA", 1)])
|
|
691
|
+
x.down([("DATA", 2)])
|
|
692
|
+
received = []
|
|
693
|
+
unsub = for_each(r, received.append)
|
|
694
|
+
# received includes replayed values 1 and 2
|
|
695
|
+
unsub()
|
|
696
|
+
```
|
|
697
|
+
"""
|
|
698
|
+
if buffer_size < 1:
|
|
699
|
+
msg = "buffer_size must be >= 1"
|
|
700
|
+
raise ValueError(msg)
|
|
701
|
+
buf: list[Any] = []
|
|
702
|
+
|
|
703
|
+
def on_msg(msg: tuple[Any, ...], _dep_index: int, actions: NodeActions) -> bool:
|
|
704
|
+
if msg[0] is MessageType.DATA:
|
|
705
|
+
val = msg[1]
|
|
706
|
+
buf.append(val)
|
|
707
|
+
if len(buf) > buffer_size:
|
|
708
|
+
buf.pop(0)
|
|
709
|
+
return False # let default dispatch handle it
|
|
710
|
+
|
|
711
|
+
opts: dict[str, Any] = {
|
|
712
|
+
"on_message": on_msg,
|
|
713
|
+
"describe_kind": "replay",
|
|
714
|
+
"initial": source.get(),
|
|
715
|
+
}
|
|
716
|
+
return _ReplayNode([source], None, opts, buf, buffer_size)
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def to_array(source: Node[Any]) -> Node[list[Any]]:
|
|
720
|
+
"""Collect all DATA values; on COMPLETE emit one DATA (the list) then COMPLETE.
|
|
721
|
+
|
|
722
|
+
Reactive version -- returns a Node. For blocking sync bridge, use :func:`to_list`.
|
|
723
|
+
"""
|
|
724
|
+
acc: list[Any] = []
|
|
725
|
+
|
|
726
|
+
def on_msg(msg: tuple[Any, ...], _dep_index: int, actions: NodeActions) -> bool:
|
|
727
|
+
if msg[0] is MessageType.DATA:
|
|
728
|
+
acc.append(msg[1] if len(msg) > 1 else None)
|
|
729
|
+
return True
|
|
730
|
+
if msg[0] is MessageType.COMPLETE:
|
|
731
|
+
actions.emit(list(acc))
|
|
732
|
+
actions.down([(MessageType.COMPLETE,)])
|
|
733
|
+
return True
|
|
734
|
+
return False
|
|
735
|
+
|
|
736
|
+
return node(
|
|
737
|
+
[source],
|
|
738
|
+
on_message=on_msg,
|
|
739
|
+
describe_kind="to_array",
|
|
740
|
+
complete_when_deps_complete=False,
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
share_replay = replay
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
__all__ = [
|
|
748
|
+
"cached",
|
|
749
|
+
"empty",
|
|
750
|
+
"first_value_from",
|
|
751
|
+
"for_each",
|
|
752
|
+
"from_any",
|
|
753
|
+
"from_async_iter",
|
|
754
|
+
"from_awaitable",
|
|
755
|
+
"from_cron",
|
|
756
|
+
"from_iter",
|
|
757
|
+
"from_timer",
|
|
758
|
+
"never",
|
|
759
|
+
"of",
|
|
760
|
+
"replay",
|
|
761
|
+
"share",
|
|
762
|
+
"share_replay",
|
|
763
|
+
"throw_error",
|
|
764
|
+
"to_array",
|
|
765
|
+
"to_list",
|
|
766
|
+
]
|