Skip to content

Commit 09c90f2

Browse files
committed
feat: fix inspection tool
1 parent 94aa9af commit 09c90f2

13 files changed

Lines changed: 304 additions & 161 deletions

TRASH-FILES.md

Lines changed: 0 additions & 2 deletions
This file was deleted.

TRASH/EmitStrategy.md

Lines changed: 0 additions & 10 deletions
This file was deleted.

TRASH/emit_with_batch.md

Lines changed: 0 additions & 43 deletions
This file was deleted.

src/graphrefly/core/runner.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,24 @@ def resolve_runner(runner: Runner | None) -> Runner:
9999
return get_default_runner()
100100

101101

102+
def is_runner_registered() -> bool:
103+
"""Return ``True`` if a default :class:`Runner` is set for the current thread.
104+
105+
**Debug / test only** — not part of the public API. Used by
106+
``harnessProfile`` to surface ``runner_registered=False`` in diagnostic
107+
output so missing-Runner stalls are immediately visible.
108+
"""
109+
try:
110+
r = _runner_tls.runner
111+
except AttributeError:
112+
return False
113+
return r is not None
114+
115+
102116
__all__ = [
103117
"Runner",
104118
"get_default_runner",
119+
"is_runner_registered",
105120
"resolve_runner",
106121
"set_default_runner",
107122
]

src/graphrefly/extra/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@
138138
cached,
139139
empty,
140140
first_value_from,
141+
first_where,
141142
for_each,
142143
from_any,
143144
from_async_iter,
@@ -250,6 +251,7 @@
250251
"log_slice",
251252
"empty",
252253
"first_value_from",
254+
"first_where",
253255
"for_each",
254256
"from_any",
255257
"from_async_iter",

src/graphrefly/extra/sources.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -543,6 +543,12 @@ def first_value_from(
543543
On ``COMPLETE`` without prior ``DATA``, raises :exc:`StopIteration`. With
544544
*timeout*, raises :exc:`TimeoutError` if no terminal message arrives in time.
545545
546+
**Important:** This subscribes to *source* and waits for a **future**
547+
emission. It does NOT read the cached value — data that has already
548+
flowed is gone. You must call this **before** the upstream emits, or
549+
use ``source.get()`` / ``source.status`` to read already-cached state.
550+
See COMPOSITION-GUIDE §2 (subscription ordering).
551+
546552
Args:
547553
source: The node to await the first value from.
548554
timeout: Optional timeout in seconds.
@@ -602,6 +608,75 @@ def sink(msgs: Messages) -> None:
602608
return got[0]
603609

604610

611+
def first_where(
612+
source: Node[Any],
613+
predicate: Callable[[Any], bool],
614+
*,
615+
timeout: float | None = None,
616+
) -> Any:
617+
"""Block until the first ``DATA`` value satisfying *predicate* arrives.
618+
619+
Subscribes directly and checks *predicate* on each ``DATA`` emission.
620+
No polling. Use in tests and bridging code where you need to wait for
621+
a specific value synchronously.
622+
623+
**Important:** This only captures **future** emissions — data that has
624+
already flowed through the node is gone and will not be seen. You must
625+
call this **before** the upstream emits. For already-cached values, use
626+
``source.get()`` / ``source.status`` instead. See COMPOSITION-GUIDE §2.
627+
628+
Args:
629+
source: The node to observe.
630+
predicate: Called with each DATA value; returns ``True`` to accept.
631+
timeout: Optional timeout in seconds.
632+
633+
Returns:
634+
The first DATA payload where ``predicate(value)`` is ``True``.
635+
636+
Example:
637+
```python
638+
val = first_where(strategy.node, lambda snap: len(snap) > 0, timeout=5.0)
639+
```
640+
"""
641+
got: list[Any | None] = [None]
642+
err_box: list[BaseException | Any | None] = [None]
643+
done = threading.Event()
644+
645+
def sink(msgs: Messages) -> None:
646+
for m in msgs:
647+
t = m[0]
648+
if t is MessageType.DATA:
649+
v = _msg_val(m)
650+
if got[0] is None and predicate(v):
651+
got[0] = v
652+
done.set()
653+
elif t is MessageType.ERROR:
654+
err_box[0] = _msg_val(m)
655+
done.set()
656+
elif t is MessageType.COMPLETE:
657+
done.set()
658+
659+
unsub = source.subscribe(sink)
660+
try:
661+
if timeout is None:
662+
done.wait()
663+
elif not done.wait(timeout):
664+
msg = "first_where timed out"
665+
raise TimeoutError(msg)
666+
finally:
667+
unsub()
668+
669+
err = err_box[0]
670+
if err is not None:
671+
if isinstance(err, BaseException):
672+
raise err
673+
raise RuntimeError(str(err))
674+
if got[0] is None:
675+
msg = "first_where: source completed without a matching value"
676+
raise StopIteration(msg)
677+
return got[0]
678+
679+
605680
# --- multicast ----------------------------------------------------------------
606681

607682

src/graphrefly/patterns/harness/profile.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from dataclasses import dataclass
1010
from typing import TYPE_CHECKING, Any
1111

12+
from graphrefly.core.runner import is_runner_registered
1213
from graphrefly.graph.profile import graph_profile
1314

1415
if TYPE_CHECKING:
@@ -36,6 +37,8 @@ class HarnessProfileResult:
3637
strategy_entries: int
3738
total_retries: int
3839
total_reingestions: int
40+
# Runner diagnostic (session doc: surfaces registered=False immediately)
41+
runner_registered: bool
3942

4043

4144
# ---------------------------------------------------------------------------
@@ -77,4 +80,5 @@ def harness_profile(
7780
strategy_entries=strategy_entries,
7881
total_retries=harness.total_retries.get() or 0,
7982
total_reingestions=harness.total_reingestions.get() or 0,
83+
runner_registered=is_runner_registered(),
8084
)

0 commit comments

Comments
 (0)