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,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
+ ]