graphrefly 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. graphrefly/__init__.py +160 -0
  2. graphrefly/compat/__init__.py +18 -0
  3. graphrefly/compat/async_utils.py +228 -0
  4. graphrefly/compat/asyncio_runner.py +89 -0
  5. graphrefly/compat/trio_runner.py +81 -0
  6. graphrefly/core/__init__.py +142 -0
  7. graphrefly/core/clock.py +20 -0
  8. graphrefly/core/dynamic_node.py +749 -0
  9. graphrefly/core/guard.py +277 -0
  10. graphrefly/core/meta.py +149 -0
  11. graphrefly/core/node.py +963 -0
  12. graphrefly/core/protocol.py +460 -0
  13. graphrefly/core/runner.py +107 -0
  14. graphrefly/core/subgraph_locks.py +296 -0
  15. graphrefly/core/sugar.py +138 -0
  16. graphrefly/core/versioning.py +193 -0
  17. graphrefly/extra/__init__.py +313 -0
  18. graphrefly/extra/adapters.py +2149 -0
  19. graphrefly/extra/backoff.py +287 -0
  20. graphrefly/extra/backpressure.py +113 -0
  21. graphrefly/extra/checkpoint.py +307 -0
  22. graphrefly/extra/composite.py +303 -0
  23. graphrefly/extra/cron.py +133 -0
  24. graphrefly/extra/data_structures.py +707 -0
  25. graphrefly/extra/resilience.py +727 -0
  26. graphrefly/extra/sources.py +766 -0
  27. graphrefly/extra/tier1.py +1067 -0
  28. graphrefly/extra/tier2.py +1802 -0
  29. graphrefly/graph/__init__.py +31 -0
  30. graphrefly/graph/graph.py +2249 -0
  31. graphrefly/integrations/__init__.py +1 -0
  32. graphrefly/integrations/fastapi.py +767 -0
  33. graphrefly/patterns/__init__.py +5 -0
  34. graphrefly/patterns/ai.py +2132 -0
  35. graphrefly/patterns/cqrs.py +515 -0
  36. graphrefly/patterns/memory.py +639 -0
  37. graphrefly/patterns/messaging.py +553 -0
  38. graphrefly/patterns/orchestration.py +536 -0
  39. graphrefly/patterns/reactive_layout/__init__.py +81 -0
  40. graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
  41. graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
  42. graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
  43. graphrefly/py.typed +1 -0
  44. graphrefly-0.1.0.dist-info/METADATA +253 -0
  45. graphrefly-0.1.0.dist-info/RECORD +47 -0
  46. graphrefly-0.1.0.dist-info/WHEEL +4 -0
  47. graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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
+ ]