graphrefly 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphrefly/__init__.py +160 -0
- graphrefly/compat/__init__.py +18 -0
- graphrefly/compat/async_utils.py +228 -0
- graphrefly/compat/asyncio_runner.py +89 -0
- graphrefly/compat/trio_runner.py +81 -0
- graphrefly/core/__init__.py +142 -0
- graphrefly/core/clock.py +20 -0
- graphrefly/core/dynamic_node.py +749 -0
- graphrefly/core/guard.py +277 -0
- graphrefly/core/meta.py +149 -0
- graphrefly/core/node.py +963 -0
- graphrefly/core/protocol.py +460 -0
- graphrefly/core/runner.py +107 -0
- graphrefly/core/subgraph_locks.py +296 -0
- graphrefly/core/sugar.py +138 -0
- graphrefly/core/versioning.py +193 -0
- graphrefly/extra/__init__.py +313 -0
- graphrefly/extra/adapters.py +2149 -0
- graphrefly/extra/backoff.py +287 -0
- graphrefly/extra/backpressure.py +113 -0
- graphrefly/extra/checkpoint.py +307 -0
- graphrefly/extra/composite.py +303 -0
- graphrefly/extra/cron.py +133 -0
- graphrefly/extra/data_structures.py +707 -0
- graphrefly/extra/resilience.py +727 -0
- graphrefly/extra/sources.py +766 -0
- graphrefly/extra/tier1.py +1067 -0
- graphrefly/extra/tier2.py +1802 -0
- graphrefly/graph/__init__.py +31 -0
- graphrefly/graph/graph.py +2249 -0
- graphrefly/integrations/__init__.py +1 -0
- graphrefly/integrations/fastapi.py +767 -0
- graphrefly/patterns/__init__.py +5 -0
- graphrefly/patterns/ai.py +2132 -0
- graphrefly/patterns/cqrs.py +515 -0
- graphrefly/patterns/memory.py +639 -0
- graphrefly/patterns/messaging.py +553 -0
- graphrefly/patterns/orchestration.py +536 -0
- graphrefly/patterns/reactive_layout/__init__.py +81 -0
- graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
- graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
- graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
- graphrefly/py.typed +1 -0
- graphrefly-0.1.0.dist-info/METADATA +253 -0
- graphrefly-0.1.0.dist-info/RECORD +47 -0
- graphrefly-0.1.0.dist-info/WHEEL +4 -0
- graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,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
|
+
]
|