opencode-python-sdk 0.1.0__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/.generated.json +39 -0
- opencode/__init__.py +156 -0
- opencode/_base_client.py +518 -0
- opencode/_client.py +167 -0
- opencode/_constants.py +27 -0
- opencode/_errors.py +547 -0
- opencode/_exceptions.py +249 -0
- opencode/_models.py +54 -0
- opencode/_pagination.py +192 -0
- opencode/_qs.py +95 -0
- opencode/_resource.py +42 -0
- opencode/_server.py +327 -0
- opencode/_streaming.py +227 -0
- opencode/_types.py +77 -0
- opencode/_unions.py +46 -0
- opencode/_version.py +8 -0
- opencode/py.typed +0 -0
- opencode/resources/__init__.py +364 -0
- opencode/resources/agent.py +177 -0
- opencode/resources/command.py +98 -0
- opencode/resources/config.py +238 -0
- opencode/resources/credential.py +382 -0
- opencode/resources/debug.py +174 -0
- opencode/resources/event.py +84 -0
- opencode/resources/experimental.py +760 -0
- opencode/resources/file.py +353 -0
- opencode/resources/form.py +98 -0
- opencode/resources/generate.py +108 -0
- opencode/resources/integration.py +1023 -0
- opencode/resources/location.py +160 -0
- opencode/resources/mcp.py +488 -0
- opencode/resources/message.py +151 -0
- opencode/resources/migration.py +118 -0
- opencode/resources/model.py +175 -0
- opencode/resources/permission.py +698 -0
- opencode/resources/plugin.py +248 -0
- opencode/resources/project.py +185 -0
- opencode/resources/provider.py +181 -0
- opencode/resources/pty.py +517 -0
- opencode/resources/reference.py +98 -0
- opencode/resources/rpc.py +111 -0
- opencode/resources/server.py +228 -0
- opencode/resources/session.py +3553 -0
- opencode/resources/shell.py +410 -0
- opencode/resources/skill.py +98 -0
- opencode/resources/vcs.py +496 -0
- opencode/resources/voice.py +111 -0
- opencode/resources/websearch.py +182 -0
- opencode/resources/worktree.py +334 -0
- opencode/types/__init__.py +1462 -0
- opencode/types/_generated.py +8540 -0
- opencode_python_sdk-0.1.0.dist-info/METADATA +317 -0
- opencode_python_sdk-0.1.0.dist-info/RECORD +55 -0
- opencode_python_sdk-0.1.0.dist-info/WHEEL +4 -0
- opencode_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
opencode/_server.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
"""Starts and stops a local ``opencode serve`` process."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import contextlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import queue
|
|
10
|
+
import secrets
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import IO, TYPE_CHECKING, Any, cast
|
|
18
|
+
|
|
19
|
+
import anyio.to_thread
|
|
20
|
+
import httpx
|
|
21
|
+
from typing_extensions import Self
|
|
22
|
+
|
|
23
|
+
from ._client import AsyncOpencode, Opencode
|
|
24
|
+
from ._constants import DEFAULT_USERNAME
|
|
25
|
+
from ._exceptions import OpencodeError
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
from types import TracebackType
|
|
30
|
+
|
|
31
|
+
from ._types import Timeout
|
|
32
|
+
|
|
33
|
+
__all__ = ["AsyncOpencodeServer", "OpencodeServer", "ServerStartError"]
|
|
34
|
+
|
|
35
|
+
_ISOLATION_ENV = {
|
|
36
|
+
# Ignore the working directory's opencode.json, AGENTS.md and .opencode folder.
|
|
37
|
+
"OPENCODE_CONFIG_PROJECT_DISABLE": "1",
|
|
38
|
+
# Ignore Claude Code skills and instructions found in the project or home directory.
|
|
39
|
+
"OPENCODE_DISABLE_CLAUDE_CODE": "1",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ServerStartError(OpencodeError):
|
|
44
|
+
"""``opencode serve`` could not be started or did not report its address in time."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, *, output: str = "") -> None:
|
|
47
|
+
super().__init__(f"{message}\n\nServer output:\n{output}" if output else message)
|
|
48
|
+
self.output = output
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class OpencodeServer:
|
|
52
|
+
"""Runs ``opencode serve`` as a child process for the lifetime of a ``with`` block.
|
|
53
|
+
|
|
54
|
+
The server listens on a free local port and requires a random password, which is passed to clients created
|
|
55
|
+
with :meth:`client`. It runs in ``--stdio`` mode, so it also exits if this process dies.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
cwd: Directory the server runs in, which is its default project location.
|
|
59
|
+
config: OpenCode configuration as a mapping, passed through ``OPENCODE_CONFIG_CONTENT``.
|
|
60
|
+
isolated: Ignore project and Claude Code configuration found on disk, for reproducible embedded use.
|
|
61
|
+
env: Extra environment variables for the server.
|
|
62
|
+
hostname: Interface to bind. Keep ``127.0.0.1`` unless the server must be reachable from other hosts.
|
|
63
|
+
port: Port to bind. ``0`` picks a free port.
|
|
64
|
+
password: Server password. A random one is generated by default.
|
|
65
|
+
binary: The ``opencode`` executable, looked up on ``PATH`` by default.
|
|
66
|
+
startup_timeout: Seconds to wait for the server to report its address.
|
|
67
|
+
|
|
68
|
+
Example::
|
|
69
|
+
|
|
70
|
+
with OpencodeServer(cwd="/path/to/repo", isolated=True) as server:
|
|
71
|
+
client = server.client()
|
|
72
|
+
print(client.server.info().version)
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
def __init__(
|
|
76
|
+
self,
|
|
77
|
+
*,
|
|
78
|
+
cwd: str | os.PathLike[str] | None = None,
|
|
79
|
+
config: Mapping[str, Any] | None = None,
|
|
80
|
+
isolated: bool = False,
|
|
81
|
+
env: Mapping[str, str] | None = None,
|
|
82
|
+
hostname: str = "127.0.0.1",
|
|
83
|
+
port: int = 0,
|
|
84
|
+
password: str | None = None,
|
|
85
|
+
binary: str | os.PathLike[str] = "opencode",
|
|
86
|
+
startup_timeout: float = 60.0,
|
|
87
|
+
) -> None:
|
|
88
|
+
self.cwd = Path(cwd) if cwd is not None else None
|
|
89
|
+
self.config = dict(config) if config is not None else None
|
|
90
|
+
self.isolated = isolated
|
|
91
|
+
self.env = dict(env or {})
|
|
92
|
+
self.hostname = hostname
|
|
93
|
+
self.port = port
|
|
94
|
+
self.password = password if password is not None else secrets.token_urlsafe(32)
|
|
95
|
+
self.binary = os.fspath(binary)
|
|
96
|
+
self.startup_timeout = startup_timeout
|
|
97
|
+
self._process: subprocess.Popen[str] | None = None
|
|
98
|
+
self._url: str | None = None
|
|
99
|
+
self._output: collections.deque[str] = collections.deque(maxlen=200)
|
|
100
|
+
self._lock = threading.Lock()
|
|
101
|
+
self._threads: list[threading.Thread] = []
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def url(self) -> str:
|
|
105
|
+
"""The server's base URL. Available once started."""
|
|
106
|
+
if self._url is None:
|
|
107
|
+
raise OpencodeError("The OpenCode server is not running. Call start() or use it as a context manager.")
|
|
108
|
+
return self._url
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def process(self) -> subprocess.Popen[str] | None:
|
|
112
|
+
return self._process
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def running(self) -> bool:
|
|
116
|
+
return self._process is not None and self._process.poll() is None
|
|
117
|
+
|
|
118
|
+
def client(self, *, directory: str | os.PathLike[str] | None = None, timeout: Timeout | None = None) -> Opencode:
|
|
119
|
+
"""Returns a sync client connected to this server."""
|
|
120
|
+
kwargs: dict[str, Any] = {} if timeout is None else {"timeout": timeout}
|
|
121
|
+
return Opencode(self.url, password=self.password, username=DEFAULT_USERNAME, directory=directory, **kwargs)
|
|
122
|
+
|
|
123
|
+
def async_client(
|
|
124
|
+
self, *, directory: str | os.PathLike[str] | None = None, timeout: Timeout | None = None
|
|
125
|
+
) -> AsyncOpencode:
|
|
126
|
+
"""Returns an async client connected to this server."""
|
|
127
|
+
kwargs: dict[str, Any] = {} if timeout is None else {"timeout": timeout}
|
|
128
|
+
return AsyncOpencode(self.url, password=self.password, username=DEFAULT_USERNAME, directory=directory, **kwargs)
|
|
129
|
+
|
|
130
|
+
def _command(self) -> list[str]:
|
|
131
|
+
executable = shutil.which(self.binary)
|
|
132
|
+
if executable is None:
|
|
133
|
+
raise ServerStartError(
|
|
134
|
+
f"Could not find the {self.binary!r} executable. Install OpenCode (https://opencode.ai) "
|
|
135
|
+
"or pass binary= with its path."
|
|
136
|
+
)
|
|
137
|
+
return [executable, "serve", "--stdio", "--hostname", self.hostname, "--port", str(self.port)]
|
|
138
|
+
|
|
139
|
+
def _environment(self) -> dict[str, str]:
|
|
140
|
+
environment = dict(os.environ)
|
|
141
|
+
# The server reads its password from the environment and removes it from what its tools inherit.
|
|
142
|
+
environment.pop("OPENCODE_SERVER_PASSWORD", None)
|
|
143
|
+
environment["OPENCODE_PASSWORD"] = self.password
|
|
144
|
+
if self.isolated:
|
|
145
|
+
environment.update(_ISOLATION_ENV)
|
|
146
|
+
if self.config is not None:
|
|
147
|
+
environment["OPENCODE_CONFIG_CONTENT"] = json.dumps(self.config)
|
|
148
|
+
environment.update(self.env)
|
|
149
|
+
return environment
|
|
150
|
+
|
|
151
|
+
def start(self) -> Self:
|
|
152
|
+
"""Starts the server and waits until it reports its address."""
|
|
153
|
+
with self._lock:
|
|
154
|
+
if self.running:
|
|
155
|
+
return self
|
|
156
|
+
self._output.clear()
|
|
157
|
+
command = self._command()
|
|
158
|
+
try:
|
|
159
|
+
process = subprocess.Popen(
|
|
160
|
+
command,
|
|
161
|
+
cwd=self.cwd,
|
|
162
|
+
env=self._environment(),
|
|
163
|
+
stdin=subprocess.PIPE,
|
|
164
|
+
stdout=subprocess.PIPE,
|
|
165
|
+
stderr=subprocess.PIPE,
|
|
166
|
+
text=True,
|
|
167
|
+
encoding="utf-8",
|
|
168
|
+
errors="replace",
|
|
169
|
+
bufsize=1,
|
|
170
|
+
# Keep Ctrl+C in a terminal from reaching the server before we can shut it down cleanly.
|
|
171
|
+
start_new_session=sys.platform != "win32",
|
|
172
|
+
)
|
|
173
|
+
except OSError as exc:
|
|
174
|
+
raise ServerStartError(f"Could not start {command[0]}: {exc}") from exc
|
|
175
|
+
self._process = process
|
|
176
|
+
lines: queue.Queue[str | None] = queue.Queue()
|
|
177
|
+
assert process.stdout is not None
|
|
178
|
+
assert process.stderr is not None
|
|
179
|
+
self._pump(process.stdout, lines)
|
|
180
|
+
self._pump(process.stderr, None)
|
|
181
|
+
|
|
182
|
+
url = self._wait_for_url(process, lines)
|
|
183
|
+
if url is None:
|
|
184
|
+
output = "\n".join(self._output)
|
|
185
|
+
self._shutdown(process)
|
|
186
|
+
self._process = None
|
|
187
|
+
raise ServerStartError("OpenCode server did not start", output=output)
|
|
188
|
+
self._url = url
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def _pump(self, stream: IO[str], lines: queue.Queue[str | None] | None) -> None:
|
|
192
|
+
def run() -> None:
|
|
193
|
+
try:
|
|
194
|
+
for line in stream:
|
|
195
|
+
stripped = line.rstrip("\n")
|
|
196
|
+
self._output.append(stripped)
|
|
197
|
+
if lines is not None:
|
|
198
|
+
lines.put(stripped)
|
|
199
|
+
except (OSError, ValueError):
|
|
200
|
+
pass
|
|
201
|
+
finally:
|
|
202
|
+
if lines is not None:
|
|
203
|
+
lines.put(None)
|
|
204
|
+
|
|
205
|
+
thread = threading.Thread(target=run, name="opencode-server-output", daemon=True)
|
|
206
|
+
thread.start()
|
|
207
|
+
self._threads.append(thread)
|
|
208
|
+
|
|
209
|
+
def _wait_for_url(self, process: subprocess.Popen[str], lines: queue.Queue[str | None]) -> str | None:
|
|
210
|
+
deadline = time.monotonic() + self.startup_timeout
|
|
211
|
+
while (remaining := deadline - time.monotonic()) > 0:
|
|
212
|
+
try:
|
|
213
|
+
line = lines.get(timeout=min(remaining, 0.5))
|
|
214
|
+
except queue.Empty:
|
|
215
|
+
if process.poll() is not None:
|
|
216
|
+
return None
|
|
217
|
+
continue
|
|
218
|
+
if line is None:
|
|
219
|
+
return None
|
|
220
|
+
try:
|
|
221
|
+
message = json.loads(line)
|
|
222
|
+
except ValueError:
|
|
223
|
+
continue
|
|
224
|
+
url: object = cast("dict[str, Any]", message).get("url") if isinstance(message, dict) else None
|
|
225
|
+
if isinstance(url, str):
|
|
226
|
+
return str(httpx.URL(url))
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def stop(self, timeout: float = 10.0) -> None:
|
|
230
|
+
"""Stops the server: closes its stdin, then terminates it if it has not exited within ``timeout``."""
|
|
231
|
+
with self._lock:
|
|
232
|
+
process = self._process
|
|
233
|
+
self._process = None
|
|
234
|
+
self._url = None
|
|
235
|
+
if process is not None:
|
|
236
|
+
self._shutdown(process, timeout)
|
|
237
|
+
|
|
238
|
+
def _shutdown(self, process: subprocess.Popen[str], timeout: float = 10.0) -> None:
|
|
239
|
+
self._terminate(process, timeout)
|
|
240
|
+
# The output threads finish at end of file; then the pipes can be closed.
|
|
241
|
+
for thread in self._threads:
|
|
242
|
+
thread.join(timeout=2)
|
|
243
|
+
self._threads.clear()
|
|
244
|
+
for stream in (process.stdout, process.stderr):
|
|
245
|
+
if stream is not None:
|
|
246
|
+
with contextlib.suppress(OSError):
|
|
247
|
+
stream.close()
|
|
248
|
+
|
|
249
|
+
@staticmethod
|
|
250
|
+
def _terminate(process: subprocess.Popen[str], timeout: float = 10.0) -> None:
|
|
251
|
+
if process.stdin is not None:
|
|
252
|
+
with contextlib.suppress(OSError):
|
|
253
|
+
process.stdin.close()
|
|
254
|
+
try:
|
|
255
|
+
process.wait(timeout=timeout)
|
|
256
|
+
return
|
|
257
|
+
except subprocess.TimeoutExpired:
|
|
258
|
+
process.terminate()
|
|
259
|
+
try:
|
|
260
|
+
process.wait(timeout=5)
|
|
261
|
+
except subprocess.TimeoutExpired:
|
|
262
|
+
process.kill()
|
|
263
|
+
process.wait()
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def output(self) -> str:
|
|
267
|
+
"""The last lines the server wrote, for diagnostics."""
|
|
268
|
+
return "\n".join(self._output)
|
|
269
|
+
|
|
270
|
+
def __enter__(self) -> Self:
|
|
271
|
+
return self.start()
|
|
272
|
+
|
|
273
|
+
def __exit__(
|
|
274
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
275
|
+
) -> None:
|
|
276
|
+
self.stop()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
class AsyncOpencodeServer:
|
|
280
|
+
"""Async wrapper around :class:`OpencodeServer`; takes the same arguments.
|
|
281
|
+
|
|
282
|
+
Example::
|
|
283
|
+
|
|
284
|
+
async with AsyncOpencodeServer(isolated=True) as server:
|
|
285
|
+
async with server.client() as client:
|
|
286
|
+
info = await client.server.info()
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
290
|
+
self._server = OpencodeServer(**kwargs)
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def url(self) -> str:
|
|
294
|
+
return self._server.url
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def password(self) -> str:
|
|
298
|
+
return self._server.password
|
|
299
|
+
|
|
300
|
+
@property
|
|
301
|
+
def running(self) -> bool:
|
|
302
|
+
return self._server.running
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def output(self) -> str:
|
|
306
|
+
return self._server.output
|
|
307
|
+
|
|
308
|
+
def client(
|
|
309
|
+
self, *, directory: str | os.PathLike[str] | None = None, timeout: Timeout | None = None
|
|
310
|
+
) -> AsyncOpencode:
|
|
311
|
+
"""Returns an async client connected to this server."""
|
|
312
|
+
return self._server.async_client(directory=directory, timeout=timeout)
|
|
313
|
+
|
|
314
|
+
async def start(self) -> Self:
|
|
315
|
+
await anyio.to_thread.run_sync(self._server.start)
|
|
316
|
+
return self
|
|
317
|
+
|
|
318
|
+
async def stop(self, timeout: float = 10.0) -> None:
|
|
319
|
+
await anyio.to_thread.run_sync(lambda: self._server.stop(timeout))
|
|
320
|
+
|
|
321
|
+
async def __aenter__(self) -> Self:
|
|
322
|
+
return await self.start()
|
|
323
|
+
|
|
324
|
+
async def __aexit__(
|
|
325
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
326
|
+
) -> None:
|
|
327
|
+
await self.stop()
|
opencode/_streaming.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Server-sent event streams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import pydantic
|
|
12
|
+
from typing_extensions import Self
|
|
13
|
+
|
|
14
|
+
from ._exceptions import APIConnectionError, APIResponseValidationError, validation_summary
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import AsyncGenerator, AsyncIterator, Generator, Iterator
|
|
18
|
+
from types import TracebackType
|
|
19
|
+
|
|
20
|
+
__all__ = ["AsyncStream", "ServerSentEvent", "Stream"]
|
|
21
|
+
|
|
22
|
+
_T = TypeVar("_T")
|
|
23
|
+
|
|
24
|
+
MAX_EVENT_BYTES = 16 * 1024 * 1024
|
|
25
|
+
"""Upper bound for one buffered event, matching the official TypeScript client."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class ServerSentEvent:
|
|
30
|
+
"""One decoded server-sent event."""
|
|
31
|
+
|
|
32
|
+
data: str
|
|
33
|
+
event: str | None = None
|
|
34
|
+
id: str | None = None
|
|
35
|
+
retry: int | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class _Decoder:
|
|
40
|
+
"""Incremental SSE decoder following the WHATWG event-stream parsing rules."""
|
|
41
|
+
|
|
42
|
+
_data: list[str] = field(default_factory=list[str])
|
|
43
|
+
_event: str | None = None
|
|
44
|
+
_id: str | None = None
|
|
45
|
+
_retry: int | None = None
|
|
46
|
+
_size: int = 0
|
|
47
|
+
|
|
48
|
+
def feed_line(self, line: str) -> ServerSentEvent | None:
|
|
49
|
+
if line == "":
|
|
50
|
+
return self._dispatch()
|
|
51
|
+
if line.startswith(":"):
|
|
52
|
+
return None
|
|
53
|
+
name, _, value = line.partition(":")
|
|
54
|
+
value = value.removeprefix(" ")
|
|
55
|
+
if name == "data":
|
|
56
|
+
self._size += len(value)
|
|
57
|
+
if self._size > MAX_EVENT_BYTES:
|
|
58
|
+
raise ValueError(f"Server-sent event exceeds {MAX_EVENT_BYTES} bytes")
|
|
59
|
+
self._data.append(value)
|
|
60
|
+
elif name == "event":
|
|
61
|
+
self._event = value
|
|
62
|
+
elif name == "id":
|
|
63
|
+
if "\0" not in value:
|
|
64
|
+
self._id = value
|
|
65
|
+
elif name == "retry" and value.isdigit():
|
|
66
|
+
self._retry = int(value)
|
|
67
|
+
return None
|
|
68
|
+
|
|
69
|
+
def _dispatch(self) -> ServerSentEvent | None:
|
|
70
|
+
if not self._data:
|
|
71
|
+
self._event = None
|
|
72
|
+
return None
|
|
73
|
+
event = ServerSentEvent(data="\n".join(self._data), event=self._event, id=self._id, retry=self._retry)
|
|
74
|
+
self._data = []
|
|
75
|
+
self._event = None
|
|
76
|
+
self._retry = None
|
|
77
|
+
self._size = 0
|
|
78
|
+
return event
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_NEWLINE = re.compile(r"\r\n|\r|\n")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class _LineSplitter:
|
|
85
|
+
"""Splits decoded text into lines on CRLF, CR or LF, across chunk boundaries."""
|
|
86
|
+
|
|
87
|
+
def __init__(self) -> None:
|
|
88
|
+
self._buffer = ""
|
|
89
|
+
|
|
90
|
+
def feed(self, chunk: str) -> list[str]:
|
|
91
|
+
buffer = self._buffer + chunk
|
|
92
|
+
lines: list[str] = []
|
|
93
|
+
start = 0
|
|
94
|
+
while (match := _NEWLINE.search(buffer, start)) is not None:
|
|
95
|
+
if match.group() == "\r" and match.end() == len(buffer):
|
|
96
|
+
break # may be the first half of a CRLF split across chunks
|
|
97
|
+
lines.append(buffer[start : match.start()])
|
|
98
|
+
start = match.end()
|
|
99
|
+
self._buffer = buffer[start:]
|
|
100
|
+
if len(self._buffer) > MAX_EVENT_BYTES:
|
|
101
|
+
raise ValueError(f"Server-sent event line exceeds {MAX_EVENT_BYTES} bytes")
|
|
102
|
+
return lines
|
|
103
|
+
|
|
104
|
+
def flush(self) -> list[str]:
|
|
105
|
+
"""Returns the final partial line, then an empty line so a pending event is dispatched."""
|
|
106
|
+
rest, self._buffer = self._buffer.removesuffix("\r"), ""
|
|
107
|
+
return [rest, ""] if rest else [""]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class _StreamBase(Generic[_T]):
|
|
111
|
+
def __init__(self, response: httpx.Response, adapter: pydantic.TypeAdapter[_T]) -> None:
|
|
112
|
+
self.response = response
|
|
113
|
+
self._adapter = adapter
|
|
114
|
+
|
|
115
|
+
def _decode(self, event: ServerSentEvent) -> _T:
|
|
116
|
+
try:
|
|
117
|
+
payload: Any = json.loads(event.data)
|
|
118
|
+
except json.JSONDecodeError as exc:
|
|
119
|
+
raise APIResponseValidationError(self.response, event.data, message="Malformed server-sent event") from exc
|
|
120
|
+
try:
|
|
121
|
+
return self._adapter.validate_python(payload)
|
|
122
|
+
except pydantic.ValidationError as exc:
|
|
123
|
+
raise APIResponseValidationError(self.response, payload, message=validation_summary(exc)) from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class Stream(_StreamBase[_T]):
|
|
127
|
+
"""A synchronous stream of decoded events.
|
|
128
|
+
|
|
129
|
+
Iterate it to receive events. Close it, or use it as a context manager, to release the connection::
|
|
130
|
+
|
|
131
|
+
with client.event.subscribe() as events:
|
|
132
|
+
for event in events:
|
|
133
|
+
...
|
|
134
|
+
"""
|
|
135
|
+
|
|
136
|
+
def __init__(self, response: httpx.Response, adapter: pydantic.TypeAdapter[_T]) -> None:
|
|
137
|
+
super().__init__(response, adapter)
|
|
138
|
+
self._iterator: Generator[_T, None, None] = self._events()
|
|
139
|
+
|
|
140
|
+
def __iter__(self) -> Iterator[_T]:
|
|
141
|
+
return self._iterator
|
|
142
|
+
|
|
143
|
+
def __next__(self) -> _T:
|
|
144
|
+
return next(self._iterator)
|
|
145
|
+
|
|
146
|
+
def _events(self) -> Generator[_T, None, None]:
|
|
147
|
+
decoder = _Decoder()
|
|
148
|
+
try:
|
|
149
|
+
splitter = _LineSplitter()
|
|
150
|
+
for chunk in self.response.iter_text():
|
|
151
|
+
for line in splitter.feed(chunk):
|
|
152
|
+
if (event := decoder.feed_line(line)) is not None:
|
|
153
|
+
yield self._decode(event)
|
|
154
|
+
for line in splitter.flush():
|
|
155
|
+
if (event := decoder.feed_line(line)) is not None:
|
|
156
|
+
yield self._decode(event)
|
|
157
|
+
except (httpx.TransportError, ValueError) as exc:
|
|
158
|
+
if isinstance(exc, ValueError):
|
|
159
|
+
raise APIResponseValidationError(self.response, None, message=str(exc)) from exc
|
|
160
|
+
raise APIConnectionError(request=self.response.request, message=f"Stream interrupted: {exc}") from exc
|
|
161
|
+
finally:
|
|
162
|
+
self.response.close()
|
|
163
|
+
|
|
164
|
+
def close(self) -> None:
|
|
165
|
+
"""Closes the underlying connection."""
|
|
166
|
+
self._iterator.close()
|
|
167
|
+
self.response.close()
|
|
168
|
+
|
|
169
|
+
def __enter__(self) -> Self:
|
|
170
|
+
return self
|
|
171
|
+
|
|
172
|
+
def __exit__(
|
|
173
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
174
|
+
) -> None:
|
|
175
|
+
self.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class AsyncStream(_StreamBase[_T]):
|
|
179
|
+
"""An asynchronous stream of decoded events.
|
|
180
|
+
|
|
181
|
+
Iterate it with ``async for``. Close it, or use it as an async context manager, to release the connection::
|
|
182
|
+
|
|
183
|
+
async with await client.event.subscribe() as events:
|
|
184
|
+
async for event in events:
|
|
185
|
+
...
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
def __init__(self, response: httpx.Response, adapter: pydantic.TypeAdapter[_T]) -> None:
|
|
189
|
+
super().__init__(response, adapter)
|
|
190
|
+
self._iterator: AsyncGenerator[_T, None] = self._events()
|
|
191
|
+
|
|
192
|
+
def __aiter__(self) -> AsyncIterator[_T]:
|
|
193
|
+
return self._iterator
|
|
194
|
+
|
|
195
|
+
async def __anext__(self) -> _T:
|
|
196
|
+
return await self._iterator.__anext__()
|
|
197
|
+
|
|
198
|
+
async def _events(self) -> AsyncGenerator[_T, None]:
|
|
199
|
+
decoder = _Decoder()
|
|
200
|
+
try:
|
|
201
|
+
splitter = _LineSplitter()
|
|
202
|
+
async for chunk in self.response.aiter_text():
|
|
203
|
+
for line in splitter.feed(chunk):
|
|
204
|
+
if (event := decoder.feed_line(line)) is not None:
|
|
205
|
+
yield self._decode(event)
|
|
206
|
+
for line in splitter.flush():
|
|
207
|
+
if (event := decoder.feed_line(line)) is not None:
|
|
208
|
+
yield self._decode(event)
|
|
209
|
+
except (httpx.TransportError, ValueError) as exc:
|
|
210
|
+
if isinstance(exc, ValueError):
|
|
211
|
+
raise APIResponseValidationError(self.response, None, message=str(exc)) from exc
|
|
212
|
+
raise APIConnectionError(request=self.response.request, message=f"Stream interrupted: {exc}") from exc
|
|
213
|
+
finally:
|
|
214
|
+
await self.response.aclose()
|
|
215
|
+
|
|
216
|
+
async def close(self) -> None:
|
|
217
|
+
"""Closes the underlying connection."""
|
|
218
|
+
await self._iterator.aclose()
|
|
219
|
+
await self.response.aclose()
|
|
220
|
+
|
|
221
|
+
async def __aenter__(self) -> Self:
|
|
222
|
+
return self
|
|
223
|
+
|
|
224
|
+
async def __aexit__(
|
|
225
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
226
|
+
) -> None:
|
|
227
|
+
await self.close()
|
opencode/_types.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Shared typing helpers used by the client and the generated code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from typing import Annotated, Any, Final, Literal, TypeAlias, TypeVar
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import pydantic
|
|
10
|
+
from typing_extensions import override
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"NOT_GIVEN",
|
|
14
|
+
"Body",
|
|
15
|
+
"Headers",
|
|
16
|
+
"NotGiven",
|
|
17
|
+
"OpenEnum",
|
|
18
|
+
"Query",
|
|
19
|
+
"Timeout",
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
_T = TypeVar("_T")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class NotGiven:
|
|
26
|
+
"""Marks an argument that was not passed, as opposed to one passed as ``None``.
|
|
27
|
+
|
|
28
|
+
The OpenCode API distinguishes a field that is absent from a field set to ``null``, so optional
|
|
29
|
+
arguments default to ``NOT_GIVEN`` and are left out of the request entirely.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
_instance: NotGiven | None = None
|
|
33
|
+
|
|
34
|
+
def __new__(cls) -> NotGiven:
|
|
35
|
+
if cls._instance is None:
|
|
36
|
+
cls._instance = super().__new__(cls)
|
|
37
|
+
return cls._instance
|
|
38
|
+
|
|
39
|
+
def __bool__(self) -> Literal[False]:
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
@override
|
|
43
|
+
def __repr__(self) -> str:
|
|
44
|
+
return "NOT_GIVEN"
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
def __reduce__(self) -> str:
|
|
48
|
+
return "NOT_GIVEN"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
NOT_GIVEN: Final = NotGiven()
|
|
52
|
+
|
|
53
|
+
Headers: TypeAlias = Mapping[str, str | NotGiven]
|
|
54
|
+
"""Extra request headers. A ``NOT_GIVEN`` value removes a header the client would otherwise send."""
|
|
55
|
+
|
|
56
|
+
Query: TypeAlias = Mapping[str, object]
|
|
57
|
+
"""Extra query parameters, encoded the same way as generated parameters."""
|
|
58
|
+
|
|
59
|
+
Body: TypeAlias = Mapping[str, object]
|
|
60
|
+
"""Extra JSON body fields, merged over the generated body."""
|
|
61
|
+
|
|
62
|
+
Timeout: TypeAlias = float | httpx.Timeout | None
|
|
63
|
+
"""A timeout in seconds, an ``httpx.Timeout``, or ``None`` to wait forever."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _accept_unknown_value(value: Any, handler: pydantic.ValidatorFunctionWrapHandler) -> Any:
|
|
67
|
+
try:
|
|
68
|
+
return handler(value)
|
|
69
|
+
except pydantic.ValidationError:
|
|
70
|
+
# Newer servers may add enum members. Keep the raw value instead of failing the whole response.
|
|
71
|
+
if isinstance(value, (str, int, float, bool)):
|
|
72
|
+
return value
|
|
73
|
+
raise
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
OpenEnum = Annotated[_T, pydantic.WrapValidator(_accept_unknown_value)]
|
|
77
|
+
"""A ``Literal`` enum that type-checks strictly but accepts values added by newer servers at runtime."""
|
opencode/_unions.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Runtime support for discriminated unions in the generated models."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING, Any, cast
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Callable, Iterable
|
|
10
|
+
|
|
11
|
+
__all__ = ["UNKNOWN_TAG", "discriminator"]
|
|
12
|
+
|
|
13
|
+
UNKNOWN_TAG = "__unknown__"
|
|
14
|
+
"""Tag of the fallback member that receives variants this client version does not know."""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def discriminator(
|
|
18
|
+
key: str,
|
|
19
|
+
attribute: str,
|
|
20
|
+
tags: Iterable[str],
|
|
21
|
+
patterns: Iterable[tuple[str, str]] = (),
|
|
22
|
+
) -> Callable[[Any], str]:
|
|
23
|
+
"""Builds a ``pydantic.Discriminator`` function.
|
|
24
|
+
|
|
25
|
+
``key`` is the wire field and ``attribute`` the model attribute that hold the tag. The tag selects a member by
|
|
26
|
+
exact value, then by regular expression. Anything else selects the ``UNKNOWN_TAG`` member, so a newer server can
|
|
27
|
+
add variants without breaking older clients.
|
|
28
|
+
"""
|
|
29
|
+
known = frozenset(tags)
|
|
30
|
+
compiled = [(re.compile(pattern), member) for pattern, member in patterns]
|
|
31
|
+
|
|
32
|
+
def select(value: Any) -> str:
|
|
33
|
+
tag: object = (
|
|
34
|
+
cast("dict[str, Any]", value).get(key) if isinstance(value, dict) else getattr(value, attribute, None)
|
|
35
|
+
)
|
|
36
|
+
if isinstance(tag, bool):
|
|
37
|
+
tag = "true" if tag else "false"
|
|
38
|
+
if isinstance(tag, str):
|
|
39
|
+
if tag in known:
|
|
40
|
+
return tag
|
|
41
|
+
for pattern, member in compiled:
|
|
42
|
+
if pattern.search(tag):
|
|
43
|
+
return member
|
|
44
|
+
return UNKNOWN_TAG
|
|
45
|
+
|
|
46
|
+
return select
|
opencode/_version.py
ADDED
opencode/py.typed
ADDED
|
File without changes
|