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,460 @@
|
|
|
1
|
+
"""GraphReFly message types, message shape aliases, and batch semantics.
|
|
2
|
+
|
|
3
|
+
Canonical message ordering (within a composite batch)
|
|
4
|
+
=====================================================
|
|
5
|
+
|
|
6
|
+
When multiple message types appear in a single ``down()`` call, the canonical
|
|
7
|
+
delivery order is determined by **signal tier**:
|
|
8
|
+
|
|
9
|
+
====== ==================== =================== ====================================
|
|
10
|
+
Tier Signals Role Batch behavior
|
|
11
|
+
====== ==================== =================== ====================================
|
|
12
|
+
0 DIRTY, INVALIDATE Notification Immediate (never deferred)
|
|
13
|
+
1 PAUSE, RESUME Flow control Immediate (never deferred)
|
|
14
|
+
2 DATA, RESOLVED Value settlement Deferred inside ``batch()``
|
|
15
|
+
3 COMPLETE, ERROR Terminal lifecycle Deferred to after phase-2
|
|
16
|
+
4 TEARDOWN Destruction Immediate (usually sent alone)
|
|
17
|
+
====== ==================== =================== ====================================
|
|
18
|
+
|
|
19
|
+
**Rule:** Within ``emit_with_batch(strategy="partition")``, messages are partitioned
|
|
20
|
+
by tier and delivered in tier order. This ensures phase-2 values reach sinks
|
|
21
|
+
before terminal signals mark the node as done.
|
|
22
|
+
|
|
23
|
+
Unknown message types (forward-compat) are tier 0 (immediate).
|
|
24
|
+
|
|
25
|
+
Meta node bypass rules (centralized — GRAPHREFLY-SPEC §2.3)
|
|
26
|
+
============================================================
|
|
27
|
+
|
|
28
|
+
- **INVALIDATE** via ``graph.signal()`` — no-op on meta nodes.
|
|
29
|
+
- **COMPLETE / ERROR** — not propagated from parent to meta.
|
|
30
|
+
- **TEARDOWN** — propagated from parent to meta, releasing meta resources.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import sys
|
|
36
|
+
import threading
|
|
37
|
+
from collections.abc import Callable # noqa: TC003 — runtime type for _wrap_deferred_subgraph
|
|
38
|
+
from contextlib import contextmanager
|
|
39
|
+
from enum import StrEnum
|
|
40
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from collections.abc import Generator
|
|
44
|
+
|
|
45
|
+
# ---------------------------------------------------------------------------
|
|
46
|
+
# Message vocabulary — GRAPHREFLY-SPEC § 1.2, Appendix A
|
|
47
|
+
# ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class MessageType(StrEnum):
|
|
51
|
+
"""Wire discriminator for node messages (always the first tuple element)."""
|
|
52
|
+
|
|
53
|
+
DATA = "DATA"
|
|
54
|
+
DIRTY = "DIRTY"
|
|
55
|
+
RESOLVED = "RESOLVED"
|
|
56
|
+
INVALIDATE = "INVALIDATE"
|
|
57
|
+
PAUSE = "PAUSE"
|
|
58
|
+
RESUME = "RESUME"
|
|
59
|
+
TEARDOWN = "TEARDOWN"
|
|
60
|
+
COMPLETE = "COMPLETE"
|
|
61
|
+
ERROR = "ERROR"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# Tuple with payload, or single-element tuple for type-only messages (e.g. DIRTY).
|
|
65
|
+
type Message = tuple[MessageType, Any] | tuple[MessageType]
|
|
66
|
+
type Messages = list[Message]
|
|
67
|
+
|
|
68
|
+
# Phase-2 messages deferred by batch(); DIRTY and other signals flush immediately.
|
|
69
|
+
_BATCH_DEFER_TYPES: frozenset[MessageType] = frozenset({MessageType.DATA, MessageType.RESOLVED})
|
|
70
|
+
|
|
71
|
+
# Terminals (GRAPHREFLY-SPEC § 1.3 #4): flush deferred phase-2 first so they are not observed after.
|
|
72
|
+
_TERMINAL_TYPES: frozenset[MessageType] = frozenset({MessageType.COMPLETE, MessageType.ERROR})
|
|
73
|
+
|
|
74
|
+
type EmitStrategy = Literal["partition", "sequential"]
|
|
75
|
+
type DeferWhen = Literal["depth", "batching"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _wrap_deferred_subgraph(
|
|
79
|
+
fn: Callable[[], None],
|
|
80
|
+
subgraph_lock: object | None,
|
|
81
|
+
) -> Callable[[], None]:
|
|
82
|
+
"""Re-acquire subgraph write lock when running deferred phase-2 (batch drain)."""
|
|
83
|
+
if subgraph_lock is None:
|
|
84
|
+
return fn
|
|
85
|
+
from graphrefly.core.subgraph_locks import acquire_subgraph_write_lock_with_defer
|
|
86
|
+
|
|
87
|
+
def wrapped() -> None:
|
|
88
|
+
with acquire_subgraph_write_lock_with_defer(subgraph_lock):
|
|
89
|
+
fn()
|
|
90
|
+
|
|
91
|
+
return wrapped
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Batch — defers DATA/RESOLVED; DIRTY propagates immediately (GRAPHREFLY-SPEC § 1.3 #7)
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
_batch_tls = threading.local()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class _BatchState:
|
|
102
|
+
__slots__ = ("depth", "flush_in_progress", "pending", "pending_phase3")
|
|
103
|
+
|
|
104
|
+
def __init__(self) -> None:
|
|
105
|
+
self.depth = 0
|
|
106
|
+
self.flush_in_progress = False
|
|
107
|
+
self.pending: list[Callable[[], None]] = []
|
|
108
|
+
self.pending_phase3: list[Callable[[], None]] = []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _batch_state() -> _BatchState:
|
|
112
|
+
try:
|
|
113
|
+
return _batch_tls.state # type: ignore[no-any-return]
|
|
114
|
+
except AttributeError:
|
|
115
|
+
bs = _BatchState()
|
|
116
|
+
_batch_tls.state = bs
|
|
117
|
+
return bs
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_MAX_DRAIN_ITERATIONS = 1000
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _drain_pending(bs: _BatchState) -> None:
|
|
124
|
+
"""Run all queued deferred callbacks until the queue is quiescent.
|
|
125
|
+
|
|
126
|
+
Drain order: phase-2 (DATA/RESOLVED) until empty, then phase-3 (meta
|
|
127
|
+
companion emissions). If phase-3 callbacks enqueue new phase-2 work,
|
|
128
|
+
the outer loop catches it and drains phase-2 again before re-entering
|
|
129
|
+
phase-3.
|
|
130
|
+
"""
|
|
131
|
+
owns_flush = not bs.flush_in_progress
|
|
132
|
+
if owns_flush:
|
|
133
|
+
bs.flush_in_progress = True
|
|
134
|
+
errors: list[Exception] = []
|
|
135
|
+
iterations = 0
|
|
136
|
+
try:
|
|
137
|
+
while bs.pending or bs.pending_phase3:
|
|
138
|
+
# Phase-2: parent node settlements.
|
|
139
|
+
while bs.pending:
|
|
140
|
+
iterations += 1
|
|
141
|
+
if iterations > _MAX_DRAIN_ITERATIONS:
|
|
142
|
+
bs.pending = []
|
|
143
|
+
bs.pending_phase3 = []
|
|
144
|
+
msg = f"batch drain exceeded {_MAX_DRAIN_ITERATIONS} iterations"
|
|
145
|
+
raise RuntimeError(msg)
|
|
146
|
+
batch = bs.pending
|
|
147
|
+
bs.pending = []
|
|
148
|
+
for fn in batch:
|
|
149
|
+
try:
|
|
150
|
+
fn()
|
|
151
|
+
except Exception as e:
|
|
152
|
+
errors.append(e)
|
|
153
|
+
# Phase-3: meta companion emissions (after parent settlement).
|
|
154
|
+
if bs.pending_phase3:
|
|
155
|
+
iterations += 1
|
|
156
|
+
if iterations > _MAX_DRAIN_ITERATIONS:
|
|
157
|
+
bs.pending = []
|
|
158
|
+
bs.pending_phase3 = []
|
|
159
|
+
msg = f"batch drain exceeded {_MAX_DRAIN_ITERATIONS} iterations"
|
|
160
|
+
raise RuntimeError(msg)
|
|
161
|
+
batch = bs.pending_phase3
|
|
162
|
+
bs.pending_phase3 = []
|
|
163
|
+
for fn in batch:
|
|
164
|
+
try:
|
|
165
|
+
fn()
|
|
166
|
+
except Exception as e:
|
|
167
|
+
errors.append(e)
|
|
168
|
+
finally:
|
|
169
|
+
if owns_flush:
|
|
170
|
+
bs.flush_in_progress = False
|
|
171
|
+
if len(errors) == 1:
|
|
172
|
+
raise errors[0]
|
|
173
|
+
if len(errors) > 1:
|
|
174
|
+
raise ExceptionGroup("batch drain", errors)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _should_defer_phase2(bs: _BatchState, defer_when: DeferWhen) -> bool:
|
|
178
|
+
if defer_when == "depth":
|
|
179
|
+
return bs.depth > 0
|
|
180
|
+
return bs.depth > 0 or bs.flush_in_progress
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@contextmanager
|
|
184
|
+
def batch() -> Generator[None]:
|
|
185
|
+
"""Defer phase-2 messages (DATA, RESOLVED) until the outermost batch exits.
|
|
186
|
+
|
|
187
|
+
``DIRTY`` and non-phase-2 types propagate immediately. Nested batches share
|
|
188
|
+
one defer queue; flush runs only when the outermost context exits. Each
|
|
189
|
+
thread has isolated batch state (GRAPHREFLY-SPEC §4.2).
|
|
190
|
+
|
|
191
|
+
If the outermost context exits with an exception, deferred phase-2 work is
|
|
192
|
+
discarded. While the drain loop is running (``flush_in_progress``), nested
|
|
193
|
+
:func:`emit_with_batch` calls with ``defer_when="batching"`` still defer
|
|
194
|
+
``DATA``/``RESOLVED`` until the queue drains.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
A context manager that yields ``None``; has no return value.
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
```python
|
|
201
|
+
from graphrefly import state, batch
|
|
202
|
+
x = state(0)
|
|
203
|
+
y = state(0)
|
|
204
|
+
with batch():
|
|
205
|
+
x.down([("DATA", 1)])
|
|
206
|
+
y.down([("DATA", 2)])
|
|
207
|
+
# Downstream sees both updates atomically
|
|
208
|
+
```
|
|
209
|
+
"""
|
|
210
|
+
bs = _batch_state()
|
|
211
|
+
bs.depth += 1
|
|
212
|
+
try:
|
|
213
|
+
yield
|
|
214
|
+
finally:
|
|
215
|
+
bs.depth -= 1
|
|
216
|
+
if bs.depth == 0:
|
|
217
|
+
if sys.exc_info()[1] is not None:
|
|
218
|
+
if not bs.flush_in_progress:
|
|
219
|
+
bs.pending.clear()
|
|
220
|
+
bs.pending_phase3.clear()
|
|
221
|
+
else:
|
|
222
|
+
_drain_pending(bs)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def is_batching() -> bool:
|
|
226
|
+
"""Return True while inside ``batch()`` or while deferred phase-2 work is draining.
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
``True`` when batch depth > 0 or the drain loop is active.
|
|
230
|
+
|
|
231
|
+
Example:
|
|
232
|
+
```python
|
|
233
|
+
from graphrefly import batch, is_batching
|
|
234
|
+
assert not is_batching()
|
|
235
|
+
with batch():
|
|
236
|
+
assert is_batching()
|
|
237
|
+
```
|
|
238
|
+
"""
|
|
239
|
+
bs = _batch_state()
|
|
240
|
+
return bs.depth > 0 or bs.flush_in_progress
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def is_phase2_message(msg: Message) -> bool:
|
|
244
|
+
"""True for DATA and RESOLVED (phase-2 tuples deferred under batching)."""
|
|
245
|
+
t = msg[0]
|
|
246
|
+
return t is MessageType.DATA or t is MessageType.RESOLVED
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def is_terminal_message(t: MessageType) -> bool:
|
|
250
|
+
"""True for COMPLETE or ERROR (tier 3 — delivered after phase-2 in the same batch)."""
|
|
251
|
+
return t is MessageType.COMPLETE or t is MessageType.ERROR
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def message_tier(t: MessageType) -> int:
|
|
255
|
+
"""Return the signal tier for a message type (see module docstring).
|
|
256
|
+
|
|
257
|
+
0: notification (DIRTY, INVALIDATE) — immediate
|
|
258
|
+
1: flow control (PAUSE, RESUME) — immediate
|
|
259
|
+
2: value (DATA, RESOLVED) — deferred inside batch()
|
|
260
|
+
3: terminal (COMPLETE, ERROR) — delivered after phase-2
|
|
261
|
+
4: destruction (TEARDOWN) — immediate, usually alone
|
|
262
|
+
0 for unknown types (forward-compat: immediate)
|
|
263
|
+
"""
|
|
264
|
+
if t is MessageType.DIRTY or t is MessageType.INVALIDATE:
|
|
265
|
+
return 0
|
|
266
|
+
if t is MessageType.PAUSE or t is MessageType.RESUME:
|
|
267
|
+
return 1
|
|
268
|
+
if t is MessageType.DATA or t is MessageType.RESOLVED:
|
|
269
|
+
return 2
|
|
270
|
+
if t is MessageType.COMPLETE or t is MessageType.ERROR:
|
|
271
|
+
return 3
|
|
272
|
+
if t is MessageType.TEARDOWN:
|
|
273
|
+
return 4
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def propagates_to_meta(t: MessageType) -> bool:
|
|
278
|
+
"""Whether *t* should be propagated from a parent node to its companion meta nodes.
|
|
279
|
+
|
|
280
|
+
Only TEARDOWN propagates; COMPLETE/ERROR/INVALIDATE do not.
|
|
281
|
+
"""
|
|
282
|
+
return t is MessageType.TEARDOWN
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def partition_for_batch(messages: Messages) -> tuple[Messages, Messages, Messages]:
|
|
286
|
+
"""Split *messages* into three groups by signal tier.
|
|
287
|
+
|
|
288
|
+
Returns ``(immediate, deferred, terminal)`` — tier 0-1/4, tier 2, tier 3.
|
|
289
|
+
Order within each group is preserved.
|
|
290
|
+
"""
|
|
291
|
+
immediate: Messages = []
|
|
292
|
+
deferred: Messages = []
|
|
293
|
+
terminal: Messages = []
|
|
294
|
+
for m in messages:
|
|
295
|
+
if is_phase2_message(m):
|
|
296
|
+
deferred.append(m)
|
|
297
|
+
elif is_terminal_message(m[0]):
|
|
298
|
+
terminal.append(m)
|
|
299
|
+
else:
|
|
300
|
+
immediate.append(m)
|
|
301
|
+
return immediate, deferred, terminal
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def emit_with_batch(
|
|
305
|
+
sink: Callable[[Messages], None],
|
|
306
|
+
messages: Messages,
|
|
307
|
+
*,
|
|
308
|
+
phase: int = 2,
|
|
309
|
+
strategy: EmitStrategy = "sequential",
|
|
310
|
+
defer_when: DeferWhen = "batching",
|
|
311
|
+
subgraph_lock: object | None = None,
|
|
312
|
+
) -> None:
|
|
313
|
+
"""Deliver *messages* to *sink* with batch-aware deferral.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
sink: Callable receiving a :class:`~graphrefly.core.protocol.Messages` list.
|
|
317
|
+
messages: The messages to deliver.
|
|
318
|
+
phase: ``2`` (default) for standard DATA/RESOLVED deferral; ``3`` for
|
|
319
|
+
meta companion emissions that must arrive after parent settlements.
|
|
320
|
+
strategy: ``"partition"`` (default for :class:`~graphrefly.core.node.NodeImpl`)
|
|
321
|
+
splits messages into immediate vs phase-2 groups; ``"sequential"`` walks
|
|
322
|
+
each message in order and handles ``COMPLETE``/``ERROR`` after phase-2.
|
|
323
|
+
defer_when: ``"batching"`` (default) defers phase-2 while
|
|
324
|
+
:func:`is_batching` (depth or drain in progress); ``"depth"`` defers
|
|
325
|
+
only while batch depth > 0.
|
|
326
|
+
subgraph_lock: When set, re-acquires the subgraph write lock around deferred
|
|
327
|
+
phase-2 calls to serialize batch drains with other writers.
|
|
328
|
+
|
|
329
|
+
Example:
|
|
330
|
+
```python
|
|
331
|
+
from graphrefly.core.protocol import emit_with_batch, MessageType, batch
|
|
332
|
+
received = []
|
|
333
|
+
sink = lambda msgs: received.extend(msgs)
|
|
334
|
+
with batch():
|
|
335
|
+
emit_with_batch(sink, [("DATA", 1), ("DATA", 2)])
|
|
336
|
+
# Both DATA messages flushed together after batch exits
|
|
337
|
+
assert len(received) == 2
|
|
338
|
+
```
|
|
339
|
+
"""
|
|
340
|
+
if not messages:
|
|
341
|
+
return
|
|
342
|
+
queue_attr = "pending_phase3" if phase == 3 else "pending"
|
|
343
|
+
if strategy == "partition":
|
|
344
|
+
_emit_partition(sink, messages, defer_when, subgraph_lock, queue_attr)
|
|
345
|
+
else:
|
|
346
|
+
_emit_sequential(sink, messages, defer_when, subgraph_lock, queue_attr)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def _emit_partition(
|
|
350
|
+
sink: Callable[[Messages], None],
|
|
351
|
+
messages: Messages,
|
|
352
|
+
defer_when: DeferWhen,
|
|
353
|
+
subgraph_lock: object | None,
|
|
354
|
+
queue_attr: str = "pending",
|
|
355
|
+
) -> None:
|
|
356
|
+
bs = _batch_state()
|
|
357
|
+
queue: list[Callable[[], None]] = getattr(bs, queue_attr)
|
|
358
|
+
# Fast path: single-message batches (most common in graph-internal propagation)
|
|
359
|
+
# skip partition_for_batch allocation entirely.
|
|
360
|
+
if len(messages) == 1:
|
|
361
|
+
t = messages[0][0]
|
|
362
|
+
if t is MessageType.DATA or t is MessageType.RESOLVED:
|
|
363
|
+
if _should_defer_phase2(bs, defer_when):
|
|
364
|
+
|
|
365
|
+
def _emit_single() -> None:
|
|
366
|
+
sink(messages)
|
|
367
|
+
|
|
368
|
+
queue.append(_wrap_deferred_subgraph(_emit_single, subgraph_lock))
|
|
369
|
+
else:
|
|
370
|
+
sink(messages)
|
|
371
|
+
elif is_terminal_message(t):
|
|
372
|
+
# Terminal single message: defer when batching so preceding deferred flushes first.
|
|
373
|
+
if _should_defer_phase2(bs, defer_when):
|
|
374
|
+
|
|
375
|
+
def _emit_terminal_single() -> None:
|
|
376
|
+
sink(messages)
|
|
377
|
+
|
|
378
|
+
queue.append(_wrap_deferred_subgraph(_emit_terminal_single, subgraph_lock))
|
|
379
|
+
else:
|
|
380
|
+
sink(messages)
|
|
381
|
+
else:
|
|
382
|
+
# Immediate: emit synchronously.
|
|
383
|
+
sink(messages)
|
|
384
|
+
return
|
|
385
|
+
# Multi-message: three-way partition by tier (see module docstring).
|
|
386
|
+
immediate, deferred, terminal = partition_for_batch(messages)
|
|
387
|
+
|
|
388
|
+
# 1. Immediate signals (tier 0-1, 4) — emit synchronously now.
|
|
389
|
+
if immediate:
|
|
390
|
+
sink(immediate)
|
|
391
|
+
|
|
392
|
+
# 2. Deferred (tier 2) + Terminal (tier 3) — canonical order preserved.
|
|
393
|
+
if _should_defer_phase2(bs, defer_when):
|
|
394
|
+
if deferred:
|
|
395
|
+
|
|
396
|
+
def _emit_deferred() -> None:
|
|
397
|
+
sink(deferred)
|
|
398
|
+
|
|
399
|
+
queue.append(_wrap_deferred_subgraph(_emit_deferred, subgraph_lock))
|
|
400
|
+
if terminal:
|
|
401
|
+
|
|
402
|
+
def _emit_terminal() -> None:
|
|
403
|
+
sink(terminal)
|
|
404
|
+
|
|
405
|
+
queue.append(_wrap_deferred_subgraph(_emit_terminal, subgraph_lock))
|
|
406
|
+
else:
|
|
407
|
+
if deferred:
|
|
408
|
+
sink(deferred)
|
|
409
|
+
if terminal:
|
|
410
|
+
sink(terminal)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _emit_sequential(
|
|
414
|
+
sink: Callable[[Messages], None],
|
|
415
|
+
messages: Messages,
|
|
416
|
+
defer_when: DeferWhen,
|
|
417
|
+
subgraph_lock: object | None,
|
|
418
|
+
queue_attr: str = "pending",
|
|
419
|
+
) -> None:
|
|
420
|
+
bs = _batch_state()
|
|
421
|
+
queue: list[Callable[[], None]] = getattr(bs, queue_attr)
|
|
422
|
+
for msg in messages:
|
|
423
|
+
kind = msg[0]
|
|
424
|
+
if kind in _BATCH_DEFER_TYPES and _should_defer_phase2(bs, defer_when):
|
|
425
|
+
|
|
426
|
+
def _emit(m: Message = msg, s: Callable[[Messages], None] = sink) -> None:
|
|
427
|
+
s([m])
|
|
428
|
+
|
|
429
|
+
queue.append(_wrap_deferred_subgraph(_emit, subgraph_lock))
|
|
430
|
+
elif kind in _TERMINAL_TYPES and _should_defer_phase2(bs, defer_when):
|
|
431
|
+
# Terminal: defer so preceding deferred flushes first.
|
|
432
|
+
def _emit_term(m: Message = msg, s: Callable[[Messages], None] = sink) -> None:
|
|
433
|
+
s([m])
|
|
434
|
+
|
|
435
|
+
queue.append(_wrap_deferred_subgraph(_emit_term, subgraph_lock))
|
|
436
|
+
else:
|
|
437
|
+
sink([msg])
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def dispatch_messages(messages: Messages, sink: Callable[[Messages], None]) -> None:
|
|
441
|
+
"""Backward-compatible alias: ``emit_with_batch(sink, messages)`` with defaults."""
|
|
442
|
+
emit_with_batch(sink, messages)
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
__all__ = [
|
|
446
|
+
"DeferWhen",
|
|
447
|
+
"EmitStrategy",
|
|
448
|
+
"Message",
|
|
449
|
+
"MessageType",
|
|
450
|
+
"Messages",
|
|
451
|
+
"batch",
|
|
452
|
+
"dispatch_messages",
|
|
453
|
+
"emit_with_batch",
|
|
454
|
+
"is_batching",
|
|
455
|
+
"is_phase2_message",
|
|
456
|
+
"is_terminal_message",
|
|
457
|
+
"message_tier",
|
|
458
|
+
"partition_for_batch",
|
|
459
|
+
"propagates_to_meta",
|
|
460
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Runner protocol — async event loop integration for GraphReFly (roadmap §5.1).
|
|
2
|
+
|
|
3
|
+
A :class:`Runner` bridges GraphReFly's synchronous reactive core to an async
|
|
4
|
+
event loop (asyncio, trio, etc.). Sources like :func:`~graphrefly.extra.sources.from_awaitable`
|
|
5
|
+
and :func:`~graphrefly.extra.sources.from_async_iter` use the runner to schedule
|
|
6
|
+
coroutines instead of spawning daemon threads.
|
|
7
|
+
|
|
8
|
+
Usage::
|
|
9
|
+
|
|
10
|
+
from graphrefly.core.runner import set_default_runner
|
|
11
|
+
from graphrefly.compat import AsyncioRunner
|
|
12
|
+
|
|
13
|
+
runner = AsyncioRunner.from_running()
|
|
14
|
+
set_default_runner(runner)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import threading
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Callable, Coroutine
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# Runner protocol
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@runtime_checkable
|
|
31
|
+
class Runner(Protocol):
|
|
32
|
+
"""Bridge between GraphReFly's sync core and an async event loop.
|
|
33
|
+
|
|
34
|
+
Implementations must be safe to call ``schedule()`` from any thread.
|
|
35
|
+
``on_result`` / ``on_error`` may be invoked from the event loop thread
|
|
36
|
+
(which may differ from the calling thread).
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def schedule(
|
|
40
|
+
self,
|
|
41
|
+
coro: Coroutine[Any, Any, Any],
|
|
42
|
+
on_result: Callable[[Any], None],
|
|
43
|
+
on_error: Callable[[BaseException], None],
|
|
44
|
+
) -> Callable[[], None]:
|
|
45
|
+
"""Schedule *coro* for execution; return a cancel function.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
coro: The coroutine to run.
|
|
49
|
+
on_result: Called with the coroutine's return value on success.
|
|
50
|
+
on_error: Called with the exception on failure.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
A callable that cancels the scheduled coroutine (best-effort).
|
|
54
|
+
"""
|
|
55
|
+
...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Default runner — thread-local
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
_runner_tls = threading.local()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def set_default_runner(runner: Runner | None) -> None:
|
|
66
|
+
"""Set the default :class:`Runner` for the current thread.
|
|
67
|
+
|
|
68
|
+
Pass ``None`` to clear the runner (subsequent calls to
|
|
69
|
+
:func:`get_default_runner` will raise).
|
|
70
|
+
"""
|
|
71
|
+
_runner_tls.runner = runner
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def get_default_runner() -> Runner:
|
|
75
|
+
"""Return the current thread's default :class:`Runner`.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
RuntimeError: If no runner has been configured via
|
|
79
|
+
:func:`set_default_runner`.
|
|
80
|
+
"""
|
|
81
|
+
try:
|
|
82
|
+
r = _runner_tls.runner
|
|
83
|
+
except AttributeError:
|
|
84
|
+
r = None
|
|
85
|
+
if r is None:
|
|
86
|
+
msg = (
|
|
87
|
+
"No Runner configured. Call set_default_runner() with an "
|
|
88
|
+
"AsyncioRunner or TrioRunner before using async sources. "
|
|
89
|
+
"Example: set_default_runner(AsyncioRunner.from_running())"
|
|
90
|
+
)
|
|
91
|
+
raise RuntimeError(msg)
|
|
92
|
+
return r # type: ignore[no-any-return]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_runner(runner: Runner | None) -> Runner:
|
|
96
|
+
"""Return *runner* if provided, otherwise :func:`get_default_runner`."""
|
|
97
|
+
if runner is not None:
|
|
98
|
+
return runner
|
|
99
|
+
return get_default_runner()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
__all__ = [
|
|
103
|
+
"Runner",
|
|
104
|
+
"get_default_runner",
|
|
105
|
+
"resolve_runner",
|
|
106
|
+
"set_default_runner",
|
|
107
|
+
]
|