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
@@ -0,0 +1,249 @@
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
+ status = 400
151
+
152
+
153
+ class AuthenticationError(APIStatusError):
154
+ status = 401
155
+
156
+
157
+ class PermissionDeniedError(APIStatusError):
158
+ status = 403
159
+
160
+
161
+ class NotFoundError(APIStatusError):
162
+ status = 404
163
+
164
+
165
+ class ConflictError(APIStatusError):
166
+ status = 409
167
+ tag = "ConflictError"
168
+
169
+
170
+ class ContentTooLargeError(APIStatusError):
171
+ status = 413
172
+
173
+
174
+ class UnprocessableEntityError(APIStatusError):
175
+ status = 422
176
+
177
+
178
+ class RateLimitError(APIStatusError):
179
+ status = 429
180
+
181
+
182
+ class InternalServerError(APIStatusError):
183
+ status = 500
184
+
185
+
186
+ class NotImplementedServerError(InternalServerError):
187
+ status = 501
188
+
189
+
190
+ class ServiceUnavailableError(InternalServerError):
191
+ status = 503
192
+ tag = "ServiceUnavailableError"
193
+
194
+
195
+ _STATUS_ERRORS: dict[int, type[APIStatusError]] = {
196
+ 400: BadRequestError,
197
+ 401: AuthenticationError,
198
+ 403: PermissionDeniedError,
199
+ 404: NotFoundError,
200
+ 409: ConflictError,
201
+ 413: ContentTooLargeError,
202
+ 422: UnprocessableEntityError,
203
+ 429: RateLimitError,
204
+ 500: InternalServerError,
205
+ 501: NotImplementedServerError,
206
+ 503: ServiceUnavailableError,
207
+ }
208
+
209
+
210
+ def status_error_class(
211
+ status_code: int, tag: str | None, tagged: dict[str, type[APIStatusError]]
212
+ ) -> type[APIStatusError]:
213
+ """Picks the most specific error class for a status code and server tag."""
214
+ # The tag is the server's own classification, so it wins over the status code.
215
+ if tag is not None and tag in tagged:
216
+ return tagged[tag]
217
+ if status_code in _STATUS_ERRORS:
218
+ return _STATUS_ERRORS[status_code]
219
+ if status_code >= 500:
220
+ return InternalServerError
221
+ return APIStatusError
222
+
223
+
224
+ def error_message(body: object | None, status_code: int) -> str:
225
+ """Extracts a human-readable message from an error body."""
226
+ message = _string_field(body, "message")
227
+ if message is None and isinstance(body, dict):
228
+ message = _string_field(cast("dict[str, Any]", body).get("data"), "message")
229
+ if message:
230
+ return message
231
+ if isinstance(body, str) and body.strip():
232
+ return body.strip()[:500]
233
+ return f"Request failed with status {status_code}"
234
+
235
+
236
+ def validation_summary(error: pydantic.ValidationError, limit: int = 5) -> str:
237
+ """Describes a validation failure by location and reason only, never echoing response values."""
238
+ issues = error.errors(include_input=False, include_url=False)
239
+ parts = [f"{'.'.join(str(item) for item in issue['loc']) or '<root>'}: {issue['msg']}" for issue in issues[:limit]]
240
+ more = f" (+{len(issues) - limit} more)" if len(issues) > limit else ""
241
+ return f"Response did not match the expected schema: {'; '.join(parts)}{more}"
242
+
243
+
244
+ def _string_field(body: object | None, key: str) -> str | None:
245
+ if isinstance(body, dict):
246
+ value: object = cast("dict[str, Any]", body).get(key)
247
+ if isinstance(value, str):
248
+ return value
249
+ 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
opencode/_qs.py ADDED
@@ -0,0 +1,95 @@
1
+ """Request encoding: query strings, path segments and JSON bodies.
2
+
3
+ The rules match the official TypeScript client so both send byte-identical requests:
4
+
5
+ - ``None`` becomes the string ``"null"``; ``NOT_GIVEN`` leaves the parameter out;
6
+ - booleans become ``"true"`` / ``"false"``;
7
+ - lists repeat the key once per item;
8
+ - objects use deep-object style, ``location[directory]=/repo``.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import datetime as dt
15
+ import enum
16
+ from collections.abc import Mapping, Sequence
17
+ from typing import Any, cast
18
+ from urllib.parse import quote
19
+
20
+ import pydantic
21
+
22
+ from ._types import NotGiven
23
+
24
+ __all__ = ["encode_path", "encode_path_segment", "encode_query", "to_jsonable"]
25
+
26
+
27
+ def encode_query(params: Mapping[str, object]) -> list[tuple[str, str]]:
28
+ """Flattens query parameters into ordered ``(key, value)`` pairs."""
29
+ pairs: list[tuple[str, str]] = []
30
+ for key, value in params.items():
31
+ _append(pairs, key, to_jsonable(value))
32
+ return pairs
33
+
34
+
35
+ def _append(pairs: list[tuple[str, str]], key: str, value: object) -> None:
36
+ if isinstance(value, NotGiven):
37
+ return
38
+ if value is None:
39
+ pairs.append((key, "null"))
40
+ elif isinstance(value, bool):
41
+ pairs.append((key, "true" if value else "false"))
42
+ elif isinstance(value, (list, tuple)):
43
+ for item in cast("Sequence[object]", value):
44
+ _append(pairs, key, item)
45
+ elif isinstance(value, dict):
46
+ for child, item in cast("dict[str, object]", value).items():
47
+ _append(pairs, f"{key}[{child}]", item)
48
+ else:
49
+ pairs.append((key, str(value)))
50
+
51
+
52
+ def encode_path_segment(value: object) -> str:
53
+ """Encodes one path parameter, escaping ``/`` and every other reserved character.
54
+
55
+ ``.`` and ``..`` are percent-encoded too, so URL normalization cannot turn a parameter into a different route.
56
+ """
57
+ text = str(value)
58
+ if text == "":
59
+ raise ValueError("Path parameters must not be empty")
60
+ return _encode_part(text)
61
+
62
+
63
+ def encode_path(value: str) -> str:
64
+ """Encodes a trailing wildcard path, keeping ``/`` separators and encoding dot segments."""
65
+ return "/".join(_encode_part(part) for part in value.split("/"))
66
+
67
+
68
+ def _encode_part(part: str) -> str:
69
+ if part in (".", ".."):
70
+ return part.replace(".", "%2E")
71
+ return quote(part, safe="")
72
+
73
+
74
+ def to_jsonable(value: Any) -> Any:
75
+ """Converts models and standard containers to plain JSON values, dropping ``NOT_GIVEN`` entries."""
76
+ if isinstance(value, pydantic.BaseModel):
77
+ return value.model_dump(mode="json", by_alias=True, exclude_unset=True)
78
+ if isinstance(value, NotGiven) or value is None or isinstance(value, (str, int, float, bool)):
79
+ return value
80
+ if isinstance(value, Mapping):
81
+ mapping = cast("Mapping[object, object]", value)
82
+ return {str(key): to_jsonable(item) for key, item in mapping.items() if not isinstance(item, NotGiven)}
83
+ if isinstance(value, (bytes, bytearray, memoryview)):
84
+ return base64.b64encode(bytes(cast("bytes", value))).decode("ascii")
85
+ if isinstance(value, Sequence):
86
+ return [to_jsonable(item) for item in cast("Sequence[object]", value)]
87
+ if isinstance(value, (set, frozenset)):
88
+ return [to_jsonable(item) for item in cast("set[object]", value)]
89
+ if isinstance(value, enum.Enum):
90
+ return to_jsonable(value.value)
91
+ if isinstance(value, (dt.datetime, dt.date)):
92
+ return value.isoformat()
93
+ value_type: Any = value.__class__
94
+ adapter: pydantic.TypeAdapter[Any] = pydantic.TypeAdapter(value_type)
95
+ return adapter.dump_python(value, mode="json", by_alias=True)
opencode/_resource.py ADDED
@@ -0,0 +1,42 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from ._types import NotGiven
6
+
7
+ if TYPE_CHECKING:
8
+ from ._client import AsyncOpencode, Opencode
9
+
10
+ __all__ = ["AsyncAPIResource", "SyncAPIResource"]
11
+
12
+
13
+ class SyncAPIResource:
14
+ """Base class for generated resource groups of the sync client."""
15
+
16
+ _client: Opencode
17
+
18
+ def __init__(self, client: Opencode) -> None:
19
+ self._client = client
20
+
21
+ def _location(self, location: object) -> object:
22
+ """Falls back to the client's ``directory`` for endpoints that take a ``location``."""
23
+ return _default_location(location, self._client.directory)
24
+
25
+
26
+ class AsyncAPIResource:
27
+ """Base class for generated resource groups of the async client."""
28
+
29
+ _client: AsyncOpencode
30
+
31
+ def __init__(self, client: AsyncOpencode) -> None:
32
+ self._client = client
33
+
34
+ def _location(self, location: object) -> object:
35
+ """Falls back to the client's ``directory`` for endpoints that take a ``location``."""
36
+ return _default_location(location, self._client.directory)
37
+
38
+
39
+ def _default_location(location: object, directory: str | None) -> object:
40
+ if isinstance(location, NotGiven) and directory is not None:
41
+ return {"directory": directory}
42
+ return location