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/__init__.py +52 -0
- opencode/_client.py +23 -0
- opencode/_events.py +729 -0
- opencode/_pty.py +586 -0
- opencode/_rpc.py +482 -0
- opencode/_streaming.py +22 -1
- opencode/resources/experimental.py +3 -2
- opencode/resources/pty.py +3 -2
- {opencode_python_sdk-0.1.1.dist-info → opencode_python_sdk-0.1.3.dist-info}/METADATA +100 -1
- {opencode_python_sdk-0.1.1.dist-info → opencode_python_sdk-0.1.3.dist-info}/RECORD +12 -9
- {opencode_python_sdk-0.1.1.dist-info → opencode_python_sdk-0.1.3.dist-info}/WHEEL +0 -0
- {opencode_python_sdk-0.1.1.dist-info → opencode_python_sdk-0.1.3.dist-info}/licenses/LICENSE +0 -0
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()
|