opencode-python-sdk 0.1.2__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 +48 -0
- opencode/_client.py +23 -0
- opencode/_events.py +729 -0
- opencode/_rpc.py +482 -0
- opencode/_streaming.py +22 -1
- {opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.1.3.dist-info}/METADATA +77 -1
- {opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.1.3.dist-info}/RECORD +9 -7
- {opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.1.3.dist-info}/WHEEL +0 -0
- {opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.1.3.dist-info}/licenses/LICENSE +0 -0
opencode/_rpc.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Typed calls to plugin RPCs.
|
|
2
|
+
|
|
3
|
+
A plugin registers an RPC with an id, methods and events. Declare the same shape once as a class, then call it
|
|
4
|
+
through ``client.rpc(Definition)``, the counterpart of ``client.rpc(definition)`` in the official TypeScript client::
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
from opencode import RpcDefinition, rpc_event, rpc_method
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AddInput(BaseModel):
|
|
11
|
+
amount: int
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Total(BaseModel):
|
|
15
|
+
total: int
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Counter(RpcDefinition, id="counter"):
|
|
19
|
+
add = rpc_method(AddInput, Total)
|
|
20
|
+
reset = rpc_method(None, Total)
|
|
21
|
+
changed = rpc_event(Total)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
counter = client.rpc(Counter)
|
|
25
|
+
print(counter.call(Counter.add, AddInput(amount=2)).total)
|
|
26
|
+
with counter.subscribe(Counter.changed) as changes:
|
|
27
|
+
for change in changes:
|
|
28
|
+
print(change.data.total)
|
|
29
|
+
|
|
30
|
+
Inputs are validated and serialized, and outputs and event data validated, with pydantic, so any type pydantic
|
|
31
|
+
accepts works: models, dataclasses, ``TypedDict``, ``list[int]``. RPC failures raise :class:`RpcCallError`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypeVar, cast, overload
|
|
38
|
+
|
|
39
|
+
import pydantic
|
|
40
|
+
from typing_extensions import Self
|
|
41
|
+
|
|
42
|
+
from ._base_client import type_adapter
|
|
43
|
+
from ._errors import RpcError, RpcInternalError
|
|
44
|
+
from ._exceptions import OpencodeError, validation_summary
|
|
45
|
+
from ._types import NOT_GIVEN, NotGiven
|
|
46
|
+
from .resources.rpc import AsyncRpcResource, RpcResource
|
|
47
|
+
from .types import RpcEvent
|
|
48
|
+
|
|
49
|
+
if TYPE_CHECKING:
|
|
50
|
+
from collections.abc import Mapping
|
|
51
|
+
from types import TracebackType
|
|
52
|
+
|
|
53
|
+
from ._client import AsyncOpencode, Opencode
|
|
54
|
+
from ._events import AsyncSubscription, Subscription
|
|
55
|
+
from ._types import Headers, Timeout
|
|
56
|
+
from .types import Location
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"AsyncRpcClient",
|
|
60
|
+
"AsyncRpcEvents",
|
|
61
|
+
"RpcCallError",
|
|
62
|
+
"RpcCallInternalError",
|
|
63
|
+
"RpcClient",
|
|
64
|
+
"RpcDefinition",
|
|
65
|
+
"RpcEventDefinition",
|
|
66
|
+
"RpcEventPayload",
|
|
67
|
+
"RpcEvents",
|
|
68
|
+
"RpcMethod",
|
|
69
|
+
"rpc_event",
|
|
70
|
+
"rpc_method",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
_D = TypeVar("_D", bound="RpcDefinition")
|
|
74
|
+
_I = TypeVar("_I")
|
|
75
|
+
_O = TypeVar("_O")
|
|
76
|
+
_T = TypeVar("_T")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class RpcCallError(OpencodeError):
|
|
80
|
+
"""A plugin RPC method failed (the server answered with ``RpcError``).
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
rpc_id: The RPC that was called.
|
|
84
|
+
method: The method that was called.
|
|
85
|
+
type: The error type the plugin reported, for example ``"not_found"``.
|
|
86
|
+
message: The error message.
|
|
87
|
+
data: Error details, validated against the method's ``errors`` declaration when it names this type.
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
def __init__(self, rpc_id: str, method: str, type: str, message: str, data: object = None) -> None:
|
|
91
|
+
super().__init__(f"{rpc_id}.{method} failed with {type}: {message}")
|
|
92
|
+
self.rpc_id = rpc_id
|
|
93
|
+
self.method = method
|
|
94
|
+
self.type = type
|
|
95
|
+
self.message = message
|
|
96
|
+
self.data = data
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class RpcCallInternalError(RpcCallError):
|
|
100
|
+
"""The plugin RPC crashed or returned output that does not match its declaration.
|
|
101
|
+
|
|
102
|
+
``type`` is ``"rpc.internal"`` or ``"rpc.invalid_output"``. Output this client cannot validate against the
|
|
103
|
+
method's declared output type is also reported as ``"rpc.invalid_output"``.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class RpcMethod(Generic[_I, _O]):
|
|
108
|
+
"""One method of an :class:`RpcDefinition`. Create it with :func:`rpc_method`."""
|
|
109
|
+
|
|
110
|
+
name: str
|
|
111
|
+
|
|
112
|
+
def __init__(self, input: Any, output: Any, *, name: str | None, errors: Mapping[str, Any] | None) -> None:
|
|
113
|
+
self.input = input
|
|
114
|
+
self.output = output
|
|
115
|
+
self.errors = dict(errors or {})
|
|
116
|
+
if name is not None:
|
|
117
|
+
self.name = name
|
|
118
|
+
|
|
119
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
120
|
+
if not hasattr(self, "name"):
|
|
121
|
+
self.name = name
|
|
122
|
+
|
|
123
|
+
def encode(self, value: _I) -> object:
|
|
124
|
+
"""Validates ``value`` against the input type and returns its JSON form."""
|
|
125
|
+
if self.input is None:
|
|
126
|
+
return NOT_GIVEN if value is None else value
|
|
127
|
+
adapter = type_adapter(self.input)
|
|
128
|
+
return adapter.dump_python(adapter.validate_python(value), mode="json", by_alias=True)
|
|
129
|
+
|
|
130
|
+
def decode(self, rpc_id: str, value: object) -> _O:
|
|
131
|
+
"""Validates a method's output."""
|
|
132
|
+
if self.output is None:
|
|
133
|
+
return cast("_O", None)
|
|
134
|
+
try:
|
|
135
|
+
return cast("_O", type_adapter(self.output).validate_python(value))
|
|
136
|
+
except pydantic.ValidationError as exc:
|
|
137
|
+
raise RpcCallInternalError(rpc_id, self.name, "rpc.invalid_output", validation_summary(exc), value) from exc
|
|
138
|
+
|
|
139
|
+
def error(self, rpc_id: str, exc: RpcError | RpcInternalError) -> RpcCallError:
|
|
140
|
+
"""Maps a transport error to :class:`RpcCallError`, decoding declared error data."""
|
|
141
|
+
body = exc.error
|
|
142
|
+
data: object = body.data
|
|
143
|
+
if body.type in self.errors and data is not None:
|
|
144
|
+
try:
|
|
145
|
+
data = type_adapter(self.errors[body.type]).validate_python(data)
|
|
146
|
+
except pydantic.ValidationError:
|
|
147
|
+
data = body.data
|
|
148
|
+
cls = RpcCallInternalError if isinstance(exc, RpcInternalError) else RpcCallError
|
|
149
|
+
return cls(rpc_id, self.name, body.type, body.message, data)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class RpcEventDefinition(Generic[_T]):
|
|
153
|
+
"""One event of an :class:`RpcDefinition`. Create it with :func:`rpc_event`."""
|
|
154
|
+
|
|
155
|
+
name: str
|
|
156
|
+
|
|
157
|
+
def __init__(self, data: Any, *, name: str | None) -> None:
|
|
158
|
+
self.data = data
|
|
159
|
+
if name is not None:
|
|
160
|
+
self.name = name
|
|
161
|
+
|
|
162
|
+
def __set_name__(self, owner: type, name: str) -> None:
|
|
163
|
+
if not hasattr(self, "name"):
|
|
164
|
+
self.name = name
|
|
165
|
+
|
|
166
|
+
def parse(self, event: RpcEvent) -> RpcEventPayload[_T]:
|
|
167
|
+
"""Validates the data of a raw RPC event of this kind.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
pydantic.ValidationError: The data does not match the declared type.
|
|
171
|
+
"""
|
|
172
|
+
data = cast("_T", type_adapter(self.data).validate_python(event.data))
|
|
173
|
+
return RpcEventPayload(name=self.name, data=data, event=event)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@overload
|
|
177
|
+
def rpc_method(
|
|
178
|
+
input: None, output: type[_O], *, name: str | None = None, errors: Mapping[str, Any] | None = None
|
|
179
|
+
) -> RpcMethod[None, _O]: ...
|
|
180
|
+
@overload
|
|
181
|
+
def rpc_method(
|
|
182
|
+
input: type[_I], output: type[_O], *, name: str | None = None, errors: Mapping[str, Any] | None = None
|
|
183
|
+
) -> RpcMethod[_I, _O]: ...
|
|
184
|
+
def rpc_method(
|
|
185
|
+
input: Any, output: Any, *, name: str | None = None, errors: Mapping[str, Any] | None = None
|
|
186
|
+
) -> RpcMethod[Any, Any]:
|
|
187
|
+
"""Declares an RPC method on an :class:`RpcDefinition`.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
input: The input type, or ``None`` for a method without input.
|
|
191
|
+
output: The output type. Use ``type(None)`` for a method that returns nothing.
|
|
192
|
+
name: The method name on the server. Defaults to the attribute name.
|
|
193
|
+
errors: Data types of the errors the method declares, by error type. Matching error data is validated
|
|
194
|
+
into these types on :attr:`RpcCallError.data`.
|
|
195
|
+
"""
|
|
196
|
+
return RpcMethod(input, output, name=name, errors=errors)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def rpc_event(data: type[_T], *, name: str | None = None) -> RpcEventDefinition[_T]:
|
|
200
|
+
"""Declares an RPC event on an :class:`RpcDefinition`.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
data: The type of the event's ``data``.
|
|
204
|
+
name: The event name on the server. Defaults to the attribute name.
|
|
205
|
+
"""
|
|
206
|
+
return RpcEventDefinition(data, name=name)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class RpcDefinition:
|
|
210
|
+
"""Base class for plugin RPC declarations. Pass the RPC id as a class keyword::
|
|
211
|
+
|
|
212
|
+
class Counter(RpcDefinition, id="counter"):
|
|
213
|
+
add = rpc_method(AddInput, Total)
|
|
214
|
+
changed = rpc_event(Total)
|
|
215
|
+
|
|
216
|
+
The class is only a declaration; bind it to a client with ``client.rpc(Counter)``.
|
|
217
|
+
"""
|
|
218
|
+
|
|
219
|
+
id: ClassVar[str]
|
|
220
|
+
methods: ClassVar[dict[str, RpcMethod[Any, Any]]]
|
|
221
|
+
events: ClassVar[dict[str, RpcEventDefinition[Any]]]
|
|
222
|
+
|
|
223
|
+
def __init_subclass__(cls, *, id: str | None = None, **kwargs: Any) -> None:
|
|
224
|
+
super().__init_subclass__(**kwargs)
|
|
225
|
+
if id is not None:
|
|
226
|
+
cls.id = id
|
|
227
|
+
elif not hasattr(cls, "id"):
|
|
228
|
+
raise TypeError(f"{cls.__name__} needs an RPC id: class {cls.__name__}(RpcDefinition, id='...')")
|
|
229
|
+
methods: dict[str, RpcMethod[Any, Any]] = {}
|
|
230
|
+
events: dict[str, RpcEventDefinition[Any]] = {}
|
|
231
|
+
for base in reversed(cls.__mro__):
|
|
232
|
+
for value in vars(base).values():
|
|
233
|
+
if isinstance(value, RpcMethod):
|
|
234
|
+
methods[value.name] = value
|
|
235
|
+
elif isinstance(value, RpcEventDefinition):
|
|
236
|
+
events[value.name] = value
|
|
237
|
+
cls.methods = methods
|
|
238
|
+
cls.events = events
|
|
239
|
+
|
|
240
|
+
@classmethod
|
|
241
|
+
def event_type(cls, event: RpcEventDefinition[Any] | str) -> str:
|
|
242
|
+
"""The wire ``type`` of an event of this RPC: ``rpc.<id>.<name>``."""
|
|
243
|
+
return f"rpc.{cls.id}.{event if isinstance(event, str) else event.name}"
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True)
|
|
247
|
+
class RpcEventPayload(Generic[_T]):
|
|
248
|
+
"""A plugin RPC event with validated data.
|
|
249
|
+
|
|
250
|
+
Attributes:
|
|
251
|
+
name: The event name, without the ``rpc.<id>.`` prefix.
|
|
252
|
+
data: The validated event data.
|
|
253
|
+
event: The raw event, with its ``id``, ``created`` time and ``location``.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
name: str
|
|
257
|
+
data: _T
|
|
258
|
+
event: RpcEvent
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _check_method(definition: type[RpcDefinition], method: RpcMethod[Any, Any]) -> None:
|
|
262
|
+
if not any(known is method for known in definition.methods.values()):
|
|
263
|
+
raise ValueError(f"{getattr(method, 'name', method)!r} is not a method of {definition.__name__}")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _check_event(definition: type[RpcDefinition], event: RpcEventDefinition[Any]) -> None:
|
|
267
|
+
if not any(known is event for known in definition.events.values()):
|
|
268
|
+
raise ValueError(f"{getattr(event, 'name', event)!r} is not an event of {definition.__name__}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
class RpcClient(Generic[_D]):
|
|
272
|
+
"""A plugin RPC bound to a synchronous client. Get one with ``client.rpc(Definition)``."""
|
|
273
|
+
|
|
274
|
+
def __init__(self, client: Opencode, definition: type[_D]) -> None:
|
|
275
|
+
self._client = client
|
|
276
|
+
self.definition = definition
|
|
277
|
+
|
|
278
|
+
@overload
|
|
279
|
+
def call(
|
|
280
|
+
self,
|
|
281
|
+
method: RpcMethod[None, _O],
|
|
282
|
+
input: None = None,
|
|
283
|
+
*,
|
|
284
|
+
location: Location | NotGiven | None = NOT_GIVEN,
|
|
285
|
+
extra_headers: Headers | None = None,
|
|
286
|
+
timeout: Timeout | NotGiven = NOT_GIVEN,
|
|
287
|
+
) -> _O: ...
|
|
288
|
+
@overload
|
|
289
|
+
def call(
|
|
290
|
+
self,
|
|
291
|
+
method: RpcMethod[_I, _O],
|
|
292
|
+
input: _I,
|
|
293
|
+
*,
|
|
294
|
+
location: Location | NotGiven | None = NOT_GIVEN,
|
|
295
|
+
extra_headers: Headers | None = None,
|
|
296
|
+
timeout: Timeout | NotGiven = NOT_GIVEN,
|
|
297
|
+
) -> _O: ...
|
|
298
|
+
def call(
|
|
299
|
+
self,
|
|
300
|
+
method: RpcMethod[Any, Any],
|
|
301
|
+
input: Any = None,
|
|
302
|
+
*,
|
|
303
|
+
location: Location | NotGiven | None = NOT_GIVEN,
|
|
304
|
+
extra_headers: Headers | None = None,
|
|
305
|
+
timeout: Timeout | NotGiven = NOT_GIVEN,
|
|
306
|
+
) -> Any:
|
|
307
|
+
"""Calls a method of this RPC and returns its validated output.
|
|
308
|
+
|
|
309
|
+
Raises:
|
|
310
|
+
RpcCallError: The method failed.
|
|
311
|
+
RpcCallInternalError: The method crashed or returned invalid output.
|
|
312
|
+
"""
|
|
313
|
+
_check_method(self.definition, method)
|
|
314
|
+
rpc_id = self.definition.id
|
|
315
|
+
try:
|
|
316
|
+
result = self._client.rpc.call(
|
|
317
|
+
rpc_id,
|
|
318
|
+
method.name,
|
|
319
|
+
input=method.encode(input),
|
|
320
|
+
location=location,
|
|
321
|
+
extra_headers=extra_headers,
|
|
322
|
+
timeout=timeout,
|
|
323
|
+
)
|
|
324
|
+
except (RpcError, RpcInternalError) as exc:
|
|
325
|
+
raise method.error(rpc_id, exc) from exc
|
|
326
|
+
return method.decode(rpc_id, result.output)
|
|
327
|
+
|
|
328
|
+
def events(self) -> Subscription[RpcEvent]:
|
|
329
|
+
"""Subscribes to every event of this RPC, as raw events, on the client's shared event stream."""
|
|
330
|
+
prefix = f"rpc.{self.definition.id}."
|
|
331
|
+
return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix))
|
|
332
|
+
|
|
333
|
+
def subscribe(self, event: RpcEventDefinition[_T]) -> RpcEvents[_T]:
|
|
334
|
+
"""Subscribes to one event of this RPC, with validated data, on the client's shared event stream."""
|
|
335
|
+
_check_event(self.definition, event)
|
|
336
|
+
wire = self.definition.event_type(event)
|
|
337
|
+
return RpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class AsyncRpcClient(Generic[_D]):
|
|
341
|
+
"""A plugin RPC bound to an asynchronous client. Get one with ``client.rpc(Definition)``."""
|
|
342
|
+
|
|
343
|
+
def __init__(self, client: AsyncOpencode, definition: type[_D]) -> None:
|
|
344
|
+
self._client = client
|
|
345
|
+
self.definition = definition
|
|
346
|
+
|
|
347
|
+
@overload
|
|
348
|
+
async def call(
|
|
349
|
+
self,
|
|
350
|
+
method: RpcMethod[None, _O],
|
|
351
|
+
input: None = None,
|
|
352
|
+
*,
|
|
353
|
+
location: Location | NotGiven | None = NOT_GIVEN,
|
|
354
|
+
extra_headers: Headers | None = None,
|
|
355
|
+
timeout: Timeout | NotGiven = NOT_GIVEN,
|
|
356
|
+
) -> _O: ...
|
|
357
|
+
@overload
|
|
358
|
+
async def call(
|
|
359
|
+
self,
|
|
360
|
+
method: RpcMethod[_I, _O],
|
|
361
|
+
input: _I,
|
|
362
|
+
*,
|
|
363
|
+
location: Location | NotGiven | None = NOT_GIVEN,
|
|
364
|
+
extra_headers: Headers | None = None,
|
|
365
|
+
timeout: Timeout | NotGiven = NOT_GIVEN,
|
|
366
|
+
) -> _O: ...
|
|
367
|
+
async def call(
|
|
368
|
+
self,
|
|
369
|
+
method: RpcMethod[Any, Any],
|
|
370
|
+
input: Any = None,
|
|
371
|
+
*,
|
|
372
|
+
location: Location | NotGiven | None = NOT_GIVEN,
|
|
373
|
+
extra_headers: Headers | None = None,
|
|
374
|
+
timeout: Timeout | NotGiven = NOT_GIVEN,
|
|
375
|
+
) -> Any:
|
|
376
|
+
"""Calls a method of this RPC and returns its validated output.
|
|
377
|
+
|
|
378
|
+
Raises:
|
|
379
|
+
RpcCallError: The method failed.
|
|
380
|
+
RpcCallInternalError: The method crashed or returned invalid output.
|
|
381
|
+
"""
|
|
382
|
+
_check_method(self.definition, method)
|
|
383
|
+
rpc_id = self.definition.id
|
|
384
|
+
try:
|
|
385
|
+
result = await self._client.rpc.call(
|
|
386
|
+
rpc_id,
|
|
387
|
+
method.name,
|
|
388
|
+
input=method.encode(input),
|
|
389
|
+
location=location,
|
|
390
|
+
extra_headers=extra_headers,
|
|
391
|
+
timeout=timeout,
|
|
392
|
+
)
|
|
393
|
+
except (RpcError, RpcInternalError) as exc:
|
|
394
|
+
raise method.error(rpc_id, exc) from exc
|
|
395
|
+
return method.decode(rpc_id, result.output)
|
|
396
|
+
|
|
397
|
+
def events(self) -> AsyncSubscription[RpcEvent]:
|
|
398
|
+
"""Subscribes to every event of this RPC, as raw events, on the client's shared event stream."""
|
|
399
|
+
prefix = f"rpc.{self.definition.id}."
|
|
400
|
+
return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix))
|
|
401
|
+
|
|
402
|
+
def subscribe(self, event: RpcEventDefinition[_T]) -> AsyncRpcEvents[_T]:
|
|
403
|
+
"""Subscribes to one event of this RPC, with validated data, on the client's shared event stream."""
|
|
404
|
+
_check_event(self.definition, event)
|
|
405
|
+
wire = self.definition.event_type(event)
|
|
406
|
+
return AsyncRpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class RpcEvents(Generic[_T]):
|
|
410
|
+
"""Events of one kind from a plugin RPC. Iterate with ``for``; close it, or use ``with``, to leave."""
|
|
411
|
+
|
|
412
|
+
def __init__(self, source: Subscription[RpcEvent], event: RpcEventDefinition[_T]) -> None:
|
|
413
|
+
self._source = source
|
|
414
|
+
self._event = event
|
|
415
|
+
|
|
416
|
+
def __iter__(self) -> Self:
|
|
417
|
+
return self
|
|
418
|
+
|
|
419
|
+
def __next__(self) -> RpcEventPayload[_T]:
|
|
420
|
+
return self._event.parse(next(self._source))
|
|
421
|
+
|
|
422
|
+
def get(self, timeout: float | None = None) -> RpcEventPayload[_T]:
|
|
423
|
+
"""Returns the next event, waiting up to ``timeout`` seconds. See :meth:`Subscription.get`."""
|
|
424
|
+
return self._event.parse(self._source.get(timeout))
|
|
425
|
+
|
|
426
|
+
def close(self) -> None:
|
|
427
|
+
"""Leaves the shared event stream."""
|
|
428
|
+
self._source.close()
|
|
429
|
+
|
|
430
|
+
def __enter__(self) -> Self:
|
|
431
|
+
return self
|
|
432
|
+
|
|
433
|
+
def __exit__(
|
|
434
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
435
|
+
) -> None:
|
|
436
|
+
self.close()
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class AsyncRpcEvents(Generic[_T]):
|
|
440
|
+
"""Events of one kind from a plugin RPC. Iterate with ``async for``; close it, or use ``async with``."""
|
|
441
|
+
|
|
442
|
+
def __init__(self, source: AsyncSubscription[RpcEvent], event: RpcEventDefinition[_T]) -> None:
|
|
443
|
+
self._source = source
|
|
444
|
+
self._event = event
|
|
445
|
+
|
|
446
|
+
def __aiter__(self) -> Self:
|
|
447
|
+
return self
|
|
448
|
+
|
|
449
|
+
async def __anext__(self) -> RpcEventPayload[_T]:
|
|
450
|
+
return self._event.parse(await self._source.__anext__())
|
|
451
|
+
|
|
452
|
+
async def get(self) -> RpcEventPayload[_T]:
|
|
453
|
+
"""Returns the next event. See :meth:`AsyncSubscription.get`."""
|
|
454
|
+
return self._event.parse(await self._source.get())
|
|
455
|
+
|
|
456
|
+
async def close(self) -> None:
|
|
457
|
+
"""Leaves the shared event stream."""
|
|
458
|
+
await self._source.close()
|
|
459
|
+
|
|
460
|
+
async def __aenter__(self) -> Self:
|
|
461
|
+
return self
|
|
462
|
+
|
|
463
|
+
async def __aexit__(
|
|
464
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
465
|
+
) -> None:
|
|
466
|
+
await self.close()
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class TypedRpcResource(RpcResource):
|
|
470
|
+
"""``client.rpc``: the generated ``call`` plus ``client.rpc(Definition)`` for typed access."""
|
|
471
|
+
|
|
472
|
+
def __call__(self, definition: type[_D]) -> RpcClient[_D]:
|
|
473
|
+
"""Binds a plugin RPC declaration to this client."""
|
|
474
|
+
return RpcClient(self._client, definition)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
class AsyncTypedRpcResource(AsyncRpcResource):
|
|
478
|
+
"""``client.rpc``: the generated ``call`` plus ``client.rpc(Definition)`` for typed access."""
|
|
479
|
+
|
|
480
|
+
def __call__(self, definition: type[_D]) -> AsyncRpcClient[_D]:
|
|
481
|
+
"""Binds a plugin RPC declaration to this client."""
|
|
482
|
+
return AsyncRpcClient(self._client, definition)
|
opencode/_streaming.py
CHANGED
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import contextlib
|
|
5
6
|
import json
|
|
6
7
|
import re
|
|
8
|
+
import socket
|
|
7
9
|
from dataclasses import dataclass, field
|
|
8
10
|
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
9
11
|
|
|
@@ -14,7 +16,7 @@ from typing_extensions import Self
|
|
|
14
16
|
from ._exceptions import APIConnectionError, APIResponseValidationError, validation_summary
|
|
15
17
|
|
|
16
18
|
if TYPE_CHECKING:
|
|
17
|
-
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Iterator
|
|
19
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Iterator
|
|
18
20
|
from types import TracebackType
|
|
19
21
|
|
|
20
22
|
__all__ = ["AsyncStream", "ServerSentEvent", "Stream"]
|
|
@@ -111,6 +113,12 @@ class _StreamBase(Generic[_T]):
|
|
|
111
113
|
def __init__(self, response: httpx.Response, adapter: pydantic.TypeAdapter[_T]) -> None:
|
|
112
114
|
self.response = response
|
|
113
115
|
self._adapter = adapter
|
|
116
|
+
self.on_activity: Callable[[], object] | None = None
|
|
117
|
+
"""Called for every chunk the stream receives, including keepalive comments that carry no event."""
|
|
118
|
+
|
|
119
|
+
def _activity(self) -> None:
|
|
120
|
+
if self.on_activity is not None:
|
|
121
|
+
self.on_activity()
|
|
114
122
|
|
|
115
123
|
def _decode(self, event: ServerSentEvent) -> _T:
|
|
116
124
|
try:
|
|
@@ -148,6 +156,7 @@ class Stream(_StreamBase[_T]):
|
|
|
148
156
|
try:
|
|
149
157
|
splitter = _LineSplitter()
|
|
150
158
|
for chunk in self.response.iter_text():
|
|
159
|
+
self._activity()
|
|
151
160
|
for line in splitter.feed(chunk):
|
|
152
161
|
if (event := decoder.feed_line(line)) is not None:
|
|
153
162
|
yield self._decode(event)
|
|
@@ -166,6 +175,17 @@ class Stream(_StreamBase[_T]):
|
|
|
166
175
|
self._iterator.close()
|
|
167
176
|
self.response.close()
|
|
168
177
|
|
|
178
|
+
def interrupt(self) -> None:
|
|
179
|
+
"""Unblocks a read in progress on another thread, which then ends with :class:`APIConnectionError`.
|
|
180
|
+
|
|
181
|
+
``close`` must run on the iterating thread. Call this from any other thread first to wake it.
|
|
182
|
+
"""
|
|
183
|
+
network_stream: Any = self.response.extensions.get("network_stream")
|
|
184
|
+
sock: Any = network_stream.get_extra_info("socket") if network_stream is not None else None
|
|
185
|
+
if isinstance(sock, socket.socket):
|
|
186
|
+
with contextlib.suppress(OSError):
|
|
187
|
+
sock.shutdown(socket.SHUT_RDWR)
|
|
188
|
+
|
|
169
189
|
def __enter__(self) -> Self:
|
|
170
190
|
return self
|
|
171
191
|
|
|
@@ -200,6 +220,7 @@ class AsyncStream(_StreamBase[_T]):
|
|
|
200
220
|
try:
|
|
201
221
|
splitter = _LineSplitter()
|
|
202
222
|
async for chunk in self.response.aiter_text():
|
|
223
|
+
self._activity()
|
|
203
224
|
for line in splitter.feed(chunk):
|
|
204
225
|
if (event := decoder.feed_line(line)) is not None:
|
|
205
226
|
yield self._decode(event)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opencode-python-sdk
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.3
|
|
4
4
|
Summary: Typed Python client for the OpenCode 2.x server API
|
|
5
5
|
Project-URL: Homepage, https://github.com/shrijayan/opencode-python-sdk
|
|
6
6
|
Project-URL: Documentation, https://shrijayan.github.io/opencode-python-sdk/
|
|
@@ -206,6 +206,82 @@ Events are typed (`SessionCreatedEvent`, `SessionTextDeltaEvent`, and so on). Ev
|
|
|
206
206
|
not know arrive as `UnknownEvent` with all their fields kept. Streams apply only the connect timeout by default, since
|
|
207
207
|
they stay open between events.
|
|
208
208
|
|
|
209
|
+
### Shared event stream
|
|
210
|
+
|
|
211
|
+
`client.events` shares one connection among every subscriber, like `client.event` in the TypeScript client. The
|
|
212
|
+
connection opens with the first subscriber and closes when the last one leaves. Each subscriber has its own queue of
|
|
213
|
+
up to 4096 events; one that falls further behind fails with `EventOverflowError` without affecting the others. The
|
|
214
|
+
latest `server.connected` event is replayed to subscribers that join later.
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
from opencode.types import SessionIdleEvent
|
|
218
|
+
|
|
219
|
+
with client.events.of_type(SessionIdleEvent).for_session(session.id) as idle:
|
|
220
|
+
client.session.prompt(session.id, text="Hello")
|
|
221
|
+
event = idle.get(timeout=300) # typed as SessionIdleEvent
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
- Filters chain: `of_type(*classes)` narrows the yielded type, `for_session(id)` keeps one session's events, and
|
|
225
|
+
`where(predicate)` takes any test. Entering the `with` block joins the stream, so nothing published after that is
|
|
226
|
+
missed.
|
|
227
|
+
- `subscribe(on_activity=callback)` calls `callback` for every chunk received, keepalive comments included, as a
|
|
228
|
+
liveness signal.
|
|
229
|
+
- `close()` (or leaving the `with` block) ends the subscription, also from another thread. In async code, cancel a wait
|
|
230
|
+
with `anyio.fail_after`, `asyncio.timeout` or a trio cancel scope; the subscription stays usable.
|
|
231
|
+
- Reconnection is opt-in: `EventHub(client, reconnect=True)` (or `AsyncEventHub`, or a `Reconnect(...)` policy) reopens
|
|
232
|
+
the connection with exponential backoff after connection errors, 5xx and 429. Subscribers see `server.connected`
|
|
233
|
+
once. **The stream is volatile**: events published while disconnected are lost, so re-read state through the API
|
|
234
|
+
after a reconnect when it matters.
|
|
235
|
+
|
|
236
|
+
The async hub works the same way under asyncio and trio:
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
async with client.events.for_session(session.id) as events:
|
|
240
|
+
async for event in events:
|
|
241
|
+
print(event.type)
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
`client.event.subscribe()` still opens a dedicated connection.
|
|
245
|
+
|
|
246
|
+
### Plugin RPC
|
|
247
|
+
|
|
248
|
+
Declare a plugin RPC once as a class, then call it with typed inputs and outputs:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
from pydantic import BaseModel
|
|
252
|
+
from opencode import RpcCallError, RpcDefinition, rpc_event, rpc_method
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
class AddInput(BaseModel):
|
|
256
|
+
amount: int
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class Total(BaseModel):
|
|
260
|
+
total: int
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class Counter(RpcDefinition, id="counter"):
|
|
264
|
+
add = rpc_method(AddInput, Total)
|
|
265
|
+
reset = rpc_method(None, Total)
|
|
266
|
+
changed = rpc_event(Total)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
counter = client.rpc(Counter)
|
|
270
|
+
try:
|
|
271
|
+
print(counter.call(Counter.add, AddInput(amount=2)).total)
|
|
272
|
+
except RpcCallError as error:
|
|
273
|
+
print(error.type, error.message, error.data)
|
|
274
|
+
|
|
275
|
+
with counter.subscribe(Counter.changed) as changes: # only rpc.counter.changed, with validated data
|
|
276
|
+
for change in changes:
|
|
277
|
+
print(change.data.total)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
Any type pydantic validates works for inputs, outputs and event data. `rpc_method(..., errors={"not_found": Model})`
|
|
281
|
+
validates the `data` of declared errors. Failures raise `RpcCallError`; crashes and outputs that do not match the
|
|
282
|
+
declared type raise `RpcCallInternalError`. `counter.events()` yields every raw event of the RPC. The untyped
|
|
283
|
+
`client.rpc.call(rpc_id, method, input=...)` is still available.
|
|
284
|
+
|
|
209
285
|
## Terminals
|
|
210
286
|
|
|
211
287
|
`client.pty.attach()` opens an interactive connection to a PTY over a WebSocket. It needs the `pty` extra:
|
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
opencode/.generated.json,sha256=QNCFazAYhcwsGf0l4A2EeSpm2HOM7TRBE648Zyoeq_s,1383
|
|
2
|
-
opencode/__init__.py,sha256
|
|
2
|
+
opencode/__init__.py,sha256=-jyJAzJw0GVbe4ZgtdAIiblDo2Xl5EZWk_22hOqmD2Y,5243
|
|
3
3
|
opencode/_base_client.py,sha256=3BwhIXL-cFGoRUuwDzTeFH21O7pihUR5CryOlS1Wylo,20108
|
|
4
|
-
opencode/_client.py,sha256=
|
|
4
|
+
opencode/_client.py,sha256=3zY2lGLi_9FBpbG1UIWmklgSn86sJJZv0Wf7Z7sWNDk,7687
|
|
5
5
|
opencode/_constants.py,sha256=-B_D8RHElL4mYN__WVf4bag4D12dJHbL69_EMfi7bXo,1132
|
|
6
6
|
opencode/_errors.py,sha256=dWjsyGV0aDBKsvJrGNKGJn41Yue66yBBwGTT2pTV7E8,16431
|
|
7
|
+
opencode/_events.py,sha256=KwVvFMyPEBy-wopfbTcu7nDWwgo0zqSdWpuTmTl3dFc,30099
|
|
7
8
|
opencode/_exceptions.py,sha256=kqQiaSM6zbfyiXXddwapIymW7J61ZlAa767Yko5Y9EI,8720
|
|
8
9
|
opencode/_models.py,sha256=2esvD2CmslOHCbuLNkN0sGDcgMAqeoMmB7hZMPzo1uA,1988
|
|
9
10
|
opencode/_pagination.py,sha256=uvB-0IwfkGqbYIYiUW_ax_oAE-ldZJSWHtjluVcZlNk,7052
|
|
10
11
|
opencode/_pty.py,sha256=wdqBF7K40ikzas8i203pem2iov8nbgYDC-RqH6ZX41E,23429
|
|
11
12
|
opencode/_qs.py,sha256=8EWbLzd-iLComngGRyO95f_z76j4tUeBihYJdHI5NP8,3643
|
|
12
13
|
opencode/_resource.py,sha256=ovuG85mWcuXNTu5m6H9FKPN2rrD5o52oRFAGBHimPyg,1263
|
|
14
|
+
opencode/_rpc.py,sha256=s3E_6Abfbt4nRysaK-J0RFt5xx8ZCdolRJCJad2UayI,17578
|
|
13
15
|
opencode/_server.py,sha256=O-yVWU-v7HDJuVav1xXTil96BtvvbYMsHm1zLhneWKk,12849
|
|
14
|
-
opencode/_streaming.py,sha256=
|
|
16
|
+
opencode/_streaming.py,sha256=TfnfZaNucjLrpfOB9m1i_sRtjYoACztTflavrhwvnDE,8861
|
|
15
17
|
opencode/_types.py,sha256=Z8bYxzpZhPRtisvPUv9R5g4oBlC23iXR70fFzReTOc0,2194
|
|
16
18
|
opencode/_unions.py,sha256=dW0dTmP2QK2lL6RhyK2YsjD2yNbO8Ah6pk-i5ySW3lo,1518
|
|
17
19
|
opencode/_version.py,sha256=zNErHu8z_g6ScOeYcO8QAiktg036FVPaUiIIZefeoLU,259
|
|
@@ -50,7 +52,7 @@ opencode/resources/websearch.py,sha256=xr1vaS6dwPsSZJUtVSkRtqtzZuoP9xg_cr1bBpeKo
|
|
|
50
52
|
opencode/resources/worktree.py,sha256=mohf9rIm4PPJTMbJmieygQkaC8rc7LgF0GO4u-2D9OU,10950
|
|
51
53
|
opencode/types/__init__.py,sha256=5JEAe89RPSwJw1yz0kufBxtEJgRsMpZXj7KTbzGX45A,60935
|
|
52
54
|
opencode/types/_generated.py,sha256=c3zBAfVpZGJ9TX4FqrMZASmoxRntel_QzVuP85BOJwU,235015
|
|
53
|
-
opencode_python_sdk-0.1.
|
|
54
|
-
opencode_python_sdk-0.1.
|
|
55
|
-
opencode_python_sdk-0.1.
|
|
56
|
-
opencode_python_sdk-0.1.
|
|
55
|
+
opencode_python_sdk-0.1.3.dist-info/METADATA,sha256=8bH3l7_tYHkst05cMXdAxJCvpbYHZWxSLKKXR1ItkHE,15746
|
|
56
|
+
opencode_python_sdk-0.1.3.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
57
|
+
opencode_python_sdk-0.1.3.dist-info/licenses/LICENSE,sha256=SbiOG1an_-gZ0gwGLS0tsbUnMHA7tYX22FgP1EqzrH4,1093
|
|
58
|
+
opencode_python_sdk-0.1.3.dist-info/RECORD,,
|
|
File without changes
|
{opencode_python_sdk-0.1.2.dist-info → opencode_python_sdk-0.1.3.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|