opencode-python-sdk 0.0.1.dev1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- opencode/.generated.json +39 -0
- opencode/__init__.py +221 -0
- opencode/_base_client.py +539 -0
- opencode/_client.py +255 -0
- opencode/_constants.py +27 -0
- opencode/_errors.py +547 -0
- opencode/_events.py +732 -0
- opencode/_exceptions.py +271 -0
- opencode/_models.py +54 -0
- opencode/_pagination.py +192 -0
- opencode/_pty.py +586 -0
- opencode/_qs.py +95 -0
- opencode/_resource.py +42 -0
- opencode/_rpc.py +489 -0
- opencode/_server.py +335 -0
- opencode/_streaming.py +248 -0
- opencode/_types.py +77 -0
- opencode/_unions.py +46 -0
- opencode/_version.py +8 -0
- opencode/lib/__init__.py +5 -0
- opencode/lib/_ready.py +91 -0
- opencode/lib/_session.py +231 -0
- opencode/py.typed +0 -0
- opencode/resources/__init__.py +364 -0
- opencode/resources/agent.py +179 -0
- opencode/resources/command.py +100 -0
- opencode/resources/config.py +240 -0
- opencode/resources/credential.py +384 -0
- opencode/resources/debug.py +178 -0
- opencode/resources/event.py +88 -0
- opencode/resources/experimental.py +765 -0
- opencode/resources/file.py +355 -0
- opencode/resources/form.py +100 -0
- opencode/resources/generate.py +110 -0
- opencode/resources/integration.py +1033 -0
- opencode/resources/location.py +162 -0
- opencode/resources/mcp.py +492 -0
- opencode/resources/message.py +152 -0
- opencode/resources/migration.py +118 -0
- opencode/resources/model.py +177 -0
- opencode/resources/permission.py +704 -0
- opencode/resources/plugin.py +250 -0
- opencode/resources/project.py +187 -0
- opencode/resources/provider.py +183 -0
- opencode/resources/pty.py +522 -0
- opencode/resources/reference.py +100 -0
- opencode/resources/rpc.py +113 -0
- opencode/resources/server.py +230 -0
- opencode/resources/session.py +3567 -0
- opencode/resources/shell.py +412 -0
- opencode/resources/skill.py +100 -0
- opencode/resources/vcs.py +500 -0
- opencode/resources/voice.py +113 -0
- opencode/resources/websearch.py +184 -0
- opencode/resources/worktree.py +336 -0
- opencode/testing.py +508 -0
- opencode/types/__init__.py +1462 -0
- opencode/types/_generated.py +8540 -0
- opencode_python_sdk-0.0.1.dev1.dist-info/METADATA +468 -0
- opencode_python_sdk-0.0.1.dev1.dist-info/RECORD +62 -0
- opencode_python_sdk-0.0.1.dev1.dist-info/WHEEL +4 -0
- opencode_python_sdk-0.0.1.dev1.dist-info/licenses/LICENSE +21 -0
opencode/_base_client.py
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
"""HTTP transport shared by the sync and async clients.
|
|
2
|
+
|
|
3
|
+
This module is the only place that talks to httpx. It builds requests, applies authentication, retries
|
|
4
|
+
transient failures, and turns responses into typed values or errors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import builtins
|
|
11
|
+
import email.utils
|
|
12
|
+
import functools
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import random
|
|
18
|
+
import time
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast
|
|
21
|
+
|
|
22
|
+
import anyio
|
|
23
|
+
import httpx
|
|
24
|
+
import pydantic
|
|
25
|
+
from typing_extensions import Self, override
|
|
26
|
+
|
|
27
|
+
from . import _constants as constants
|
|
28
|
+
from ._errors import TAGGED_ERRORS
|
|
29
|
+
from ._exceptions import (
|
|
30
|
+
APIConnectionError,
|
|
31
|
+
APIResponseValidationError,
|
|
32
|
+
APIStatusError,
|
|
33
|
+
APITimeoutError,
|
|
34
|
+
OpencodeError,
|
|
35
|
+
error_message,
|
|
36
|
+
status_error_class,
|
|
37
|
+
validation_summary,
|
|
38
|
+
)
|
|
39
|
+
from ._qs import encode_query, to_jsonable
|
|
40
|
+
from ._streaming import AsyncStream, Stream
|
|
41
|
+
from ._types import NOT_GIVEN, Body, Headers, NotGiven, Query, Timeout
|
|
42
|
+
from ._version import __version__
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Mapping
|
|
46
|
+
from types import TracebackType
|
|
47
|
+
|
|
48
|
+
from ._pagination import AsyncPaginator, SyncCursorPage
|
|
49
|
+
|
|
50
|
+
__all__ = ["AsyncAPIClient", "SyncAPIClient"]
|
|
51
|
+
|
|
52
|
+
log = logging.getLogger("opencode")
|
|
53
|
+
|
|
54
|
+
_T = TypeVar("_T")
|
|
55
|
+
|
|
56
|
+
ResponseKind = Literal["json", "void", "binary"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@functools.cache
|
|
60
|
+
def _cached_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
|
|
61
|
+
return pydantic.TypeAdapter(tp)
|
|
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
|
+
|
|
72
|
+
def type_adapter(tp: Any) -> pydantic.TypeAdapter[Any]:
|
|
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)
|
|
76
|
+
try:
|
|
77
|
+
return _cached_adapter(tp)
|
|
78
|
+
except TypeError: # unhashable type expression
|
|
79
|
+
return pydantic.TypeAdapter(tp)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class RequestOptions:
|
|
84
|
+
"""Everything needed to send one request. Generated methods build these."""
|
|
85
|
+
|
|
86
|
+
method: str
|
|
87
|
+
path: str
|
|
88
|
+
query: Mapping[str, object] = field(default_factory=dict[str, object])
|
|
89
|
+
headers: Mapping[str, object] = field(default_factory=dict[str, object])
|
|
90
|
+
body: object | NotGiven = NOT_GIVEN
|
|
91
|
+
content: bytes | None = None
|
|
92
|
+
extra_headers: Headers | None = None
|
|
93
|
+
extra_query: Query | None = None
|
|
94
|
+
extra_body: Body | None = None
|
|
95
|
+
timeout: Timeout | NotGiven = NOT_GIVEN
|
|
96
|
+
stream: bool = False
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def idempotent(self) -> bool:
|
|
100
|
+
return self.method.upper() in constants.IDEMPOTENT_METHODS
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _platform_headers() -> dict[str, str]:
|
|
104
|
+
return {
|
|
105
|
+
"User-Agent": f"opencode-python/{__version__} python/{platform.python_version()} httpx/{httpx.__version__}",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _resolve_password(password: str | None) -> str | None:
|
|
110
|
+
if password is not None:
|
|
111
|
+
return password or None
|
|
112
|
+
for name in constants.PASSWORD_ENVS:
|
|
113
|
+
value = os.environ.get(name)
|
|
114
|
+
if value:
|
|
115
|
+
return value
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _resolve_base_url(base_url: str | httpx.URL | None) -> httpx.URL:
|
|
120
|
+
if base_url is None:
|
|
121
|
+
base_url = os.environ.get(constants.BASE_URL_ENV)
|
|
122
|
+
if not base_url:
|
|
123
|
+
raise OpencodeError(
|
|
124
|
+
"No OpenCode server URL. Pass base_url=..., set the OPENCODE_BASE_URL environment variable, "
|
|
125
|
+
"or start a server with opencode.OpencodeServer()."
|
|
126
|
+
)
|
|
127
|
+
url = httpx.URL(str(base_url))
|
|
128
|
+
if url.scheme not in ("http", "https"):
|
|
129
|
+
raise OpencodeError(f"OpenCode base_url must be an http(s) URL, got {str(base_url)!r}")
|
|
130
|
+
# A path prefix on the base URL is kept: requests are joined relative to it.
|
|
131
|
+
return url if url.raw_path.endswith(b"/") else url.copy_with(raw_path=url.raw_path + b"/")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _BaseClient:
|
|
135
|
+
"""Configuration and request building shared by both clients."""
|
|
136
|
+
|
|
137
|
+
base_url: httpx.URL
|
|
138
|
+
username: str
|
|
139
|
+
password: str | None
|
|
140
|
+
timeout: Timeout
|
|
141
|
+
max_retries: int
|
|
142
|
+
directory: str | None
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
*,
|
|
147
|
+
base_url: str | httpx.URL | None,
|
|
148
|
+
password: str | None,
|
|
149
|
+
username: str,
|
|
150
|
+
directory: str | os.PathLike[str] | None,
|
|
151
|
+
timeout: Timeout,
|
|
152
|
+
max_retries: int,
|
|
153
|
+
default_headers: Mapping[str, str] | None,
|
|
154
|
+
default_query: Mapping[str, object] | None,
|
|
155
|
+
) -> None:
|
|
156
|
+
if max_retries < 0:
|
|
157
|
+
raise ValueError("max_retries must be zero or more")
|
|
158
|
+
self.base_url = _resolve_base_url(base_url)
|
|
159
|
+
self.username = username
|
|
160
|
+
self.password = _resolve_password(password)
|
|
161
|
+
self._authorization = (
|
|
162
|
+
None
|
|
163
|
+
if self.password is None
|
|
164
|
+
else "Basic " + base64.b64encode(f"{username}:{self.password}".encode()).decode("ascii")
|
|
165
|
+
)
|
|
166
|
+
self.directory = os.fspath(directory) if directory is not None else None
|
|
167
|
+
self.timeout = timeout
|
|
168
|
+
self.max_retries = max_retries
|
|
169
|
+
self._default_headers = dict(default_headers or {})
|
|
170
|
+
self._default_query = dict(default_query or {})
|
|
171
|
+
|
|
172
|
+
@override
|
|
173
|
+
def __repr__(self) -> str:
|
|
174
|
+
# Never include the password.
|
|
175
|
+
return f"{type(self).__name__}(base_url={str(self.base_url)!r})"
|
|
176
|
+
|
|
177
|
+
def _build_request(self, options: RequestOptions) -> httpx.Request:
|
|
178
|
+
url = self.base_url.join(options.path.lstrip("/"))
|
|
179
|
+
query = {**self._default_query, **options.query, **(options.extra_query or {})}
|
|
180
|
+
|
|
181
|
+
headers: dict[str, str] = {"Accept": "application/json", **_platform_headers(), **self._default_headers}
|
|
182
|
+
for name, value in {**options.headers, **(options.extra_headers or {})}.items():
|
|
183
|
+
if isinstance(value, NotGiven):
|
|
184
|
+
headers.pop(name, None)
|
|
185
|
+
elif value is not None:
|
|
186
|
+
headers[name] = str(value)
|
|
187
|
+
if options.stream:
|
|
188
|
+
headers["Accept"] = "text/event-stream"
|
|
189
|
+
if self._authorization is not None and "Authorization" not in headers:
|
|
190
|
+
headers["Authorization"] = self._authorization
|
|
191
|
+
|
|
192
|
+
content: bytes | None = options.content
|
|
193
|
+
if content is not None:
|
|
194
|
+
headers.setdefault("Content-Type", "application/octet-stream")
|
|
195
|
+
elif not isinstance(options.body, NotGiven) or options.extra_body:
|
|
196
|
+
body: Any = to_jsonable(options.body) if not isinstance(options.body, NotGiven) else {}
|
|
197
|
+
if options.extra_body:
|
|
198
|
+
if not isinstance(body, dict):
|
|
199
|
+
raise TypeError("extra_body can only be used with JSON object bodies")
|
|
200
|
+
extra: dict[str, Any] = to_jsonable(options.extra_body)
|
|
201
|
+
body = {**cast("dict[str, Any]", body), **extra}
|
|
202
|
+
content = _json_bytes(body)
|
|
203
|
+
headers["Content-Type"] = "application/json"
|
|
204
|
+
|
|
205
|
+
timeout = self.timeout if isinstance(options.timeout, NotGiven) else options.timeout
|
|
206
|
+
if options.stream and isinstance(options.timeout, NotGiven):
|
|
207
|
+
# Event streams stay open between events, so only bound the connect phase by default.
|
|
208
|
+
base = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout)
|
|
209
|
+
timeout = httpx.Timeout(connect=base.connect, read=None, write=base.write, pool=base.pool)
|
|
210
|
+
return httpx.Request(
|
|
211
|
+
options.method,
|
|
212
|
+
url,
|
|
213
|
+
params=httpx.QueryParams(cast("list[tuple[str, str | int | float | bool | None]]", encode_query(query))),
|
|
214
|
+
headers=headers,
|
|
215
|
+
content=content,
|
|
216
|
+
extensions={"timeout": _timeout_dict(timeout)},
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _status_error(self, response: httpx.Response, raw: object) -> APIStatusError:
|
|
220
|
+
body: object = raw
|
|
221
|
+
fields = cast("dict[str, Any]", raw) if isinstance(raw, dict) else {}
|
|
222
|
+
tag: object = fields.get("_tag")
|
|
223
|
+
cls = status_error_class(response.status_code, tag if isinstance(tag, str) else None, TAGGED_ERRORS)
|
|
224
|
+
return cls(error_message(body, response.status_code), response=response, body=body)
|
|
225
|
+
|
|
226
|
+
def _should_retry_status(self, options: RequestOptions, response: httpx.Response) -> bool:
|
|
227
|
+
statuses = constants.RETRYABLE_STATUSES if options.idempotent else constants.RETRYABLE_STATUSES_UNSAFE
|
|
228
|
+
return response.status_code in statuses
|
|
229
|
+
|
|
230
|
+
@staticmethod
|
|
231
|
+
def _should_retry_error(options: RequestOptions, error: httpx.TransportError) -> bool:
|
|
232
|
+
# These fail before the request reaches the server, so retrying never repeats work.
|
|
233
|
+
if isinstance(error, (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)):
|
|
234
|
+
return True
|
|
235
|
+
return options.idempotent
|
|
236
|
+
|
|
237
|
+
@staticmethod
|
|
238
|
+
def _retry_delay(attempt: int, response: httpx.Response | None) -> float:
|
|
239
|
+
if response is not None:
|
|
240
|
+
retry_after = _parse_retry_after(response.headers)
|
|
241
|
+
if retry_after is not None and 0 <= retry_after <= constants.MAX_RETRY_AFTER:
|
|
242
|
+
return retry_after
|
|
243
|
+
delay: float = min(constants.INITIAL_RETRY_DELAY * 2.0**attempt, constants.MAX_RETRY_DELAY)
|
|
244
|
+
return delay * (1 - 0.25 * random.random())
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def _decode_error_body(response: httpx.Response) -> object:
|
|
248
|
+
if not response.content:
|
|
249
|
+
return None
|
|
250
|
+
try:
|
|
251
|
+
return response.json()
|
|
252
|
+
except ValueError:
|
|
253
|
+
return response.text
|
|
254
|
+
|
|
255
|
+
def _parse(self, response: httpx.Response, kind: ResponseKind, response_type: Any, unwrap: bool) -> Any:
|
|
256
|
+
if kind == "void":
|
|
257
|
+
return None
|
|
258
|
+
if kind == "binary":
|
|
259
|
+
return response.content
|
|
260
|
+
try:
|
|
261
|
+
payload: Any = response.json()
|
|
262
|
+
except ValueError as exc:
|
|
263
|
+
raise APIResponseValidationError(response, response.text, message="Response is not valid JSON") from exc
|
|
264
|
+
if unwrap:
|
|
265
|
+
if not isinstance(payload, dict) or "data" not in payload:
|
|
266
|
+
raise APIResponseValidationError(
|
|
267
|
+
response, cast("object", payload), message="Response has no `data` field"
|
|
268
|
+
)
|
|
269
|
+
payload = cast("dict[str, Any]", payload)["data"]
|
|
270
|
+
try:
|
|
271
|
+
return type_adapter(response_type).validate_python(payload)
|
|
272
|
+
except pydantic.ValidationError as exc:
|
|
273
|
+
raise APIResponseValidationError(response, payload, message=validation_summary(exc)) from exc
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _json_bytes(body: object) -> bytes:
|
|
277
|
+
return json.dumps(body, ensure_ascii=False, separators=(",", ":"), allow_nan=False).encode("utf-8")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _timeout_dict(timeout: Timeout) -> dict[str, float | None]:
|
|
281
|
+
resolved = timeout if isinstance(timeout, httpx.Timeout) else httpx.Timeout(timeout)
|
|
282
|
+
return resolved.as_dict()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _parse_retry_after(headers: httpx.Headers) -> float | None:
|
|
286
|
+
milliseconds = headers.get("retry-after-ms")
|
|
287
|
+
if milliseconds is not None:
|
|
288
|
+
try:
|
|
289
|
+
return float(milliseconds) / 1000
|
|
290
|
+
except ValueError:
|
|
291
|
+
pass
|
|
292
|
+
value = headers.get("retry-after")
|
|
293
|
+
if value is None:
|
|
294
|
+
return None
|
|
295
|
+
try:
|
|
296
|
+
return float(value)
|
|
297
|
+
except ValueError:
|
|
298
|
+
parsed = email.utils.parsedate_to_datetime(value) if value else None
|
|
299
|
+
if parsed is None:
|
|
300
|
+
return None
|
|
301
|
+
return max(parsed.timestamp() - time.time(), 0.0)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class SyncAPIClient(_BaseClient):
|
|
305
|
+
"""Synchronous transport. Thread-safe: share one client across threads."""
|
|
306
|
+
|
|
307
|
+
_http: httpx.Client
|
|
308
|
+
|
|
309
|
+
def __init__(
|
|
310
|
+
self,
|
|
311
|
+
*,
|
|
312
|
+
base_url: str | httpx.URL | None,
|
|
313
|
+
password: str | None,
|
|
314
|
+
username: str,
|
|
315
|
+
directory: str | os.PathLike[str] | None,
|
|
316
|
+
timeout: Timeout,
|
|
317
|
+
max_retries: int,
|
|
318
|
+
default_headers: Mapping[str, str] | None,
|
|
319
|
+
default_query: Mapping[str, object] | None,
|
|
320
|
+
http_client: httpx.Client | None,
|
|
321
|
+
transport: httpx.BaseTransport | None = None,
|
|
322
|
+
) -> None:
|
|
323
|
+
super().__init__(
|
|
324
|
+
base_url=base_url,
|
|
325
|
+
password=password,
|
|
326
|
+
username=username,
|
|
327
|
+
directory=directory,
|
|
328
|
+
timeout=timeout,
|
|
329
|
+
max_retries=max_retries,
|
|
330
|
+
default_headers=default_headers,
|
|
331
|
+
default_query=default_query,
|
|
332
|
+
)
|
|
333
|
+
self._owns_http = http_client is None
|
|
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
|
+
)
|
|
339
|
+
|
|
340
|
+
def close(self) -> None:
|
|
341
|
+
"""Closes the connection pool, unless it was passed in by the caller."""
|
|
342
|
+
if self._owns_http:
|
|
343
|
+
self._http.close()
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def is_closed(self) -> bool:
|
|
347
|
+
return self._http.is_closed
|
|
348
|
+
|
|
349
|
+
def __enter__(self) -> Self:
|
|
350
|
+
return self
|
|
351
|
+
|
|
352
|
+
def __exit__(
|
|
353
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
354
|
+
) -> None:
|
|
355
|
+
self.close()
|
|
356
|
+
|
|
357
|
+
def _send(self, options: RequestOptions) -> httpx.Response:
|
|
358
|
+
attempt = 0
|
|
359
|
+
while True:
|
|
360
|
+
request = self._build_request(options)
|
|
361
|
+
log.debug("Request %s %s (attempt %d)", request.method, request.url, attempt + 1)
|
|
362
|
+
try:
|
|
363
|
+
response = self._http.send(request, stream=options.stream)
|
|
364
|
+
except httpx.TransportError as exc:
|
|
365
|
+
if attempt < self.max_retries and self._should_retry_error(options, exc):
|
|
366
|
+
delay = self._retry_delay(attempt, None)
|
|
367
|
+
log.info("Retrying %s %s in %.2fs after %s", request.method, request.url, delay, type(exc).__name__)
|
|
368
|
+
time.sleep(delay)
|
|
369
|
+
attempt += 1
|
|
370
|
+
continue
|
|
371
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
372
|
+
raise APITimeoutError(request) from exc
|
|
373
|
+
raise APIConnectionError(request=request, message=f"Connection error: {exc}") from exc
|
|
374
|
+
|
|
375
|
+
log.debug("Response %s %s -> %d", request.method, request.url, response.status_code)
|
|
376
|
+
if response.is_success:
|
|
377
|
+
return response
|
|
378
|
+
if attempt < self.max_retries and self._should_retry_status(options, response):
|
|
379
|
+
delay = self._retry_delay(attempt, response)
|
|
380
|
+
response.close()
|
|
381
|
+
log.info(
|
|
382
|
+
"Retrying %s %s in %.2fs after status %d", request.method, request.url, delay, response.status_code
|
|
383
|
+
)
|
|
384
|
+
time.sleep(delay)
|
|
385
|
+
attempt += 1
|
|
386
|
+
continue
|
|
387
|
+
if options.stream:
|
|
388
|
+
response.read()
|
|
389
|
+
raise self._status_error(response, self._decode_error_body(response))
|
|
390
|
+
|
|
391
|
+
def request(
|
|
392
|
+
self,
|
|
393
|
+
options: RequestOptions,
|
|
394
|
+
*,
|
|
395
|
+
kind: ResponseKind = "json",
|
|
396
|
+
response_type: Any = object,
|
|
397
|
+
unwrap: bool = False,
|
|
398
|
+
) -> Any:
|
|
399
|
+
"""Sends a request and returns the decoded response."""
|
|
400
|
+
response = self._send(options)
|
|
401
|
+
return self._parse(response, kind, response_type, unwrap)
|
|
402
|
+
|
|
403
|
+
def stream(self, options: RequestOptions, *, item_type: type[_T] | Any) -> Stream[Any]:
|
|
404
|
+
"""Opens a server-sent event stream."""
|
|
405
|
+
options.stream = True
|
|
406
|
+
response = self._send(options)
|
|
407
|
+
content_type = response.headers.get("content-type", "")
|
|
408
|
+
if content_type.split(";", 1)[0].strip().lower() != "text/event-stream":
|
|
409
|
+
response.read()
|
|
410
|
+
response.close()
|
|
411
|
+
raise APIResponseValidationError(
|
|
412
|
+
response, response.text, message=f"Expected an event stream, got {content_type!r}"
|
|
413
|
+
)
|
|
414
|
+
return Stream(response, type_adapter(item_type))
|
|
415
|
+
|
|
416
|
+
def page(self, options: RequestOptions, *, response_type: Any) -> SyncCursorPage[Any]:
|
|
417
|
+
"""Fetches the first page of a cursor-paginated list."""
|
|
418
|
+
from ._pagination import SyncCursorPage
|
|
419
|
+
|
|
420
|
+
return SyncCursorPage.fetch(self, options, response_type)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class AsyncAPIClient(_BaseClient):
|
|
424
|
+
"""Asynchronous transport. Works with asyncio and trio."""
|
|
425
|
+
|
|
426
|
+
_http: httpx.AsyncClient
|
|
427
|
+
|
|
428
|
+
def __init__(
|
|
429
|
+
self,
|
|
430
|
+
*,
|
|
431
|
+
base_url: str | httpx.URL | None,
|
|
432
|
+
password: str | None,
|
|
433
|
+
username: str,
|
|
434
|
+
directory: str | os.PathLike[str] | None,
|
|
435
|
+
timeout: Timeout,
|
|
436
|
+
max_retries: int,
|
|
437
|
+
default_headers: Mapping[str, str] | None,
|
|
438
|
+
default_query: Mapping[str, object] | None,
|
|
439
|
+
http_client: httpx.AsyncClient | None,
|
|
440
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
441
|
+
) -> None:
|
|
442
|
+
super().__init__(
|
|
443
|
+
base_url=base_url,
|
|
444
|
+
password=password,
|
|
445
|
+
username=username,
|
|
446
|
+
directory=directory,
|
|
447
|
+
timeout=timeout,
|
|
448
|
+
max_retries=max_retries,
|
|
449
|
+
default_headers=default_headers,
|
|
450
|
+
default_query=default_query,
|
|
451
|
+
)
|
|
452
|
+
self._owns_http = http_client is None
|
|
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
|
+
)
|
|
458
|
+
|
|
459
|
+
async def close(self) -> None:
|
|
460
|
+
"""Closes the connection pool, unless it was passed in by the caller."""
|
|
461
|
+
if self._owns_http:
|
|
462
|
+
await self._http.aclose()
|
|
463
|
+
|
|
464
|
+
@property
|
|
465
|
+
def is_closed(self) -> bool:
|
|
466
|
+
return self._http.is_closed
|
|
467
|
+
|
|
468
|
+
async def __aenter__(self) -> Self:
|
|
469
|
+
return self
|
|
470
|
+
|
|
471
|
+
async def __aexit__(
|
|
472
|
+
self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None
|
|
473
|
+
) -> None:
|
|
474
|
+
await self.close()
|
|
475
|
+
|
|
476
|
+
async def _send(self, options: RequestOptions) -> httpx.Response:
|
|
477
|
+
attempt = 0
|
|
478
|
+
while True:
|
|
479
|
+
request = self._build_request(options)
|
|
480
|
+
log.debug("Request %s %s (attempt %d)", request.method, request.url, attempt + 1)
|
|
481
|
+
try:
|
|
482
|
+
response = await self._http.send(request, stream=options.stream)
|
|
483
|
+
except httpx.TransportError as exc:
|
|
484
|
+
if attempt < self.max_retries and self._should_retry_error(options, exc):
|
|
485
|
+
delay = self._retry_delay(attempt, None)
|
|
486
|
+
log.info("Retrying %s %s in %.2fs after %s", request.method, request.url, delay, type(exc).__name__)
|
|
487
|
+
await anyio.sleep(delay)
|
|
488
|
+
attempt += 1
|
|
489
|
+
continue
|
|
490
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
491
|
+
raise APITimeoutError(request) from exc
|
|
492
|
+
raise APIConnectionError(request=request, message=f"Connection error: {exc}") from exc
|
|
493
|
+
|
|
494
|
+
log.debug("Response %s %s -> %d", request.method, request.url, response.status_code)
|
|
495
|
+
if response.is_success:
|
|
496
|
+
return response
|
|
497
|
+
if attempt < self.max_retries and self._should_retry_status(options, response):
|
|
498
|
+
delay = self._retry_delay(attempt, response)
|
|
499
|
+
await response.aclose()
|
|
500
|
+
log.info(
|
|
501
|
+
"Retrying %s %s in %.2fs after status %d", request.method, request.url, delay, response.status_code
|
|
502
|
+
)
|
|
503
|
+
await anyio.sleep(delay)
|
|
504
|
+
attempt += 1
|
|
505
|
+
continue
|
|
506
|
+
if options.stream:
|
|
507
|
+
await response.aread()
|
|
508
|
+
raise self._status_error(response, self._decode_error_body(response))
|
|
509
|
+
|
|
510
|
+
async def request(
|
|
511
|
+
self,
|
|
512
|
+
options: RequestOptions,
|
|
513
|
+
*,
|
|
514
|
+
kind: ResponseKind = "json",
|
|
515
|
+
response_type: Any = object,
|
|
516
|
+
unwrap: bool = False,
|
|
517
|
+
) -> Any:
|
|
518
|
+
"""Sends a request and returns the decoded response."""
|
|
519
|
+
response = await self._send(options)
|
|
520
|
+
return self._parse(response, kind, response_type, unwrap)
|
|
521
|
+
|
|
522
|
+
async def stream(self, options: RequestOptions, *, item_type: type[_T] | Any) -> AsyncStream[Any]:
|
|
523
|
+
"""Opens a server-sent event stream."""
|
|
524
|
+
options.stream = True
|
|
525
|
+
response = await self._send(options)
|
|
526
|
+
content_type = response.headers.get("content-type", "")
|
|
527
|
+
if content_type.split(";", 1)[0].strip().lower() != "text/event-stream":
|
|
528
|
+
await response.aread()
|
|
529
|
+
await response.aclose()
|
|
530
|
+
raise APIResponseValidationError(
|
|
531
|
+
response, response.text, message=f"Expected an event stream, got {content_type!r}"
|
|
532
|
+
)
|
|
533
|
+
return AsyncStream(response, type_adapter(item_type))
|
|
534
|
+
|
|
535
|
+
def page(self, options: RequestOptions, *, response_type: Any) -> AsyncPaginator[Any]:
|
|
536
|
+
"""Returns an awaitable, async-iterable paginator over a cursor-paginated list."""
|
|
537
|
+
from ._pagination import AsyncPaginator
|
|
538
|
+
|
|
539
|
+
return AsyncPaginator(self, options, response_type)
|