opencode-python-sdk 0.1.1__py3-none-any.whl → 0.1.2__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 +4 -0
- opencode/_pty.py +586 -0
- 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.2.dist-info}/METADATA +24 -1
- {opencode_python_sdk-0.1.1.dist-info → opencode_python_sdk-0.1.2.dist-info}/RECORD +8 -7
- {opencode_python_sdk-0.1.1.dist-info → opencode_python_sdk-0.1.2.dist-info}/WHEEL +0 -0
- {opencode_python_sdk-0.1.1.dist-info → opencode_python_sdk-0.1.2.dist-info}/licenses/LICENSE +0 -0
opencode/__init__.py
CHANGED
|
@@ -75,6 +75,7 @@ from ._exceptions import (
|
|
|
75
75
|
)
|
|
76
76
|
from ._models import BaseModel
|
|
77
77
|
from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage
|
|
78
|
+
from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError
|
|
78
79
|
from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError
|
|
79
80
|
from ._streaming import AsyncStream, Stream
|
|
80
81
|
from ._types import NOT_GIVEN, NotGiven
|
|
@@ -97,6 +98,7 @@ __all__ = [
|
|
|
97
98
|
"AsyncOpencode",
|
|
98
99
|
"AsyncOpencodeServer",
|
|
99
100
|
"AsyncPaginator",
|
|
101
|
+
"AsyncPtyConnection",
|
|
100
102
|
"AsyncStream",
|
|
101
103
|
"AuthenticationError",
|
|
102
104
|
"BadRequestError",
|
|
@@ -111,6 +113,8 @@ __all__ = [
|
|
|
111
113
|
"OpencodeError",
|
|
112
114
|
"OpencodeServer",
|
|
113
115
|
"PermissionDeniedError",
|
|
116
|
+
"PtyConnection",
|
|
117
|
+
"PtyConnectionError",
|
|
114
118
|
"RateLimitError",
|
|
115
119
|
"ServerStartError",
|
|
116
120
|
"ServiceUnavailableError",
|
opencode/_pty.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
"""Interactive terminals: the PTY WebSocket endpoints.
|
|
2
|
+
|
|
3
|
+
The generator skips ``pty.connect`` and ``persistentPty.connect`` because they are WebSockets, so this module
|
|
4
|
+
implements them by hand and the generated ``client.pty`` and ``client.experimental.persistent_pty`` resources
|
|
5
|
+
mix the ``attach`` methods in. It needs the optional ``websockets`` dependency::
|
|
6
|
+
|
|
7
|
+
pip install "opencode-python-sdk[pty]"
|
|
8
|
+
|
|
9
|
+
Protocol (OpenCode 2.1.2):
|
|
10
|
+
|
|
11
|
+
- Opening a socket takes a single-use ticket from ``POST .../connect-token`` (sent with
|
|
12
|
+
``x-opencode-ticket: 1``), passed as the ``ticket`` query parameter. Tickets expire after 60 seconds.
|
|
13
|
+
- ``GET /api/pty/:ptyID/connect`` takes ``location[directory]`` and an optional ``cursor`` (``-1`` skips the
|
|
14
|
+
replay). The server replays buffered output, then sends one binary control frame, ``0x00`` followed by
|
|
15
|
+
``{"cursor": n}`` JSON, then live output. Output frames are UTF-8 text. Input frames are UTF-8 text or binary;
|
|
16
|
+
invalid UTF-8 is dropped. Resizing goes through ``PUT /api/pty/:ptyID``.
|
|
17
|
+
- ``GET /api/experimental/persistent-pty/:ptyID/connect`` takes ``cursor`` (byte offset, default ``0``),
|
|
18
|
+
``role`` (``controller`` or ``observer``), ``attachment_id``, ``takeover`` and ``input_protocol``. Output frames
|
|
19
|
+
are binary; text frames are JSON events: ``attached`` first, then ``replay_complete`` after the replay, and
|
|
20
|
+
later ones such as ``resized``. With ``input_protocol=1`` every input frame is binary: one type byte (``0``
|
|
21
|
+
control, ``1`` input), big-endian ``uint16`` columns and rows, then the input bytes. A control frame claims
|
|
22
|
+
control at that size.
|
|
23
|
+
- Close code ``1000`` means the terminal ended; ``4404`` means it was not found, has exited or is unavailable.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import struct
|
|
30
|
+
import uuid
|
|
31
|
+
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
32
|
+
from urllib.parse import urlencode
|
|
33
|
+
|
|
34
|
+
from typing_extensions import Self
|
|
35
|
+
|
|
36
|
+
from ._exceptions import OpencodeError
|
|
37
|
+
from ._qs import encode_path_segment, encode_query
|
|
38
|
+
from ._resource import AsyncAPIResource, SyncAPIResource
|
|
39
|
+
from ._types import NOT_GIVEN, NotGiven
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping
|
|
43
|
+
from types import TracebackType
|
|
44
|
+
|
|
45
|
+
import httpx
|
|
46
|
+
from websockets.asyncio.client import ClientConnection as AsyncWebSocket
|
|
47
|
+
from websockets.sync.client import ClientConnection as SyncWebSocket
|
|
48
|
+
|
|
49
|
+
from .types import Location, PtyID
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"AsyncPersistentPtyAttachMixin",
|
|
53
|
+
"AsyncPtyAttachMixin",
|
|
54
|
+
"AsyncPtyConnection",
|
|
55
|
+
"PersistentPtyAttachMixin",
|
|
56
|
+
"PtyAttachMixin",
|
|
57
|
+
"PtyConnection",
|
|
58
|
+
"PtyConnectionError",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
TICKET_HEADER = "x-opencode-ticket"
|
|
62
|
+
CLOSE_NORMAL = 1000
|
|
63
|
+
CLOSE_NOT_FOUND = 4404
|
|
64
|
+
_FRAME_CONTROL = 0
|
|
65
|
+
_FRAME_INPUT = 1
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PtyConnectionError(OpencodeError):
|
|
69
|
+
"""The terminal WebSocket could not be opened or closed abnormally.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
status: HTTP status of a refused handshake (``403`` for a bad ticket, ``404`` for an unknown PTY).
|
|
73
|
+
code: WebSocket close code (``4404`` when the terminal is not found, exited or unavailable).
|
|
74
|
+
reason: WebSocket close reason.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(self, message: str, *, status: int | None = None, code: int | None = None, reason: str = "") -> None:
|
|
78
|
+
super().__init__(message)
|
|
79
|
+
self.status = status
|
|
80
|
+
self.code = code
|
|
81
|
+
self.reason = reason
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _missing() -> PtyConnectionError:
|
|
85
|
+
return PtyConnectionError(
|
|
86
|
+
'Terminal support needs the websockets package. Install it with: pip install "opencode-python-sdk[pty]"'
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _ws_url(base_url: httpx.URL, path: str, query: Mapping[str, object]) -> str:
|
|
91
|
+
url = base_url.join(path.lstrip("/"))
|
|
92
|
+
scheme = "wss" if url.scheme == "https" else "ws"
|
|
93
|
+
return str(url.copy_with(scheme=scheme, query=urlencode(encode_query(query)).encode("ascii")))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _headers(username: str, password: str | None, extra: Mapping[str, str] | None) -> dict[str, str]:
|
|
97
|
+
import base64
|
|
98
|
+
|
|
99
|
+
headers: dict[str, str] = {}
|
|
100
|
+
if password is not None:
|
|
101
|
+
headers["Authorization"] = "Basic " + base64.b64encode(f"{username}:{password}".encode()).decode("ascii")
|
|
102
|
+
headers.update(extra or {})
|
|
103
|
+
return headers
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _handshake_error(exc: Exception) -> PtyConnectionError:
|
|
107
|
+
response = getattr(exc, "response", None)
|
|
108
|
+
status = cast("int | None", getattr(response, "status_code", None))
|
|
109
|
+
return PtyConnectionError(f"Could not open the terminal WebSocket: {exc}", status=status)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _Protocol:
|
|
113
|
+
"""Frame handling shared by the sync and async connections."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, *, persistent: bool, cols: int, rows: int) -> None:
|
|
116
|
+
self.persistent = persistent
|
|
117
|
+
self.cols = cols
|
|
118
|
+
self.rows = rows
|
|
119
|
+
self.cursor: int | None = None
|
|
120
|
+
self.attached: dict[str, Any] | None = None
|
|
121
|
+
|
|
122
|
+
def encode_input(self, data: bytes | str) -> bytes | str:
|
|
123
|
+
if not self.persistent:
|
|
124
|
+
return data
|
|
125
|
+
payload = data.encode() if isinstance(data, str) else data
|
|
126
|
+
return struct.pack(">BHH", _FRAME_INPUT, self.cols, self.rows) + payload
|
|
127
|
+
|
|
128
|
+
def encode_control(self, cols: int, rows: int) -> bytes:
|
|
129
|
+
return struct.pack(">BHH", _FRAME_CONTROL, cols, rows)
|
|
130
|
+
|
|
131
|
+
def decode(self, frame: bytes | str) -> tuple[bytes | None, dict[str, Any] | None]:
|
|
132
|
+
"""Returns ``(output, event)``; control frames update the connection state and return no output."""
|
|
133
|
+
if self.persistent:
|
|
134
|
+
if isinstance(frame, bytes):
|
|
135
|
+
# Output frames are the daemon's byte range, so the resume offset advances by their length.
|
|
136
|
+
if self.cursor is not None:
|
|
137
|
+
self.cursor += len(frame)
|
|
138
|
+
return frame, None
|
|
139
|
+
event = cast("dict[str, Any]", json.loads(frame))
|
|
140
|
+
self._apply(event)
|
|
141
|
+
return None, event
|
|
142
|
+
if isinstance(frame, str):
|
|
143
|
+
return frame.encode(), None
|
|
144
|
+
if frame[:1] == b"\x00":
|
|
145
|
+
meta = cast("dict[str, Any]", json.loads(frame[1:]))
|
|
146
|
+
self.cursor = int(meta["cursor"])
|
|
147
|
+
return None, meta
|
|
148
|
+
return frame, None
|
|
149
|
+
|
|
150
|
+
def _apply(self, event: dict[str, Any]) -> None:
|
|
151
|
+
kind = event.get("type")
|
|
152
|
+
if kind == "attached":
|
|
153
|
+
self.attached = event
|
|
154
|
+
size = cast("dict[str, int]", event.get("info", {}).get("size") or {})
|
|
155
|
+
self.cols = size.get("cols", self.cols)
|
|
156
|
+
self.rows = size.get("rows", self.rows)
|
|
157
|
+
self.cursor = cast("int | None", event.get("replay", {}).get("endOffset", self.cursor))
|
|
158
|
+
elif kind == "replay_complete":
|
|
159
|
+
self.cursor = int(event["endOffset"])
|
|
160
|
+
elif kind == "resized":
|
|
161
|
+
self.cols = int(event.get("cols", self.cols))
|
|
162
|
+
self.rows = int(event.get("rows", self.rows))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _closed(code: int | None, reason: str) -> PtyConnectionError | None:
|
|
166
|
+
if code in (CLOSE_NORMAL, None):
|
|
167
|
+
return None
|
|
168
|
+
return PtyConnectionError(
|
|
169
|
+
f"Terminal connection closed with code {code}: {reason or 'no reason'}", code=code, reason=reason
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class PtyConnection:
|
|
174
|
+
"""A synchronous connection to a remote terminal.
|
|
175
|
+
|
|
176
|
+
Iterate over it, or call :meth:`recv`, for output bytes; it stops when the terminal ends. Control frames
|
|
177
|
+
are handled internally: :attr:`cursor` holds the latest output position the server reported, and
|
|
178
|
+
``on_event`` (persistent terminals) receives every JSON event.
|
|
179
|
+
|
|
180
|
+
Example::
|
|
181
|
+
|
|
182
|
+
with client.pty.attach(pty.id) as term:
|
|
183
|
+
term.send("echo hi\\n")
|
|
184
|
+
for chunk in term:
|
|
185
|
+
print(chunk.decode(errors="replace"), end="")
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(
|
|
189
|
+
self,
|
|
190
|
+
ws: SyncWebSocket,
|
|
191
|
+
protocol: _Protocol,
|
|
192
|
+
resize: Callable[[int, int], object],
|
|
193
|
+
on_event: Callable[[dict[str, Any]], object] | None = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
self._ws = ws
|
|
196
|
+
self._protocol = protocol
|
|
197
|
+
self._resize = resize
|
|
198
|
+
self._on_event = on_event
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def cursor(self) -> int | None:
|
|
202
|
+
"""Output position to pass as ``cursor`` when reattaching, once the server has reported one."""
|
|
203
|
+
return self._protocol.cursor
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def attached(self) -> dict[str, Any] | None:
|
|
207
|
+
"""The ``attached`` event of a persistent terminal (attachment id, role, info, replay offsets)."""
|
|
208
|
+
return self._protocol.attached
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def close_code(self) -> int | None:
|
|
212
|
+
"""The WebSocket close code once the connection has closed."""
|
|
213
|
+
return self._ws.protocol.close_code
|
|
214
|
+
|
|
215
|
+
def send(self, data: bytes | str) -> None:
|
|
216
|
+
"""Writes input to the terminal. ``str`` is sent as UTF-8."""
|
|
217
|
+
self._ws.send(self._protocol.encode_input(data))
|
|
218
|
+
|
|
219
|
+
def resize(self, cols: int, rows: int) -> None:
|
|
220
|
+
"""Resizes the terminal."""
|
|
221
|
+
if self._protocol.persistent:
|
|
222
|
+
self._protocol.cols, self._protocol.rows = cols, rows
|
|
223
|
+
self._ws.send(self._protocol.encode_control(cols, rows))
|
|
224
|
+
else:
|
|
225
|
+
self._resize(cols, rows)
|
|
226
|
+
|
|
227
|
+
def recv(self, timeout: float | None = None) -> bytes | None:
|
|
228
|
+
"""Returns the next output chunk, or ``None`` once the terminal has ended.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
TimeoutError: No output within ``timeout`` seconds.
|
|
232
|
+
PtyConnectionError: The server closed the connection abnormally (``4404``: terminal unavailable).
|
|
233
|
+
"""
|
|
234
|
+
while (output := self._next(timeout)) == b"":
|
|
235
|
+
pass
|
|
236
|
+
return output
|
|
237
|
+
|
|
238
|
+
def _next(self, timeout: float | None) -> bytes | None:
|
|
239
|
+
"""Reads one frame: output bytes, ``b""`` for a control frame, ``None`` after a normal close."""
|
|
240
|
+
from websockets.exceptions import ConnectionClosed
|
|
241
|
+
|
|
242
|
+
try:
|
|
243
|
+
frame = self._ws.recv(timeout)
|
|
244
|
+
except ConnectionClosed as exc:
|
|
245
|
+
error = _closed(exc.rcvd.code if exc.rcvd else None, exc.rcvd.reason if exc.rcvd else "")
|
|
246
|
+
if error is not None:
|
|
247
|
+
raise error from None
|
|
248
|
+
return None
|
|
249
|
+
output, event = self._protocol.decode(frame)
|
|
250
|
+
if event is not None and self._on_event is not None and self._protocol.persistent:
|
|
251
|
+
self._on_event(event)
|
|
252
|
+
return output or b""
|
|
253
|
+
|
|
254
|
+
def wait_attached(self, timeout: float | None) -> None:
|
|
255
|
+
"""Reads frames until the ``attached`` event of a persistent terminal; ``attach`` already does this."""
|
|
256
|
+
while self._protocol.attached is None:
|
|
257
|
+
if self._next(timeout) is None:
|
|
258
|
+
raise PtyConnectionError("Terminal connection closed before it was attached", code=self.close_code)
|
|
259
|
+
|
|
260
|
+
def __iter__(self) -> Iterator[bytes]:
|
|
261
|
+
while (chunk := self.recv()) is not None:
|
|
262
|
+
yield chunk
|
|
263
|
+
|
|
264
|
+
def close(self) -> None:
|
|
265
|
+
"""Closes the connection. The terminal keeps running on the server."""
|
|
266
|
+
self._ws.close()
|
|
267
|
+
|
|
268
|
+
def __enter__(self) -> Self:
|
|
269
|
+
return self
|
|
270
|
+
|
|
271
|
+
def __exit__(
|
|
272
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
273
|
+
) -> None:
|
|
274
|
+
self.close()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
class AsyncPtyConnection:
|
|
278
|
+
"""An asyncio connection to a remote terminal. Same interface as :class:`PtyConnection`, with awaitables.
|
|
279
|
+
|
|
280
|
+
Example::
|
|
281
|
+
|
|
282
|
+
async with await client.pty.attach(pty.id) as term:
|
|
283
|
+
await term.send("echo hi\\n")
|
|
284
|
+
async for chunk in term:
|
|
285
|
+
print(chunk.decode(errors="replace"), end="")
|
|
286
|
+
"""
|
|
287
|
+
|
|
288
|
+
def __init__(
|
|
289
|
+
self,
|
|
290
|
+
ws: AsyncWebSocket,
|
|
291
|
+
protocol: _Protocol,
|
|
292
|
+
resize: Callable[[int, int], Awaitable[object]],
|
|
293
|
+
on_event: Callable[[dict[str, Any]], object] | None = None,
|
|
294
|
+
) -> None:
|
|
295
|
+
self._ws = ws
|
|
296
|
+
self._protocol = protocol
|
|
297
|
+
self._resize = resize
|
|
298
|
+
self._on_event = on_event
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def cursor(self) -> int | None:
|
|
302
|
+
"""Output position to pass as ``cursor`` when reattaching, once the server has reported one."""
|
|
303
|
+
return self._protocol.cursor
|
|
304
|
+
|
|
305
|
+
@property
|
|
306
|
+
def attached(self) -> dict[str, Any] | None:
|
|
307
|
+
"""The ``attached`` event of a persistent terminal (attachment id, role, info, replay offsets)."""
|
|
308
|
+
return self._protocol.attached
|
|
309
|
+
|
|
310
|
+
@property
|
|
311
|
+
def close_code(self) -> int | None:
|
|
312
|
+
"""The WebSocket close code once the connection has closed."""
|
|
313
|
+
return self._ws.protocol.close_code
|
|
314
|
+
|
|
315
|
+
async def send(self, data: bytes | str) -> None:
|
|
316
|
+
"""Writes input to the terminal. ``str`` is sent as UTF-8."""
|
|
317
|
+
await self._ws.send(self._protocol.encode_input(data))
|
|
318
|
+
|
|
319
|
+
async def resize(self, cols: int, rows: int) -> None:
|
|
320
|
+
"""Resizes the terminal."""
|
|
321
|
+
if self._protocol.persistent:
|
|
322
|
+
self._protocol.cols, self._protocol.rows = cols, rows
|
|
323
|
+
await self._ws.send(self._protocol.encode_control(cols, rows))
|
|
324
|
+
else:
|
|
325
|
+
await self._resize(cols, rows)
|
|
326
|
+
|
|
327
|
+
async def recv(self) -> bytes | None:
|
|
328
|
+
"""Returns the next output chunk, or ``None`` once the terminal has ended.
|
|
329
|
+
|
|
330
|
+
Raises:
|
|
331
|
+
PtyConnectionError: The server closed the connection abnormally (``4404``: terminal unavailable).
|
|
332
|
+
"""
|
|
333
|
+
while (output := await self._next()) == b"":
|
|
334
|
+
pass
|
|
335
|
+
return output
|
|
336
|
+
|
|
337
|
+
async def _next(self) -> bytes | None:
|
|
338
|
+
"""Reads one frame: output bytes, ``b""`` for a control frame, ``None`` after a normal close."""
|
|
339
|
+
from websockets.exceptions import ConnectionClosed
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
frame = await self._ws.recv()
|
|
343
|
+
except ConnectionClosed as exc:
|
|
344
|
+
error = _closed(exc.rcvd.code if exc.rcvd else None, exc.rcvd.reason if exc.rcvd else "")
|
|
345
|
+
if error is not None:
|
|
346
|
+
raise error from None
|
|
347
|
+
return None
|
|
348
|
+
output, event = self._protocol.decode(frame)
|
|
349
|
+
if event is not None and self._on_event is not None and self._protocol.persistent:
|
|
350
|
+
self._on_event(event)
|
|
351
|
+
return output or b""
|
|
352
|
+
|
|
353
|
+
async def wait_attached(self) -> None:
|
|
354
|
+
"""Reads frames until the ``attached`` event of a persistent terminal; ``attach`` already does this."""
|
|
355
|
+
while self._protocol.attached is None:
|
|
356
|
+
if await self._next() is None:
|
|
357
|
+
raise PtyConnectionError("Terminal connection closed before it was attached", code=self.close_code)
|
|
358
|
+
|
|
359
|
+
async def __aiter__(self) -> AsyncIterator[bytes]:
|
|
360
|
+
while (chunk := await self.recv()) is not None:
|
|
361
|
+
yield chunk
|
|
362
|
+
|
|
363
|
+
async def close(self) -> None:
|
|
364
|
+
"""Closes the connection. The terminal keeps running on the server."""
|
|
365
|
+
await self._ws.close()
|
|
366
|
+
|
|
367
|
+
async def __aenter__(self) -> Self:
|
|
368
|
+
return self
|
|
369
|
+
|
|
370
|
+
async def __aexit__(
|
|
371
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
372
|
+
) -> None:
|
|
373
|
+
await self.close()
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _open_sync(url: str, headers: dict[str, str], open_timeout: float | None) -> SyncWebSocket:
|
|
377
|
+
try:
|
|
378
|
+
from websockets.exceptions import InvalidHandshake
|
|
379
|
+
from websockets.sync.client import connect
|
|
380
|
+
except ImportError:
|
|
381
|
+
raise _missing() from None
|
|
382
|
+
try:
|
|
383
|
+
return connect(url, additional_headers=headers, max_size=None, open_timeout=open_timeout)
|
|
384
|
+
except InvalidHandshake as exc:
|
|
385
|
+
raise _handshake_error(exc) from exc
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def _open_async(url: str, headers: dict[str, str], open_timeout: float | None) -> AsyncWebSocket:
|
|
389
|
+
try:
|
|
390
|
+
from websockets.asyncio.client import connect
|
|
391
|
+
from websockets.exceptions import InvalidHandshake
|
|
392
|
+
except ImportError:
|
|
393
|
+
raise _missing() from None
|
|
394
|
+
try:
|
|
395
|
+
return await connect(url, additional_headers=headers, max_size=None, open_timeout=open_timeout)
|
|
396
|
+
except InvalidHandshake as exc:
|
|
397
|
+
raise _handshake_error(exc) from exc
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _require_websockets() -> None:
|
|
401
|
+
try:
|
|
402
|
+
import websockets # noqa: F401 # pyright: ignore[reportUnusedImport]
|
|
403
|
+
except ImportError:
|
|
404
|
+
raise _missing() from None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _pty_query(location: object, cursor: int | None, ticket: str) -> dict[str, object]:
|
|
408
|
+
return {"location": location, "cursor": NOT_GIVEN if cursor is None else cursor, "ticket": ticket}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _persistent_query(
|
|
412
|
+
cursor: int, role: Literal["controller", "observer"], attachment_id: str, takeover: bool, ticket: str
|
|
413
|
+
) -> dict[str, object]:
|
|
414
|
+
return {
|
|
415
|
+
"ticket": ticket,
|
|
416
|
+
"cursor": cursor,
|
|
417
|
+
"role": role,
|
|
418
|
+
"attachment_id": attachment_id,
|
|
419
|
+
"takeover": takeover,
|
|
420
|
+
"input_protocol": 1,
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class PtyAttachMixin(SyncAPIResource):
|
|
425
|
+
"""Adds :meth:`attach` to ``client.pty``."""
|
|
426
|
+
|
|
427
|
+
def attach(
|
|
428
|
+
self,
|
|
429
|
+
pty_id: PtyID,
|
|
430
|
+
*,
|
|
431
|
+
location: Location | NotGiven | None = NOT_GIVEN,
|
|
432
|
+
cursor: int | None = None,
|
|
433
|
+
open_timeout: float | None = 10,
|
|
434
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
435
|
+
) -> PtyConnection:
|
|
436
|
+
"""Opens an interactive connection to a PTY created with :meth:`create`.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
pty_id: The PTY to attach to.
|
|
440
|
+
location: Project location; defaults to the client's ``directory``.
|
|
441
|
+
cursor: Output position to resume from (:attr:`PtyConnection.cursor` of an earlier connection).
|
|
442
|
+
``None`` replays the whole buffer, ``-1`` skips the replay.
|
|
443
|
+
open_timeout: Seconds to wait for the WebSocket handshake.
|
|
444
|
+
extra_headers: Additional handshake headers.
|
|
445
|
+
|
|
446
|
+
Raises:
|
|
447
|
+
PtyConnectionError: ``websockets`` is not installed, or the server refused the connection.
|
|
448
|
+
|
|
449
|
+
``GET /api/pty/:ptyID/connect`` (WebSocket)
|
|
450
|
+
"""
|
|
451
|
+
_require_websockets()
|
|
452
|
+
client = self._client
|
|
453
|
+
token = client.pty.connect.token(pty_id, location=location, x_opencode_ticket="1")
|
|
454
|
+
url = _ws_url(
|
|
455
|
+
client.base_url,
|
|
456
|
+
f"/api/pty/{encode_path_segment(pty_id)}/connect",
|
|
457
|
+
_pty_query(self._location(location), cursor, token.data.ticket),
|
|
458
|
+
)
|
|
459
|
+
ws = _open_sync(url, _headers(client.username, client.password, extra_headers), open_timeout)
|
|
460
|
+
|
|
461
|
+
def resize(cols: int, rows: int) -> object:
|
|
462
|
+
from .types import PtyUpdateSize
|
|
463
|
+
|
|
464
|
+
return client.pty.update(pty_id, location=location, size=PtyUpdateSize(cols=cols, rows=rows))
|
|
465
|
+
|
|
466
|
+
return PtyConnection(ws, _Protocol(persistent=False, cols=0, rows=0), resize)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class AsyncPtyAttachMixin(AsyncAPIResource):
|
|
470
|
+
"""Adds :meth:`attach` to ``client.pty``."""
|
|
471
|
+
|
|
472
|
+
async def attach(
|
|
473
|
+
self,
|
|
474
|
+
pty_id: PtyID,
|
|
475
|
+
*,
|
|
476
|
+
location: Location | NotGiven | None = NOT_GIVEN,
|
|
477
|
+
cursor: int | None = None,
|
|
478
|
+
open_timeout: float | None = 10,
|
|
479
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
480
|
+
) -> AsyncPtyConnection:
|
|
481
|
+
"""Opens an interactive connection to a PTY. See :meth:`PtyAttachMixin.attach`. Needs asyncio.
|
|
482
|
+
|
|
483
|
+
``GET /api/pty/:ptyID/connect`` (WebSocket)
|
|
484
|
+
"""
|
|
485
|
+
_require_websockets()
|
|
486
|
+
client = self._client
|
|
487
|
+
token = await client.pty.connect.token(pty_id, location=location, x_opencode_ticket="1")
|
|
488
|
+
url = _ws_url(
|
|
489
|
+
client.base_url,
|
|
490
|
+
f"/api/pty/{encode_path_segment(pty_id)}/connect",
|
|
491
|
+
_pty_query(self._location(location), cursor, token.data.ticket),
|
|
492
|
+
)
|
|
493
|
+
ws = await _open_async(url, _headers(client.username, client.password, extra_headers), open_timeout)
|
|
494
|
+
|
|
495
|
+
async def resize(cols: int, rows: int) -> object:
|
|
496
|
+
from .types import PtyUpdateSize
|
|
497
|
+
|
|
498
|
+
return await client.pty.update(pty_id, location=location, size=PtyUpdateSize(cols=cols, rows=rows))
|
|
499
|
+
|
|
500
|
+
return AsyncPtyConnection(ws, _Protocol(persistent=False, cols=0, rows=0), resize)
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
class PersistentPtyAttachMixin(SyncAPIResource):
|
|
504
|
+
"""Adds :meth:`attach` to ``client.experimental.persistent_pty``."""
|
|
505
|
+
|
|
506
|
+
def attach(
|
|
507
|
+
self,
|
|
508
|
+
pty_id: PtyID,
|
|
509
|
+
*,
|
|
510
|
+
cursor: int = 0,
|
|
511
|
+
role: Literal["controller", "observer"] = "controller",
|
|
512
|
+
attachment_id: str | None = None,
|
|
513
|
+
takeover: bool = False,
|
|
514
|
+
on_event: Callable[[dict[str, Any]], object] | None = None,
|
|
515
|
+
open_timeout: float | None = 10,
|
|
516
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
517
|
+
) -> PtyConnection:
|
|
518
|
+
"""Opens an interactive connection to a persistent terminal. *(experimental)*
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
pty_id: The terminal to attach to.
|
|
522
|
+
cursor: Byte offset to replay from; ``0`` replays everything the server kept.
|
|
523
|
+
role: ``controller`` sends input and sets the size; ``observer`` only watches.
|
|
524
|
+
attachment_id: Identifies this client across reconnects; a random id by default.
|
|
525
|
+
takeover: Take control from the current controller.
|
|
526
|
+
on_event: Called with each JSON event (``attached``, ``replay_complete``, ``resized``, ...).
|
|
527
|
+
open_timeout: Seconds to wait for the WebSocket handshake.
|
|
528
|
+
extra_headers: Additional handshake headers.
|
|
529
|
+
|
|
530
|
+
Raises:
|
|
531
|
+
PtyConnectionError: ``websockets`` is not installed, or the server refused the connection.
|
|
532
|
+
|
|
533
|
+
``GET /api/experimental/persistent-pty/:ptyID/connect`` (WebSocket)
|
|
534
|
+
"""
|
|
535
|
+
_require_websockets()
|
|
536
|
+
client = self._client
|
|
537
|
+
token = client.experimental.persistent_pty.connect_token(pty_id, x_opencode_ticket="1")
|
|
538
|
+
url = _ws_url(
|
|
539
|
+
client.base_url,
|
|
540
|
+
f"/api/experimental/persistent-pty/{encode_path_segment(pty_id)}/connect",
|
|
541
|
+
_persistent_query(cursor, role, attachment_id or str(uuid.uuid4()), takeover, token.ticket),
|
|
542
|
+
)
|
|
543
|
+
ws = _open_sync(url, _headers(client.username, client.password, extra_headers), open_timeout)
|
|
544
|
+
protocol = _Protocol(persistent=True, cols=0, rows=0)
|
|
545
|
+
connection = PtyConnection(ws, protocol, lambda cols, rows: None, on_event)
|
|
546
|
+
connection.wait_attached(open_timeout)
|
|
547
|
+
return connection
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
class AsyncPersistentPtyAttachMixin(AsyncAPIResource):
|
|
551
|
+
"""Adds :meth:`attach` to ``client.experimental.persistent_pty``."""
|
|
552
|
+
|
|
553
|
+
async def attach(
|
|
554
|
+
self,
|
|
555
|
+
pty_id: PtyID,
|
|
556
|
+
*,
|
|
557
|
+
cursor: int = 0,
|
|
558
|
+
role: Literal["controller", "observer"] = "controller",
|
|
559
|
+
attachment_id: str | None = None,
|
|
560
|
+
takeover: bool = False,
|
|
561
|
+
on_event: Callable[[dict[str, Any]], object] | None = None,
|
|
562
|
+
open_timeout: float | None = 10,
|
|
563
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
564
|
+
) -> AsyncPtyConnection:
|
|
565
|
+
"""Opens an interactive connection to a persistent terminal. See
|
|
566
|
+
:meth:`PersistentPtyAttachMixin.attach`. Needs asyncio. *(experimental)*
|
|
567
|
+
|
|
568
|
+
``GET /api/experimental/persistent-pty/:ptyID/connect`` (WebSocket)
|
|
569
|
+
"""
|
|
570
|
+
_require_websockets()
|
|
571
|
+
client = self._client
|
|
572
|
+
token = await client.experimental.persistent_pty.connect_token(pty_id, x_opencode_ticket="1")
|
|
573
|
+
url = _ws_url(
|
|
574
|
+
client.base_url,
|
|
575
|
+
f"/api/experimental/persistent-pty/{encode_path_segment(pty_id)}/connect",
|
|
576
|
+
_persistent_query(cursor, role, attachment_id or str(uuid.uuid4()), takeover, token.ticket),
|
|
577
|
+
)
|
|
578
|
+
ws = await _open_async(url, _headers(client.username, client.password, extra_headers), open_timeout)
|
|
579
|
+
protocol = _Protocol(persistent=True, cols=0, rows=0)
|
|
580
|
+
|
|
581
|
+
async def resize(cols: int, rows: int) -> None:
|
|
582
|
+
return None
|
|
583
|
+
|
|
584
|
+
connection = AsyncPtyConnection(ws, protocol, resize, on_event)
|
|
585
|
+
await connection.wait_attached()
|
|
586
|
+
return connection
|
|
@@ -11,6 +11,7 @@ from functools import cached_property
|
|
|
11
11
|
from typing import cast
|
|
12
12
|
|
|
13
13
|
from .._base_client import RequestOptions
|
|
14
|
+
from .._pty import AsyncPersistentPtyAttachMixin, PersistentPtyAttachMixin
|
|
14
15
|
from .._qs import encode_path_segment
|
|
15
16
|
from .._resource import AsyncAPIResource, SyncAPIResource
|
|
16
17
|
from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout
|
|
@@ -30,7 +31,7 @@ from ..types import (
|
|
|
30
31
|
__all__ = ["ExperimentalResource", "AsyncExperimentalResource"]
|
|
31
32
|
|
|
32
33
|
|
|
33
|
-
class ExperimentalPersistentPtyResource(
|
|
34
|
+
class ExperimentalPersistentPtyResource(PersistentPtyAttachMixin):
|
|
34
35
|
"""Operations under ``client.experimental.persistent_pty`` (OpenCode ``experimental.persistentPty``)."""
|
|
35
36
|
|
|
36
37
|
def read(
|
|
@@ -396,7 +397,7 @@ class ExperimentalResource(SyncAPIResource):
|
|
|
396
397
|
return ExperimentalPersistentPtyResource(self._client)
|
|
397
398
|
|
|
398
399
|
|
|
399
|
-
class AsyncExperimentalPersistentPtyResource(
|
|
400
|
+
class AsyncExperimentalPersistentPtyResource(AsyncPersistentPtyAttachMixin):
|
|
400
401
|
"""Operations under ``client.experimental.persistent_pty`` (OpenCode ``experimental.persistentPty``)."""
|
|
401
402
|
|
|
402
403
|
async def read(
|
opencode/resources/pty.py
CHANGED
|
@@ -10,6 +10,7 @@ from functools import cached_property
|
|
|
10
10
|
from typing import cast
|
|
11
11
|
|
|
12
12
|
from .._base_client import RequestOptions
|
|
13
|
+
from .._pty import AsyncPtyAttachMixin, PtyAttachMixin
|
|
13
14
|
from .._qs import encode_path_segment
|
|
14
15
|
from .._resource import AsyncAPIResource, SyncAPIResource
|
|
15
16
|
from .._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout
|
|
@@ -72,7 +73,7 @@ class PtyConnectResource(SyncAPIResource):
|
|
|
72
73
|
)
|
|
73
74
|
|
|
74
75
|
|
|
75
|
-
class PtyResource(
|
|
76
|
+
class PtyResource(PtyAttachMixin):
|
|
76
77
|
"""Operations under ``client.pty`` (OpenCode ``pty``)."""
|
|
77
78
|
|
|
78
79
|
@cached_property
|
|
@@ -318,7 +319,7 @@ class AsyncPtyConnectResource(AsyncAPIResource):
|
|
|
318
319
|
)
|
|
319
320
|
|
|
320
321
|
|
|
321
|
-
class AsyncPtyResource(
|
|
322
|
+
class AsyncPtyResource(AsyncPtyAttachMixin):
|
|
322
323
|
"""Operations under ``client.pty`` (OpenCode ``pty``)."""
|
|
323
324
|
|
|
324
325
|
@cached_property
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: opencode-python-sdk
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
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/
|
|
@@ -31,6 +31,8 @@ Requires-Dist: anyio<5,>=4.4
|
|
|
31
31
|
Requires-Dist: httpx<1,>=0.27
|
|
32
32
|
Requires-Dist: pydantic<3,>=2.12
|
|
33
33
|
Requires-Dist: typing-extensions>=4.12
|
|
34
|
+
Provides-Extra: pty
|
|
35
|
+
Requires-Dist: websockets<17,>=14; extra == 'pty'
|
|
34
36
|
Description-Content-Type: text/markdown
|
|
35
37
|
|
|
36
38
|
# opencode-python-sdk
|
|
@@ -204,6 +206,27 @@ Events are typed (`SessionCreatedEvent`, `SessionTextDeltaEvent`, and so on). Ev
|
|
|
204
206
|
not know arrive as `UnknownEvent` with all their fields kept. Streams apply only the connect timeout by default, since
|
|
205
207
|
they stay open between events.
|
|
206
208
|
|
|
209
|
+
## Terminals
|
|
210
|
+
|
|
211
|
+
`client.pty.attach()` opens an interactive connection to a PTY over a WebSocket. It needs the `pty` extra:
|
|
212
|
+
`pip install "opencode-python-sdk[pty]"`.
|
|
213
|
+
|
|
214
|
+
```python
|
|
215
|
+
pty = client.pty.create(command="/bin/sh").data
|
|
216
|
+
with client.pty.attach(pty.id) as term:
|
|
217
|
+
term.resize(120, 40)
|
|
218
|
+
term.send("echo hi\n")
|
|
219
|
+
for chunk in term: # output bytes, until the shell exits
|
|
220
|
+
print(chunk.decode(errors="replace"), end="")
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
`recv(timeout=...)` reads one chunk and returns `None` once the terminal has ended. `term.cursor` holds the output
|
|
224
|
+
position to pass as `cursor=` when reattaching; `cursor=-1` skips the replay of earlier output. Persistent terminals
|
|
225
|
+
work the same way through `client.experimental.persistent_pty.attach(pty_id, role=..., takeover=...)`. The async
|
|
226
|
+
client returns `AsyncPtyConnection` (asyncio only): `async with await client.pty.attach(...) as term`. A connection
|
|
227
|
+
the server refuses or closes abnormally raises `PtyConnectionError`. See
|
|
228
|
+
[`examples/terminal.py`](examples/terminal.py) for a full TTY bridge.
|
|
229
|
+
|
|
207
230
|
## Errors
|
|
208
231
|
|
|
209
232
|
Every error derives from `opencode.OpencodeError`. Errors the server declares are raised as their own class, with a
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
opencode/.generated.json,sha256=QNCFazAYhcwsGf0l4A2EeSpm2HOM7TRBE648Zyoeq_s,1383
|
|
2
|
-
opencode/__init__.py,sha256=
|
|
2
|
+
opencode/__init__.py,sha256=vr5KU114carpz_eMvR-Pekt0nJz5YgP_iylSdLKeJHg,4251
|
|
3
3
|
opencode/_base_client.py,sha256=3BwhIXL-cFGoRUuwDzTeFH21O7pihUR5CryOlS1Wylo,20108
|
|
4
4
|
opencode/_client.py,sha256=ip-UhWyHTViMnqgX3jhUmCkscahFtXnIWM2Jd2zn61I,6723
|
|
5
5
|
opencode/_constants.py,sha256=-B_D8RHElL4mYN__WVf4bag4D12dJHbL69_EMfi7bXo,1132
|
|
@@ -7,6 +7,7 @@ opencode/_errors.py,sha256=dWjsyGV0aDBKsvJrGNKGJn41Yue66yBBwGTT2pTV7E8,16431
|
|
|
7
7
|
opencode/_exceptions.py,sha256=kqQiaSM6zbfyiXXddwapIymW7J61ZlAa767Yko5Y9EI,8720
|
|
8
8
|
opencode/_models.py,sha256=2esvD2CmslOHCbuLNkN0sGDcgMAqeoMmB7hZMPzo1uA,1988
|
|
9
9
|
opencode/_pagination.py,sha256=uvB-0IwfkGqbYIYiUW_ax_oAE-ldZJSWHtjluVcZlNk,7052
|
|
10
|
+
opencode/_pty.py,sha256=wdqBF7K40ikzas8i203pem2iov8nbgYDC-RqH6ZX41E,23429
|
|
10
11
|
opencode/_qs.py,sha256=8EWbLzd-iLComngGRyO95f_z76j4tUeBihYJdHI5NP8,3643
|
|
11
12
|
opencode/_resource.py,sha256=ovuG85mWcuXNTu5m6H9FKPN2rrD5o52oRFAGBHimPyg,1263
|
|
12
13
|
opencode/_server.py,sha256=O-yVWU-v7HDJuVav1xXTil96BtvvbYMsHm1zLhneWKk,12849
|
|
@@ -22,7 +23,7 @@ opencode/resources/config.py,sha256=pnRi7pG8mJqO0Xp6dnh0a8V767Dy5Zvbr8wvsaBqaIc,
|
|
|
22
23
|
opencode/resources/credential.py,sha256=6X-IGP8uN4WVVhCFoLPWnMjfQ6dW25wms1R3YIFNggA,12018
|
|
23
24
|
opencode/resources/debug.py,sha256=gdKIJ1jSsD6mPaI_Qd3pmE5J5URIyTWZAppfulejf-c,5483
|
|
24
25
|
opencode/resources/event.py,sha256=UntmSEmFRWjSIjWvP-OkXfc-S1cpflwHyp0E2ZYGCBk,2694
|
|
25
|
-
opencode/resources/experimental.py,sha256=
|
|
26
|
+
opencode/resources/experimental.py,sha256=qjaMkBfI7aOwLWeQWlXmVtYwM1VIilry27kyCVcC1B0,28058
|
|
26
27
|
opencode/resources/file.py,sha256=leXPuTdE44BUrpIqDZ3MAAjSgv8_aKtyDROKsDZuVSE,12943
|
|
27
28
|
opencode/resources/form.py,sha256=ToPVrHbTlyUZyNrMaH3bkcfqEjvIF1UlL7CQHT2VjyE,2967
|
|
28
29
|
opencode/resources/generate.py,sha256=koElRVSJtRrP6yok3hsYOJon8MMinku8IB8zCS0MImM,3755
|
|
@@ -36,7 +37,7 @@ opencode/resources/permission.py,sha256=4J-eysN90zWIAdrXbzXfR4FvPlsyIgwvXPmRMI46
|
|
|
36
37
|
opencode/resources/plugin.py,sha256=CaZCUCdU4iAxgkZhYr6QntPmdAcJuBlip55q3q5rplw,8159
|
|
37
38
|
opencode/resources/project.py,sha256=BJkgHswUoE2UZkWYQzTg1KyT8L5l5WNlJhlBdVZUMso,6114
|
|
38
39
|
opencode/resources/provider.py,sha256=H7hdaJZ1ToyEGoSr13qUcAV-Rd1bd-pUSi6GJlY8B7A,6134
|
|
39
|
-
opencode/resources/pty.py,sha256=
|
|
40
|
+
opencode/resources/pty.py,sha256=ttlB4iwI-QyppsEClPrsdkvEtLFsrxOF5-B1iH-4QY0,17537
|
|
40
41
|
opencode/resources/reference.py,sha256=7Y9pPZYVoIaN8k3qwYY8-h54vBUdxI6lSBgJYmowq3I,3089
|
|
41
42
|
opencode/resources/rpc.py,sha256=AExEcrxgJrreOqav4k_edNcrg3Uqa9p7lilnvxVMqfo,3630
|
|
42
43
|
opencode/resources/server.py,sha256=tNuofw5_BMSCGTalkUvOc5--hn43mSVBU0HANK-L8zo,6932
|
|
@@ -49,7 +50,7 @@ opencode/resources/websearch.py,sha256=xr1vaS6dwPsSZJUtVSkRtqtzZuoP9xg_cr1bBpeKo
|
|
|
49
50
|
opencode/resources/worktree.py,sha256=mohf9rIm4PPJTMbJmieygQkaC8rc7LgF0GO4u-2D9OU,10950
|
|
50
51
|
opencode/types/__init__.py,sha256=5JEAe89RPSwJw1yz0kufBxtEJgRsMpZXj7KTbzGX45A,60935
|
|
51
52
|
opencode/types/_generated.py,sha256=c3zBAfVpZGJ9TX4FqrMZASmoxRntel_QzVuP85BOJwU,235015
|
|
52
|
-
opencode_python_sdk-0.1.
|
|
53
|
-
opencode_python_sdk-0.1.
|
|
54
|
-
opencode_python_sdk-0.1.
|
|
55
|
-
opencode_python_sdk-0.1.
|
|
53
|
+
opencode_python_sdk-0.1.2.dist-info/METADATA,sha256=pDtlrwdEM6_qXC2rXA5kk2s1LfA43YBen66Y9AiR1MU,12612
|
|
54
|
+
opencode_python_sdk-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
55
|
+
opencode_python_sdk-0.1.2.dist-info/licenses/LICENSE,sha256=SbiOG1an_-gZ0gwGLS0tsbUnMHA7tYX22FgP1EqzrH4,1093
|
|
56
|
+
opencode_python_sdk-0.1.2.dist-info/RECORD,,
|
|
File without changes
|
{opencode_python_sdk-0.1.1.dist-info → opencode_python_sdk-0.1.2.dist-info}/licenses/LICENSE
RENAMED
|
File without changes
|