opencode-python-sdk 0.2.0__py3-none-any.whl → 0.2.3__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 (45) hide show
  1. opencode/__init__.py +14 -0
  2. opencode/_aiohttp.py +44 -0
  3. opencode/_base_client.py +237 -11
  4. opencode/_client.py +115 -3
  5. opencode/_logs.py +29 -0
  6. opencode/_pagination.py +14 -0
  7. opencode/_response.py +316 -0
  8. opencode/_service.py +1018 -0
  9. opencode/resources/agent.py +63 -0
  10. opencode/resources/command.py +59 -0
  11. opencode/resources/config.py +93 -20
  12. opencode/resources/credential.py +149 -56
  13. opencode/resources/debug.py +156 -20
  14. opencode/resources/event.py +59 -0
  15. opencode/resources/experimental.py +210 -36
  16. opencode/resources/file.py +71 -0
  17. opencode/resources/form.py +59 -0
  18. opencode/resources/generate.py +59 -0
  19. opencode/resources/integration.py +538 -128
  20. opencode/resources/location.py +87 -18
  21. opencode/resources/mcp.py +252 -82
  22. opencode/resources/message.py +59 -0
  23. opencode/resources/migration.py +126 -0
  24. opencode/resources/model.py +63 -0
  25. opencode/resources/permission.py +272 -38
  26. opencode/resources/plugin.py +95 -22
  27. opencode/resources/project.py +63 -0
  28. opencode/resources/provider.py +63 -0
  29. opencode/resources/pty.py +172 -20
  30. opencode/resources/reference.py +59 -0
  31. opencode/resources/rpc.py +59 -0
  32. opencode/resources/server.py +67 -0
  33. opencode/resources/session.py +1159 -405
  34. opencode/resources/shell.py +101 -20
  35. opencode/resources/skill.py +59 -0
  36. opencode/resources/vcs.py +172 -20
  37. opencode/resources/voice.py +59 -0
  38. opencode/resources/websearch.py +63 -0
  39. opencode/resources/worktree.py +123 -40
  40. opencode/service.py +50 -0
  41. {opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.3.dist-info}/METADATA +111 -2
  42. opencode_python_sdk-0.2.3.dist-info/RECORD +67 -0
  43. opencode_python_sdk-0.2.0.dist-info/RECORD +0 -62
  44. {opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.3.dist-info}/WHEEL +0 -0
  45. {opencode_python_sdk-0.2.0.dist-info → opencode_python_sdk-0.2.3.dist-info}/licenses/LICENSE +0 -0
opencode/__init__.py CHANGED
@@ -16,6 +16,8 @@ from __future__ import annotations
16
16
 
17
17
  from typing import TYPE_CHECKING, Any
18
18
 
19
+ from . import service
20
+ from ._aiohttp import DefaultAioHttpClient
19
21
  from ._client import AsyncOpencode, Opencode
20
22
 
21
23
  # <generated:error-imports>
@@ -74,10 +76,13 @@ from ._exceptions import (
74
76
  ServiceUnavailableError,
75
77
  UnprocessableEntityError,
76
78
  )
79
+ from ._logs import setup_logging as _setup_logging
77
80
  from ._models import BaseModel
78
81
  from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage
79
82
  from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError
83
+ from ._response import APIResponse, AsyncAPIResponse
80
84
  from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError
85
+ from ._service import Endpoint, ServiceError
81
86
  from ._streaming import AsyncStream, Stream
82
87
  from ._types import NOT_GIVEN, NotGiven
83
88
  from ._version import __version__
@@ -121,10 +126,12 @@ __all__ = [
121
126
  "API_VERSION",
122
127
  "NOT_GIVEN",
123
128
  "APIConnectionError",
129
+ "APIResponse",
124
130
  "APIError",
125
131
  "APIResponseValidationError",
126
132
  "APIStatusError",
127
133
  "APITimeoutError",
134
+ "AsyncAPIResponse",
128
135
  "AsyncCursorPage",
129
136
  "AsyncOpencode",
130
137
  "AsyncOpencodeServer",
@@ -136,6 +143,8 @@ __all__ = [
136
143
  "BaseModel",
137
144
  "ConflictError",
138
145
  "ContentTooLargeError",
146
+ "DefaultAioHttpClient",
147
+ "Endpoint",
139
148
  "InternalServerError",
140
149
  "NotFoundError",
141
150
  "NotGiven",
@@ -148,11 +157,13 @@ __all__ = [
148
157
  "PtyConnectionError",
149
158
  "RateLimitError",
150
159
  "ServerStartError",
160
+ "ServiceError",
151
161
  "ServiceUnavailableError",
152
162
  "Stream",
153
163
  "SyncCursorPage",
154
164
  "UnprocessableEntityError",
155
165
  "__version__",
166
+ "service",
156
167
  "types",
157
168
  # Shared event stream and typed plugin RPC
158
169
  "SUBSCRIBER_CAPACITY",
@@ -219,3 +230,6 @@ def __getattr__(name: str) -> Any:
219
230
 
220
231
  return importlib.import_module(".types", __name__)
221
232
  raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
233
+
234
+
235
+ _setup_logging()
opencode/_aiohttp.py ADDED
@@ -0,0 +1,44 @@
1
+ """Optional aiohttp-backed HTTP client for `AsyncOpencode`.
2
+
3
+ Install the extra with ``pip install 'opencode-python-sdk[aiohttp]'`` and pass the client in::
4
+
5
+ async with AsyncOpencode(http_client=DefaultAioHttpClient()) as client:
6
+ ...
7
+
8
+ The SDK still speaks httpx: ``httpx-aiohttp`` swaps the transport underneath, so retries, timeouts and event
9
+ streams behave the same. aiohttp only runs on asyncio, so this client does not work under trio.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ import httpx
17
+
18
+ from . import _constants as constants
19
+
20
+ if TYPE_CHECKING:
21
+ _Base = httpx.AsyncClient
22
+ else:
23
+ try:
24
+ from httpx_aiohttp import HttpxAiohttpClient as _Base
25
+ except ImportError: # pragma: no cover - only without the extra
26
+ _Base = httpx.AsyncClient
27
+
28
+
29
+ class DefaultAioHttpClient(_Base):
30
+ """aiohttp-backed `httpx.AsyncClient` with the SDK's default connection limits and redirect handling.
31
+
32
+ Pass it to `AsyncOpencode` as ``http_client``. Keyword arguments go to `httpx.AsyncClient`. The SDK does not
33
+ close a client it was given, so close it yourself (``async with`` it) when you are done. Creating one without
34
+ the ``aiohttp`` extra installed raises `RuntimeError`.
35
+ """
36
+
37
+ def __init__(self, **kwargs: Any) -> None:
38
+ if _Base is httpx.AsyncClient:
39
+ raise RuntimeError(
40
+ "DefaultAioHttpClient needs the aiohttp extra: pip install 'opencode-python-sdk[aiohttp]'"
41
+ )
42
+ kwargs.setdefault("limits", constants.DEFAULT_CONNECTION_LIMITS)
43
+ kwargs.setdefault("follow_redirects", True)
44
+ super().__init__(**kwargs)
opencode/_base_client.py CHANGED
@@ -37,12 +37,13 @@ from ._exceptions import (
37
37
  validation_summary,
38
38
  )
39
39
  from ._qs import encode_query, to_jsonable
40
+ from ._response import APIResponse, AsyncAPIResponse, raw_mode
40
41
  from ._streaming import AsyncStream, Stream
41
42
  from ._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout
42
43
  from ._version import __version__
43
44
 
44
45
  if TYPE_CHECKING:
45
- from collections.abc import Mapping
46
+ from collections.abc import Callable, Coroutine, Mapping
46
47
  from types import TracebackType
47
48
 
48
49
  from ._pagination import AsyncPaginator, SyncCursorPage
@@ -354,13 +355,13 @@ class SyncAPIClient(_BaseClient):
354
355
  ) -> None:
355
356
  self.close()
356
357
 
357
- def _send(self, options: RequestOptions) -> httpx.Response:
358
+ def _send(self, options: RequestOptions, *, stream_body: bool = False) -> httpx.Response:
358
359
  attempt = 0
359
360
  while True:
360
361
  request = self._build_request(options)
361
362
  log.debug("Request %s %s (attempt %d)", request.method, request.url, attempt + 1)
362
363
  try:
363
- response = self._http.send(request, stream=options.stream)
364
+ response = self._http.send(request, stream=options.stream or stream_body)
364
365
  except httpx.TransportError as exc:
365
366
  if attempt < self.max_retries and self._should_retry_error(options, exc):
366
367
  delay = self._retry_delay(attempt, None)
@@ -384,7 +385,7 @@ class SyncAPIClient(_BaseClient):
384
385
  time.sleep(delay)
385
386
  attempt += 1
386
387
  continue
387
- if options.stream:
388
+ if options.stream or stream_body:
388
389
  response.read()
389
390
  raise self._status_error(response, self._decode_error_body(response))
390
391
 
@@ -397,13 +398,23 @@ class SyncAPIClient(_BaseClient):
397
398
  unwrap: bool = False,
398
399
  ) -> Any:
399
400
  """Sends a request and returns the decoded response."""
401
+ if raw_mode() is not None:
402
+ return self._raw(options, lambda response: self._parse(response, kind, response_type, unwrap))
400
403
  response = self._send(options)
401
404
  return self._parse(response, kind, response_type, unwrap)
402
405
 
406
+ def _raw(self, options: RequestOptions, parser: Callable[[httpx.Response], Any]) -> APIResponse[Any]:
407
+ response = self._send(options, stream_body=raw_mode() == "stream")
408
+ return APIResponse(response, parser, event_stream=options.stream)
409
+
403
410
  def stream(self, options: RequestOptions, *, item_type: type[_T] | Any) -> Stream[Any]:
404
411
  """Opens a server-sent event stream."""
405
412
  options.stream = True
406
- response = self._send(options)
413
+ if raw_mode() is not None:
414
+ return cast("Stream[Any]", self._raw(options, lambda response: self._open_stream(response, item_type)))
415
+ return self._open_stream(self._send(options), item_type)
416
+
417
+ def _open_stream(self, response: httpx.Response, item_type: Any) -> Stream[Any]:
407
418
  content_type = response.headers.get("content-type", "")
408
419
  if content_type.split(";", 1)[0].strip().lower() != "text/event-stream":
409
420
  response.read()
@@ -413,10 +424,109 @@ class SyncAPIClient(_BaseClient):
413
424
  )
414
425
  return Stream(response, type_adapter(item_type))
415
426
 
427
+ def get(
428
+ self,
429
+ path: str,
430
+ *,
431
+ cast_to: Any = object,
432
+ query: Query | None = None,
433
+ headers: Headers | None = None,
434
+ timeout: Timeout | NotGiven = NOT_GIVEN,
435
+ ) -> Any:
436
+ """Sends ``GET path`` (relative to ``base_url``) with the client's auth, retries and error mapping.
437
+
438
+ For endpoints this client does not cover yet. The JSON response is validated against ``cast_to``.
439
+ """
440
+ options = RequestOptions(method="GET", path=path, extra_query=query, extra_headers=headers, timeout=timeout)
441
+ return self.request(options, response_type=cast_to)
442
+
443
+ def post(
444
+ self,
445
+ path: str,
446
+ *,
447
+ cast_to: Any = object,
448
+ body: object | NotGiven = NOT_GIVEN,
449
+ query: Query | None = None,
450
+ headers: Headers | None = None,
451
+ timeout: Timeout | NotGiven = NOT_GIVEN,
452
+ ) -> Any:
453
+ """Sends ``POST path`` (relative to ``base_url``) with the client's auth, retries and error mapping.
454
+
455
+ For endpoints this client does not cover yet. The JSON response is validated against ``cast_to``.
456
+ """
457
+ options = RequestOptions(
458
+ method="POST", path=path, body=body, extra_query=query, extra_headers=headers, timeout=timeout
459
+ )
460
+ return self.request(options, response_type=cast_to)
461
+
462
+ def put(
463
+ self,
464
+ path: str,
465
+ *,
466
+ cast_to: Any = object,
467
+ body: object | NotGiven = NOT_GIVEN,
468
+ query: Query | None = None,
469
+ headers: Headers | None = None,
470
+ timeout: Timeout | NotGiven = NOT_GIVEN,
471
+ ) -> Any:
472
+ """Sends ``PUT path`` (relative to ``base_url``) with the client's auth, retries and error mapping.
473
+
474
+ For endpoints this client does not cover yet. The JSON response is validated against ``cast_to``.
475
+ """
476
+ options = RequestOptions(
477
+ method="PUT", path=path, body=body, extra_query=query, extra_headers=headers, timeout=timeout
478
+ )
479
+ return self.request(options, response_type=cast_to)
480
+
481
+ def patch(
482
+ self,
483
+ path: str,
484
+ *,
485
+ cast_to: Any = object,
486
+ body: object | NotGiven = NOT_GIVEN,
487
+ query: Query | None = None,
488
+ headers: Headers | None = None,
489
+ timeout: Timeout | NotGiven = NOT_GIVEN,
490
+ ) -> Any:
491
+ """Sends ``PATCH path`` (relative to ``base_url``) with the client's auth, retries and error mapping.
492
+
493
+ For endpoints this client does not cover yet. The JSON response is validated against ``cast_to``.
494
+ """
495
+ options = RequestOptions(
496
+ method="PATCH", path=path, body=body, extra_query=query, extra_headers=headers, timeout=timeout
497
+ )
498
+ return self.request(options, response_type=cast_to)
499
+
500
+ def delete(
501
+ self,
502
+ path: str,
503
+ *,
504
+ cast_to: Any = object,
505
+ query: Query | None = None,
506
+ headers: Headers | None = None,
507
+ timeout: Timeout | NotGiven = NOT_GIVEN,
508
+ ) -> Any:
509
+ """Sends ``DELETE path`` (relative to ``base_url``) with the client's auth, retries and error mapping.
510
+
511
+ For endpoints this client does not cover yet. The JSON response is validated against ``cast_to``.
512
+ """
513
+ options = RequestOptions(method="DELETE", path=path, extra_query=query, extra_headers=headers, timeout=timeout)
514
+ return self.request(options, response_type=cast_to)
515
+
416
516
  def page(self, options: RequestOptions, *, response_type: Any) -> SyncCursorPage[Any]:
417
517
  """Fetches the first page of a cursor-paginated list."""
418
518
  from ._pagination import SyncCursorPage
419
519
 
520
+ if raw_mode() is not None:
521
+ return cast(
522
+ "SyncCursorPage[Any]",
523
+ self._raw(
524
+ options,
525
+ lambda response: SyncCursorPage.build(
526
+ self, options, response_type, self._parse(response, "json", response_type, False)
527
+ ),
528
+ ),
529
+ )
420
530
  return SyncCursorPage.fetch(self, options, response_type)
421
531
 
422
532
 
@@ -473,13 +583,13 @@ class AsyncAPIClient(_BaseClient):
473
583
  ) -> None:
474
584
  await self.close()
475
585
 
476
- async def _send(self, options: RequestOptions) -> httpx.Response:
586
+ async def _send(self, options: RequestOptions, *, stream_body: bool = False) -> httpx.Response:
477
587
  attempt = 0
478
588
  while True:
479
589
  request = self._build_request(options)
480
590
  log.debug("Request %s %s (attempt %d)", request.method, request.url, attempt + 1)
481
591
  try:
482
- response = await self._http.send(request, stream=options.stream)
592
+ response = await self._http.send(request, stream=options.stream or stream_body)
483
593
  except httpx.TransportError as exc:
484
594
  if attempt < self.max_retries and self._should_retry_error(options, exc):
485
595
  delay = self._retry_delay(attempt, None)
@@ -503,7 +613,7 @@ class AsyncAPIClient(_BaseClient):
503
613
  await anyio.sleep(delay)
504
614
  attempt += 1
505
615
  continue
506
- if options.stream:
616
+ if options.stream or stream_body:
507
617
  await response.aread()
508
618
  raise self._status_error(response, self._decode_error_body(response))
509
619
 
@@ -516,13 +626,31 @@ class AsyncAPIClient(_BaseClient):
516
626
  unwrap: bool = False,
517
627
  ) -> Any:
518
628
  """Sends a request and returns the decoded response."""
629
+ if raw_mode() is not None:
630
+ return await self._raw(options, lambda response: self._parse(response, kind, response_type, unwrap))
519
631
  response = await self._send(options)
520
632
  return self._parse(response, kind, response_type, unwrap)
521
633
 
634
+ async def _raw(self, options: RequestOptions, parser: Callable[[httpx.Response], Any]) -> AsyncAPIResponse[Any]:
635
+ response = await self._send(options, stream_body=raw_mode() == "stream")
636
+ return AsyncAPIResponse(response, parser, event_stream=options.stream)
637
+
522
638
  async def stream(self, options: RequestOptions, *, item_type: type[_T] | Any) -> AsyncStream[Any]:
523
639
  """Opens a server-sent event stream."""
524
640
  options.stream = True
525
- response = await self._send(options)
641
+ if raw_mode() is not None:
642
+ raw = await self._raw(options, lambda response: self._open_stream(response, item_type))
643
+ return cast("AsyncStream[Any]", raw)
644
+ return await self._open_async_stream(await self._send(options), item_type)
645
+
646
+ @staticmethod
647
+ def _open_stream(response: httpx.Response, item_type: Any) -> AsyncStream[Any]:
648
+ content_type = response.headers.get("content-type", "")
649
+ if content_type.split(";", 1)[0].strip().lower() != "text/event-stream":
650
+ raise APIResponseValidationError(response, None, message=f"Expected an event stream, got {content_type!r}")
651
+ return AsyncStream(response, type_adapter(item_type))
652
+
653
+ async def _open_async_stream(self, response: httpx.Response, item_type: Any) -> AsyncStream[Any]:
526
654
  content_type = response.headers.get("content-type", "")
527
655
  if content_type.split(";", 1)[0].strip().lower() != "text/event-stream":
528
656
  await response.aread()
@@ -532,8 +660,106 @@ class AsyncAPIClient(_BaseClient):
532
660
  )
533
661
  return AsyncStream(response, type_adapter(item_type))
534
662
 
663
+ async def get(
664
+ self,
665
+ path: str,
666
+ *,
667
+ cast_to: Any = object,
668
+ query: Query | None = None,
669
+ headers: Headers | None = None,
670
+ timeout: Timeout | NotGiven = NOT_GIVEN,
671
+ ) -> Any:
672
+ """Sends ``GET path`` (relative to ``base_url``) with the client's auth, retries and error mapping.
673
+
674
+ For endpoints this client does not cover yet. The JSON response is validated against ``cast_to``.
675
+ """
676
+ options = RequestOptions(method="GET", path=path, extra_query=query, extra_headers=headers, timeout=timeout)
677
+ return await self.request(options, response_type=cast_to)
678
+
679
+ async def post(
680
+ self,
681
+ path: str,
682
+ *,
683
+ cast_to: Any = object,
684
+ body: object | NotGiven = NOT_GIVEN,
685
+ query: Query | None = None,
686
+ headers: Headers | None = None,
687
+ timeout: Timeout | NotGiven = NOT_GIVEN,
688
+ ) -> Any:
689
+ """Sends ``POST path`` (relative to ``base_url``) with the client's auth, retries and error mapping.
690
+
691
+ For endpoints this client does not cover yet. The JSON response is validated against ``cast_to``.
692
+ """
693
+ options = RequestOptions(
694
+ method="POST", path=path, body=body, extra_query=query, extra_headers=headers, timeout=timeout
695
+ )
696
+ return await self.request(options, response_type=cast_to)
697
+
698
+ async def put(
699
+ self,
700
+ path: str,
701
+ *,
702
+ cast_to: Any = object,
703
+ body: object | NotGiven = NOT_GIVEN,
704
+ query: Query | None = None,
705
+ headers: Headers | None = None,
706
+ timeout: Timeout | NotGiven = NOT_GIVEN,
707
+ ) -> Any:
708
+ """Sends ``PUT path`` (relative to ``base_url``) with the client's auth, retries and error mapping.
709
+
710
+ For endpoints this client does not cover yet. The JSON response is validated against ``cast_to``.
711
+ """
712
+ options = RequestOptions(
713
+ method="PUT", path=path, body=body, extra_query=query, extra_headers=headers, timeout=timeout
714
+ )
715
+ return await self.request(options, response_type=cast_to)
716
+
717
+ async def patch(
718
+ self,
719
+ path: str,
720
+ *,
721
+ cast_to: Any = object,
722
+ body: object | NotGiven = NOT_GIVEN,
723
+ query: Query | None = None,
724
+ headers: Headers | None = None,
725
+ timeout: Timeout | NotGiven = NOT_GIVEN,
726
+ ) -> Any:
727
+ """Sends ``PATCH path`` (relative to ``base_url``) with the client's auth, retries and error mapping.
728
+
729
+ For endpoints this client does not cover yet. The JSON response is validated against ``cast_to``.
730
+ """
731
+ options = RequestOptions(
732
+ method="PATCH", path=path, body=body, extra_query=query, extra_headers=headers, timeout=timeout
733
+ )
734
+ return await self.request(options, response_type=cast_to)
735
+
736
+ async def delete(
737
+ self,
738
+ path: str,
739
+ *,
740
+ cast_to: Any = object,
741
+ query: Query | None = None,
742
+ headers: Headers | None = None,
743
+ timeout: Timeout | NotGiven = NOT_GIVEN,
744
+ ) -> Any:
745
+ """Sends ``DELETE path`` (relative to ``base_url``) with the client's auth, retries and error mapping.
746
+
747
+ For endpoints this client does not cover yet. The JSON response is validated against ``cast_to``.
748
+ """
749
+ options = RequestOptions(method="DELETE", path=path, extra_query=query, extra_headers=headers, timeout=timeout)
750
+ return await self.request(options, response_type=cast_to)
751
+
535
752
  def page(self, options: RequestOptions, *, response_type: Any) -> AsyncPaginator[Any]:
536
753
  """Returns an awaitable, async-iterable paginator over a cursor-paginated list."""
537
- from ._pagination import AsyncPaginator
538
-
754
+ from ._pagination import AsyncCursorPage, AsyncPaginator
755
+
756
+ if raw_mode() is not None:
757
+ # Awaited by the raw-response wrapper: the raw form of a list call is the first page's response.
758
+ raw: Coroutine[Any, Any, AsyncAPIResponse[Any]] = self._raw(
759
+ options,
760
+ lambda response: AsyncCursorPage.build(
761
+ self, options, response_type, self._parse(response, "json", response_type, False)
762
+ ),
763
+ )
764
+ return cast("AsyncPaginator[Any]", raw)
539
765
  return AsyncPaginator(self, options, response_type)
opencode/_client.py CHANGED
@@ -9,12 +9,21 @@ 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 Iterable, Mapping
26
+ from collections.abc import Callable, Iterable, Mapping, Sequence
18
27
 
19
28
  import httpx
20
29
 
@@ -82,6 +91,67 @@ class Opencode(SyncAPIClient, SyncResources):
82
91
  transport=transport,
83
92
  )
84
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,
153
+ )
154
+
85
155
  @cached_property
86
156
  def events(self) -> EventHub:
87
157
  """The shared event stream: one connection for every subscriber. See :class:`opencode.EventHub`."""
@@ -132,7 +202,8 @@ class Opencode(SyncAPIClient, SyncResources):
132
202
  ) -> Self:
133
203
  """Returns a client with some options changed. It shares this client's connection pool.
134
204
 
135
- ``default_headers`` and ``default_query`` are merged over the current values.
205
+ ``default_headers`` and ``default_query`` are merged over the current values. Closing the copy leaves the
206
+ pool open; it closes with this client. Also available as ``copy``.
136
207
  """
137
208
  client = type(self)(
138
209
  base_url=base_url or self.base_url,
@@ -147,6 +218,8 @@ class Opencode(SyncAPIClient, SyncResources):
147
218
  )
148
219
  return client
149
220
 
221
+ copy = with_options
222
+
150
223
 
151
224
  class AsyncOpencode(AsyncAPIClient, AsyncResources):
152
225
  """Asynchronous client for an OpenCode server. Works with asyncio and trio.
@@ -189,6 +262,42 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
189
262
  transport=transport,
190
263
  )
191
264
 
265
+ @classmethod
266
+ async def from_service(
267
+ cls,
268
+ *,
269
+ start: bool = True,
270
+ file: str | os.PathLike[str] | None = None,
271
+ version: Version | None = None,
272
+ command: Sequence[str] | None = None,
273
+ env: Mapping[str, str] | None = None,
274
+ on_start: Callable[[EnsureReason, str | None], None] | None = None,
275
+ directory: str | os.PathLike[str] | None = None,
276
+ timeout: Timeout = DEFAULT_TIMEOUT,
277
+ max_retries: int = DEFAULT_MAX_RETRIES,
278
+ default_headers: Mapping[str, str] | None = None,
279
+ default_query: Mapping[str, object] | None = None,
280
+ ) -> Self:
281
+ """Async version of :meth:`Opencode.from_service`. Await it: ``client = await AsyncOpencode.from_service()``."""
282
+ if start:
283
+ endpoint = await async_ensure(file, version, command=command, env=env, on_start=on_start)
284
+ else:
285
+ found = await async_discover(file, version)
286
+ if found is None:
287
+ raise ServiceError("No matching OpenCode background service is running")
288
+ endpoint = found
289
+ return cls(
290
+ endpoint.url,
291
+ # An empty password sends no credentials instead of falling back to OPENCODE_PASSWORD.
292
+ password="" if endpoint.auth is None else endpoint.auth.password,
293
+ username=DEFAULT_USERNAME if endpoint.auth is None else endpoint.auth.username,
294
+ directory=directory,
295
+ timeout=timeout,
296
+ max_retries=max_retries,
297
+ default_headers=default_headers,
298
+ default_query=default_query,
299
+ )
300
+
192
301
  @cached_property
193
302
  def events(self) -> AsyncEventHub:
194
303
  """The shared event stream: one connection for every subscriber. See :class:`opencode.AsyncEventHub`."""
@@ -239,7 +348,8 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
239
348
  ) -> Self:
240
349
  """Returns a client with some options changed. It shares this client's connection pool.
241
350
 
242
- ``default_headers`` and ``default_query`` are merged over the current values.
351
+ ``default_headers`` and ``default_query`` are merged over the current values. Closing the copy leaves the
352
+ pool open; it closes with this client. Also available as ``copy``.
243
353
  """
244
354
  client = type(self)(
245
355
  base_url=base_url or self.base_url,
@@ -253,3 +363,5 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
253
363
  http_client=self._http,
254
364
  )
255
365
  return client
366
+
367
+ copy = with_options
opencode/_logs.py ADDED
@@ -0,0 +1,29 @@
1
+ """``OPENCODE_LOG`` support: configure the ``opencode`` logger from the environment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+
8
+ logger = logging.getLogger("opencode")
9
+
10
+ _LEVELS = {"debug": logging.DEBUG, "info": logging.INFO}
11
+
12
+
13
+ def setup_logging() -> None:
14
+ """Attach a stderr handler to the ``opencode`` logger when ``OPENCODE_LOG`` is ``debug`` or ``info``.
15
+
16
+ Does nothing when the variable is unset or has any other value. The OpenCode CLI reads
17
+ ``OPENCODE_LOG_LEVEL``, not ``OPENCODE_LOG``, so the two do not clash.
18
+ """
19
+ level = _LEVELS.get(os.environ.get("OPENCODE_LOG", "").strip().lower())
20
+ if level is None:
21
+ return
22
+ if not any(getattr(h, "_opencode_env", False) for h in logger.handlers):
23
+ handler = logging.StreamHandler()
24
+ handler.setFormatter(
25
+ logging.Formatter("[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s", "%Y-%m-%d %H:%M:%S")
26
+ )
27
+ handler._opencode_env = True # type: ignore[attr-defined]
28
+ logger.addHandler(handler)
29
+ logger.setLevel(level)
opencode/_pagination.py CHANGED
@@ -79,6 +79,13 @@ class SyncCursorPage(_PageBase[_T]):
79
79
  @classmethod
80
80
  def fetch(cls, client: SyncAPIClient, options: RequestOptions, response_type: Any) -> SyncCursorPage[Any]:
81
81
  payload = client.request(options, response_type=response_type)
82
+ return cls.build(client, options, response_type, payload)
83
+
84
+ @classmethod
85
+ def build(
86
+ cls, client: SyncAPIClient, options: RequestOptions, response_type: Any, payload: Any
87
+ ) -> SyncCursorPage[Any]:
88
+ """Builds a page from an already parsed response."""
82
89
  data, next_cursor, previous_cursor = _page_parts(payload)
83
90
  return cls(
84
91
  client=client,
@@ -134,6 +141,13 @@ class AsyncCursorPage(_PageBase[_T]):
134
141
  @classmethod
135
142
  async def fetch(cls, client: AsyncAPIClient, options: RequestOptions, response_type: Any) -> AsyncCursorPage[Any]:
136
143
  payload = await client.request(options, response_type=response_type)
144
+ return cls.build(client, options, response_type, payload)
145
+
146
+ @classmethod
147
+ def build(
148
+ cls, client: AsyncAPIClient, options: RequestOptions, response_type: Any, payload: Any
149
+ ) -> AsyncCursorPage[Any]:
150
+ """Builds a page from an already parsed response."""
137
151
  data, next_cursor, previous_cursor = _page_parts(payload)
138
152
  return cls(
139
153
  client=client,