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,1802 @@
1
+ """Tier 2 operators — async/time/dynamic — from :func:`~graphrefly.core.node.node` (roadmap §2.2).
2
+
3
+ Dynamic ``*_map`` operators subscribe to inner :class:`~graphrefly.core.node.Node` values from
4
+ the mapper. Time-based operators use :class:`threading.Timer`; timers are cancelled on
5
+ unsubscribe (same lifecycle as a no-deps ``node`` producer).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import threading
11
+ from collections import deque
12
+ from contextlib import suppress
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from graphrefly.core.node import Node, NodeActions, node
16
+ from graphrefly.core.protocol import Messages, MessageType
17
+ from graphrefly.extra.sources import from_any
18
+
19
+ if TYPE_CHECKING:
20
+ from collections.abc import Callable
21
+
22
+ from graphrefly.core.sugar import PipeOperator
23
+
24
+ _UNSET: Any = object()
25
+
26
+
27
+ def _msg_val(m: tuple[Any, ...]) -> Any:
28
+ """Payload for a ``DATA`` / ``ERROR`` tuple (GraphReFly messages are at least two elements)."""
29
+ assert len(m) >= 2
30
+ return m[1]
31
+
32
+
33
+ def _as_node(value: Any) -> Node[Any]:
34
+ """Coerce mapper outputs to Node via ``from_any`` (roadmap §3.1b)."""
35
+ return from_any(value)
36
+
37
+
38
+ # --- dynamic inner subscription (switch / concat / flat / exhaust) ------------
39
+
40
+
41
+ def _forward_inner(
42
+ inner: Node[Any],
43
+ actions: NodeActions,
44
+ on_inner_complete: Callable[[], None],
45
+ ) -> Callable[[], None]:
46
+ """Subscribe to *inner*, forwarding all messages except COMPLETE to *actions*.
47
+
48
+ On inner COMPLETE, calls *on_inner_complete* (but does NOT forward the COMPLETE
49
+ message itself — the caller decides when the output completes).
50
+ On inner ERROR, forwards the error then calls *on_inner_complete*.
51
+
52
+ Returns an unsubscribe callable.
53
+
54
+ Matches TS ``forwardInner``.
55
+ """
56
+ unsub: Callable[[], None] | None = None
57
+ finished = False
58
+ emitted = False
59
+
60
+ def finish() -> None:
61
+ nonlocal finished
62
+ if finished:
63
+ return
64
+ finished = True
65
+ on_inner_complete()
66
+
67
+ def inner_sink(msgs: Messages) -> None:
68
+ nonlocal emitted
69
+ saw_complete = False
70
+ saw_error = False
71
+ out: Messages = []
72
+ for m in msgs:
73
+ if m[0] is MessageType.COMPLETE:
74
+ saw_complete = True
75
+ else:
76
+ if m[0] is MessageType.DATA:
77
+ emitted = True
78
+ if m[0] is MessageType.ERROR:
79
+ saw_error = True
80
+ out.append(m)
81
+ if out:
82
+ actions.down(out)
83
+ if saw_error or saw_complete:
84
+ finish()
85
+
86
+ unsub = inner.subscribe(inner_sink)
87
+
88
+ # Emit inner's current value only if subscribe didn't already emit DATA.
89
+ # Source nodes (state) don't emit DATA on subscribe, but their value
90
+ # is already settled. Derived nodes that compute during subscribe will
91
+ # have set emitted=True via inner_sink, so we skip the manual emit.
92
+ # None is a valid DATA payload (Node[None] / void sources).
93
+ if unsub is not None and not emitted and inner.status in ("settled", "resolved"):
94
+ actions.down([(MessageType.DATA, inner.get())])
95
+
96
+ if inner.status in ("completed", "errored"):
97
+ finish()
98
+
99
+ def stop() -> None:
100
+ nonlocal unsub
101
+ if unsub is not None:
102
+ unsub()
103
+ unsub = None
104
+
105
+ return stop
106
+
107
+
108
+ def switch_map(
109
+ fn: Callable[[Any], Any],
110
+ *,
111
+ initial: Any = _UNSET,
112
+ ) -> PipeOperator:
113
+ """Map each outer settled value to an inner node; keep only the latest inner subscription.
114
+
115
+ On each outer ``DATA``, the previous inner is unsubscribed. Inner ``DATA`` / ``RESOLVED`` /
116
+ ``DIRTY`` are forwarded; inner ``ERROR`` always terminates; inner ``COMPLETE`` completes
117
+ the output only if the outer has already completed.
118
+
119
+ Args:
120
+ fn: ``outer_value -> source`` for the active inner. Return ``Node``, scalar,
121
+ awaitable, iterable, or async iterable (coerced via
122
+ :func:`graphrefly.extra.sources.from_any`).
123
+ initial: Optional seed for :meth:`~graphrefly.core.node.Node.get` before the first inner
124
+ emission.
125
+
126
+ Returns:
127
+ A unary pipe operator ``(Node) -> Node``.
128
+
129
+ Example:
130
+ ```python
131
+ from graphrefly import state, pipe
132
+ from graphrefly.extra.tier2 import switch_map
133
+ from graphrefly.extra import of
134
+ src = state(1)
135
+ out = pipe(src, switch_map(lambda v: of(v * 10)))
136
+ ```
137
+ """
138
+
139
+ has_initial = initial is not _UNSET
140
+
141
+ def _op(outer: Node[Any]) -> Node[Any]:
142
+ inner_unsub: Callable[[], None] | None = None
143
+ source_done = False
144
+ attached = False
145
+
146
+ def clear_inner() -> None:
147
+ nonlocal inner_unsub
148
+ if inner_unsub is not None:
149
+ inner_unsub()
150
+ inner_unsub = None
151
+
152
+ def attach(v: Any, a: NodeActions) -> None:
153
+ nonlocal attached, inner_unsub
154
+ attached = True
155
+ clear_inner()
156
+
157
+ def _on_inner_complete() -> None:
158
+ clear_inner()
159
+ if source_done:
160
+ a.down([(MessageType.COMPLETE,)])
161
+
162
+ inner_unsub = _forward_inner(_as_node(fn(v)), a, _on_inner_complete)
163
+
164
+ def compute(deps: list[Any], a: NodeActions) -> Any:
165
+ if not attached:
166
+ attach(deps[0], a)
167
+ return clear_inner
168
+
169
+ def on_message(msg: Any, _index: int, a: NodeActions) -> bool:
170
+ nonlocal source_done
171
+ t = msg[0]
172
+ if t is MessageType.ERROR:
173
+ clear_inner()
174
+ a.down([msg])
175
+ return True
176
+ if t is MessageType.COMPLETE:
177
+ source_done = True
178
+ if inner_unsub is None:
179
+ a.down([(MessageType.COMPLETE,)])
180
+ return True
181
+ if t is MessageType.DIRTY:
182
+ a.down([(MessageType.DIRTY,)])
183
+ return True
184
+ if t is MessageType.RESOLVED:
185
+ a.down([(MessageType.RESOLVED,)])
186
+ return True
187
+ if t is MessageType.DATA:
188
+ attach(_msg_val(msg), a)
189
+ return True
190
+ return False
191
+
192
+ opts: dict[str, Any] = {
193
+ "describe_kind": "switch_map",
194
+ "complete_when_deps_complete": False,
195
+ "on_message": on_message,
196
+ }
197
+ if has_initial:
198
+ opts["initial"] = initial
199
+ return node([outer], compute, **opts)
200
+
201
+ return _op
202
+
203
+
204
+ def concat_map(
205
+ fn: Callable[[Any], Any],
206
+ *,
207
+ initial: Any = _UNSET,
208
+ max_buffer: int = 0,
209
+ ) -> PipeOperator:
210
+ """Map outer values to inner nodes; run inners strictly one after another.
211
+
212
+ While an inner is active, outer ``DATA`` values are queued. ``max_buffer > 0`` drops the
213
+ oldest queued value when the queue would exceed that length.
214
+
215
+ Args:
216
+ fn: ``outer_value -> source`` (coerced via :func:`graphrefly.extra.sources.from_any`).
217
+ initial: Optional initial ``get()`` value.
218
+ max_buffer: Maximum queued outer keys (``0`` = unlimited).
219
+
220
+ Returns:
221
+ A unary pipe operator ``(Node) -> Node``.
222
+
223
+ Example:
224
+ ```python
225
+ from graphrefly import state, pipe
226
+ from graphrefly.extra.tier2 import concat_map
227
+ from graphrefly.extra import of
228
+ src = state(1)
229
+ out = pipe(src, concat_map(lambda v: of(v, v + 1)))
230
+ ```
231
+ """
232
+
233
+ has_initial = initial is not _UNSET
234
+
235
+ def _op(outer: Node[Any]) -> Node[Any]:
236
+ queue: deque[Any] = deque()
237
+ inner_unsub: Callable[[], None] | None = None
238
+ source_done = False
239
+ attached = False
240
+
241
+ def clear_inner() -> None:
242
+ nonlocal inner_unsub
243
+ if inner_unsub is not None:
244
+ inner_unsub()
245
+ inner_unsub = None
246
+
247
+ def try_pump(a: NodeActions) -> None:
248
+ nonlocal inner_unsub
249
+ if inner_unsub is not None:
250
+ return
251
+ if len(queue) == 0:
252
+ if source_done:
253
+ a.down([(MessageType.COMPLETE,)])
254
+ return
255
+ v = queue.popleft()
256
+
257
+ def _on_inner_complete() -> None:
258
+ clear_inner()
259
+ try_pump(a)
260
+
261
+ inner_unsub = _forward_inner(_as_node(fn(v)), a, _on_inner_complete)
262
+
263
+ def enqueue(v: Any, a: NodeActions) -> None:
264
+ nonlocal attached
265
+ attached = True
266
+ if max_buffer > 0 and len(queue) >= max_buffer:
267
+ queue.popleft()
268
+ queue.append(v)
269
+ try_pump(a)
270
+
271
+ def compute(deps: list[Any], a: NodeActions) -> Any:
272
+ if not attached:
273
+ enqueue(deps[0], a)
274
+ return clear_inner
275
+
276
+ def on_message(msg: Any, _index: int, a: NodeActions) -> bool:
277
+ nonlocal source_done
278
+ t = msg[0]
279
+ if t is MessageType.ERROR:
280
+ clear_inner()
281
+ queue.clear()
282
+ a.down([msg])
283
+ return True
284
+ if t is MessageType.COMPLETE:
285
+ source_done = True
286
+ try_pump(a)
287
+ return True
288
+ if t is MessageType.DIRTY:
289
+ a.down([(MessageType.DIRTY,)])
290
+ return True
291
+ if t is MessageType.RESOLVED:
292
+ a.down([(MessageType.RESOLVED,)])
293
+ return True
294
+ if t is MessageType.DATA:
295
+ enqueue(_msg_val(msg), a)
296
+ return True
297
+ return False
298
+
299
+ opts: dict[str, Any] = {
300
+ "describe_kind": "concat_map",
301
+ "complete_when_deps_complete": False,
302
+ "on_message": on_message,
303
+ }
304
+ if has_initial:
305
+ opts["initial"] = initial
306
+ return node([outer], compute, **opts)
307
+
308
+ return _op
309
+
310
+
311
+ def flat_map(
312
+ fn: Callable[[Any], Any],
313
+ *,
314
+ initial: Any = _UNSET,
315
+ concurrent: int | None = None,
316
+ ) -> PipeOperator:
317
+ """Map each outer value to an inner node; subscribe to every inner concurrently (merge).
318
+
319
+ Completes when the outer has completed and every inner subscription has ended.
320
+
321
+ Args:
322
+ fn: ``outer_value -> source`` (coerced via :func:`graphrefly.extra.sources.from_any`).
323
+ initial: Optional initial ``get()`` value.
324
+ concurrent: When set, limit the number of concurrently active inner subscriptions.
325
+ Outer values beyond this limit are buffered and drained as inner subscriptions
326
+ complete.
327
+
328
+ Returns:
329
+ A unary pipe operator ``(Node) -> Node``.
330
+
331
+ Example:
332
+ ```python
333
+ from graphrefly import state, pipe
334
+ from graphrefly.extra.tier2 import flat_map
335
+ from graphrefly.extra import of
336
+ src = state(1)
337
+ out = pipe(src, flat_map(lambda v: of(v * 2)))
338
+ ```
339
+ """
340
+
341
+ has_initial = initial is not _UNSET
342
+ max_concurrent = float("inf") if concurrent is None else max(concurrent, 1)
343
+
344
+ def _op(outer: Node[Any]) -> Node[Any]:
345
+ active = 0
346
+ source_done = False
347
+ inner_stops: list[Callable[[], None]] = []
348
+ buffer: deque[Any] = deque()
349
+ attached = False
350
+
351
+ def try_complete(a: NodeActions) -> None:
352
+ if source_done and active == 0 and len(buffer) == 0:
353
+ a.down([(MessageType.COMPLETE,)])
354
+
355
+ def spawn(v: Any, a: NodeActions) -> None:
356
+ nonlocal active
357
+ active += 1
358
+ stop: Callable[[], None] | None = None
359
+
360
+ def on_done() -> None:
361
+ nonlocal stop, active
362
+ if stop is not None:
363
+ with suppress(ValueError):
364
+ inner_stops.remove(stop)
365
+ stop = None
366
+ active -= 1
367
+ drain_buffer(a)
368
+ try_complete(a)
369
+
370
+ stop = _forward_inner(_as_node(fn(v)), a, on_done)
371
+ inner_stops.append(stop)
372
+
373
+ def drain_buffer(a: NodeActions) -> None:
374
+ while buffer and active < max_concurrent:
375
+ spawn(buffer.popleft(), a)
376
+
377
+ def enqueue(v: Any, a: NodeActions) -> None:
378
+ if active < max_concurrent:
379
+ spawn(v, a)
380
+ else:
381
+ buffer.append(v)
382
+
383
+ def clear_all() -> None:
384
+ nonlocal active
385
+ for u in list(inner_stops):
386
+ u()
387
+ inner_stops.clear()
388
+ active = 0
389
+ buffer.clear()
390
+
391
+ def compute(deps: list[Any], a: NodeActions) -> Any:
392
+ nonlocal attached
393
+ if not attached:
394
+ attached = True
395
+ enqueue(deps[0], a)
396
+ return clear_all
397
+
398
+ def on_message(msg: Any, _index: int, a: NodeActions) -> bool:
399
+ nonlocal source_done
400
+ t = msg[0]
401
+ if t is MessageType.ERROR:
402
+ clear_all()
403
+ a.down([msg])
404
+ return True
405
+ if t is MessageType.COMPLETE:
406
+ source_done = True
407
+ try_complete(a)
408
+ return True
409
+ if t is MessageType.DIRTY:
410
+ a.down([(MessageType.DIRTY,)])
411
+ return True
412
+ if t is MessageType.RESOLVED:
413
+ a.down([(MessageType.RESOLVED,)])
414
+ return True
415
+ if t is MessageType.DATA:
416
+ enqueue(_msg_val(msg), a)
417
+ return True
418
+ return False
419
+
420
+ opts: dict[str, Any] = {
421
+ "describe_kind": "flat_map",
422
+ "complete_when_deps_complete": False,
423
+ "on_message": on_message,
424
+ }
425
+ if has_initial:
426
+ opts["initial"] = initial
427
+ return node([outer], compute, **opts)
428
+
429
+ return _op
430
+
431
+
432
+ def exhaust_map(fn: Callable[[Any], Any], *, initial: Any = _UNSET) -> PipeOperator:
433
+ """Like :func:`switch_map`, but ignores new outer ``DATA`` while the current inner is active.
434
+
435
+ Args:
436
+ fn: ``outer_value -> source`` (coerced via :func:`graphrefly.extra.sources.from_any`).
437
+ initial: Optional initial ``get()`` value.
438
+
439
+ Returns:
440
+ A unary pipe operator ``(Node) -> Node``.
441
+
442
+ Example:
443
+ ```python
444
+ from graphrefly import state, pipe
445
+ from graphrefly.extra.tier2 import exhaust_map
446
+ from graphrefly.extra import of
447
+ src = state(1)
448
+ out = pipe(src, exhaust_map(lambda v: of(v)))
449
+ ```
450
+ """
451
+
452
+ has_initial = initial is not _UNSET
453
+
454
+ def _op(outer: Node[Any]) -> Node[Any]:
455
+ inner_unsub: Callable[[], None] | None = None
456
+ source_done = False
457
+ attached = False
458
+
459
+ def clear_inner() -> None:
460
+ nonlocal inner_unsub
461
+ if inner_unsub is not None:
462
+ inner_unsub()
463
+ inner_unsub = None
464
+
465
+ def attach(v: Any, a: NodeActions) -> None:
466
+ nonlocal attached, inner_unsub
467
+ attached = True
468
+
469
+ def _on_inner_complete() -> None:
470
+ clear_inner()
471
+ if source_done:
472
+ a.down([(MessageType.COMPLETE,)])
473
+
474
+ inner_unsub = _forward_inner(_as_node(fn(v)), a, _on_inner_complete)
475
+
476
+ def compute(deps: list[Any], a: NodeActions) -> Any:
477
+ if not attached and inner_unsub is None:
478
+ attach(deps[0], a)
479
+ return clear_inner
480
+
481
+ def on_message(msg: Any, _index: int, a: NodeActions) -> bool:
482
+ nonlocal source_done
483
+ t = msg[0]
484
+ if t is MessageType.ERROR:
485
+ clear_inner()
486
+ a.down([msg])
487
+ return True
488
+ if t is MessageType.COMPLETE:
489
+ source_done = True
490
+ if inner_unsub is None:
491
+ a.down([(MessageType.COMPLETE,)])
492
+ return True
493
+ if t is MessageType.DIRTY:
494
+ a.down([(MessageType.DIRTY,)])
495
+ return True
496
+ if t is MessageType.RESOLVED:
497
+ a.down([(MessageType.RESOLVED,)])
498
+ return True
499
+ if t is MessageType.DATA:
500
+ if inner_unsub is not None:
501
+ a.down([(MessageType.RESOLVED,)])
502
+ return True
503
+ attach(_msg_val(msg), a)
504
+ return True
505
+ return False
506
+
507
+ opts: dict[str, Any] = {
508
+ "describe_kind": "exhaust_map",
509
+ "complete_when_deps_complete": False,
510
+ "on_message": on_message,
511
+ }
512
+ if has_initial:
513
+ opts["initial"] = initial
514
+ return node([outer], compute, **opts)
515
+
516
+ return _op
517
+
518
+
519
+ # --- time / scheduling (threading.Timer) --------------------------------------
520
+
521
+
522
+ def debounce(seconds: float) -> PipeOperator:
523
+ """Emit the latest upstream ``DATA`` only after ``seconds`` of silence; flush on ``COMPLETE``.
524
+
525
+ Timer is cancelled on upstream ``ERROR`` or unsubscribe.
526
+
527
+ Args:
528
+ seconds: Silence window in seconds; timer resets on each upstream ``DATA``.
529
+
530
+ Returns:
531
+ A unary pipe operator ``(Node) -> Node``.
532
+
533
+ Example:
534
+ ```python
535
+ from graphrefly import state, pipe
536
+ from graphrefly.extra.tier2 import debounce
537
+ src = state(0)
538
+ out = pipe(src, debounce(0.05))
539
+ ```
540
+ """
541
+
542
+ def _op(src: Node[Any]) -> Node[Any]:
543
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
544
+ timer: threading.Timer | None = None
545
+ pending: Any = None
546
+ has_pending = False
547
+
548
+ def cancel_timer() -> None:
549
+ nonlocal timer
550
+ if timer is not None:
551
+ timer.cancel()
552
+ timer = None
553
+
554
+ def flush() -> None:
555
+ nonlocal timer, has_pending
556
+ timer = None
557
+ if not has_pending:
558
+ return
559
+ v = pending
560
+ has_pending = False
561
+ actions.emit(v)
562
+
563
+ def outer_sink(msgs: Messages) -> None:
564
+ nonlocal timer, pending, has_pending
565
+ for m in msgs:
566
+ t = m[0]
567
+ if t is MessageType.DATA:
568
+ cancel_timer()
569
+ pending = _msg_val(m)
570
+ has_pending = True
571
+ tt = threading.Timer(seconds, flush)
572
+ tt.daemon = True
573
+ tt.start()
574
+ timer = tt
575
+ elif t is MessageType.DIRTY:
576
+ actions.down([(MessageType.DIRTY,)])
577
+ elif t is MessageType.RESOLVED:
578
+ actions.down([(MessageType.RESOLVED,)])
579
+ elif t is MessageType.COMPLETE:
580
+ cancel_timer()
581
+ if has_pending:
582
+ has_pending = False
583
+ actions.emit(pending)
584
+ actions.down([(MessageType.COMPLETE,)])
585
+ elif t is MessageType.ERROR:
586
+ cancel_timer()
587
+ has_pending = False
588
+ actions.down([m])
589
+ else:
590
+ actions.down([m])
591
+
592
+ outer_unsub = src.subscribe(outer_sink)
593
+
594
+ def cleanup() -> None:
595
+ cancel_timer()
596
+ outer_unsub()
597
+
598
+ return cleanup
599
+
600
+ return node(start, describe_kind="debounce", complete_when_deps_complete=False)
601
+
602
+ return _op
603
+
604
+
605
+ def throttle(seconds: float, *, leading: bool = True, trailing: bool = False) -> PipeOperator:
606
+ """Rate-limit: emit at most one ``DATA`` per ``seconds`` window.
607
+
608
+ Args:
609
+ seconds: Window length in seconds.
610
+ leading: Emit the first value at the window start (default ``True``).
611
+ trailing: Emit the latest suppressed value when the window closes.
612
+
613
+ Returns:
614
+ A unary pipe operator ``(Node) -> Node``.
615
+
616
+ Example:
617
+ ```python
618
+ from graphrefly import state, pipe
619
+ from graphrefly.extra.tier2 import throttle
620
+ src = state(0)
621
+ out = pipe(src, throttle(0.1))
622
+ ```
623
+ """
624
+
625
+ def _op(src: Node[Any]) -> Node[Any]:
626
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
627
+ window: threading.Timer | None = None
628
+ latest: Any = None
629
+ had_trailing_candidate = False
630
+
631
+ def cancel_window() -> None:
632
+ nonlocal window
633
+ if window is not None:
634
+ window.cancel()
635
+ window = None
636
+
637
+ def close_window() -> None:
638
+ nonlocal window, had_trailing_candidate
639
+ window = None
640
+ if trailing and had_trailing_candidate:
641
+ actions.emit(latest)
642
+ had_trailing_candidate = False
643
+
644
+ def outer_sink(msgs: Messages) -> None:
645
+ nonlocal window, latest, had_trailing_candidate
646
+ for m in msgs:
647
+ t = m[0]
648
+ if t is MessageType.DATA:
649
+ v = _msg_val(m)
650
+ latest = v
651
+ if window is not None:
652
+ had_trailing_candidate = True
653
+ continue
654
+ if leading:
655
+ actions.emit(v)
656
+ else:
657
+ had_trailing_candidate = True
658
+ tt = threading.Timer(seconds, close_window)
659
+ tt.daemon = True
660
+ tt.start()
661
+ window = tt
662
+ elif t is MessageType.DIRTY:
663
+ actions.down([(MessageType.DIRTY,)])
664
+ elif t is MessageType.RESOLVED:
665
+ actions.down([(MessageType.RESOLVED,)])
666
+ elif t is MessageType.COMPLETE:
667
+ cancel_window()
668
+ actions.down([(MessageType.COMPLETE,)])
669
+ elif t is MessageType.ERROR:
670
+ cancel_window()
671
+ actions.down([m])
672
+ else:
673
+ actions.down([m])
674
+
675
+ outer_unsub = src.subscribe(outer_sink)
676
+
677
+ def cleanup() -> None:
678
+ cancel_window()
679
+ outer_unsub()
680
+
681
+ return cleanup
682
+
683
+ return node(
684
+ start,
685
+ describe_kind="throttle",
686
+ complete_when_deps_complete=False,
687
+ )
688
+
689
+ return _op
690
+
691
+
692
+ def sample(notifier: Node[Any]) -> PipeOperator:
693
+ """Emit the primary's latest value whenever ``notifier`` settles with ``DATA``.
694
+
695
+ Source messages are intercepted via ``on_message``; only notifier ``DATA``
696
+ (dep index 1) triggers ``src.get()`` emission. Matches TS ``sample`` architecture.
697
+
698
+ Args:
699
+ notifier: Node whose ``DATA`` triggers sampling of the primary's latest value.
700
+
701
+ Returns:
702
+ A unary pipe operator ``(Node) -> Node``.
703
+
704
+ Example:
705
+ ```python
706
+ from graphrefly import state, pipe
707
+ from graphrefly.extra.tier2 import sample
708
+ src = state(0)
709
+ tick = state(None)
710
+ out = pipe(src, sample(tick))
711
+ ```
712
+ """
713
+
714
+ def _op(src: Node[Any]) -> Node[Any]:
715
+ def compute(_deps: list[Any], _a: NodeActions) -> Any:
716
+ return None
717
+
718
+ def on_message(msg: Any, index: int, a: NodeActions) -> bool:
719
+ t = msg[0]
720
+ if t is MessageType.ERROR:
721
+ a.down([msg])
722
+ return True
723
+ if t is MessageType.COMPLETE:
724
+ a.down([msg])
725
+ return True
726
+ if index == 1 and t is MessageType.DATA:
727
+ a.emit(src.get())
728
+ return True
729
+ if index == 1 and t is MessageType.RESOLVED:
730
+ return True
731
+ return index == 0
732
+
733
+ return node(
734
+ [src, notifier],
735
+ compute,
736
+ on_message=on_message,
737
+ describe_kind="sample",
738
+ complete_when_deps_complete=False,
739
+ )
740
+
741
+ return _op
742
+
743
+
744
+ def audit(seconds: float) -> PipeOperator:
745
+ """Emit the latest upstream value after ``seconds`` of trailing silence (Rx ``auditTime``).
746
+
747
+ Each ``DATA`` stores the latest value and restarts the timer. When the timer fires,
748
+ the stored value is emitted. No leading-edge emission.
749
+
750
+ Args:
751
+ seconds: Trailing window duration in seconds.
752
+
753
+ Returns:
754
+ A unary pipe operator ``(Node) -> Node``.
755
+
756
+ Example:
757
+ ```python
758
+ from graphrefly import state, pipe
759
+ from graphrefly.extra.tier2 import audit
760
+ src = state(0)
761
+ out = pipe(src, audit(0.05))
762
+ ```
763
+ """
764
+
765
+ def _op(src: Node[Any]) -> Node[Any]:
766
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
767
+ timer: threading.Timer | None = None
768
+ latest: Any = None
769
+ has = False
770
+
771
+ def cancel_timer() -> None:
772
+ nonlocal timer
773
+ if timer is not None:
774
+ timer.cancel()
775
+ timer = None
776
+
777
+ def fire() -> None:
778
+ nonlocal timer, has
779
+ timer = None
780
+ if has:
781
+ has = False
782
+ actions.emit(latest)
783
+
784
+ def outer_sink(msgs: Messages) -> None:
785
+ nonlocal timer, latest, has
786
+ for m in msgs:
787
+ t = m[0]
788
+ if t is MessageType.DATA:
789
+ latest = _msg_val(m)
790
+ has = True
791
+ cancel_timer()
792
+ tt = threading.Timer(seconds, fire)
793
+ tt.daemon = True
794
+ tt.start()
795
+ timer = tt
796
+ elif t is MessageType.DIRTY:
797
+ actions.down([(MessageType.DIRTY,)])
798
+ elif t is MessageType.RESOLVED:
799
+ actions.down([(MessageType.RESOLVED,)])
800
+ elif t is MessageType.COMPLETE:
801
+ cancel_timer()
802
+ actions.down([(MessageType.COMPLETE,)])
803
+ elif t is MessageType.ERROR:
804
+ cancel_timer()
805
+ actions.down([m])
806
+ else:
807
+ actions.down([m])
808
+
809
+ outer_unsub = src.subscribe(outer_sink)
810
+
811
+ def cleanup() -> None:
812
+ cancel_timer()
813
+ outer_unsub()
814
+
815
+ return cleanup
816
+
817
+ return node(start, describe_kind="audit", complete_when_deps_complete=False)
818
+
819
+ return _op
820
+
821
+
822
+ def delay(seconds: float) -> PipeOperator:
823
+ """Delay each ``DATA`` message by ``seconds`` (one timer per pending value, FIFO order).
824
+
825
+ Args:
826
+ seconds: Delay in seconds applied to each ``DATA`` message.
827
+
828
+ Returns:
829
+ A unary pipe operator ``(Node) -> Node``.
830
+
831
+ Example:
832
+ ```python
833
+ from graphrefly import state, pipe
834
+ from graphrefly.extra.tier2 import delay
835
+ src = state(0)
836
+ out = pipe(src, delay(0.01))
837
+ ```
838
+ """
839
+
840
+ def _op(src: Node[Any]) -> Node[Any]:
841
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
842
+ timers: list[threading.Timer] = []
843
+
844
+ def outer_sink(msgs: Messages) -> None:
845
+ for m in msgs:
846
+ t = m[0]
847
+ if t is MessageType.DATA:
848
+ v = _msg_val(m)
849
+
850
+ def fire(val: Any = v) -> None:
851
+ with suppress(IndexError):
852
+ timers.pop(0)
853
+ actions.emit(val)
854
+
855
+ tt = threading.Timer(seconds, fire)
856
+ tt.daemon = True
857
+ timers.append(tt)
858
+ tt.start()
859
+ elif t is MessageType.DIRTY:
860
+ actions.down([(MessageType.DIRTY,)])
861
+ elif t is MessageType.RESOLVED:
862
+ actions.down([(MessageType.RESOLVED,)])
863
+ elif t is MessageType.COMPLETE:
864
+ actions.down([(MessageType.COMPLETE,)])
865
+ elif t is MessageType.ERROR:
866
+ for tt in timers:
867
+ tt.cancel()
868
+ timers.clear()
869
+ actions.down([m])
870
+ else:
871
+ actions.down([m])
872
+
873
+ outer_unsub = src.subscribe(outer_sink)
874
+
875
+ def cleanup() -> None:
876
+ for tt in timers:
877
+ tt.cancel()
878
+ timers.clear()
879
+ outer_unsub()
880
+
881
+ return cleanup
882
+
883
+ return node(start, describe_kind="delay", complete_when_deps_complete=False)
884
+
885
+ return _op
886
+
887
+
888
+ def timeout(seconds: float, *, error: BaseException | None = None) -> PipeOperator:
889
+ """Emit ``ERROR`` if no ``DATA`` arrives within ``seconds`` after subscribe or last ``DATA``.
890
+
891
+ Timer resets on each ``DATA``; unsubscribe cancels the watchdog.
892
+
893
+ Args:
894
+ seconds: Timeout window in seconds.
895
+ error: Exception to send as the ``ERROR`` payload (default: :exc:`TimeoutError`).
896
+
897
+ Returns:
898
+ A unary pipe operator ``(Node) -> Node``.
899
+
900
+ Example:
901
+ ```python
902
+ from graphrefly.extra.tier2 import timeout
903
+ from graphrefly.extra import never
904
+ from graphrefly.extra.sources import first_value_from
905
+ n = timeout(0.001)(never())
906
+ try:
907
+ first_value_from(n)
908
+ except Exception:
909
+ pass
910
+ ```
911
+ """
912
+
913
+ err = error if error is not None else TimeoutError("timeout")
914
+
915
+ def _op(src: Node[Any]) -> Node[Any]:
916
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
917
+ timer: threading.Timer | None = None
918
+
919
+ def cancel_timer() -> None:
920
+ nonlocal timer
921
+ if timer is not None:
922
+ timer.cancel()
923
+ timer = None
924
+
925
+ def schedule() -> None:
926
+ nonlocal timer
927
+ cancel_timer()
928
+
929
+ def fire() -> None:
930
+ nonlocal timer
931
+ timer = None
932
+ actions.down([(MessageType.ERROR, err)])
933
+
934
+ tt = threading.Timer(seconds, fire)
935
+ tt.daemon = True
936
+ tt.start()
937
+ timer = tt
938
+
939
+ schedule()
940
+
941
+ def outer_sink(msgs: Messages) -> None:
942
+ for m in msgs:
943
+ t = m[0]
944
+ if t is MessageType.DATA:
945
+ cancel_timer()
946
+ actions.emit(_msg_val(m))
947
+ schedule()
948
+ elif t is MessageType.DIRTY:
949
+ actions.down([(MessageType.DIRTY,)])
950
+ elif t is MessageType.RESOLVED:
951
+ actions.down([(MessageType.RESOLVED,)])
952
+ elif t is MessageType.COMPLETE:
953
+ cancel_timer()
954
+ actions.down([(MessageType.COMPLETE,)])
955
+ elif t is MessageType.ERROR:
956
+ cancel_timer()
957
+ actions.down([m])
958
+ else:
959
+ actions.down([m])
960
+
961
+ outer_unsub = src.subscribe(outer_sink)
962
+
963
+ def cleanup() -> None:
964
+ cancel_timer()
965
+ outer_unsub()
966
+
967
+ return cleanup
968
+
969
+ return node(start, describe_kind="timeout", complete_when_deps_complete=False)
970
+
971
+ return _op
972
+
973
+
974
+ # --- buffers ------------------------------------------------------------------
975
+
976
+
977
+ def buffer(notifier: Node[Any]) -> PipeOperator:
978
+ """Collect ``DATA`` values in a buffer; emit the list when ``notifier`` emits ``DATA``.
979
+
980
+ Args:
981
+ notifier: Node whose ``DATA`` flushes the accumulated buffer.
982
+
983
+ Returns:
984
+ A unary pipe operator ``(Node) -> Node[list]``.
985
+
986
+ Example:
987
+ ```python
988
+ from graphrefly import state, pipe
989
+ from graphrefly.extra.tier2 import buffer
990
+ src = state(0)
991
+ flush = state(None)
992
+ out = pipe(src, buffer(flush))
993
+ ```
994
+ """
995
+
996
+ def _op(src: Node[Any]) -> Node[Any]:
997
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
998
+ buf: list[Any] = []
999
+
1000
+ def src_sink(msgs: Messages) -> None:
1001
+ for m in msgs:
1002
+ t = m[0]
1003
+ if t is MessageType.DATA:
1004
+ buf.append(_msg_val(m))
1005
+ elif t is MessageType.DIRTY:
1006
+ actions.down([(MessageType.DIRTY,)])
1007
+ elif t is MessageType.RESOLVED:
1008
+ actions.down([(MessageType.RESOLVED,)])
1009
+ elif t is MessageType.COMPLETE:
1010
+ actions.down([(MessageType.COMPLETE,)])
1011
+ elif t is MessageType.ERROR:
1012
+ buf.clear()
1013
+ actions.down([m])
1014
+ else:
1015
+ actions.down([m])
1016
+
1017
+ def n_sink(msgs: Messages) -> None:
1018
+ for m in msgs:
1019
+ t = m[0]
1020
+ if t is MessageType.DATA:
1021
+ if buf:
1022
+ actions.emit(list(buf))
1023
+ buf.clear()
1024
+ elif t is MessageType.DIRTY:
1025
+ actions.down([(MessageType.DIRTY,)])
1026
+ elif t is MessageType.RESOLVED:
1027
+ actions.down([(MessageType.RESOLVED,)])
1028
+ elif t is MessageType.COMPLETE:
1029
+ actions.down([(MessageType.COMPLETE,)])
1030
+ elif t is MessageType.ERROR:
1031
+ buf.clear()
1032
+ actions.down([m])
1033
+ else:
1034
+ actions.down([m])
1035
+
1036
+ u0 = src.subscribe(src_sink)
1037
+ u1 = notifier.subscribe(n_sink)
1038
+
1039
+ def cleanup() -> None:
1040
+ buf.clear()
1041
+ u0()
1042
+ u1()
1043
+
1044
+ return cleanup
1045
+
1046
+ return node(start, describe_kind="buffer", complete_when_deps_complete=False)
1047
+
1048
+ return _op
1049
+
1050
+
1051
+ def buffer_count(n: int) -> PipeOperator:
1052
+ """Emit a list of every ``n`` consecutive ``DATA`` values as a single emission.
1053
+
1054
+ Args:
1055
+ n: Number of ``DATA`` values to collect per emitted list.
1056
+
1057
+ Returns:
1058
+ A unary pipe operator ``(Node) -> Node[list]``.
1059
+
1060
+ Example:
1061
+ ```python
1062
+ from graphrefly.extra import of
1063
+ from graphrefly.extra.tier2 import buffer_count
1064
+ from graphrefly.extra.sources import first_value_from
1065
+ out = buffer_count(3)(of(1, 2, 3, 4))
1066
+ assert first_value_from(out) == [1, 2, 3]
1067
+ ```
1068
+ """
1069
+
1070
+ if n <= 0:
1071
+ msg = "buffer_count expects n > 0"
1072
+ raise ValueError(msg)
1073
+
1074
+ def _op(src: Node[Any]) -> Node[Any]:
1075
+ acc: list[Any] = []
1076
+
1077
+ def compute(_deps: list[Any], actions: NodeActions) -> Any:
1078
+ return None
1079
+
1080
+ def on_message(msg: Any, _index: int, actions: NodeActions) -> bool:
1081
+ t = msg[0]
1082
+ if t is MessageType.DATA:
1083
+ acc.append(_msg_val(msg))
1084
+ if len(acc) >= n:
1085
+ actions.emit(list(acc))
1086
+ acc.clear()
1087
+ return True
1088
+ if t is MessageType.DIRTY:
1089
+ actions.down([(MessageType.DIRTY,)])
1090
+ return True
1091
+ if t is MessageType.RESOLVED:
1092
+ actions.down([(MessageType.RESOLVED,)])
1093
+ return True
1094
+ if t is MessageType.COMPLETE:
1095
+ if acc:
1096
+ actions.emit(list(acc))
1097
+ acc.clear()
1098
+ actions.down([(MessageType.COMPLETE,)])
1099
+ return True
1100
+ if t is MessageType.ERROR:
1101
+ acc.clear()
1102
+ actions.down([msg])
1103
+ return True
1104
+ actions.down([msg])
1105
+ return True
1106
+
1107
+ return node(
1108
+ [src],
1109
+ compute,
1110
+ on_message=on_message,
1111
+ describe_kind="buffer_count",
1112
+ complete_when_deps_complete=False,
1113
+ )
1114
+
1115
+ return _op
1116
+
1117
+
1118
+ def buffer_time(seconds: float) -> PipeOperator:
1119
+ """Emit a list of ``DATA`` values collected over each timed window of ``seconds``.
1120
+
1121
+ Args:
1122
+ seconds: Window duration in seconds.
1123
+
1124
+ Returns:
1125
+ A unary pipe operator ``(Node) -> Node[list]``.
1126
+
1127
+ Example:
1128
+ ```python
1129
+ from graphrefly import state, pipe
1130
+ from graphrefly.extra.tier2 import buffer_time
1131
+ src = state(0)
1132
+ out = pipe(src, buffer_time(0.1))
1133
+ ```
1134
+ """
1135
+
1136
+ def _op(src: Node[Any]) -> Node[Any]:
1137
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
1138
+ buf: list[Any] = []
1139
+ timer: threading.Timer | None = None
1140
+
1141
+ def cancel() -> None:
1142
+ nonlocal timer
1143
+ if timer is not None:
1144
+ timer.cancel()
1145
+ timer = None
1146
+
1147
+ def flush() -> None:
1148
+ nonlocal timer
1149
+ timer = None
1150
+ if buf:
1151
+ actions.emit(list(buf))
1152
+ buf.clear()
1153
+
1154
+ def arm() -> None:
1155
+ nonlocal timer
1156
+ cancel()
1157
+ tt = threading.Timer(seconds, flush)
1158
+ tt.daemon = True
1159
+ tt.start()
1160
+ timer = tt
1161
+
1162
+ arm()
1163
+
1164
+ def outer_sink(msgs: Messages) -> None:
1165
+ for m in msgs:
1166
+ t = m[0]
1167
+ if t is MessageType.DATA:
1168
+ buf.append(_msg_val(m))
1169
+ elif t is MessageType.DIRTY:
1170
+ actions.down([(MessageType.DIRTY,)])
1171
+ elif t is MessageType.RESOLVED:
1172
+ actions.down([(MessageType.RESOLVED,)])
1173
+ elif t is MessageType.COMPLETE:
1174
+ cancel()
1175
+ flush()
1176
+ actions.down([(MessageType.COMPLETE,)])
1177
+ elif t is MessageType.ERROR:
1178
+ cancel()
1179
+ buf.clear()
1180
+ actions.down([m])
1181
+ else:
1182
+ actions.down([m])
1183
+
1184
+ outer_unsub = src.subscribe(outer_sink)
1185
+
1186
+ def cleanup() -> None:
1187
+ cancel()
1188
+ buf.clear()
1189
+ outer_unsub()
1190
+
1191
+ return cleanup
1192
+
1193
+ return node(start, describe_kind="buffer_time", complete_when_deps_complete=False)
1194
+
1195
+ return _op
1196
+
1197
+
1198
+ # --- sources / misc -----------------------------------------------------------
1199
+
1200
+
1201
+ def interval(seconds: float) -> Node[Any]:
1202
+ """Create a producer that emits ``0, 1, 2, …`` at a fixed timer interval.
1203
+
1204
+ Args:
1205
+ seconds: Interval between emissions in seconds.
1206
+
1207
+ Returns:
1208
+ A :class:`~graphrefly.core.node.Node` that emits on a recurring timer thread.
1209
+
1210
+ Example:
1211
+ ```python
1212
+ from graphrefly.extra.tier2 import interval
1213
+ from graphrefly.extra.sources import first_value_from
1214
+ n = interval(0.001)
1215
+ assert first_value_from(n) == 0
1216
+ ```
1217
+ """
1218
+
1219
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
1220
+ n = 0
1221
+ timer: threading.Timer | None = None
1222
+ stopped = False
1223
+
1224
+ def cancel() -> None:
1225
+ nonlocal timer
1226
+ if timer is not None:
1227
+ timer.cancel()
1228
+ timer = None
1229
+
1230
+ def tick() -> None:
1231
+ nonlocal n, timer
1232
+ if stopped:
1233
+ return
1234
+ actions.emit(n)
1235
+ n += 1
1236
+ tt = threading.Timer(seconds, tick)
1237
+ tt.daemon = True
1238
+ tt.start()
1239
+ timer = tt
1240
+
1241
+ tt0 = threading.Timer(seconds, tick)
1242
+ tt0.daemon = True
1243
+ tt0.start()
1244
+ timer = tt0
1245
+
1246
+ def cleanup() -> None:
1247
+ nonlocal stopped
1248
+ stopped = True
1249
+ cancel()
1250
+
1251
+ return cleanup
1252
+
1253
+ # No ``initial``: first tick emits ``0``; matching ``initial=0`` would coalesce to RESOLVED.
1254
+ return node(start, describe_kind="interval", complete_when_deps_complete=False)
1255
+
1256
+
1257
+ def repeat(times: int) -> PipeOperator:
1258
+ """Play the source to ``COMPLETE``, then re-subscribe, repeating ``times`` passes total.
1259
+
1260
+ Each pass ends when the source emits ``COMPLETE``; the operator then
1261
+ subscribes again until all passes have finished.
1262
+
1263
+ Args:
1264
+ times: Total number of source passes to play through.
1265
+
1266
+ Returns:
1267
+ A unary pipe operator ``(Node) -> Node``.
1268
+
1269
+ Example:
1270
+ ```python
1271
+ from graphrefly.extra import of
1272
+ from graphrefly.extra.tier2 import repeat
1273
+ from graphrefly.extra.sources import to_list
1274
+ assert to_list(repeat(3)(of(1))) == [1, 1, 1]
1275
+ ```
1276
+ """
1277
+
1278
+ if times <= 0:
1279
+ msg = "repeat expects times > 0"
1280
+ raise ValueError(msg)
1281
+
1282
+ def _op(src: Node[Any]) -> Node[Any]:
1283
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
1284
+ remaining = times
1285
+ outer_unsub: Callable[[], None] | None = None
1286
+
1287
+ def detach() -> None:
1288
+ nonlocal outer_unsub
1289
+ if outer_unsub is not None:
1290
+ outer_unsub()
1291
+ outer_unsub = None
1292
+
1293
+ def attach() -> None:
1294
+ nonlocal outer_unsub
1295
+ detach()
1296
+
1297
+ def outer_sink(msgs: Messages) -> None:
1298
+ nonlocal remaining
1299
+ for m in msgs:
1300
+ t = m[0]
1301
+ if t is MessageType.DATA:
1302
+ actions.emit(_msg_val(m))
1303
+ elif t is MessageType.DIRTY:
1304
+ actions.down([(MessageType.DIRTY,)])
1305
+ elif t is MessageType.RESOLVED:
1306
+ actions.down([(MessageType.RESOLVED,)])
1307
+ elif t is MessageType.COMPLETE:
1308
+ remaining -= 1
1309
+ detach()
1310
+ if remaining <= 0:
1311
+ actions.down([(MessageType.COMPLETE,)])
1312
+ else:
1313
+ attach()
1314
+ elif t is MessageType.ERROR:
1315
+ detach()
1316
+ actions.down([m])
1317
+ else:
1318
+ actions.down([m])
1319
+
1320
+ outer_unsub = src.subscribe(outer_sink)
1321
+
1322
+ attach()
1323
+
1324
+ def cleanup() -> None:
1325
+ detach()
1326
+
1327
+ return cleanup
1328
+
1329
+ return node(start, describe_kind="repeat", complete_when_deps_complete=False)
1330
+
1331
+ return _op
1332
+
1333
+
1334
+ def gate(control: Node[Any]) -> PipeOperator:
1335
+ """Forward ``DATA`` only when ``control`` is truthy; otherwise emit ``RESOLVED``.
1336
+
1337
+ This is a value-level gate using a boolean control signal. See :func:`pausable`
1338
+ for protocol-level ``PAUSE``/``RESUME`` buffering.
1339
+
1340
+ Args:
1341
+ control: Boolean-valued node; ``True`` lets values through, ``False`` suppresses them.
1342
+
1343
+ Returns:
1344
+ A unary pipe operator ``(Node) -> Node``.
1345
+
1346
+ Example:
1347
+ ```python
1348
+ from graphrefly import state, pipe
1349
+ from graphrefly.extra.tier2 import gate
1350
+ src = state(1)
1351
+ ctrl = state(True)
1352
+ out = pipe(src, gate(ctrl))
1353
+ ```
1354
+ """
1355
+
1356
+ def _op(src: Node[Any]) -> Node[Any]:
1357
+ def compute(deps: list[Any], actions: NodeActions) -> Any:
1358
+ if not deps[1]:
1359
+ actions.down([(MessageType.RESOLVED,)])
1360
+ return None
1361
+ return deps[0]
1362
+
1363
+ return node([src, control], compute, describe_kind="gate")
1364
+
1365
+ return _op
1366
+
1367
+
1368
+ def pausable() -> PipeOperator:
1369
+ """Buffer ``DIRTY``/``DATA``/``RESOLVED`` while ``PAUSE`` is in effect; flush on ``RESUME``.
1370
+
1371
+ Protocol-level pause/resume using ``PAUSE``/``RESUME`` message types. Matches
1372
+ TypeScript ``pausable`` semantics.
1373
+
1374
+ Returns:
1375
+ A unary pipe operator ``(Node) -> Node``.
1376
+
1377
+ Example:
1378
+ ```python
1379
+ from graphrefly import state, pipe
1380
+ from graphrefly.extra.tier2 import pausable
1381
+ from graphrefly.core.protocol import MessageType
1382
+ src = state(0)
1383
+ out = pipe(src, pausable())
1384
+ out.down([(MessageType.PAUSE,)])
1385
+ out.down([(MessageType.RESUME,)])
1386
+ ```
1387
+ """
1388
+
1389
+ def _op(src: Node[Any]) -> Node[Any]:
1390
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
1391
+ paused = False
1392
+ backlog: list[Any] = []
1393
+
1394
+ def outer_sink(msgs: Messages) -> None:
1395
+ nonlocal paused
1396
+ for m in msgs:
1397
+ t = m[0]
1398
+ if t is MessageType.PAUSE:
1399
+ paused = True
1400
+ actions.down([m])
1401
+ elif t is MessageType.RESUME:
1402
+ paused = False
1403
+ actions.down([m])
1404
+ for bm in backlog:
1405
+ actions.down([bm])
1406
+ backlog.clear()
1407
+ elif paused and t in (
1408
+ MessageType.DIRTY,
1409
+ MessageType.DATA,
1410
+ MessageType.RESOLVED,
1411
+ ):
1412
+ backlog.append(m)
1413
+ else:
1414
+ actions.down([m])
1415
+
1416
+ outer_unsub = src.subscribe(outer_sink)
1417
+
1418
+ def cleanup() -> None:
1419
+ backlog.clear()
1420
+ outer_unsub()
1421
+
1422
+ return cleanup
1423
+
1424
+ return node(start, describe_kind="pausable", complete_when_deps_complete=False)
1425
+
1426
+ return _op
1427
+
1428
+
1429
+ def rescue(recover: Callable[[BaseException], Any]) -> PipeOperator:
1430
+ """Turn upstream ``ERROR`` into a ``DATA`` value produced by ``recover(exc)``.
1431
+
1432
+ Args:
1433
+ recover: Callable ``(exception) -> value`` whose return is emitted as ``DATA``.
1434
+
1435
+ Returns:
1436
+ A unary pipe operator ``(Node) -> Node``.
1437
+
1438
+ Example:
1439
+ ```python
1440
+ from graphrefly.extra import throw_error
1441
+ from graphrefly.extra.tier2 import rescue
1442
+ from graphrefly.extra.sources import first_value_from
1443
+ n = rescue(lambda e: -1)(throw_error(ValueError("oops")))
1444
+ assert first_value_from(n) == -1
1445
+ ```
1446
+ """
1447
+
1448
+ def _op(src: Node[Any]) -> Node[Any]:
1449
+ def compute(deps: list[Any], _actions: NodeActions) -> Any:
1450
+ return deps[0]
1451
+
1452
+ def on_message(msg: Any, _index: int, actions: NodeActions) -> bool:
1453
+ if msg[0] is MessageType.ERROR:
1454
+ try:
1455
+ actions.emit(recover(_msg_val(msg)))
1456
+ except BaseException as err: # noqa: BLE001 — re-raise as ERROR
1457
+ actions.down([(MessageType.ERROR, err)])
1458
+ return True
1459
+ return False
1460
+
1461
+ return node(
1462
+ [src],
1463
+ compute,
1464
+ on_message=on_message,
1465
+ describe_kind="rescue",
1466
+ complete_when_deps_complete=False,
1467
+ )
1468
+
1469
+ return _op
1470
+
1471
+
1472
+ # --- window operators (true sub-node windows) --------------------------------
1473
+
1474
+
1475
+ def window(notifier: Node[Any]) -> PipeOperator:
1476
+ """Split source ``DATA`` into sub-node windows; open a new window on each notifier ``DATA``.
1477
+
1478
+ Each emitted value is a :class:`~graphrefly.core.node.Node` receiving the
1479
+ ``DATA`` values belonging to that window.
1480
+
1481
+ Args:
1482
+ notifier: Node whose ``DATA`` opens a new window.
1483
+
1484
+ Returns:
1485
+ A unary pipe operator ``(Node) -> Node[Node]``.
1486
+
1487
+ Example:
1488
+ ```python
1489
+ from graphrefly import state, pipe
1490
+ from graphrefly.extra.tier2 import window
1491
+ src = state(0)
1492
+ tick = state(None)
1493
+ out = pipe(src, window(tick))
1494
+ ```
1495
+ """
1496
+
1497
+ def _op(src: Node[Any]) -> Node[Any]:
1498
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
1499
+ win_actions: NodeActions | None = None
1500
+ win_unsub: Callable[[], None] | None = None
1501
+
1502
+ def close_win() -> None:
1503
+ nonlocal win_actions, win_unsub
1504
+ if win_actions is not None:
1505
+ win_actions.down([(MessageType.COMPLETE,)])
1506
+ win_actions = None
1507
+ if win_unsub is not None:
1508
+ win_unsub()
1509
+ win_unsub = None
1510
+
1511
+ def open_win() -> None:
1512
+ nonlocal win_actions, win_unsub
1513
+ holder: list[NodeActions | None] = [None]
1514
+
1515
+ def win_start(_d: list[Any], wa: NodeActions) -> Callable[[], None]:
1516
+ holder[0] = wa
1517
+ return lambda: None
1518
+
1519
+ w = node(win_start, describe_kind="window_inner", complete_when_deps_complete=False)
1520
+ win_unsub = w.subscribe(lambda _msgs: None)
1521
+ win_actions = holder[0]
1522
+ actions.emit(w)
1523
+
1524
+ def src_sink(msgs: Messages) -> None:
1525
+ nonlocal win_actions
1526
+ for m in msgs:
1527
+ t = m[0]
1528
+ if t is MessageType.DATA:
1529
+ if win_actions is None:
1530
+ open_win()
1531
+ if win_actions is not None:
1532
+ win_actions.down([(MessageType.DATA, _msg_val(m))])
1533
+ elif t is MessageType.COMPLETE:
1534
+ close_win()
1535
+ actions.down([(MessageType.COMPLETE,)])
1536
+ elif t is MessageType.ERROR:
1537
+ if win_actions is not None:
1538
+ win_actions.down([m])
1539
+ win_actions = None
1540
+ actions.down([m])
1541
+ elif t is MessageType.DIRTY:
1542
+ actions.down([(MessageType.DIRTY,)])
1543
+ elif t is MessageType.RESOLVED:
1544
+ actions.down([(MessageType.RESOLVED,)])
1545
+ else:
1546
+ actions.down([m])
1547
+
1548
+ def n_sink(msgs: Messages) -> None:
1549
+ for m in msgs:
1550
+ if m[0] is MessageType.DATA:
1551
+ close_win()
1552
+ open_win()
1553
+
1554
+ u0 = src.subscribe(src_sink)
1555
+ u1 = notifier.subscribe(n_sink)
1556
+
1557
+ def cleanup() -> None:
1558
+ close_win()
1559
+ u0()
1560
+ u1()
1561
+
1562
+ return cleanup
1563
+
1564
+ return node(start, describe_kind="window", complete_when_deps_complete=False)
1565
+
1566
+ return _op
1567
+
1568
+
1569
+ def window_count(n: int) -> PipeOperator:
1570
+ """Split source ``DATA`` into sub-node windows of ``n`` items each.
1571
+
1572
+ Args:
1573
+ n: Number of ``DATA`` values per sub-node window.
1574
+
1575
+ Returns:
1576
+ A unary pipe operator ``(Node) -> Node[Node]``.
1577
+
1578
+ Example:
1579
+ ```python
1580
+ from graphrefly import state, pipe
1581
+ from graphrefly.extra.tier2 import window_count
1582
+ src = state(0)
1583
+ out = pipe(src, window_count(3))
1584
+ ```
1585
+ """
1586
+
1587
+ if n <= 0:
1588
+ msg = "window_count expects n > 0"
1589
+ raise ValueError(msg)
1590
+
1591
+ def _op(src: Node[Any]) -> Node[Any]:
1592
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
1593
+ win_actions: NodeActions | None = None
1594
+ win_unsub: Callable[[], None] | None = None
1595
+ count = 0
1596
+
1597
+ def close_win() -> None:
1598
+ nonlocal win_actions, win_unsub
1599
+ if win_actions is not None:
1600
+ win_actions.down([(MessageType.COMPLETE,)])
1601
+ win_actions = None
1602
+ if win_unsub is not None:
1603
+ win_unsub()
1604
+ win_unsub = None
1605
+
1606
+ def open_win() -> None:
1607
+ nonlocal win_actions, win_unsub, count
1608
+ holder: list[NodeActions | None] = [None]
1609
+
1610
+ def win_start(_d: list[Any], wa: NodeActions) -> Callable[[], None]:
1611
+ holder[0] = wa
1612
+ return lambda: None
1613
+
1614
+ w = node(win_start, describe_kind="window_inner", complete_when_deps_complete=False)
1615
+ win_unsub = w.subscribe(lambda _msgs: None)
1616
+ win_actions = holder[0]
1617
+ count = 0
1618
+ actions.emit(w)
1619
+
1620
+ def outer_sink(msgs: Messages) -> None:
1621
+ nonlocal win_actions, count
1622
+ for m in msgs:
1623
+ t = m[0]
1624
+ if t is MessageType.DATA:
1625
+ if win_actions is None:
1626
+ open_win()
1627
+ if win_actions is not None:
1628
+ win_actions.down([(MessageType.DATA, _msg_val(m))])
1629
+ count += 1
1630
+ if count >= n:
1631
+ close_win()
1632
+ elif t is MessageType.COMPLETE:
1633
+ close_win()
1634
+ actions.down([(MessageType.COMPLETE,)])
1635
+ elif t is MessageType.ERROR:
1636
+ if win_actions is not None:
1637
+ win_actions.down([m])
1638
+ win_actions = None
1639
+ actions.down([m])
1640
+ elif t is MessageType.DIRTY:
1641
+ actions.down([(MessageType.DIRTY,)])
1642
+ elif t is MessageType.RESOLVED:
1643
+ actions.down([(MessageType.RESOLVED,)])
1644
+ else:
1645
+ actions.down([m])
1646
+
1647
+ outer_unsub = src.subscribe(outer_sink)
1648
+
1649
+ def cleanup() -> None:
1650
+ close_win()
1651
+ outer_unsub()
1652
+
1653
+ return cleanup
1654
+
1655
+ return node(start, describe_kind="window_count", complete_when_deps_complete=False)
1656
+
1657
+ return _op
1658
+
1659
+
1660
+ def window_time(seconds: float) -> PipeOperator:
1661
+ """Split source ``DATA`` into sub-node windows, each lasting ``seconds``.
1662
+
1663
+ Each emitted value is a :class:`~graphrefly.core.node.Node` receiving values
1664
+ collected during that time window.
1665
+
1666
+ Args:
1667
+ seconds: Duration of each window in seconds.
1668
+
1669
+ Returns:
1670
+ A unary pipe operator ``(Node) -> Node[Node]``.
1671
+
1672
+ Example:
1673
+ ```python
1674
+ from graphrefly import state, pipe
1675
+ from graphrefly.extra.tier2 import window_time
1676
+ src = state(0)
1677
+ out = pipe(src, window_time(0.1))
1678
+ ```
1679
+ """
1680
+
1681
+ def _op(src: Node[Any]) -> Node[Any]:
1682
+ def start(_deps: list[Any], actions: NodeActions) -> Callable[[], None]:
1683
+ win_actions: NodeActions | None = None
1684
+ win_unsub: Callable[[], None] | None = None
1685
+ timer: threading.Timer | None = None
1686
+
1687
+ def close_win() -> None:
1688
+ nonlocal win_actions, win_unsub
1689
+ if win_actions is not None:
1690
+ win_actions.down([(MessageType.COMPLETE,)])
1691
+ win_actions = None
1692
+ if win_unsub is not None:
1693
+ win_unsub()
1694
+ win_unsub = None
1695
+
1696
+ def open_win() -> None:
1697
+ nonlocal win_actions, win_unsub
1698
+ holder: list[NodeActions | None] = [None]
1699
+
1700
+ def win_start(_d: list[Any], wa: NodeActions) -> Callable[[], None]:
1701
+ holder[0] = wa
1702
+ return lambda: None
1703
+
1704
+ w = node(win_start, describe_kind="window_inner", complete_when_deps_complete=False)
1705
+ win_unsub = w.subscribe(lambda _msgs: None)
1706
+ win_actions = holder[0]
1707
+ actions.emit(w)
1708
+
1709
+ def rotate() -> None:
1710
+ close_win()
1711
+ open_win()
1712
+ arm()
1713
+
1714
+ def arm() -> None:
1715
+ nonlocal timer
1716
+ if timer is not None:
1717
+ timer.cancel()
1718
+ tt = threading.Timer(seconds, rotate)
1719
+ tt.daemon = True
1720
+ tt.start()
1721
+ timer = tt
1722
+
1723
+ open_win()
1724
+ arm()
1725
+
1726
+ def outer_sink(msgs: Messages) -> None:
1727
+ nonlocal timer, win_actions
1728
+ for m in msgs:
1729
+ t = m[0]
1730
+ if t is MessageType.DATA:
1731
+ if win_actions is not None:
1732
+ win_actions.down([(MessageType.DATA, _msg_val(m))])
1733
+ elif t is MessageType.COMPLETE:
1734
+ if timer is not None:
1735
+ timer.cancel()
1736
+ timer = None
1737
+ close_win()
1738
+ actions.down([(MessageType.COMPLETE,)])
1739
+ elif t is MessageType.ERROR:
1740
+ if timer is not None:
1741
+ timer.cancel()
1742
+ timer = None
1743
+ if win_actions is not None:
1744
+ win_actions.down([m])
1745
+ win_actions = None
1746
+ actions.down([m])
1747
+ elif t is MessageType.DIRTY:
1748
+ actions.down([(MessageType.DIRTY,)])
1749
+ elif t is MessageType.RESOLVED:
1750
+ actions.down([(MessageType.RESOLVED,)])
1751
+ else:
1752
+ actions.down([m])
1753
+
1754
+ outer_unsub = src.subscribe(outer_sink)
1755
+
1756
+ def cleanup() -> None:
1757
+ nonlocal timer
1758
+ if timer is not None:
1759
+ timer.cancel()
1760
+ timer = None
1761
+ close_win()
1762
+ outer_unsub()
1763
+
1764
+ return cleanup
1765
+
1766
+ return node(start, describe_kind="window_time", complete_when_deps_complete=False)
1767
+
1768
+ return _op
1769
+
1770
+
1771
+ debounce_time = debounce
1772
+ throttle_time = throttle
1773
+ catch_error = rescue
1774
+ merge_map = flat_map
1775
+
1776
+ __all__ = [
1777
+ "audit",
1778
+ "buffer",
1779
+ "buffer_count",
1780
+ "buffer_time",
1781
+ "catch_error",
1782
+ "concat_map",
1783
+ "debounce",
1784
+ "debounce_time",
1785
+ "delay",
1786
+ "exhaust_map",
1787
+ "flat_map",
1788
+ "gate",
1789
+ "interval",
1790
+ "merge_map",
1791
+ "pausable",
1792
+ "repeat",
1793
+ "rescue",
1794
+ "sample",
1795
+ "switch_map",
1796
+ "throttle",
1797
+ "throttle_time",
1798
+ "timeout",
1799
+ "window",
1800
+ "window_count",
1801
+ "window_time",
1802
+ ]