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