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