opencode-python-sdk 0.1.1__py3-none-any.whl → 0.1.3__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.
opencode/_events.py ADDED
@@ -0,0 +1,729 @@
1
+ """A shared, multiplexed event stream.
2
+
3
+ ``GET /api/event`` delivers every event of the server to every connection. Opening one connection per consumer
4
+ wastes server work and sockets, so :class:`EventHub` and :class:`AsyncEventHub` share a single connection among all
5
+ of their subscribers, the way ``client.event`` does in the official TypeScript client:
6
+
7
+ - The connection opens with the first subscriber and closes when the last one leaves.
8
+ - Each subscriber has its own bounded queue (:data:`SUBSCRIBER_CAPACITY` events). A subscriber that falls that far
9
+ behind fails with :class:`EventOverflowError`; the others keep receiving.
10
+ - The most recent ``server.connected`` event is replayed to subscribers that join after it arrived.
11
+
12
+ Beyond the TypeScript client, a hub can reconnect with backoff (:class:`Reconnect`), and subscriptions can be
13
+ narrowed with :meth:`Subscription.of_type`, :meth:`Subscription.for_session` and :meth:`Subscription.where`.
14
+
15
+ The event stream is volatile by contract: events published while no connection is open, including while a hub
16
+ waits to reconnect, are never delivered. Use it to react to changes, and read state through the API when exact
17
+ history matters.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import collections
24
+ import logging
25
+ import random
26
+ import threading
27
+ from dataclasses import dataclass
28
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
29
+
30
+ import anyio
31
+ from typing_extensions import Self
32
+
33
+ from ._exceptions import APIConnectionError, APIStatusError, OpencodeError
34
+ from .types import ServerConnectedEvent
35
+
36
+ if TYPE_CHECKING:
37
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
38
+ from types import TracebackType
39
+
40
+ from ._client import AsyncOpencode, Opencode
41
+ from ._streaming import AsyncStream, Stream
42
+ from .types import Event
43
+
44
+ __all__ = [
45
+ "SUBSCRIBER_CAPACITY",
46
+ "AsyncEventHub",
47
+ "AsyncSubscription",
48
+ "EventHub",
49
+ "EventOverflowError",
50
+ "Reconnect",
51
+ "Subscription",
52
+ ]
53
+
54
+ log = logging.getLogger("opencode")
55
+
56
+ _E = TypeVar("_E")
57
+ _T = TypeVar("_T")
58
+
59
+ SUBSCRIBER_CAPACITY = 4096
60
+ """Events a subscriber may have queued before it fails with :class:`EventOverflowError`. Matches the TS client."""
61
+
62
+
63
+ class EventOverflowError(OpencodeError):
64
+ """A subscriber fell more than :data:`SUBSCRIBER_CAPACITY` events behind the shared stream.
65
+
66
+ Only that subscriber fails. Its queued events are discarded, since they no longer form a gap-free sequence.
67
+ """
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Reconnect:
72
+ """Opt-in reconnection policy for an event hub.
73
+
74
+ After the connection ends or fails with a connection error, a 5xx or a 429, the hub waits and connects again.
75
+ The delay starts at ``initial_delay`` seconds, doubles per failed attempt up to ``max_delay``, gets up to 25%
76
+ jitter, and resets once a connection delivers an event. Other errors (authentication, malformed events) still
77
+ end every subscription.
78
+
79
+ Events published while disconnected are lost. The server sends ``server.connected`` again on every connection;
80
+ subscribers see it only once, and late subscribers get the latest one.
81
+
82
+ Args:
83
+ initial_delay: Seconds before the first reconnection attempt.
84
+ max_delay: Upper bound for the delay between attempts.
85
+ max_attempts: Consecutive failed attempts before giving up, or ``None`` to retry forever.
86
+ """
87
+
88
+ initial_delay: float = 0.5
89
+ max_delay: float = 30.0
90
+ max_attempts: int | None = None
91
+
92
+ def delay(self, attempt: int) -> float:
93
+ """Seconds to wait before reconnection attempt ``attempt`` (counting from zero)."""
94
+ delay = min(self.initial_delay * 2.0**attempt, self.max_delay)
95
+ return delay * (1 - 0.25 * random.random())
96
+
97
+ def allows(self, attempt: int) -> bool:
98
+ """Whether reconnection attempt ``attempt`` (counting from zero) may run."""
99
+ return self.max_attempts is None or attempt < self.max_attempts
100
+
101
+
102
+ def _policy(reconnect: bool | Reconnect) -> Reconnect | None:
103
+ if isinstance(reconnect, Reconnect):
104
+ return reconnect
105
+ return Reconnect() if reconnect else None
106
+
107
+
108
+ def _retryable(error: BaseException | None) -> bool:
109
+ """Whether a connection that ended with ``error`` (``None`` for a clean end) may be reopened."""
110
+ if error is None or isinstance(error, APIConnectionError):
111
+ return True
112
+ if isinstance(error, APIStatusError):
113
+ return error.status_code >= 500 or error.status_code == 429
114
+ return False
115
+
116
+
117
+ def session_id_of(event: object) -> str | None:
118
+ """Returns the session an event belongs to, or ``None`` for events outside any session.
119
+
120
+ Looks for ``data.session_id`` on typed events and ``data["sessionID"]`` on raw ones.
121
+ """
122
+ data: Any = getattr(event, "data", None)
123
+ if isinstance(data, dict):
124
+ value: object = cast("dict[str, Any]", data).get("sessionID")
125
+ else:
126
+ value = getattr(data, "session_id", None)
127
+ return value if isinstance(value, str) else None
128
+
129
+
130
+ class _Filters(Generic[_E]):
131
+ """Narrowing helpers shared by sync and async subscriptions."""
132
+
133
+ _predicates: tuple[Callable[[Any], bool], ...]
134
+
135
+ def _accepts(self, event: object) -> bool:
136
+ return all(predicate(event) for predicate in self._predicates)
137
+
138
+
139
+ _End = anyio.EndOfStream
140
+ """Raised by :meth:`AsyncSubscription.get` once the stream is over."""
141
+
142
+ # Completion states of one subscriber.
143
+ _OPEN: object = object()
144
+ _DONE: object = object()
145
+
146
+
147
+ class _SyncSubscriber:
148
+ def __init__(self, accepts: Callable[[object], bool], on_activity: Callable[[], object] | None) -> None:
149
+ self.accepts = accepts
150
+ self.on_activity = on_activity
151
+ self.queue: collections.deque[object] = collections.deque()
152
+ self.completion: object = _OPEN
153
+ self.cond = threading.Condition()
154
+
155
+ def push(self, event: object) -> bool:
156
+ """Queues an event. Returns ``False`` when the subscriber overflowed and must be dropped."""
157
+ if not self.accepts(event):
158
+ return True
159
+ with self.cond:
160
+ if self.completion is not _OPEN:
161
+ return True
162
+ if len(self.queue) >= SUBSCRIBER_CAPACITY:
163
+ self._finish(EventOverflowError(f"Event subscriber exceeded its {SUBSCRIBER_CAPACITY}-event capacity"))
164
+ return False
165
+ self.queue.append(event)
166
+ self.cond.notify_all()
167
+ return True
168
+
169
+ def finish(self, completion: object, *, discard: bool) -> None:
170
+ with self.cond:
171
+ if self.completion is _OPEN:
172
+ self._finish(completion, discard=discard)
173
+
174
+ def _finish(self, completion: object, *, discard: bool = True) -> None:
175
+ self.completion = completion
176
+ if discard:
177
+ self.queue.clear()
178
+ self.cond.notify_all()
179
+
180
+
181
+ class _SyncConnection:
182
+ def __init__(self) -> None:
183
+ self.subscribers: set[_SyncSubscriber] = set()
184
+ self.connected: ServerConnectedEvent | None = None
185
+ self.stopped = threading.Event()
186
+ self.stream: Stream[Event] | None = None
187
+ self.delivered = False
188
+
189
+ def activity(self) -> None:
190
+ for subscriber in list(self.subscribers):
191
+ if subscriber.on_activity is not None:
192
+ subscriber.on_activity()
193
+
194
+
195
+ class EventHub:
196
+ """One shared event connection for any number of synchronous subscribers.
197
+
198
+ ``client.events`` is the hub of a client. Create another hub with different options, such as reconnection, with
199
+ ``EventHub(client, reconnect=True)``. A background thread reads the connection while it is open.
200
+
201
+ Example::
202
+
203
+ with client.events.of_type(SessionIdleEvent).for_session(session.id) as idle:
204
+ client.session.prompt(session.id, text="Hello")
205
+ next(idle)
206
+
207
+ Args:
208
+ client: The client that opens the connection.
209
+ reconnect: ``True`` or a :class:`Reconnect` policy to reopen the connection after it drops. Off by default.
210
+ """
211
+
212
+ def __init__(self, client: Opencode, *, reconnect: bool | Reconnect = False) -> None:
213
+ self._client = client
214
+ self._reconnect = _policy(reconnect)
215
+ self._lock = threading.Lock()
216
+ self._current: _SyncConnection | None = None
217
+
218
+ def subscribe(self, *, on_activity: Callable[[], object] | None = None) -> Subscription[Event]:
219
+ """Returns a subscription to every event. It joins the shared connection when first iterated.
220
+
221
+ Args:
222
+ on_activity: Called on the reader thread for every chunk received, including keepalive comments.
223
+ Use it as a liveness signal; keep it fast.
224
+ """
225
+ return Subscription(self, (), on_activity)
226
+
227
+ def of_type(self, *types: type[_T]) -> Subscription[_T]:
228
+ """Shortcut for ``subscribe().of_type(*types)``."""
229
+ return self.subscribe().of_type(*types)
230
+
231
+ def for_session(self, session_id: str) -> Subscription[Event]:
232
+ """Shortcut for ``subscribe().for_session(session_id)``."""
233
+ return self.subscribe().for_session(session_id)
234
+
235
+ def __iter__(self) -> Iterator[Event]:
236
+ """Iterates a new subscription. Prefer ``with hub.subscribe() as events`` to release it deterministically."""
237
+ return iter(self.subscribe())
238
+
239
+ def _join(self, subscriber: _SyncSubscriber) -> _SyncConnection:
240
+ with self._lock:
241
+ connection = self._current
242
+ fresh = connection is None
243
+ if connection is None:
244
+ connection = self._current = _SyncConnection()
245
+ connection.subscribers.add(subscriber)
246
+ if connection.connected is not None:
247
+ subscriber.push(connection.connected)
248
+ if fresh:
249
+ threading.Thread(target=self._run, args=(connection,), name="opencode-events", daemon=True).start()
250
+ return connection
251
+
252
+ def _leave(self, connection: _SyncConnection, subscriber: _SyncSubscriber) -> None:
253
+ with self._lock:
254
+ connection.subscribers.discard(subscriber)
255
+ if not connection.subscribers:
256
+ self._stop(connection)
257
+
258
+ def _stop(self, connection: _SyncConnection) -> None:
259
+ # Called with the lock held.
260
+ connection.stopped.set()
261
+ if self._current is connection:
262
+ self._current = None
263
+ if connection.stream is not None:
264
+ connection.stream.interrupt()
265
+
266
+ def _publish(self, connection: _SyncConnection, event: Event) -> None:
267
+ for subscriber in list(connection.subscribers):
268
+ if not subscriber.push(event):
269
+ self._leave(connection, subscriber)
270
+
271
+ def _run(self, connection: _SyncConnection) -> None:
272
+ error: BaseException | None = None
273
+ attempt = 0
274
+ try:
275
+ while not connection.stopped.is_set():
276
+ error = None
277
+ connection.delivered = False
278
+ try:
279
+ self._read(connection)
280
+ except Exception as exc:
281
+ error = exc
282
+ if connection.stopped.is_set():
283
+ error = None
284
+ break
285
+ if connection.delivered:
286
+ attempt = 0
287
+ if self._reconnect is None or not _retryable(error) or not self._reconnect.allows(attempt):
288
+ break
289
+ delay = self._reconnect.delay(attempt)
290
+ log.info("Event stream ended (%r); reconnecting in %.2fs", error, delay)
291
+ attempt += 1
292
+ if connection.stopped.wait(delay):
293
+ error = None
294
+ break
295
+ finally:
296
+ with self._lock:
297
+ self._stop(connection)
298
+ subscribers = list(connection.subscribers)
299
+ completion = _DONE if error is None else error
300
+ for subscriber in subscribers:
301
+ subscriber.finish(completion, discard=False)
302
+
303
+ def _read(self, connection: _SyncConnection) -> None:
304
+ """Reads one connection until it ends, setting ``connection.delivered`` once an event arrives."""
305
+ stream = self._client.event.subscribe()
306
+ with self._lock:
307
+ connection.stream = stream
308
+ stopped = connection.stopped.is_set()
309
+ try:
310
+ if stopped:
311
+ return
312
+ stream.on_activity = connection.activity
313
+ # A fresh connection replays nothing yet; reconnects must not repeat server.connected.
314
+ seen_connected = connection.connected is not None
315
+ for event in stream:
316
+ if connection.stopped.is_set():
317
+ return
318
+ connection.delivered = True
319
+ if isinstance(event, ServerConnectedEvent):
320
+ connection.connected = event
321
+ if seen_connected:
322
+ continue
323
+ seen_connected = True
324
+ self._publish(connection, event)
325
+ finally:
326
+ connection.stream = None
327
+ stream.close()
328
+
329
+
330
+ class Subscription(_Filters[_E]):
331
+ """One consumer of a shared event stream. Iterate it with ``for``; close it, or use ``with``, to leave.
332
+
333
+ The subscription joins the shared connection when entered as a context manager or first iterated, so filters
334
+ can be chained before that::
335
+
336
+ for event in client.events.for_session(session_id).of_type(SessionIdleEvent):
337
+ ...
338
+
339
+ Iteration ends when the connection ends, and raises the connection's error if it failed, after delivering the
340
+ events already queued. ``close()`` may be called from any thread to stop a blocked iteration.
341
+ """
342
+
343
+ def __init__(
344
+ self,
345
+ hub: EventHub,
346
+ predicates: tuple[Callable[[Any], bool], ...],
347
+ on_activity: Callable[[], object] | None,
348
+ ) -> None:
349
+ self._hub = hub
350
+ self._predicates = predicates
351
+ self._on_activity = on_activity
352
+ self._subscriber: _SyncSubscriber | None = None
353
+ self._connection: _SyncConnection | None = None
354
+ self._closed = False
355
+
356
+ def _narrowed(self, predicate: Callable[[Any], bool]) -> Any:
357
+ if self._subscriber is not None:
358
+ raise RuntimeError("Filters must be added before the subscription is iterated")
359
+ return Subscription[Any](self._hub, (*self._predicates, predicate), self._on_activity)
360
+
361
+ def of_type(self, *types: type[_T]) -> Subscription[_T]:
362
+ """Returns a subscription that only yields events that are instances of ``types``.
363
+
364
+ Type checkers narrow the yielded type, so ``of_type(SessionIdleEvent)`` yields ``SessionIdleEvent``.
365
+ """
366
+ if not types:
367
+ raise TypeError("of_type() needs at least one event type")
368
+ return cast("Subscription[_T]", self._narrowed(lambda event: isinstance(event, types)))
369
+
370
+ def for_session(self, session_id: str) -> Subscription[_E]:
371
+ """Returns a subscription that only yields events of one session (see :func:`session_id_of`)."""
372
+ return cast("Subscription[_E]", self._narrowed(lambda event: session_id_of(event) == session_id))
373
+
374
+ def where(self, predicate: Callable[[_E], bool]) -> Subscription[_E]:
375
+ """Returns a subscription that only yields events for which ``predicate`` returns true.
376
+
377
+ The predicate runs on the reader thread, after the other filters of this subscription.
378
+ """
379
+ return cast("Subscription[_E]", self._narrowed(predicate))
380
+
381
+ def __iter__(self) -> Self:
382
+ return self
383
+
384
+ def __next__(self) -> _E:
385
+ return self.get()
386
+
387
+ def get(self, timeout: float | None = None) -> _E:
388
+ """Returns the next event, waiting up to ``timeout`` seconds (forever when ``None``).
389
+
390
+ Raises:
391
+ StopIteration: The connection ended or the subscription was closed.
392
+ TimeoutError: No event arrived in time. The subscription stays usable.
393
+ EventOverflowError: This subscriber fell too far behind.
394
+ """
395
+ subscriber = self._start()
396
+ if subscriber is None:
397
+ raise StopIteration
398
+ with subscriber.cond:
399
+ if not subscriber.cond.wait_for(lambda: subscriber.queue or subscriber.completion is not _OPEN, timeout):
400
+ raise TimeoutError("No event arrived in time")
401
+ if subscriber.queue:
402
+ return cast("_E", subscriber.queue.popleft())
403
+ completion = subscriber.completion
404
+ if isinstance(completion, BaseException):
405
+ raise completion
406
+ raise StopIteration
407
+
408
+ def _start(self) -> _SyncSubscriber | None:
409
+ if self._subscriber is None and not self._closed:
410
+ self._subscriber = _SyncSubscriber(self._accepts, self._on_activity)
411
+ self._connection = self._hub._join(self._subscriber) # pyright: ignore[reportPrivateUsage]
412
+ return self._subscriber
413
+
414
+ def close(self) -> None:
415
+ """Leaves the shared stream. Pending and future iterations end. Safe to call more than once."""
416
+ self._closed = True
417
+ subscriber, connection = self._subscriber, self._connection
418
+ if subscriber is not None and connection is not None:
419
+ subscriber.finish(_DONE, discard=True)
420
+ self._hub._leave(connection, subscriber) # pyright: ignore[reportPrivateUsage]
421
+
422
+ def __enter__(self) -> Self:
423
+ """Joins the shared stream now, so every event published after this point is delivered."""
424
+ self._start()
425
+ return self
426
+
427
+ def __exit__(
428
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
429
+ ) -> None:
430
+ self.close()
431
+
432
+
433
+ class _AsyncSubscriber:
434
+ def __init__(self, accepts: Callable[[object], bool], on_activity: Callable[[], object] | None) -> None:
435
+ self.accepts = accepts
436
+ self.on_activity = on_activity
437
+ self.queue: collections.deque[object] = collections.deque()
438
+ self.completion: object = _OPEN
439
+ self.wakeup: anyio.Event | None = None
440
+
441
+ def _wake(self) -> None:
442
+ if self.wakeup is not None:
443
+ self.wakeup.set()
444
+ self.wakeup = None
445
+
446
+ def push(self, event: object) -> bool:
447
+ if self.completion is not _OPEN or not self.accepts(event):
448
+ return True
449
+ if len(self.queue) >= SUBSCRIBER_CAPACITY:
450
+ self.finish(EventOverflowError(f"Event subscriber exceeded its {SUBSCRIBER_CAPACITY}-event capacity"))
451
+ return False
452
+ self.queue.append(event)
453
+ self._wake()
454
+ return True
455
+
456
+ def finish(self, completion: object, *, discard: bool = True) -> None:
457
+ if self.completion is not _OPEN:
458
+ return
459
+ self.completion = completion
460
+ if discard:
461
+ self.queue.clear()
462
+ self._wake()
463
+
464
+
465
+ class _AsyncConnection:
466
+ def __init__(self) -> None:
467
+ self.subscribers: set[_AsyncSubscriber] = set()
468
+ self.connected: ServerConnectedEvent | None = None
469
+ self.stopped = False
470
+ self.scope: anyio.CancelScope | None = None
471
+ self.delivered = False
472
+
473
+ def is_stopped(self) -> bool:
474
+ # A method, so type checkers do not narrow ``stopped`` across awaits that may change it.
475
+ return self.stopped
476
+
477
+ def activity(self) -> None:
478
+ for subscriber in list(self.subscribers):
479
+ if subscriber.on_activity is not None:
480
+ subscriber.on_activity()
481
+
482
+
483
+ _background: set[object] = set()
484
+
485
+
486
+ def _spawn(run: Callable[[], Awaitable[None]]) -> None:
487
+ """Starts ``run`` as a detached task of the running event loop (asyncio or trio)."""
488
+ try:
489
+ loop = asyncio.get_running_loop()
490
+ except RuntimeError:
491
+ import trio # only reachable under trio
492
+
493
+ trio.lowlevel.spawn_system_task(run, name="opencode-events")
494
+ return
495
+ task = loop.create_task(cast("Any", run()), name="opencode-events")
496
+ _background.add(task)
497
+ task.add_done_callback(_background.discard)
498
+
499
+
500
+ class AsyncEventHub:
501
+ """One shared event connection for any number of asynchronous subscribers. Works with asyncio and trio.
502
+
503
+ ``client.events`` is the hub of a client. Create another hub with different options, such as reconnection, with
504
+ ``AsyncEventHub(client, reconnect=True)``. The connection is read by a detached task of the running event loop,
505
+ and a hub must only be used from one event loop.
506
+
507
+ Example::
508
+
509
+ async with client.events.of_type(SessionIdleEvent).for_session(session.id) as idle:
510
+ await client.session.prompt(session.id, text="Hello")
511
+ await idle.get()
512
+
513
+ Args:
514
+ client: The client that opens the connection.
515
+ reconnect: ``True`` or a :class:`Reconnect` policy to reopen the connection after it drops. Off by default.
516
+ """
517
+
518
+ def __init__(self, client: AsyncOpencode, *, reconnect: bool | Reconnect = False) -> None:
519
+ self._client = client
520
+ self._reconnect = _policy(reconnect)
521
+ self._current: _AsyncConnection | None = None
522
+
523
+ def subscribe(self, *, on_activity: Callable[[], object] | None = None) -> AsyncSubscription[Event]:
524
+ """Returns a subscription to every event. It joins the shared connection when first iterated.
525
+
526
+ Args:
527
+ on_activity: Called for every chunk received, including keepalive comments. Use it as a liveness
528
+ signal; keep it fast and non-blocking.
529
+ """
530
+ return AsyncSubscription(self, (), on_activity)
531
+
532
+ def of_type(self, *types: type[_T]) -> AsyncSubscription[_T]:
533
+ """Shortcut for ``subscribe().of_type(*types)``."""
534
+ return self.subscribe().of_type(*types)
535
+
536
+ def for_session(self, session_id: str) -> AsyncSubscription[Event]:
537
+ """Shortcut for ``subscribe().for_session(session_id)``."""
538
+ return self.subscribe().for_session(session_id)
539
+
540
+ def __aiter__(self) -> AsyncIterator[Event]:
541
+ """Iterates a new subscription. Prefer ``async with hub.subscribe() as events`` to release it."""
542
+ return self.subscribe()
543
+
544
+ def _join(self, subscriber: _AsyncSubscriber) -> _AsyncConnection:
545
+ connection = self._current
546
+ if connection is None:
547
+ connection = self._current = _AsyncConnection()
548
+ connection.subscribers.add(subscriber)
549
+ _spawn(lambda: self._run(connection))
550
+ else:
551
+ connection.subscribers.add(subscriber)
552
+ if connection.connected is not None:
553
+ subscriber.push(connection.connected)
554
+ return connection
555
+
556
+ def _leave(self, connection: _AsyncConnection, subscriber: _AsyncSubscriber) -> None:
557
+ connection.subscribers.discard(subscriber)
558
+ if not connection.subscribers:
559
+ self._stop(connection)
560
+
561
+ def _stop(self, connection: _AsyncConnection) -> None:
562
+ connection.stopped = True
563
+ if self._current is connection:
564
+ self._current = None
565
+ if connection.scope is not None:
566
+ connection.scope.cancel()
567
+
568
+ def _publish(self, connection: _AsyncConnection, event: Event) -> None:
569
+ for subscriber in list(connection.subscribers):
570
+ if not subscriber.push(event):
571
+ self._leave(connection, subscriber)
572
+
573
+ async def _run(self, connection: _AsyncConnection) -> None:
574
+ error: BaseException | None = None
575
+ attempt = 0
576
+ try:
577
+ with anyio.CancelScope() as scope:
578
+ connection.scope = scope
579
+ if connection.stopped:
580
+ scope.cancel()
581
+ while not connection.stopped:
582
+ error = None
583
+ connection.delivered = False
584
+ try:
585
+ await self._read(connection)
586
+ except Exception as exc:
587
+ error = exc
588
+ if connection.is_stopped():
589
+ break
590
+ if connection.delivered:
591
+ attempt = 0
592
+ if self._reconnect is None or not _retryable(error) or not self._reconnect.allows(attempt):
593
+ break
594
+ delay = self._reconnect.delay(attempt)
595
+ log.info("Event stream ended (%r); reconnecting in %.2fs", error, delay)
596
+ attempt += 1
597
+ await anyio.sleep(delay)
598
+ finally:
599
+ if connection.stopped:
600
+ error = None
601
+ self._stop(connection)
602
+ completion = _DONE if error is None else error
603
+ for subscriber in list(connection.subscribers):
604
+ subscriber.finish(completion, discard=False)
605
+
606
+ async def _read(self, connection: _AsyncConnection) -> None:
607
+ """Reads one connection until it ends, setting ``connection.delivered`` once an event arrives."""
608
+ stream: AsyncStream[Event] = await self._client.event.subscribe()
609
+ try:
610
+ stream.on_activity = connection.activity
611
+ seen_connected = connection.connected is not None
612
+ async for event in stream:
613
+ connection.delivered = True
614
+ if isinstance(event, ServerConnectedEvent):
615
+ connection.connected = event
616
+ if seen_connected:
617
+ continue
618
+ seen_connected = True
619
+ self._publish(connection, event)
620
+ finally:
621
+ with anyio.CancelScope(shield=True):
622
+ await stream.close()
623
+
624
+
625
+ class AsyncSubscription(_Filters[_E]):
626
+ """One consumer of a shared event stream. Iterate it with ``async for``; close it, or use ``async with``.
627
+
628
+ The subscription joins the shared connection when entered as a context manager or first iterated, so filters
629
+ can be chained before that::
630
+
631
+ async for event in client.events.for_session(session_id).of_type(SessionIdleEvent):
632
+ ...
633
+
634
+ Iteration ends when the connection ends, and raises the connection's error if it failed, after delivering the
635
+ events already queued. Cancelling a task that waits for an event (``anyio.fail_after``, ``asyncio.timeout``,
636
+ trio cancel scopes) leaves the subscription usable; close it to leave the stream.
637
+ """
638
+
639
+ def __init__(
640
+ self,
641
+ hub: AsyncEventHub,
642
+ predicates: tuple[Callable[[Any], bool], ...],
643
+ on_activity: Callable[[], object] | None,
644
+ ) -> None:
645
+ self._hub = hub
646
+ self._predicates = predicates
647
+ self._on_activity = on_activity
648
+ self._subscriber: _AsyncSubscriber | None = None
649
+ self._connection: _AsyncConnection | None = None
650
+ self._closed = False
651
+
652
+ def _narrowed(self, predicate: Callable[[Any], bool]) -> Any:
653
+ if self._subscriber is not None:
654
+ raise RuntimeError("Filters must be added before the subscription is iterated")
655
+ return AsyncSubscription[Any](self._hub, (*self._predicates, predicate), self._on_activity)
656
+
657
+ def of_type(self, *types: type[_T]) -> AsyncSubscription[_T]:
658
+ """Returns a subscription that only yields events that are instances of ``types``.
659
+
660
+ Type checkers narrow the yielded type, so ``of_type(SessionIdleEvent)`` yields ``SessionIdleEvent``.
661
+ """
662
+ if not types:
663
+ raise TypeError("of_type() needs at least one event type")
664
+ return cast("AsyncSubscription[_T]", self._narrowed(lambda event: isinstance(event, types)))
665
+
666
+ def for_session(self, session_id: str) -> AsyncSubscription[_E]:
667
+ """Returns a subscription that only yields events of one session (see :func:`session_id_of`)."""
668
+ return cast("AsyncSubscription[_E]", self._narrowed(lambda event: session_id_of(event) == session_id))
669
+
670
+ def where(self, predicate: Callable[[_E], bool]) -> AsyncSubscription[_E]:
671
+ """Returns a subscription that only yields events for which ``predicate`` returns true."""
672
+ return cast("AsyncSubscription[_E]", self._narrowed(predicate))
673
+
674
+ def __aiter__(self) -> Self:
675
+ return self
676
+
677
+ async def __anext__(self) -> _E:
678
+ try:
679
+ return await self.get()
680
+ except _End:
681
+ raise StopAsyncIteration from None
682
+
683
+ async def get(self) -> _E:
684
+ """Returns the next event. Bound the wait with ``anyio.fail_after`` or your event loop's timeout.
685
+
686
+ Raises:
687
+ EndOfStream: The connection ended or the subscription was closed.
688
+ EventOverflowError: This subscriber fell too far behind.
689
+ """
690
+ subscriber = self._start()
691
+ if subscriber is None:
692
+ raise _End
693
+ while not subscriber.queue and subscriber.completion is _OPEN:
694
+ wakeup = subscriber.wakeup = subscriber.wakeup or anyio.Event()
695
+ await wakeup.wait()
696
+ if subscriber.queue:
697
+ return cast("_E", subscriber.queue.popleft())
698
+ if isinstance(subscriber.completion, BaseException):
699
+ raise subscriber.completion
700
+ raise _End
701
+
702
+ def _start(self) -> _AsyncSubscriber | None:
703
+ if self._subscriber is None and not self._closed:
704
+ self._subscriber = _AsyncSubscriber(self._accepts, self._on_activity)
705
+ self._connection = self._hub._join(self._subscriber) # pyright: ignore[reportPrivateUsage]
706
+ return self._subscriber
707
+
708
+ async def close(self) -> None:
709
+ """Leaves the shared stream. Pending and future iterations end. Safe to call more than once."""
710
+ self.close_nowait()
711
+ await anyio.lowlevel.checkpoint()
712
+
713
+ def close_nowait(self) -> None:
714
+ """Synchronous form of :meth:`close`, for callbacks and ``finally`` blocks that cannot await."""
715
+ self._closed = True
716
+ subscriber, connection = self._subscriber, self._connection
717
+ if subscriber is not None and connection is not None:
718
+ subscriber.finish(_DONE)
719
+ self._hub._leave(connection, subscriber) # pyright: ignore[reportPrivateUsage]
720
+
721
+ async def __aenter__(self) -> Self:
722
+ """Joins the shared stream now, so every event published after this point is delivered."""
723
+ self._start()
724
+ return self
725
+
726
+ async def __aexit__(
727
+ self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
728
+ ) -> None:
729
+ self.close_nowait()