opencode-python-sdk 0.0.1.dev1__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 (62) hide show
  1. opencode/.generated.json +39 -0
  2. opencode/__init__.py +221 -0
  3. opencode/_base_client.py +539 -0
  4. opencode/_client.py +255 -0
  5. opencode/_constants.py +27 -0
  6. opencode/_errors.py +547 -0
  7. opencode/_events.py +732 -0
  8. opencode/_exceptions.py +271 -0
  9. opencode/_models.py +54 -0
  10. opencode/_pagination.py +192 -0
  11. opencode/_pty.py +586 -0
  12. opencode/_qs.py +95 -0
  13. opencode/_resource.py +42 -0
  14. opencode/_rpc.py +489 -0
  15. opencode/_server.py +335 -0
  16. opencode/_streaming.py +248 -0
  17. opencode/_types.py +77 -0
  18. opencode/_unions.py +46 -0
  19. opencode/_version.py +8 -0
  20. opencode/lib/__init__.py +5 -0
  21. opencode/lib/_ready.py +91 -0
  22. opencode/lib/_session.py +231 -0
  23. opencode/py.typed +0 -0
  24. opencode/resources/__init__.py +364 -0
  25. opencode/resources/agent.py +179 -0
  26. opencode/resources/command.py +100 -0
  27. opencode/resources/config.py +240 -0
  28. opencode/resources/credential.py +384 -0
  29. opencode/resources/debug.py +178 -0
  30. opencode/resources/event.py +88 -0
  31. opencode/resources/experimental.py +765 -0
  32. opencode/resources/file.py +355 -0
  33. opencode/resources/form.py +100 -0
  34. opencode/resources/generate.py +110 -0
  35. opencode/resources/integration.py +1033 -0
  36. opencode/resources/location.py +162 -0
  37. opencode/resources/mcp.py +492 -0
  38. opencode/resources/message.py +152 -0
  39. opencode/resources/migration.py +118 -0
  40. opencode/resources/model.py +177 -0
  41. opencode/resources/permission.py +704 -0
  42. opencode/resources/plugin.py +250 -0
  43. opencode/resources/project.py +187 -0
  44. opencode/resources/provider.py +183 -0
  45. opencode/resources/pty.py +522 -0
  46. opencode/resources/reference.py +100 -0
  47. opencode/resources/rpc.py +113 -0
  48. opencode/resources/server.py +230 -0
  49. opencode/resources/session.py +3567 -0
  50. opencode/resources/shell.py +412 -0
  51. opencode/resources/skill.py +100 -0
  52. opencode/resources/vcs.py +500 -0
  53. opencode/resources/voice.py +113 -0
  54. opencode/resources/websearch.py +184 -0
  55. opencode/resources/worktree.py +336 -0
  56. opencode/testing.py +508 -0
  57. opencode/types/__init__.py +1462 -0
  58. opencode/types/_generated.py +8540 -0
  59. opencode_python_sdk-0.0.1.dev1.dist-info/METADATA +468 -0
  60. opencode_python_sdk-0.0.1.dev1.dist-info/RECORD +62 -0
  61. opencode_python_sdk-0.0.1.dev1.dist-info/WHEEL +4 -0
  62. opencode_python_sdk-0.0.1.dev1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,271 @@
1
+ """Exception hierarchy.
2
+
3
+ Every error raised by the client derives from :class:`OpencodeError`::
4
+
5
+ OpencodeError
6
+ └── APIError
7
+ ├── APIConnectionError
8
+ │ └── APITimeoutError
9
+ ├── APIResponseValidationError
10
+ └── APIStatusError
11
+ ├── BadRequestError 400
12
+ ├── AuthenticationError 401
13
+ ├── PermissionDeniedError 403
14
+ ├── NotFoundError 404
15
+ ├── ConflictError 409
16
+ ├── ContentTooLargeError 413
17
+ ├── UnprocessableEntityError 422
18
+ ├── RateLimitError 429
19
+ └── InternalServerError 5xx
20
+ ├── NotImplementedServerError 501
21
+ └── ServiceUnavailableError 503
22
+
23
+ The server tags each declared error with a ``_tag`` such as ``"SessionNotFoundError"``. Tagged errors are raised
24
+ as the matching subclass from :mod:`opencode._errors` (for example ``SessionNotFoundError(NotFoundError)``), so
25
+ callers can catch a precise error or any error of that status.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast
31
+
32
+ import pydantic
33
+ from typing_extensions import override
34
+
35
+ if TYPE_CHECKING:
36
+ import httpx
37
+
38
+ _M = TypeVar("_M", bound=pydantic.BaseModel)
39
+
40
+ __all__ = [
41
+ "APIConnectionError",
42
+ "APIError",
43
+ "APIResponseValidationError",
44
+ "APIStatusError",
45
+ "APITimeoutError",
46
+ "AuthenticationError",
47
+ "BadRequestError",
48
+ "ConflictError",
49
+ "ContentTooLargeError",
50
+ "InternalServerError",
51
+ "NotFoundError",
52
+ "NotImplementedServerError",
53
+ "OpencodeError",
54
+ "PermissionDeniedError",
55
+ "RateLimitError",
56
+ "ServiceUnavailableError",
57
+ "UnprocessableEntityError",
58
+ ]
59
+
60
+
61
+ class OpencodeError(Exception):
62
+ """Base class for every error raised by this package."""
63
+
64
+
65
+ class APIError(OpencodeError):
66
+ """An error that happened while talking to the OpenCode server."""
67
+
68
+ message: str
69
+ request: httpx.Request
70
+ body: object | None
71
+ """The decoded JSON error body, the raw text when it is not JSON, or ``None``."""
72
+
73
+ def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None:
74
+ super().__init__(message)
75
+ self.message = message
76
+ self.request = request
77
+ self.body = body
78
+
79
+
80
+ class APIConnectionError(APIError):
81
+ """The request could not reach the server, or the connection broke before a response arrived."""
82
+
83
+ def __init__(self, *, request: httpx.Request, message: str = "Connection error.") -> None:
84
+ super().__init__(message, request, body=None)
85
+
86
+
87
+ class APITimeoutError(APIConnectionError):
88
+ """The request timed out."""
89
+
90
+ def __init__(self, request: httpx.Request) -> None:
91
+ super().__init__(request=request, message="Request timed out.")
92
+
93
+
94
+ class APIResponseValidationError(APIError):
95
+ """The server answered, but the response does not match the expected shape."""
96
+
97
+ response: httpx.Response
98
+ status_code: int
99
+
100
+ def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None:
101
+ super().__init__(
102
+ message or "The server response did not match the expected schema.", response.request, body=body
103
+ )
104
+ self.response = response
105
+ self.status_code = response.status_code
106
+
107
+
108
+ class APIStatusError(APIError):
109
+ """The server returned a non-success HTTP status."""
110
+
111
+ status: ClassVar[int | None] = None
112
+ """The HTTP status this class represents, or ``None`` for the generic base."""
113
+
114
+ tag: ClassVar[str | None] = None
115
+ """The server ``_tag`` this class represents, or ``None`` for status-only classes."""
116
+
117
+ response: httpx.Response
118
+ status_code: int
119
+ error_tag: str | None
120
+ """The ``_tag`` the server sent, which can be a tag this client version does not know yet."""
121
+
122
+ def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None:
123
+ super().__init__(message, response.request, body=body)
124
+ self.response = response
125
+ self.status_code = response.status_code
126
+ self.error_tag = _string_field(body, "_tag")
127
+
128
+ def _typed_body(self, model: type[_M]) -> _M:
129
+ raw: object = self.body
130
+ body: dict[str, Any] = cast("dict[str, Any]", raw) if isinstance(raw, dict) else {}
131
+ try:
132
+ return model.model_validate(body)
133
+ except pydantic.ValidationError:
134
+ # Keep the error usable even when the body does not match this client's schema.
135
+ return model.model_construct(**body)
136
+
137
+ @property
138
+ def request_id(self) -> str | None:
139
+ """The server's request identifier, when it sends one."""
140
+ value: str | None = self.response.headers.get("x-request-id")
141
+ return value
142
+
143
+ @override
144
+ def __str__(self) -> str:
145
+ detail = f"{self.status_code} {self.error_tag}" if self.error_tag else str(self.status_code)
146
+ return f"{self.message} ({detail})"
147
+
148
+
149
+ class BadRequestError(APIStatusError):
150
+ """The server rejected the request as malformed (400)."""
151
+
152
+ status = 400
153
+
154
+
155
+ class AuthenticationError(APIStatusError):
156
+ """The password was missing or wrong (401)."""
157
+
158
+ status = 401
159
+
160
+
161
+ class PermissionDeniedError(APIStatusError):
162
+ """The request is not allowed (403)."""
163
+
164
+ status = 403
165
+
166
+
167
+ class NotFoundError(APIStatusError):
168
+ """The requested resource does not exist (404)."""
169
+
170
+ status = 404
171
+
172
+
173
+ class ConflictError(APIStatusError):
174
+ """The request conflicts with the current state, for example a busy session (409)."""
175
+
176
+ status = 409
177
+ tag = "ConflictError"
178
+
179
+
180
+ class ContentTooLargeError(APIStatusError):
181
+ """The request body is too large (413)."""
182
+
183
+ status = 413
184
+
185
+
186
+ class UnprocessableEntityError(APIStatusError):
187
+ """The request was well formed but could not be processed (422)."""
188
+
189
+ status = 422
190
+
191
+
192
+ class RateLimitError(APIStatusError):
193
+ """Too many requests (429). Retried automatically, honoring ``Retry-After``."""
194
+
195
+ status = 429
196
+
197
+
198
+ class InternalServerError(APIStatusError):
199
+ """The server failed (500, and the base class for every 5xx)."""
200
+
201
+ status = 500
202
+
203
+
204
+ class NotImplementedServerError(InternalServerError):
205
+ """The server does not implement this endpoint (501)."""
206
+
207
+ status = 501
208
+
209
+
210
+ class ServiceUnavailableError(InternalServerError):
211
+ """The server is temporarily unavailable (503). Retried automatically."""
212
+
213
+ status = 503
214
+ tag = "ServiceUnavailableError"
215
+
216
+
217
+ _STATUS_ERRORS: dict[int, type[APIStatusError]] = {
218
+ 400: BadRequestError,
219
+ 401: AuthenticationError,
220
+ 403: PermissionDeniedError,
221
+ 404: NotFoundError,
222
+ 409: ConflictError,
223
+ 413: ContentTooLargeError,
224
+ 422: UnprocessableEntityError,
225
+ 429: RateLimitError,
226
+ 500: InternalServerError,
227
+ 501: NotImplementedServerError,
228
+ 503: ServiceUnavailableError,
229
+ }
230
+
231
+
232
+ def status_error_class(
233
+ status_code: int, tag: str | None, tagged: dict[str, type[APIStatusError]]
234
+ ) -> type[APIStatusError]:
235
+ """Picks the most specific error class for a status code and server tag."""
236
+ # The tag is the server's own classification, so it wins over the status code.
237
+ if tag is not None and tag in tagged:
238
+ return tagged[tag]
239
+ if status_code in _STATUS_ERRORS:
240
+ return _STATUS_ERRORS[status_code]
241
+ if status_code >= 500:
242
+ return InternalServerError
243
+ return APIStatusError
244
+
245
+
246
+ def error_message(body: object | None, status_code: int) -> str:
247
+ """Extracts a human-readable message from an error body."""
248
+ message = _string_field(body, "message")
249
+ if message is None and isinstance(body, dict):
250
+ message = _string_field(cast("dict[str, Any]", body).get("data"), "message")
251
+ if message:
252
+ return message
253
+ if isinstance(body, str) and body.strip():
254
+ return body.strip()[:500]
255
+ return f"Request failed with status {status_code}"
256
+
257
+
258
+ def validation_summary(error: pydantic.ValidationError, limit: int = 5) -> str:
259
+ """Describes a validation failure by location and reason only, never echoing response values."""
260
+ issues = error.errors(include_input=False, include_url=False)
261
+ parts = [f"{'.'.join(str(item) for item in issue['loc']) or '<root>'}: {issue['msg']}" for issue in issues[:limit]]
262
+ more = f" (+{len(issues) - limit} more)" if len(issues) > limit else ""
263
+ return f"Response did not match the expected schema: {'; '.join(parts)}{more}"
264
+
265
+
266
+ def _string_field(body: object | None, key: str) -> str | None:
267
+ if isinstance(body, dict):
268
+ value: object = cast("dict[str, Any]", body).get(key)
269
+ if isinstance(value, str):
270
+ return value
271
+ return None
opencode/_models.py ADDED
@@ -0,0 +1,54 @@
1
+ """Base model for every OpenCode API type."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, ClassVar
6
+
7
+ import pydantic
8
+ from pydantic import ConfigDict
9
+ from typing_extensions import override
10
+
11
+ __all__ = ["BaseModel", "is_none"]
12
+
13
+
14
+ def is_none(value: object) -> bool:
15
+ """Field ``exclude_if`` predicate: optional fields that are not nullable are left out instead of sent as null."""
16
+ return value is None
17
+
18
+
19
+ class BaseModel(pydantic.BaseModel):
20
+ """Base class for OpenCode API models.
21
+
22
+ Attributes use ``snake_case`` while the wire format uses OpenCode's ``camelCase`` names. Models accept either
23
+ spelling on input and always serialize to the wire names.
24
+
25
+ Unknown fields are kept rather than rejected, so a client keeps working against a newer server. They are
26
+ available through ``model_extra`` and attribute access.
27
+ """
28
+
29
+ model_config = ConfigDict(
30
+ extra="allow",
31
+ validate_by_name=True,
32
+ validate_by_alias=True,
33
+ serialize_by_alias=True,
34
+ defer_build=True,
35
+ protected_namespaces=(),
36
+ use_enum_values=True,
37
+ coerce_numbers_to_str=False,
38
+ )
39
+
40
+ __opencode_always_set__: ClassVar[tuple[str, ...]] = ()
41
+ """Fields serialized even when left at their default, such as a union member's ``type`` tag."""
42
+
43
+ @override
44
+ def model_post_init(self, context: Any, /) -> None:
45
+ if self.__opencode_always_set__:
46
+ self.__pydantic_fields_set__.update(self.__opencode_always_set__)
47
+
48
+ def to_dict(self, *, exclude_unset: bool = True) -> dict[str, Any]:
49
+ """Returns the model as a JSON-compatible dict using wire field names."""
50
+ return self.model_dump(mode="json", by_alias=True, exclude_unset=exclude_unset)
51
+
52
+ def to_json(self, *, indent: int | None = None, exclude_unset: bool = True) -> str:
53
+ """Returns the model as JSON text using wire field names."""
54
+ return self.model_dump_json(by_alias=True, exclude_unset=exclude_unset, indent=indent)
@@ -0,0 +1,192 @@
1
+ """Cursor pagination.
2
+
3
+ OpenCode list endpoints return ``{"data": [...], "cursor": {"previous": ..., "next": ...}}``. A page iterates over
4
+ every item of every following page, fetching lazily::
5
+
6
+ for message in client.message.list("ses_..."):
7
+ ...
8
+
9
+ Use :meth:`SyncCursorPage.iter_pages` or the cursor properties for manual control.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar
16
+
17
+ if TYPE_CHECKING:
18
+ from collections.abc import AsyncIterator, Generator, Iterator
19
+
20
+ from ._base_client import AsyncAPIClient, RequestOptions, SyncAPIClient
21
+
22
+ __all__ = ["AsyncCursorPage", "AsyncPaginator", "SyncCursorPage"]
23
+
24
+ _T = TypeVar("_T")
25
+
26
+
27
+ def _page_parts(payload: Any) -> tuple[list[Any], str | None, str | None]:
28
+ cursor = payload.cursor
29
+ return list(payload.data), getattr(cursor, "next", None), getattr(cursor, "previous", None)
30
+
31
+
32
+ def _follow(options: RequestOptions, cursor: str) -> RequestOptions:
33
+ query = {key: value for key, value in options.query.items() if key != "order"}
34
+ # The server rejects `order` together with a cursor: the cursor already encodes the direction.
35
+ query["cursor"] = cursor
36
+ return dataclasses.replace(options, query=query)
37
+
38
+
39
+ class _PageBase(Generic[_T]):
40
+ data: list[_T]
41
+ """Items on this page."""
42
+ next_cursor: str | None
43
+ """Cursor for the following page, or ``None`` on the last page."""
44
+ previous_cursor: str | None
45
+ """Cursor for the preceding page, or ``None`` on the first page."""
46
+
47
+ def __init__(
48
+ self, *, data: list[_T], next_cursor: str | None, previous_cursor: str | None, options: RequestOptions
49
+ ) -> None:
50
+ self.data = data
51
+ self.next_cursor = next_cursor
52
+ self.previous_cursor = previous_cursor
53
+ self._options = options
54
+
55
+ def has_next_page(self) -> bool:
56
+ return bool(self.next_cursor)
57
+
58
+ def __len__(self) -> int:
59
+ return len(self.data)
60
+
61
+
62
+ class SyncCursorPage(_PageBase[_T]):
63
+ """One page of results. Iterating it walks this page and every page after it."""
64
+
65
+ def __init__(
66
+ self,
67
+ *,
68
+ client: SyncAPIClient,
69
+ data: list[_T],
70
+ next_cursor: str | None,
71
+ previous_cursor: str | None,
72
+ options: RequestOptions,
73
+ response_type: Any,
74
+ ) -> None:
75
+ super().__init__(data=data, next_cursor=next_cursor, previous_cursor=previous_cursor, options=options)
76
+ self._client = client
77
+ self._response_type = response_type
78
+
79
+ @classmethod
80
+ def fetch(cls, client: SyncAPIClient, options: RequestOptions, response_type: Any) -> SyncCursorPage[Any]:
81
+ payload = client.request(options, response_type=response_type)
82
+ data, next_cursor, previous_cursor = _page_parts(payload)
83
+ return cls(
84
+ client=client,
85
+ data=data,
86
+ next_cursor=next_cursor,
87
+ previous_cursor=previous_cursor,
88
+ options=options,
89
+ response_type=response_type,
90
+ )
91
+
92
+ def next_page(self) -> SyncCursorPage[_T]:
93
+ """Fetches the following page. Raises ``StopIteration`` on the last page."""
94
+ if not self.next_cursor:
95
+ raise StopIteration("No more pages")
96
+ return SyncCursorPage.fetch(self._client, _follow(self._options, self.next_cursor), self._response_type)
97
+
98
+ def previous_page(self) -> SyncCursorPage[_T]:
99
+ """Fetches the preceding page. Raises ``StopIteration`` on the first page."""
100
+ if not self.previous_cursor:
101
+ raise StopIteration("No previous page")
102
+ return SyncCursorPage.fetch(self._client, _follow(self._options, self.previous_cursor), self._response_type)
103
+
104
+ def iter_pages(self) -> Generator[SyncCursorPage[_T], None, None]:
105
+ """Yields this page and then each following page."""
106
+ page: SyncCursorPage[_T] = self
107
+ yield page
108
+ while page.has_next_page():
109
+ page = page.next_page()
110
+ yield page
111
+
112
+ def __iter__(self) -> Iterator[_T]:
113
+ for page in self.iter_pages():
114
+ yield from page.data
115
+
116
+
117
+ class AsyncCursorPage(_PageBase[_T]):
118
+ """One page of results. ``async for`` walks this page and every page after it."""
119
+
120
+ def __init__(
121
+ self,
122
+ *,
123
+ client: AsyncAPIClient,
124
+ data: list[_T],
125
+ next_cursor: str | None,
126
+ previous_cursor: str | None,
127
+ options: RequestOptions,
128
+ response_type: Any,
129
+ ) -> None:
130
+ super().__init__(data=data, next_cursor=next_cursor, previous_cursor=previous_cursor, options=options)
131
+ self._client = client
132
+ self._response_type = response_type
133
+
134
+ @classmethod
135
+ async def fetch(cls, client: AsyncAPIClient, options: RequestOptions, response_type: Any) -> AsyncCursorPage[Any]:
136
+ payload = await client.request(options, response_type=response_type)
137
+ data, next_cursor, previous_cursor = _page_parts(payload)
138
+ return cls(
139
+ client=client,
140
+ data=data,
141
+ next_cursor=next_cursor,
142
+ previous_cursor=previous_cursor,
143
+ options=options,
144
+ response_type=response_type,
145
+ )
146
+
147
+ async def next_page(self) -> AsyncCursorPage[_T]:
148
+ """Fetches the following page. Raises ``StopAsyncIteration`` on the last page."""
149
+ if not self.next_cursor:
150
+ raise StopAsyncIteration("No more pages")
151
+ return await AsyncCursorPage.fetch(self._client, _follow(self._options, self.next_cursor), self._response_type)
152
+
153
+ async def previous_page(self) -> AsyncCursorPage[_T]:
154
+ """Fetches the preceding page. Raises ``StopAsyncIteration`` on the first page."""
155
+ if not self.previous_cursor:
156
+ raise StopAsyncIteration("No previous page")
157
+ return await AsyncCursorPage.fetch(
158
+ self._client, _follow(self._options, self.previous_cursor), self._response_type
159
+ )
160
+
161
+ async def iter_pages(self) -> AsyncIterator[AsyncCursorPage[_T]]:
162
+ """Yields this page and then each following page."""
163
+ page: AsyncCursorPage[_T] = self
164
+ yield page
165
+ while page.has_next_page():
166
+ page = await page.next_page()
167
+ yield page
168
+
169
+ async def __aiter__(self) -> AsyncIterator[_T]:
170
+ async for page in self.iter_pages():
171
+ for item in page.data:
172
+ yield item
173
+
174
+
175
+ class AsyncPaginator(Generic[_T]):
176
+ """Returned by async list methods.
177
+
178
+ ``await`` it for the first page, or ``async for`` over it to walk every item across pages.
179
+ """
180
+
181
+ def __init__(self, client: AsyncAPIClient, options: RequestOptions, response_type: Any) -> None:
182
+ self._client = client
183
+ self._options = options
184
+ self._response_type = response_type
185
+
186
+ def __await__(self) -> Generator[Any, None, AsyncCursorPage[_T]]:
187
+ return AsyncCursorPage.fetch(self._client, self._options, self._response_type).__await__()
188
+
189
+ async def __aiter__(self) -> AsyncIterator[_T]:
190
+ page: AsyncCursorPage[_T] = await self
191
+ async for item in page:
192
+ yield item