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,1067 @@
1
+ """Tier 1 sync operators built from :func:`~graphrefly.core.node.node` (roadmap §2.1).
2
+
3
+ Most callables return a :class:`~graphrefly.core.sugar.PipeOperator` for use with
4
+ :func:`~graphrefly.core.sugar.pipe`. ``combine``, ``merge``, ``zip``, and ``race`` return a
5
+ :class:`~graphrefly.core.node.Node` directly.
6
+
7
+ Use Google-style docstrings here (``docs/docs-guidance.md``); they feed the site via
8
+ ``website/scripts/gen_api_docs.py``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import operator as op
14
+ from collections import deque
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import Callable
19
+
20
+ from graphrefly.core.node import Node, NodeActions, node
21
+ from graphrefly.core.protocol import MessageType
22
+ from graphrefly.core.sugar import PipeOperator, pipe, producer
23
+
24
+ # --- unary transforms ---------------------------------------------------------
25
+
26
+
27
+ def map(
28
+ fn: Callable[[Any], Any],
29
+ *,
30
+ equals: Callable[[Any, Any], bool] | None = None,
31
+ ) -> PipeOperator:
32
+ """Map each upstream settled value through ``fn``.
33
+
34
+ Args:
35
+ fn: Transform applied to each dependency value.
36
+ equals: Optional equality for ``RESOLVED`` detection on the inner node.
37
+
38
+ Returns:
39
+ A :class:`~graphrefly.core.sugar.PipeOperator` wrapping the upstream node.
40
+
41
+ Examples:
42
+ >>> from graphrefly import pipe, state
43
+ >>> from graphrefly.extra import map as grf_map
44
+ >>> n = pipe(state(1), grf_map(lambda x: x * 2))
45
+ """
46
+
47
+ def _op(src: Node[Any]) -> Node[Any]:
48
+ opts: dict[str, Any] = {"describe_kind": "map"}
49
+ if equals is not None:
50
+ opts["equals"] = equals
51
+ return node([src], lambda deps, _: fn(deps[0]), **opts)
52
+
53
+ return _op
54
+
55
+
56
+ def filter(
57
+ predicate: Callable[[Any], bool],
58
+ ) -> PipeOperator:
59
+ """Forward values where ``predicate`` is true; otherwise emit ``RESOLVED`` (no ``DATA``).
60
+
61
+ Pure predicate gate — no implicit dedup (use ``distinct_until_changed`` for that).
62
+
63
+ Args:
64
+ predicate: Inclusion test for each value.
65
+
66
+ Returns:
67
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
68
+
69
+ Examples:
70
+ >>> from graphrefly import pipe, state
71
+ >>> from graphrefly.extra import filter as grf_filter
72
+ >>> n = pipe(state(1), grf_filter(lambda x: x > 0))
73
+ """
74
+
75
+ def _op(src: Node[Any]) -> Node[Any]:
76
+ def compute(deps: list[Any], actions: NodeActions) -> Any:
77
+ v = deps[0]
78
+ if not predicate(v):
79
+ actions.down([(MessageType.RESOLVED,)])
80
+ return None
81
+ return v
82
+
83
+ return node([src], compute, describe_kind="filter")
84
+
85
+ return _op
86
+
87
+
88
+ def scan(
89
+ reducer: Callable[[Any, Any], Any],
90
+ seed: Any,
91
+ *,
92
+ equals: Callable[[Any, Any], bool] | None = None,
93
+ ) -> PipeOperator:
94
+ """Fold upstream values with ``reducer(acc, value) -> acc``; emit accumulator after each step.
95
+
96
+ Unlike RxJS, seed is always required — there is no seedless mode where the first value
97
+ silently becomes the accumulator.
98
+
99
+ Args:
100
+ reducer: Accumulator update.
101
+ seed: Initial accumulator (also used for ``initial`` on the inner node).
102
+ equals: Optional equality for consecutive emissions (default ``operator.eq``).
103
+
104
+ Returns:
105
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
106
+
107
+ Examples:
108
+ >>> from graphrefly import pipe, state
109
+ >>> from graphrefly.extra import scan as grf_scan
110
+ >>> n = pipe(state(1), grf_scan(lambda a, x: a + x, 0))
111
+ """
112
+
113
+ def _op(src: Node[Any]) -> Node[Any]:
114
+ acc = [seed]
115
+
116
+ def compute(deps: list[Any], _actions: NodeActions) -> Any:
117
+ v = deps[0]
118
+ acc[0] = reducer(acc[0], v)
119
+ return acc[0]
120
+
121
+ eq = equals if equals is not None else op.eq
122
+ return node(
123
+ [src],
124
+ compute,
125
+ describe_kind="scan",
126
+ initial=seed,
127
+ equals=eq,
128
+ reset_on_teardown=True,
129
+ )
130
+
131
+ return _op
132
+
133
+
134
+ def reduce( # noqa: A001 — roadmap API name
135
+ reducer: Callable[[Any, Any], Any],
136
+ seed: Any,
137
+ ) -> PipeOperator:
138
+ """Reduce to one value emitted when the source completes.
139
+
140
+ Unlike RxJS, seed is always required. If the source completes without emitting DATA,
141
+ the seed value is emitted (RxJS would throw without a seed).
142
+
143
+ On an empty completion (no prior ``DATA``), emits ``seed``.
144
+
145
+ Args:
146
+ reducer: Accumulator update (return value is not used until completion).
147
+ seed: Value used when the source completes with no prior ``DATA``.
148
+
149
+ Returns:
150
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
151
+
152
+ Examples:
153
+ >>> from graphrefly import pipe, state
154
+ >>> from graphrefly.extra import reduce as grf_reduce
155
+ >>> n = pipe(state(1), grf_reduce(lambda a, x: a + x, 0))
156
+ """
157
+
158
+ def _op(src: Node[Any]) -> Node[Any]:
159
+ acc = [seed]
160
+ saw_data = [False]
161
+
162
+ def compute(deps: list[Any], _actions: NodeActions) -> Any:
163
+ saw_data[0] = True
164
+ acc[0] = reducer(acc[0], deps[0])
165
+ return None
166
+
167
+ def on_message(msg: Any, _index: int, actions: NodeActions) -> bool:
168
+ if msg[0] is MessageType.COMPLETE:
169
+ if not saw_data[0]:
170
+ acc[0] = seed
171
+ actions.emit(acc[0])
172
+ actions.down([(MessageType.COMPLETE,)])
173
+ return True
174
+ return False
175
+
176
+ return node(
177
+ [src],
178
+ compute,
179
+ on_message=on_message,
180
+ describe_kind="reduce",
181
+ complete_when_deps_complete=False,
182
+ )
183
+
184
+ return _op
185
+
186
+
187
+ def take(n: int) -> PipeOperator:
188
+ """Emit at most ``n`` wire ``DATA`` values, then ``COMPLETE`` (``RESOLVED`` does not count).
189
+
190
+ Args:
191
+ n: Maximum ``DATA`` emissions; ``n <= 0`` completes immediately.
192
+
193
+ Returns:
194
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
195
+
196
+ Examples:
197
+ >>> from graphrefly import pipe, state
198
+ >>> from graphrefly.extra import take as grf_take
199
+ >>> n = pipe(state(0), grf_take(3))
200
+ """
201
+
202
+ def _op(src: Node[Any]) -> Node[Any]:
203
+ count = [0]
204
+ holder: list[Node[Any] | None] = [None]
205
+
206
+ def compute(_deps: list[Any], actions: NodeActions) -> Any:
207
+ if n <= 0:
208
+ actions.down([(MessageType.COMPLETE,)])
209
+ u = holder[0]
210
+ if u is not None:
211
+ u.unsubscribe()
212
+ return None
213
+
214
+ def on_message(msg: Any, _index: int, actions: NodeActions) -> bool:
215
+ if n <= 0:
216
+ if msg[0] is MessageType.ERROR:
217
+ actions.down([msg])
218
+ return True
219
+ t = msg[0]
220
+ if t is MessageType.DIRTY:
221
+ actions.down([(MessageType.DIRTY,)])
222
+ return True
223
+ if t is MessageType.RESOLVED:
224
+ actions.down([(MessageType.RESOLVED,)])
225
+ return True
226
+ if t is MessageType.DATA:
227
+ count[0] += 1
228
+ actions.emit(msg[1])
229
+ if count[0] >= n:
230
+ actions.down([(MessageType.COMPLETE,)])
231
+ u = holder[0]
232
+ if u is not None:
233
+ u.unsubscribe()
234
+ return True
235
+ return False
236
+
237
+ out = node(
238
+ [src],
239
+ compute,
240
+ on_message=on_message,
241
+ describe_kind="take",
242
+ complete_when_deps_complete=False,
243
+ )
244
+ holder[0] = out
245
+ return out
246
+
247
+ return _op
248
+
249
+
250
+ def skip(n: int) -> PipeOperator:
251
+ """Drop the first ``n`` wire ``DATA`` payloads (``RESOLVED`` does not advance the counter).
252
+
253
+ Args:
254
+ n: Number of ``DATA`` values to suppress before forwarding.
255
+
256
+ Returns:
257
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
258
+
259
+ Examples:
260
+ >>> from graphrefly import pipe, state
261
+ >>> from graphrefly.extra import skip as grf_skip
262
+ >>> n = pipe(state(0), grf_skip(2))
263
+ """
264
+
265
+ def _op(src: Node[Any]) -> Node[Any]:
266
+ count = [0]
267
+ last: list[Any] = [None]
268
+
269
+ def compute(_deps: list[Any], _actions: NodeActions) -> Any:
270
+ return last[0]
271
+
272
+ def on_message(msg: Any, _index: int, actions: NodeActions) -> bool:
273
+ t = msg[0]
274
+ if t is MessageType.DIRTY:
275
+ actions.down([(MessageType.DIRTY,)])
276
+ return True
277
+ if t is MessageType.RESOLVED:
278
+ actions.down([(MessageType.RESOLVED,)])
279
+ return True
280
+ if t is MessageType.DATA:
281
+ count[0] += 1
282
+ if count[0] <= n:
283
+ actions.down([(MessageType.RESOLVED,)])
284
+ else:
285
+ last[0] = msg[1]
286
+ actions.emit(msg[1])
287
+ return True
288
+ return False
289
+
290
+ return node(
291
+ [src],
292
+ compute,
293
+ on_message=on_message,
294
+ describe_kind="skip",
295
+ )
296
+
297
+ return _op
298
+
299
+
300
+ def take_while(predicate: Callable[[Any], bool]) -> PipeOperator:
301
+ """Emit while ``predicate`` holds; on first false, ``COMPLETE``.
302
+
303
+ Predicate exceptions propagate via node-level error handling (spec §2.4).
304
+
305
+ Args:
306
+ predicate: Continuation test for each value.
307
+
308
+ Returns:
309
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
310
+
311
+ Examples:
312
+ >>> from graphrefly import pipe, state
313
+ >>> from graphrefly.extra import take_while as grf_tw
314
+ >>> n = pipe(state(1), grf_tw(lambda x: x < 10))
315
+ """
316
+
317
+ def _op(src: Node[Any]) -> Node[Any]:
318
+ done = [False]
319
+
320
+ def compute(deps: list[Any], actions: NodeActions) -> Any:
321
+ if done[0]:
322
+ return None
323
+ v = deps[0]
324
+ if not predicate(v):
325
+ done[0] = True
326
+ actions.down([(MessageType.COMPLETE,)])
327
+ return None
328
+ return v
329
+
330
+ return node(
331
+ [src],
332
+ compute,
333
+ describe_kind="take_while",
334
+ complete_when_deps_complete=False,
335
+ )
336
+
337
+ return _op
338
+
339
+
340
+ def take_until(
341
+ notifier: Node[Any],
342
+ *,
343
+ predicate: Callable[[Any], bool] | None = None,
344
+ ) -> PipeOperator:
345
+ """Forward the main source until ``notifier`` matches ``predicate``, then ``COMPLETE``.
346
+
347
+ Default ``predicate`` fires on ``DATA`` from the notifier (full message tuple is passed in).
348
+
349
+ Args:
350
+ notifier: Second input observed for the stop condition.
351
+ predicate: Optional ``(msg) -> bool``; default tests ``msg[0] is MessageType.DATA``.
352
+
353
+ Returns:
354
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
355
+
356
+ Examples:
357
+ >>> from graphrefly import pipe, producer, state
358
+ >>> from graphrefly.extra import take_until as grf_tu
359
+ >>> stop = producer(lambda _d, a: a.emit(0))
360
+ >>> n = pipe(state(1), grf_tu(stop))
361
+ """
362
+ pred = predicate if predicate is not None else (lambda msg: msg[0] is MessageType.DATA)
363
+
364
+ def _op(src: Node[Any]) -> Node[Any]:
365
+ fired = [False]
366
+
367
+ def compute(deps: list[Any], _actions: NodeActions) -> Any:
368
+ return deps[0]
369
+
370
+ def on_message(msg: Any, index: int, actions: NodeActions) -> bool:
371
+ if fired[0]:
372
+ return True
373
+ if index == 1:
374
+ if pred(msg):
375
+ fired[0] = True
376
+ actions.down([(MessageType.COMPLETE,)])
377
+ return True
378
+ return True
379
+ return False
380
+
381
+ return node(
382
+ [src, notifier],
383
+ compute,
384
+ on_message=on_message,
385
+ describe_kind="take_until",
386
+ complete_when_deps_complete=False,
387
+ )
388
+
389
+ return _op
390
+
391
+
392
+ def first() -> PipeOperator:
393
+ """Emit the first ``DATA`` then ``COMPLETE`` (same as ``take(1)``).
394
+
395
+ Returns:
396
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
397
+
398
+ Examples:
399
+ >>> from graphrefly import pipe, state
400
+ >>> from graphrefly.extra import first as grf_first
401
+ >>> n = pipe(state(42), grf_first())
402
+ """
403
+ return take(1)
404
+
405
+
406
+ def find(predicate: Callable[[Any], bool]) -> PipeOperator:
407
+ """Emit the first value satisfying ``predicate``, then ``COMPLETE``.
408
+
409
+ Args:
410
+ predicate: Match test.
411
+
412
+ Returns:
413
+ A unary callable ``(Node) -> Node`` composed from ``filter`` and ``take(1)``.
414
+
415
+ Examples:
416
+ >>> from graphrefly import pipe, state
417
+ >>> from graphrefly.extra import find as grf_find
418
+ >>> n = pipe(state(1), grf_find(lambda x: x > 0))
419
+ """
420
+ return lambda src: pipe(src, filter(predicate), take(1))
421
+
422
+
423
+ def element_at(index: int) -> PipeOperator:
424
+ """Emit the value at zero-based ``DATA`` index ``index``, then ``COMPLETE``.
425
+
426
+ Args:
427
+ index: Number of prior ``DATA`` emissions to skip.
428
+
429
+ Returns:
430
+ A unary callable ``(Node) -> Node`` composed from ``skip`` and ``take(1)``.
431
+
432
+ Examples:
433
+ >>> from graphrefly import pipe, state
434
+ >>> from graphrefly.extra import element_at as grf_at
435
+ >>> n = pipe(state(0), grf_at(2))
436
+ """
437
+ return lambda src: pipe(src, skip(index), take(1))
438
+
439
+
440
+ _LAST_NO_DEFAULT = object()
441
+
442
+
443
+ def last(*, default: Any = _LAST_NO_DEFAULT) -> PipeOperator:
444
+ """Buffer ``DATA``; on ``COMPLETE``, emit the final value (or ``default``).
445
+
446
+ If no ``default`` is given and the source completes without emitting, only ``COMPLETE``
447
+ is forwarded (no ``DATA``).
448
+
449
+ Args:
450
+ default: Optional value emitted when the source completes empty.
451
+
452
+ Returns:
453
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
454
+
455
+ Examples:
456
+ >>> from graphrefly import pipe, state
457
+ >>> from graphrefly.extra import last as grf_last
458
+ >>> n = pipe(state(1), grf_last(default=0))
459
+ """
460
+ use_default = default is not _LAST_NO_DEFAULT
461
+
462
+ def _op(src: Node[Any]) -> Node[Any]:
463
+ buf: list[Any] = [None]
464
+ has_data = [False]
465
+
466
+ def compute(_deps: list[Any], _actions: NodeActions) -> Any:
467
+ return None
468
+
469
+ def on_message(msg: Any, _index: int, actions: NodeActions) -> bool:
470
+ t = msg[0]
471
+ if t is MessageType.DATA:
472
+ buf[0] = msg[1]
473
+ has_data[0] = True
474
+ return True
475
+ if t is MessageType.COMPLETE:
476
+ if has_data[0]:
477
+ actions.emit(buf[0])
478
+ elif use_default:
479
+ actions.emit(default)
480
+ actions.down([(MessageType.COMPLETE,)])
481
+ return True
482
+ return False
483
+
484
+ init = default if use_default else None
485
+ return node(
486
+ [src],
487
+ compute,
488
+ on_message=on_message,
489
+ describe_kind="last",
490
+ initial=init,
491
+ complete_when_deps_complete=False,
492
+ )
493
+
494
+ return _op
495
+
496
+
497
+ def start_with(value: Any) -> PipeOperator:
498
+ """Emit ``value`` as ``DATA`` first, then forward every value from the source.
499
+
500
+ Args:
501
+ value: Prepended emission before upstream values.
502
+
503
+ Returns:
504
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
505
+
506
+ Examples:
507
+ >>> from graphrefly import pipe, state
508
+ >>> from graphrefly.extra import start_with as grf_sw
509
+ >>> n = pipe(state(2), grf_sw(0))
510
+ """
511
+
512
+ def _op(src: Node[Any]) -> Node[Any]:
513
+ prepended = [False]
514
+
515
+ def compute(deps: list[Any], actions: NodeActions) -> Any:
516
+ if not prepended[0]:
517
+ prepended[0] = True
518
+ actions.emit(value)
519
+ actions.emit(deps[0])
520
+ return None
521
+
522
+ return node([src], compute, describe_kind="start_with")
523
+
524
+ return _op
525
+
526
+
527
+ def tap(fn_or_observer: Callable[[Any], None] | dict[str, Callable[..., None]]) -> PipeOperator:
528
+ """Invoke side effects for each emission; value passes through unchanged.
529
+
530
+ When ``fn_or_observer`` is a callable, it is invoked for each ``DATA`` value (classic mode).
531
+
532
+ When ``fn_or_observer`` is a dict, it may contain keys ``data``, ``error``, and ``complete``,
533
+ each a callable invoked for the corresponding message type:
534
+
535
+ - ``data(value)`` — called on each ``DATA``
536
+ - ``error(err)`` — called on ``ERROR``
537
+ - ``complete()`` — called on ``COMPLETE``
538
+
539
+ Args:
540
+ fn_or_observer: A callable ``(value) -> None`` or an observer dict.
541
+
542
+ Returns:
543
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
544
+
545
+ Examples:
546
+ >>> from graphrefly import pipe, state
547
+ >>> from graphrefly.extra import tap as grf_tap
548
+ >>> n = pipe(state(1), grf_tap(lambda x: None))
549
+ >>> n2 = pipe(state(1), grf_tap({"data": lambda x: None, "complete": lambda: None}))
550
+ """
551
+
552
+ if callable(fn_or_observer):
553
+ side_effect = fn_or_observer
554
+
555
+ def _op_fn(src: Node[Any]) -> Node[Any]:
556
+ def compute(deps: list[Any], _actions: NodeActions) -> Any:
557
+ v = deps[0]
558
+ side_effect(v)
559
+ return v
560
+
561
+ return node([src], compute, describe_kind="tap")
562
+
563
+ return _op_fn
564
+
565
+ # Observer dict mode
566
+ obs = fn_or_observer
567
+ on_data: Callable[[Any], None] | None = obs.get("data")
568
+ on_error: Callable[[Any], None] | None = obs.get("error")
569
+ on_complete: Callable[[], None] | None = obs.get("complete")
570
+
571
+ def _op_obs(src: Node[Any]) -> Node[Any]:
572
+ def compute(deps: list[Any], _actions: NodeActions) -> Any:
573
+ v = deps[0]
574
+ if on_data is not None:
575
+ on_data(v)
576
+ return v
577
+
578
+ def on_message(msg: Any, _index: int, actions: NodeActions) -> bool:
579
+ t = msg[0]
580
+ if t is MessageType.ERROR:
581
+ if on_error is not None:
582
+ on_error(msg[1] if len(msg) > 1 else None)
583
+ actions.down([msg])
584
+ return True
585
+ if t is MessageType.COMPLETE:
586
+ if on_complete is not None:
587
+ on_complete()
588
+ actions.down([(MessageType.COMPLETE,)])
589
+ return True
590
+ return False
591
+
592
+ return node([src], compute, on_message=on_message, describe_kind="tap")
593
+
594
+ return _op_obs
595
+
596
+
597
+ def distinct_until_changed(
598
+ equals: Callable[[Any, Any], bool] | None = None,
599
+ ) -> PipeOperator:
600
+ """Suppress consecutive duplicates using ``equals`` (default: ``operator.eq``).
601
+
602
+ Args:
603
+ equals: Optional binary equality for adjacent values.
604
+
605
+ Returns:
606
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
607
+
608
+ Examples:
609
+ >>> from graphrefly import pipe, state
610
+ >>> from graphrefly.extra import distinct_until_changed as grf_duc
611
+ >>> n = pipe(state(1), grf_duc())
612
+ """
613
+ eq = equals if equals is not None else op.eq
614
+
615
+ def _op(src: Node[Any]) -> Node[Any]:
616
+ return node([src], lambda d, _: d[0], equals=eq, describe_kind="distinct_until_changed")
617
+
618
+ return _op
619
+
620
+
621
+ def pairwise() -> PipeOperator:
622
+ """Emit ``(previous, current)`` pairs; the first upstream value yields ``RESOLVED`` only.
623
+
624
+ Returns:
625
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
626
+
627
+ Examples:
628
+ >>> from graphrefly import pipe, state
629
+ >>> from graphrefly.extra import pairwise as grf_pw
630
+ >>> n = pipe(state(0), grf_pw())
631
+ """
632
+
633
+ def _op(src: Node[Any]) -> Node[Any]:
634
+ prev: list[Any] = [object()]
635
+ p0 = prev[0]
636
+
637
+ def compute(deps: list[Any], actions: NodeActions) -> Any:
638
+ v = deps[0]
639
+ if prev[0] is p0:
640
+ prev[0] = v
641
+ actions.down([(MessageType.RESOLVED,)])
642
+ return None
643
+ pair = (prev[0], v)
644
+ prev[0] = v
645
+ return pair
646
+
647
+ return node([src], compute, describe_kind="pairwise")
648
+
649
+ return _op
650
+
651
+
652
+ # --- multi-source -------------------------------------------------------------
653
+
654
+
655
+ def combine(*sources: Node[Any]) -> Node[Any]:
656
+ """Combine latest values from all sources into a tuple whenever any settles.
657
+
658
+ Args:
659
+ *sources: Upstream nodes (empty → empty tuple node).
660
+
661
+ Returns:
662
+ A :class:`~graphrefly.core.node.Node` emitting ``tuple`` of dependency values.
663
+
664
+ Examples:
665
+ >>> from graphrefly.extra import combine
666
+ >>> from graphrefly import state
667
+ >>> n = combine(state(1), state("a"))
668
+ """
669
+ srcs = list(sources)
670
+ if not srcs:
671
+ return node([], lambda _d, _a: (), describe_kind="combine")
672
+ return node(srcs, lambda deps, _: tuple(deps), describe_kind="combine")
673
+
674
+
675
+ def with_latest_from(other: Node[Any]) -> PipeOperator:
676
+ """When the primary source settles, emit ``(primary, latest_secondary)``.
677
+
678
+ Updates from ``other`` alone refresh the cached secondary value but do not emit.
679
+
680
+ Args:
681
+ other: Secondary node whose latest value is paired on primary emissions.
682
+
683
+ Returns:
684
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
685
+
686
+ Examples:
687
+ >>> from graphrefly import pipe, state
688
+ >>> from graphrefly.extra import with_latest_from as grf_wlf
689
+ >>> n = pipe(state(1), grf_wlf(state("x")))
690
+ """
691
+
692
+ def _op(main: Node[Any]) -> Node[Any]:
693
+ latest_b: list[Any] = [None]
694
+ has_b = [False]
695
+
696
+ def on_message(msg: Any, i: int, actions: NodeActions) -> bool:
697
+ t = msg[0]
698
+ if i == 1 and t in (MessageType.DATA, MessageType.RESOLVED):
699
+ latest_b[0] = other.get()
700
+ has_b[0] = True
701
+ return True
702
+ if i == 0 and t in (MessageType.DATA, MessageType.RESOLVED):
703
+ if not has_b[0]:
704
+ latest_b[0] = other.get()
705
+ has_b[0] = True
706
+ actions.emit((main.get(), latest_b[0]))
707
+ return True
708
+ if i == 0 and t is MessageType.DIRTY:
709
+ actions.down([(MessageType.DIRTY,)])
710
+ return True
711
+ if i == 1 and t is MessageType.DIRTY:
712
+ return True
713
+ if t in (MessageType.COMPLETE, MessageType.ERROR):
714
+ actions.down([msg])
715
+ return True
716
+ actions.down([msg])
717
+ return True
718
+
719
+ return node(
720
+ [main, other],
721
+ lambda _d, _a: None,
722
+ on_message=on_message,
723
+ describe_kind="with_latest_from",
724
+ )
725
+
726
+ return _op
727
+
728
+
729
+ def merge(*sources: Node[Any]) -> Node[Any]:
730
+ """Merge ``DATA`` from any dependency; ``COMPLETE`` only after every source completes.
731
+
732
+ Args:
733
+ *sources: Upstreams to merge (empty → immediate ``COMPLETE`` node).
734
+
735
+ Returns:
736
+ A :class:`~graphrefly.core.node.Node`.
737
+
738
+ Examples:
739
+ >>> from graphrefly.extra import merge
740
+ >>> from graphrefly import state
741
+ >>> n = merge(state(1), state(2))
742
+ """
743
+ srcs = list(sources)
744
+ if not srcs:
745
+
746
+ def _empty_fn(_d: list[Any], a: NodeActions) -> None:
747
+ a.down([(MessageType.COMPLETE,)])
748
+
749
+ return producer(_empty_fn)
750
+ if len(srcs) == 1:
751
+ return srcs[0]
752
+
753
+ n = len(srcs)
754
+ dirty_mask = [0]
755
+ latest: list[Any] = [None]
756
+ active = [n]
757
+ any_data = [False]
758
+
759
+ def compute(_deps: list[Any], _actions: NodeActions) -> Any:
760
+ return latest[0]
761
+
762
+ def on_message(msg: Any, index: int, actions: NodeActions) -> bool:
763
+ t = msg[0]
764
+ dm = dirty_mask[0]
765
+ if t is MessageType.DIRTY:
766
+ was_clean = dm == 0
767
+ dirty_mask[0] = dm | (1 << index)
768
+ if was_clean:
769
+ any_data[0] = False
770
+ actions.down([(MessageType.DIRTY,)])
771
+ return True
772
+ if t is MessageType.RESOLVED:
773
+ if dm & (1 << index):
774
+ dirty_mask[0] = dm & ~(1 << index)
775
+ if dirty_mask[0] == 0 and not any_data[0]:
776
+ actions.down([(MessageType.RESOLVED,)])
777
+ return True
778
+ if t is MessageType.DATA:
779
+ dirty_mask[0] = dirty_mask[0] & ~(1 << index)
780
+ any_data[0] = True
781
+ latest[0] = msg[1]
782
+ actions.emit(msg[1])
783
+ return True
784
+ if t is MessageType.COMPLETE:
785
+ dirty_mask[0] = dirty_mask[0] & ~(1 << index)
786
+ active[0] -= 1
787
+ if active[0] == 0:
788
+ actions.down([(MessageType.COMPLETE,)])
789
+ return True
790
+ if t is MessageType.ERROR:
791
+ actions.down([msg])
792
+ return True
793
+ return False
794
+
795
+ return node(
796
+ srcs,
797
+ compute,
798
+ on_message=on_message,
799
+ describe_kind="merge",
800
+ complete_when_deps_complete=False,
801
+ )
802
+
803
+
804
+ def zip( # noqa: A001
805
+ *sources: Node[Any],
806
+ max_buffer: int = 0,
807
+ ) -> Node[Any]:
808
+ """Zip one ``DATA`` from each source per cycle into a tuple.
809
+
810
+ Args:
811
+ *sources: Upstreams to zip.
812
+ max_buffer: When ``> 0``, drop oldest queued values per source beyond this depth.
813
+
814
+ Returns:
815
+ A :class:`~graphrefly.core.node.Node` emitting tuples.
816
+
817
+ Examples:
818
+ >>> from graphrefly.extra import zip as grf_zip
819
+ >>> from graphrefly import state
820
+ >>> n = grf_zip(state(1), state(2))
821
+ """
822
+ srcs = list(sources)
823
+ if not srcs:
824
+ return node([], lambda _d, _a: (), describe_kind="zip")
825
+ if len(srcs) == 1:
826
+ return node(srcs, lambda d, _: (d[0],), describe_kind="zip")
827
+
828
+ n = len(srcs)
829
+ buffers = [deque[Any]() for _ in range(n)]
830
+ dirty_mask = [0]
831
+ active = [n]
832
+ any_data = [False]
833
+
834
+ def try_emit(actions: NodeActions) -> None:
835
+ while all(len(b) > 0 for b in buffers):
836
+ tup = tuple(b.popleft() for b in buffers)
837
+ actions.emit(tup)
838
+
839
+ def compute(_deps: list[Any], _actions: NodeActions) -> Any:
840
+ return None
841
+
842
+ def on_message(msg: Any, index: int, actions: NodeActions) -> bool:
843
+ t = msg[0]
844
+ dm = dirty_mask[0]
845
+ if t is MessageType.DIRTY:
846
+ was_clean = dm == 0
847
+ dirty_mask[0] = dm | (1 << index)
848
+ if was_clean:
849
+ any_data[0] = False
850
+ actions.down([(MessageType.DIRTY,)])
851
+ return True
852
+ if t is MessageType.RESOLVED:
853
+ if dm & (1 << index):
854
+ dirty_mask[0] = dm & ~(1 << index)
855
+ if dirty_mask[0] == 0:
856
+ if any_data[0]:
857
+ try_emit(actions)
858
+ else:
859
+ actions.down([(MessageType.RESOLVED,)])
860
+ return True
861
+ if t is MessageType.DATA:
862
+ dirty_mask[0] = dm & ~(1 << index)
863
+ buffers[index].append(msg[1])
864
+ if max_buffer > 0 and len(buffers[index]) > max_buffer:
865
+ buffers[index].popleft()
866
+ any_data[0] = True
867
+ if dirty_mask[0] == 0:
868
+ try_emit(actions)
869
+ return True
870
+ if t is MessageType.COMPLETE:
871
+ active[0] -= 1
872
+ if active[0] == 0 or len(buffers[index]) == 0:
873
+ actions.down([(MessageType.COMPLETE,)])
874
+ return True
875
+ if t is MessageType.ERROR:
876
+ actions.down([msg])
877
+ return True
878
+ return False
879
+
880
+ return node(
881
+ srcs,
882
+ compute,
883
+ on_message=on_message,
884
+ describe_kind="zip",
885
+ complete_when_deps_complete=False,
886
+ )
887
+
888
+
889
+ def concat(second: Node[Any]) -> PipeOperator:
890
+ """Play ``first`` to completion, then continue with ``second``.
891
+
892
+ While ``first`` is active, ``DATA`` from ``second`` is buffered and replayed at handoff.
893
+
894
+ Args:
895
+ second: Segment played after the primary completes.
896
+
897
+ Returns:
898
+ A :class:`~graphrefly.core.sugar.PipeOperator`.
899
+
900
+ Examples:
901
+ >>> from graphrefly import pipe, state
902
+ >>> from graphrefly.extra import concat as grf_cat
903
+ >>> n = pipe(state(1), grf_cat(state(2)))
904
+ """
905
+
906
+ def op(first: Node[Any]) -> Node[Any]:
907
+ phase = [0] # 0 = first, 1 = second
908
+ holder: list[Node[Any] | None] = [None]
909
+ pending_values: deque[Any] = deque()
910
+
911
+ def flush_pending(actions: NodeActions) -> None:
912
+ while pending_values:
913
+ actions.emit(pending_values.popleft())
914
+
915
+ def compute(deps: list[Any], _actions: NodeActions) -> Any:
916
+ return None
917
+
918
+ def on_message(msg: Any, index: int, actions: NodeActions) -> bool:
919
+ t = msg[0]
920
+ if phase[0] == 0 and index == 1:
921
+ if t is MessageType.DATA:
922
+ pending_values.append(msg[1])
923
+ elif t is MessageType.ERROR:
924
+ actions.down([msg])
925
+ return True
926
+ if phase[0] == 1 and index == 0:
927
+ return True
928
+ if t is MessageType.COMPLETE and phase[0] == 0 and index == 0:
929
+ phase[0] = 1
930
+ flush_pending(actions)
931
+ return True
932
+ if t is MessageType.DIRTY:
933
+ actions.down([(MessageType.DIRTY,)])
934
+ return True
935
+ if t is MessageType.RESOLVED:
936
+ actions.down([(MessageType.RESOLVED,)])
937
+ return True
938
+ if t is MessageType.DATA:
939
+ actions.emit(msg[1])
940
+ return True
941
+ if t is MessageType.COMPLETE:
942
+ actions.down([(MessageType.COMPLETE,)])
943
+ u = holder[0]
944
+ if u is not None:
945
+ u.unsubscribe()
946
+ return True
947
+ if t is MessageType.ERROR:
948
+ actions.down([msg])
949
+ return True
950
+ return False
951
+
952
+ out = node(
953
+ [first, second],
954
+ compute,
955
+ on_message=on_message,
956
+ describe_kind="concat",
957
+ complete_when_deps_complete=False,
958
+ )
959
+ holder[0] = out
960
+ return out
961
+
962
+ return op
963
+
964
+
965
+ def race(*sources: Node[Any]) -> Node[Any]:
966
+ """First source to emit ``DATA`` wins; subsequent traffic follows only that source.
967
+
968
+ Args:
969
+ *sources: Contestants (empty → immediate ``COMPLETE``; one node is returned as-is).
970
+
971
+ Returns:
972
+ A :class:`~graphrefly.core.node.Node`.
973
+
974
+ Examples:
975
+ >>> from graphrefly.extra import race
976
+ >>> from graphrefly import state
977
+ >>> n = race(state(1), state(2))
978
+ """
979
+ srcs = list(sources)
980
+ if not srcs:
981
+
982
+ def _empty_race(_d: list[Any], a: NodeActions) -> None:
983
+ a.down([(MessageType.COMPLETE,)])
984
+
985
+ return producer(_empty_race)
986
+ if len(srcs) == 1:
987
+ return srcs[0]
988
+
989
+ winner: list[int | None] = [None]
990
+
991
+ def compute(_deps: list[Any], _actions: NodeActions) -> Any:
992
+ return None
993
+
994
+ def on_message(msg: Any, index: int, actions: NodeActions) -> bool:
995
+ t = msg[0]
996
+ w = winner[0]
997
+ if w is not None and index != w:
998
+ return True
999
+ if t is MessageType.DATA and w is None:
1000
+ winner[0] = index
1001
+ actions.emit(msg[1])
1002
+ return True
1003
+ if w is not None and index == w:
1004
+ if t is MessageType.DATA:
1005
+ actions.emit(msg[1])
1006
+ return True
1007
+ if t is MessageType.DIRTY:
1008
+ actions.down([(MessageType.DIRTY,)])
1009
+ return True
1010
+ if t is MessageType.RESOLVED:
1011
+ actions.down([(MessageType.RESOLVED,)])
1012
+ return True
1013
+ if t in (MessageType.COMPLETE, MessageType.ERROR):
1014
+ actions.down([msg])
1015
+ return True
1016
+ return True
1017
+ if w is None:
1018
+ if t is MessageType.DIRTY:
1019
+ actions.down([(MessageType.DIRTY,)])
1020
+ return True
1021
+ if t is MessageType.RESOLVED:
1022
+ actions.down([(MessageType.RESOLVED,)])
1023
+ return True
1024
+ if t is MessageType.COMPLETE:
1025
+ actions.down([(MessageType.COMPLETE,)])
1026
+ return True
1027
+ if t is MessageType.ERROR:
1028
+ actions.down([msg])
1029
+ return True
1030
+ return False
1031
+
1032
+ return node(
1033
+ srcs,
1034
+ compute,
1035
+ on_message=on_message,
1036
+ describe_kind="race",
1037
+ complete_when_deps_complete=False,
1038
+ )
1039
+
1040
+
1041
+ combine_latest = combine
1042
+
1043
+ __all__ = [
1044
+ "combine",
1045
+ "combine_latest",
1046
+ "concat",
1047
+ "distinct_until_changed",
1048
+ "element_at",
1049
+ "filter",
1050
+ "find",
1051
+ "first",
1052
+ "last",
1053
+ "map",
1054
+ "merge",
1055
+ "pairwise",
1056
+ "race",
1057
+ "reduce",
1058
+ "scan",
1059
+ "skip",
1060
+ "start_with",
1061
+ "take",
1062
+ "take_until",
1063
+ "take_while",
1064
+ "tap",
1065
+ "with_latest_from",
1066
+ "zip",
1067
+ ]