opencode-python-sdk 0.1.3__py3-none-any.whl → 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. opencode/__init__.py +19 -1
  2. opencode/_base_client.py +24 -3
  3. opencode/_client.py +173 -2
  4. opencode/_events.py +5 -2
  5. opencode/_rpc.py +9 -2
  6. opencode/_service.py +1018 -0
  7. opencode/lib/__init__.py +5 -0
  8. opencode/lib/_ready.py +91 -0
  9. opencode/lib/_session.py +231 -0
  10. opencode/resources/agent.py +13 -11
  11. opencode/resources/command.py +9 -7
  12. opencode/resources/config.py +12 -10
  13. opencode/resources/credential.py +13 -11
  14. opencode/resources/debug.py +9 -7
  15. opencode/resources/event.py +9 -5
  16. opencode/resources/experimental.py +31 -29
  17. opencode/resources/file.py +15 -13
  18. opencode/resources/form.py +9 -7
  19. opencode/resources/generate.py +9 -7
  20. opencode/resources/integration.py +28 -26
  21. opencode/resources/location.py +9 -7
  22. opencode/resources/mcp.py +13 -11
  23. opencode/resources/message.py +9 -8
  24. opencode/resources/migration.py +10 -12
  25. opencode/resources/model.py +12 -10
  26. opencode/resources/permission.py +29 -27
  27. opencode/resources/plugin.py +12 -10
  28. opencode/resources/project.py +14 -12
  29. opencode/resources/provider.py +13 -11
  30. opencode/resources/pty.py +23 -21
  31. opencode/resources/reference.py +9 -7
  32. opencode/resources/rpc.py +9 -7
  33. opencode/resources/server.py +14 -12
  34. opencode/resources/session.py +92 -90
  35. opencode/resources/shell.py +19 -17
  36. opencode/resources/skill.py +9 -7
  37. opencode/resources/vcs.py +22 -20
  38. opencode/resources/voice.py +9 -7
  39. opencode/resources/websearch.py +13 -11
  40. opencode/resources/worktree.py +13 -11
  41. opencode/service.py +50 -0
  42. opencode/testing.py +508 -0
  43. {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.2.dist-info}/METADATA +71 -1
  44. opencode_python_sdk-0.2.2.dist-info/RECORD +64 -0
  45. opencode_python_sdk-0.1.3.dist-info/RECORD +0 -58
  46. {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.2.dist-info}/WHEEL +0 -0
  47. {opencode_python_sdk-0.1.3.dist-info → opencode_python_sdk-0.2.2.dist-info}/licenses/LICENSE +0 -0
opencode/__init__.py CHANGED
@@ -14,7 +14,9 @@ Quick start::
14
14
 
15
15
  from __future__ import annotations
16
16
 
17
- from . import types
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from . import service
18
20
  from ._client import AsyncOpencode, Opencode
19
21
 
20
22
  # <generated:error-imports>
@@ -77,6 +79,7 @@ from ._models import BaseModel
77
79
  from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage
78
80
  from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError
79
81
  from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError
82
+ from ._service import Endpoint, ServiceError
80
83
  from ._streaming import AsyncStream, Stream
81
84
  from ._types import NOT_GIVEN, NotGiven
82
85
  from ._version import __version__
@@ -108,6 +111,9 @@ from ._rpc import (
108
111
  rpc_method,
109
112
  )
110
113
 
114
+ if TYPE_CHECKING:
115
+ from . import types
116
+
111
117
  # <generated:api-version>
112
118
  API_VERSION = "2.1.2"
113
119
  """The OpenCode server version this client was generated from."""
@@ -132,6 +138,7 @@ __all__ = [
132
138
  "BaseModel",
133
139
  "ConflictError",
134
140
  "ContentTooLargeError",
141
+ "Endpoint",
135
142
  "InternalServerError",
136
143
  "NotFoundError",
137
144
  "NotGiven",
@@ -144,11 +151,13 @@ __all__ = [
144
151
  "PtyConnectionError",
145
152
  "RateLimitError",
146
153
  "ServerStartError",
154
+ "ServiceError",
147
155
  "ServiceUnavailableError",
148
156
  "Stream",
149
157
  "SyncCursorPage",
150
158
  "UnprocessableEntityError",
151
159
  "__version__",
160
+ "service",
152
161
  "types",
153
162
  # Shared event stream and typed plugin RPC
154
163
  "SUBSCRIBER_CAPACITY",
@@ -206,3 +215,12 @@ __all__ = [
206
215
  "WorktreeError",
207
216
  # </generated:error-exports>
208
217
  ]
218
+
219
+
220
+ def __getattr__(name: str) -> Any:
221
+ # `opencode.types` builds several hundred models, so it is imported on first use rather than with the package.
222
+ if name == "types":
223
+ import importlib
224
+
225
+ return importlib.import_module(".types", __name__)
226
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
opencode/_base_client.py CHANGED
@@ -7,6 +7,7 @@ transient failures, and turns responses into typed values or errors.
7
7
  from __future__ import annotations
8
8
 
9
9
  import base64
10
+ import builtins
10
11
  import email.utils
11
12
  import functools
12
13
  import json
@@ -60,8 +61,18 @@ def _cached_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
60
61
  return pydantic.TypeAdapter(tp)
61
62
 
62
63
 
64
+ @functools.cache
65
+ def _resolve_type(expr: str) -> Any:
66
+ # Generated resources name their response types as strings, so `import opencode` does not build every model.
67
+ from . import types
68
+
69
+ return eval(expr, {**vars(types), "builtins": builtins}) # expressions come from the generator
70
+
71
+
63
72
  def type_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
64
- """Returns a cached ``TypeAdapter`` for a response type."""
73
+ """Returns a cached ``TypeAdapter`` for a response type, or for a type expression over ``opencode.types``."""
74
+ if isinstance(tp, str):
75
+ tp = _resolve_type(tp)
65
76
  try:
66
77
  return _cached_adapter(tp)
67
78
  except TypeError: # unhashable type expression
@@ -307,6 +318,7 @@ class SyncAPIClient(_BaseClient):
307
318
  default_headers: Mapping[str, str] | None,
308
319
  default_query: Mapping[str, object] | None,
309
320
  http_client: httpx.Client | None,
321
+ transport: httpx.BaseTransport | None = None,
310
322
  ) -> None:
311
323
  super().__init__(
312
324
  base_url=base_url,
@@ -319,7 +331,11 @@ class SyncAPIClient(_BaseClient):
319
331
  default_query=default_query,
320
332
  )
321
333
  self._owns_http = http_client is None
322
- self._http = http_client or httpx.Client(limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True)
334
+ if http_client is not None and transport is not None:
335
+ raise ValueError("Pass either http_client or transport, not both")
336
+ self._http = http_client or httpx.Client(
337
+ limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True, transport=transport
338
+ )
323
339
 
324
340
  def close(self) -> None:
325
341
  """Closes the connection pool, unless it was passed in by the caller."""
@@ -421,6 +437,7 @@ class AsyncAPIClient(_BaseClient):
421
437
  default_headers: Mapping[str, str] | None,
422
438
  default_query: Mapping[str, object] | None,
423
439
  http_client: httpx.AsyncClient | None,
440
+ transport: httpx.AsyncBaseTransport | None = None,
424
441
  ) -> None:
425
442
  super().__init__(
426
443
  base_url=base_url,
@@ -433,7 +450,11 @@ class AsyncAPIClient(_BaseClient):
433
450
  default_query=default_query,
434
451
  )
435
452
  self._owns_http = http_client is None
436
- self._http = http_client or httpx.AsyncClient(limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True)
453
+ if http_client is not None and transport is not None:
454
+ raise ValueError("Pass either http_client or transport, not both")
455
+ self._http = http_client or httpx.AsyncClient(
456
+ limits=constants.DEFAULT_CONNECTION_LIMITS, follow_redirects=True, transport=transport
457
+ )
437
458
 
438
459
  async def close(self) -> None:
439
460
  """Closes the connection pool, unless it was passed in by the caller."""
opencode/_client.py CHANGED
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  from functools import cached_property
4
- from typing import TYPE_CHECKING
4
+ from typing import TYPE_CHECKING, Any
5
5
 
6
6
  from typing_extensions import Self
7
7
 
@@ -9,17 +9,31 @@ from ._base_client import AsyncAPIClient, SyncAPIClient
9
9
  from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, DEFAULT_USERNAME
10
10
  from ._events import AsyncEventHub, EventHub
11
11
  from ._rpc import AsyncTypedRpcResource, TypedRpcResource
12
+ from ._service import (
13
+ EnsureReason,
14
+ ServiceError,
15
+ Version,
16
+ async_discover,
17
+ async_ensure,
18
+ discover as discover_service,
19
+ ensure as ensure_service,
20
+ )
12
21
  from ._types import NOT_GIVEN, NotGiven, Timeout
13
22
  from .resources import AsyncResources, SyncResources
14
23
 
15
24
  if TYPE_CHECKING:
16
25
  import os
17
- from collections.abc import Mapping
26
+ from collections.abc import Callable, Iterable, Mapping, Sequence
18
27
 
19
28
  import httpx
20
29
 
30
+ from .testing import FakeServer
31
+
21
32
  __all__ = ["AsyncOpencode", "Opencode"]
22
33
 
34
+ FAKE_BASE_URL = "http://opencode.fake"
35
+ """Base URL of clients made by ``Opencode.fake()``."""
36
+
23
37
 
24
38
  class Opencode(SyncAPIClient, SyncResources):
25
39
  """Synchronous client for an OpenCode server.
@@ -38,6 +52,8 @@ class Opencode(SyncAPIClient, SyncResources):
38
52
  default_query: Query parameters sent with every request.
39
53
  http_client: A preconfigured ``httpx.Client`` (proxies, custom transports, event hooks). The caller keeps
40
54
  ownership and must close it.
55
+ transport: An ``httpx`` transport for the client's own connection pool, such as
56
+ :class:`opencode.testing.FakeServer`. Cannot be combined with ``http_client``.
41
57
 
42
58
  Example::
43
59
 
@@ -60,6 +76,7 @@ class Opencode(SyncAPIClient, SyncResources):
60
76
  default_headers: Mapping[str, str] | None = None,
61
77
  default_query: Mapping[str, object] | None = None,
62
78
  http_client: httpx.Client | None = None,
79
+ transport: httpx.BaseTransport | None = None,
63
80
  ) -> None:
64
81
  super().__init__(
65
82
  base_url=base_url,
@@ -71,6 +88,68 @@ class Opencode(SyncAPIClient, SyncResources):
71
88
  default_headers=default_headers,
72
89
  default_query=default_query,
73
90
  http_client=http_client,
91
+ transport=transport,
92
+ )
93
+
94
+ @classmethod
95
+ def from_service(
96
+ cls,
97
+ *,
98
+ start: bool = True,
99
+ file: str | os.PathLike[str] | None = None,
100
+ version: Version | None = None,
101
+ command: Sequence[str] | None = None,
102
+ env: Mapping[str, str] | None = None,
103
+ on_start: Callable[[EnsureReason, str | None], None] | None = None,
104
+ directory: str | os.PathLike[str] | None = None,
105
+ timeout: Timeout = DEFAULT_TIMEOUT,
106
+ max_retries: int = DEFAULT_MAX_RETRIES,
107
+ default_headers: Mapping[str, str] | None = None,
108
+ default_query: Mapping[str, object] | None = None,
109
+ ) -> Self:
110
+ """Connect to the local background service, starting ``opencode serve --service`` if needed.
111
+
112
+ Uses :func:`opencode.service.ensure`, or :func:`opencode.service.discover` when ``start`` is false, and
113
+ sends the service's password with every request.
114
+
115
+ Args:
116
+ start: Start or replace the service when no matching one is running. When false, raise instead.
117
+ file: Registration file. Defaults to ``$XDG_STATE_HOME/opencode/service.json``.
118
+ version: Required exact version, or a predicate on the service version.
119
+ command: Service command and arguments, used when starting it.
120
+ env: Variables added to the environment of a started service.
121
+ on_start: Called once before a service process is started.
122
+ directory: Default project directory, as in the constructor.
123
+ timeout: Default request timeout, as in the constructor.
124
+ max_retries: Retries for failed requests, as in the constructor.
125
+ default_headers: Headers sent with every request.
126
+ default_query: Query parameters sent with every request.
127
+
128
+ Raises:
129
+ ServiceError: No matching service is running and ``start`` is false, or it could not be started.
130
+
131
+ Example::
132
+
133
+ with Opencode.from_service() as client:
134
+ print(client.server.info().version)
135
+ """
136
+ if start:
137
+ endpoint = ensure_service(file, version, command=command, env=env, on_start=on_start)
138
+ else:
139
+ found = discover_service(file, version)
140
+ if found is None:
141
+ raise ServiceError("No matching OpenCode background service is running")
142
+ endpoint = found
143
+ return cls(
144
+ endpoint.url,
145
+ # An empty password sends no credentials instead of falling back to OPENCODE_PASSWORD.
146
+ password="" if endpoint.auth is None else endpoint.auth.password,
147
+ username=DEFAULT_USERNAME if endpoint.auth is None else endpoint.auth.username,
148
+ directory=directory,
149
+ timeout=timeout,
150
+ max_retries=max_retries,
151
+ default_headers=default_headers,
152
+ default_query=default_query,
74
153
  )
75
154
 
76
155
  @cached_property
@@ -83,6 +162,33 @@ class Opencode(SyncAPIClient, SyncResources):
83
162
  """Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access."""
84
163
  return TypedRpcResource(self)
85
164
 
165
+ @classmethod
166
+ def fake(cls, server: FakeServer | None = None, **kwargs: Any) -> Self:
167
+ """Returns a client wired to an in-process :class:`opencode.testing.FakeServer`, for tests.
168
+
169
+ Pass your own ``server`` to configure replies and inspect requests; other arguments go to the constructor.
170
+ """
171
+ from .testing import FakeServer
172
+
173
+ kwargs.setdefault("password", "")
174
+ return cls(FAKE_BASE_URL, transport=server if server is not None else FakeServer(), **kwargs)
175
+
176
+ def wait_until_ready(
177
+ self, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
178
+ ) -> None:
179
+ """Blocks until the server answers, the named ``agents`` are listed and the named ``mcp_servers`` are
180
+ connected.
181
+
182
+ Agents load asynchronously after the server starts, so call this before creating sessions on a freshly
183
+ booted server. Connection errors and 503s while the server boots count as not ready yet.
184
+
185
+ Raises:
186
+ TimeoutError: Something was still missing after ``timeout`` seconds. The message names it.
187
+ """
188
+ from .lib._ready import wait_until_ready
189
+
190
+ wait_until_ready(self, agents=agents, mcp_servers=mcp_servers, timeout=timeout)
191
+
86
192
  def with_options(
87
193
  self,
88
194
  *,
@@ -138,6 +244,7 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
138
244
  default_headers: Mapping[str, str] | None = None,
139
245
  default_query: Mapping[str, object] | None = None,
140
246
  http_client: httpx.AsyncClient | None = None,
247
+ transport: httpx.AsyncBaseTransport | None = None,
141
248
  ) -> None:
142
249
  super().__init__(
143
250
  base_url=base_url,
@@ -149,6 +256,43 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
149
256
  default_headers=default_headers,
150
257
  default_query=default_query,
151
258
  http_client=http_client,
259
+ transport=transport,
260
+ )
261
+
262
+ @classmethod
263
+ async def from_service(
264
+ cls,
265
+ *,
266
+ start: bool = True,
267
+ file: str | os.PathLike[str] | None = None,
268
+ version: Version | None = None,
269
+ command: Sequence[str] | None = None,
270
+ env: Mapping[str, str] | None = None,
271
+ on_start: Callable[[EnsureReason, str | None], None] | None = None,
272
+ directory: str | os.PathLike[str] | None = None,
273
+ timeout: Timeout = DEFAULT_TIMEOUT,
274
+ max_retries: int = DEFAULT_MAX_RETRIES,
275
+ default_headers: Mapping[str, str] | None = None,
276
+ default_query: Mapping[str, object] | None = None,
277
+ ) -> Self:
278
+ """Async version of :meth:`Opencode.from_service`. Await it: ``client = await AsyncOpencode.from_service()``."""
279
+ if start:
280
+ endpoint = await async_ensure(file, version, command=command, env=env, on_start=on_start)
281
+ else:
282
+ found = await async_discover(file, version)
283
+ if found is None:
284
+ raise ServiceError("No matching OpenCode background service is running")
285
+ endpoint = found
286
+ return cls(
287
+ endpoint.url,
288
+ # An empty password sends no credentials instead of falling back to OPENCODE_PASSWORD.
289
+ password="" if endpoint.auth is None else endpoint.auth.password,
290
+ username=DEFAULT_USERNAME if endpoint.auth is None else endpoint.auth.username,
291
+ directory=directory,
292
+ timeout=timeout,
293
+ max_retries=max_retries,
294
+ default_headers=default_headers,
295
+ default_query=default_query,
152
296
  )
153
297
 
154
298
  @cached_property
@@ -161,6 +305,33 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
161
305
  """Plugin RPC calls: ``client.rpc.call(...)``, or ``client.rpc(Definition)`` for typed access."""
162
306
  return AsyncTypedRpcResource(self)
163
307
 
308
+ @classmethod
309
+ def fake(cls, server: FakeServer | None = None, **kwargs: Any) -> Self:
310
+ """Returns a client wired to an in-process :class:`opencode.testing.FakeServer`, for tests.
311
+
312
+ Pass your own ``server`` to configure replies and inspect requests; other arguments go to the constructor.
313
+ """
314
+ from .testing import FakeServer
315
+
316
+ kwargs.setdefault("password", "")
317
+ return cls(FAKE_BASE_URL, transport=server if server is not None else FakeServer(), **kwargs)
318
+
319
+ async def wait_until_ready(
320
+ self, *, agents: Iterable[str] = (), mcp_servers: Iterable[str] = (), timeout: float = 60
321
+ ) -> None:
322
+ """Blocks until the server answers, the named ``agents`` are listed and the named ``mcp_servers`` are
323
+ connected.
324
+
325
+ Agents load asynchronously after the server starts, so call this before creating sessions on a freshly
326
+ booted server. Connection errors and 503s while the server boots count as not ready yet.
327
+
328
+ Raises:
329
+ TimeoutError: Something was still missing after ``timeout`` seconds. The message names it.
330
+ """
331
+ from .lib._ready import async_wait_until_ready
332
+
333
+ await async_wait_until_ready(self, agents=agents, mcp_servers=mcp_servers, timeout=timeout)
334
+
164
335
  def with_options(
165
336
  self,
166
337
  *,
opencode/_events.py CHANGED
@@ -31,7 +31,6 @@ import anyio
31
31
  from typing_extensions import Self
32
32
 
33
33
  from ._exceptions import APIConnectionError, APIStatusError, OpencodeError
34
- from .types import ServerConnectedEvent
35
34
 
36
35
  if TYPE_CHECKING:
37
36
  from collections.abc import AsyncIterator, Awaitable, Callable, Iterator
@@ -39,7 +38,7 @@ if TYPE_CHECKING:
39
38
 
40
39
  from ._client import AsyncOpencode, Opencode
41
40
  from ._streaming import AsyncStream, Stream
42
- from .types import Event
41
+ from .types import Event, ServerConnectedEvent
43
42
 
44
43
  __all__ = [
45
44
  "SUBSCRIBER_CAPACITY",
@@ -302,6 +301,8 @@ class EventHub:
302
301
 
303
302
  def _read(self, connection: _SyncConnection) -> None:
304
303
  """Reads one connection until it ends, setting ``connection.delivered`` once an event arrives."""
304
+ from .types import ServerConnectedEvent
305
+
305
306
  stream = self._client.event.subscribe()
306
307
  with self._lock:
307
308
  connection.stream = stream
@@ -605,6 +606,8 @@ class AsyncEventHub:
605
606
 
606
607
  async def _read(self, connection: _AsyncConnection) -> None:
607
608
  """Reads one connection until it ends, setting ``connection.delivered`` once an event arrives."""
609
+ from .types import ServerConnectedEvent
610
+
608
611
  stream: AsyncStream[Event] = await self._client.event.subscribe()
609
612
  try:
610
613
  stream.on_activity = connection.activity
opencode/_rpc.py CHANGED
@@ -44,7 +44,6 @@ from ._errors import RpcError, RpcInternalError
44
44
  from ._exceptions import OpencodeError, validation_summary
45
45
  from ._types import NOT_GIVEN, NotGiven
46
46
  from .resources.rpc import AsyncRpcResource, RpcResource
47
- from .types import RpcEvent
48
47
 
49
48
  if TYPE_CHECKING:
50
49
  from collections.abc import Mapping
@@ -53,7 +52,7 @@ if TYPE_CHECKING:
53
52
  from ._client import AsyncOpencode, Opencode
54
53
  from ._events import AsyncSubscription, Subscription
55
54
  from ._types import Headers, Timeout
56
- from .types import Location
55
+ from .types import Location, RpcEvent
57
56
 
58
57
  __all__ = [
59
58
  "AsyncRpcClient",
@@ -327,11 +326,15 @@ class RpcClient(Generic[_D]):
327
326
 
328
327
  def events(self) -> Subscription[RpcEvent]:
329
328
  """Subscribes to every event of this RPC, as raw events, on the client's shared event stream."""
329
+ from .types import RpcEvent
330
+
330
331
  prefix = f"rpc.{self.definition.id}."
331
332
  return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix))
332
333
 
333
334
  def subscribe(self, event: RpcEventDefinition[_T]) -> RpcEvents[_T]:
334
335
  """Subscribes to one event of this RPC, with validated data, on the client's shared event stream."""
336
+ from .types import RpcEvent
337
+
335
338
  _check_event(self.definition, event)
336
339
  wire = self.definition.event_type(event)
337
340
  return RpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event)
@@ -396,11 +399,15 @@ class AsyncRpcClient(Generic[_D]):
396
399
 
397
400
  def events(self) -> AsyncSubscription[RpcEvent]:
398
401
  """Subscribes to every event of this RPC, as raw events, on the client's shared event stream."""
402
+ from .types import RpcEvent
403
+
399
404
  prefix = f"rpc.{self.definition.id}."
400
405
  return self._client.events.of_type(RpcEvent).where(lambda event: event.type.startswith(prefix))
401
406
 
402
407
  def subscribe(self, event: RpcEventDefinition[_T]) -> AsyncRpcEvents[_T]:
403
408
  """Subscribes to one event of this RPC, with validated data, on the client's shared event stream."""
409
+ from .types import RpcEvent
410
+
404
411
  _check_event(self.definition, event)
405
412
  wire = self.definition.event_type(event)
406
413
  return AsyncRpcEvents(self._client.events.of_type(RpcEvent).where(lambda raw: raw.type == wire), event)