opencode-python-sdk 0.2.2__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 (43) hide show
  1. opencode/__init__.py +9 -0
  2. opencode/_aiohttp.py +44 -0
  3. opencode/_base_client.py +237 -11
  4. opencode/_client.py +8 -2
  5. opencode/_logs.py +29 -0
  6. opencode/_pagination.py +14 -0
  7. opencode/_response.py +316 -0
  8. opencode/resources/agent.py +63 -0
  9. opencode/resources/command.py +59 -0
  10. opencode/resources/config.py +93 -20
  11. opencode/resources/credential.py +149 -56
  12. opencode/resources/debug.py +156 -20
  13. opencode/resources/event.py +59 -0
  14. opencode/resources/experimental.py +210 -36
  15. opencode/resources/file.py +71 -0
  16. opencode/resources/form.py +59 -0
  17. opencode/resources/generate.py +59 -0
  18. opencode/resources/integration.py +538 -128
  19. opencode/resources/location.py +87 -18
  20. opencode/resources/mcp.py +252 -82
  21. opencode/resources/message.py +59 -0
  22. opencode/resources/migration.py +126 -0
  23. opencode/resources/model.py +63 -0
  24. opencode/resources/permission.py +272 -38
  25. opencode/resources/plugin.py +95 -22
  26. opencode/resources/project.py +63 -0
  27. opencode/resources/provider.py +63 -0
  28. opencode/resources/pty.py +172 -20
  29. opencode/resources/reference.py +59 -0
  30. opencode/resources/rpc.py +59 -0
  31. opencode/resources/server.py +67 -0
  32. opencode/resources/session.py +1159 -405
  33. opencode/resources/shell.py +101 -20
  34. opencode/resources/skill.py +59 -0
  35. opencode/resources/vcs.py +172 -20
  36. opencode/resources/voice.py +59 -0
  37. opencode/resources/websearch.py +63 -0
  38. opencode/resources/worktree.py +123 -40
  39. {opencode_python_sdk-0.2.2.dist-info → opencode_python_sdk-0.2.3.dist-info}/METADATA +90 -2
  40. opencode_python_sdk-0.2.3.dist-info/RECORD +67 -0
  41. opencode_python_sdk-0.2.2.dist-info/RECORD +0 -64
  42. {opencode_python_sdk-0.2.2.dist-info → opencode_python_sdk-0.2.3.dist-info}/WHEEL +0 -0
  43. {opencode_python_sdk-0.2.2.dist-info → opencode_python_sdk-0.2.3.dist-info}/licenses/LICENSE +0 -0
opencode/__init__.py CHANGED
@@ -17,6 +17,7 @@ from __future__ import annotations
17
17
  from typing import TYPE_CHECKING, Any
18
18
 
19
19
  from . import service
20
+ from ._aiohttp import DefaultAioHttpClient
20
21
  from ._client import AsyncOpencode, Opencode
21
22
 
22
23
  # <generated:error-imports>
@@ -75,9 +76,11 @@ from ._exceptions import (
75
76
  ServiceUnavailableError,
76
77
  UnprocessableEntityError,
77
78
  )
79
+ from ._logs import setup_logging as _setup_logging
78
80
  from ._models import BaseModel
79
81
  from ._pagination import AsyncCursorPage, AsyncPaginator, SyncCursorPage
80
82
  from ._pty import AsyncPtyConnection, PtyConnection, PtyConnectionError
83
+ from ._response import APIResponse, AsyncAPIResponse
81
84
  from ._server import AsyncOpencodeServer, OpencodeServer, ServerStartError
82
85
  from ._service import Endpoint, ServiceError
83
86
  from ._streaming import AsyncStream, Stream
@@ -123,10 +126,12 @@ __all__ = [
123
126
  "API_VERSION",
124
127
  "NOT_GIVEN",
125
128
  "APIConnectionError",
129
+ "APIResponse",
126
130
  "APIError",
127
131
  "APIResponseValidationError",
128
132
  "APIStatusError",
129
133
  "APITimeoutError",
134
+ "AsyncAPIResponse",
130
135
  "AsyncCursorPage",
131
136
  "AsyncOpencode",
132
137
  "AsyncOpencodeServer",
@@ -138,6 +143,7 @@ __all__ = [
138
143
  "BaseModel",
139
144
  "ConflictError",
140
145
  "ContentTooLargeError",
146
+ "DefaultAioHttpClient",
141
147
  "Endpoint",
142
148
  "InternalServerError",
143
149
  "NotFoundError",
@@ -224,3 +230,6 @@ def __getattr__(name: str) -> Any:
224
230
 
225
231
  return importlib.import_module(".types", __name__)
226
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
@@ -202,7 +202,8 @@ class Opencode(SyncAPIClient, SyncResources):
202
202
  ) -> Self:
203
203
  """Returns a client with some options changed. It shares this client's connection pool.
204
204
 
205
- ``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``.
206
207
  """
207
208
  client = type(self)(
208
209
  base_url=base_url or self.base_url,
@@ -217,6 +218,8 @@ class Opencode(SyncAPIClient, SyncResources):
217
218
  )
218
219
  return client
219
220
 
221
+ copy = with_options
222
+
220
223
 
221
224
  class AsyncOpencode(AsyncAPIClient, AsyncResources):
222
225
  """Asynchronous client for an OpenCode server. Works with asyncio and trio.
@@ -345,7 +348,8 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
345
348
  ) -> Self:
346
349
  """Returns a client with some options changed. It shares this client's connection pool.
347
350
 
348
- ``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``.
349
353
  """
350
354
  client = type(self)(
351
355
  base_url=base_url or self.base_url,
@@ -359,3 +363,5 @@ class AsyncOpencode(AsyncAPIClient, AsyncResources):
359
363
  http_client=self._http,
360
364
  )
361
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,