@@ -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
0 commit comments