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.
Files changed (55) hide show
  1. opencode/.generated.json +39 -0
  2. opencode/__init__.py +156 -0
  3. opencode/_base_client.py +518 -0
  4. opencode/_client.py +167 -0
  5. opencode/_constants.py +27 -0
  6. opencode/_errors.py +547 -0
  7. opencode/_exceptions.py +249 -0
  8. opencode/_models.py +54 -0
  9. opencode/_pagination.py +192 -0
  10. opencode/_qs.py +95 -0
  11. opencode/_resource.py +42 -0
  12. opencode/_server.py +327 -0
  13. opencode/_streaming.py +227 -0
  14. opencode/_types.py +77 -0
  15. opencode/_unions.py +46 -0
  16. opencode/_version.py +8 -0
  17. opencode/py.typed +0 -0
  18. opencode/resources/__init__.py +364 -0
  19. opencode/resources/agent.py +177 -0
  20. opencode/resources/command.py +98 -0
  21. opencode/resources/config.py +238 -0
  22. opencode/resources/credential.py +382 -0
  23. opencode/resources/debug.py +174 -0
  24. opencode/resources/event.py +84 -0
  25. opencode/resources/experimental.py +760 -0
  26. opencode/resources/file.py +353 -0
  27. opencode/resources/form.py +98 -0
  28. opencode/resources/generate.py +108 -0
  29. opencode/resources/integration.py +1023 -0
  30. opencode/resources/location.py +160 -0
  31. opencode/resources/mcp.py +488 -0
  32. opencode/resources/message.py +151 -0
  33. opencode/resources/migration.py +118 -0
  34. opencode/resources/model.py +175 -0
  35. opencode/resources/permission.py +698 -0
  36. opencode/resources/plugin.py +248 -0
  37. opencode/resources/project.py +185 -0
  38. opencode/resources/provider.py +181 -0
  39. opencode/resources/pty.py +517 -0
  40. opencode/resources/reference.py +98 -0
  41. opencode/resources/rpc.py +111 -0
  42. opencode/resources/server.py +228 -0
  43. opencode/resources/session.py +3553 -0
  44. opencode/resources/shell.py +410 -0
  45. opencode/resources/skill.py +98 -0
  46. opencode/resources/vcs.py +496 -0
  47. opencode/resources/voice.py +111 -0
  48. opencode/resources/websearch.py +182 -0
  49. opencode/resources/worktree.py +334 -0
  50. opencode/types/__init__.py +1462 -0
  51. opencode/types/_generated.py +8540 -0
  52. opencode_python_sdk-0.1.0.dist-info/METADATA +317 -0
  53. opencode_python_sdk-0.1.0.dist-info/RECORD +55 -0
  54. opencode_python_sdk-0.1.0.dist-info/WHEEL +4 -0
  55. 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
@@ -0,0 +1,8 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("opencode-python-sdk")
7
+ except PackageNotFoundError: # running from a source tree that is not installed
8
+ __version__ = "0.0.0"
opencode/py.typed ADDED
File without changes