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/_exceptions.py
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
"""Exception hierarchy.
|
|
2
|
+
|
|
3
|
+
Every error raised by the client derives from :class:`OpencodeError`::
|
|
4
|
+
|
|
5
|
+
OpencodeError
|
|
6
|
+
└── APIError
|
|
7
|
+
├── APIConnectionError
|
|
8
|
+
│ └── APITimeoutError
|
|
9
|
+
├── APIResponseValidationError
|
|
10
|
+
└── APIStatusError
|
|
11
|
+
├── BadRequestError 400
|
|
12
|
+
├── AuthenticationError 401
|
|
13
|
+
├── PermissionDeniedError 403
|
|
14
|
+
├── NotFoundError 404
|
|
15
|
+
├── ConflictError 409
|
|
16
|
+
├── ContentTooLargeError 413
|
|
17
|
+
├── UnprocessableEntityError 422
|
|
18
|
+
├── RateLimitError 429
|
|
19
|
+
└── InternalServerError 5xx
|
|
20
|
+
├── NotImplementedServerError 501
|
|
21
|
+
└── ServiceUnavailableError 503
|
|
22
|
+
|
|
23
|
+
The server tags each declared error with a ``_tag`` such as ``"SessionNotFoundError"``. Tagged errors are raised
|
|
24
|
+
as the matching subclass from :mod:`opencode._errors` (for example ``SessionNotFoundError(NotFoundError)``), so
|
|
25
|
+
callers can catch a precise error or any error of that status.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import TYPE_CHECKING, Any, ClassVar, TypeVar, cast
|
|
31
|
+
|
|
32
|
+
import pydantic
|
|
33
|
+
from typing_extensions import override
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
import httpx
|
|
37
|
+
|
|
38
|
+
_M = TypeVar("_M", bound=pydantic.BaseModel)
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"APIConnectionError",
|
|
42
|
+
"APIError",
|
|
43
|
+
"APIResponseValidationError",
|
|
44
|
+
"APIStatusError",
|
|
45
|
+
"APITimeoutError",
|
|
46
|
+
"AuthenticationError",
|
|
47
|
+
"BadRequestError",
|
|
48
|
+
"ConflictError",
|
|
49
|
+
"ContentTooLargeError",
|
|
50
|
+
"InternalServerError",
|
|
51
|
+
"NotFoundError",
|
|
52
|
+
"NotImplementedServerError",
|
|
53
|
+
"OpencodeError",
|
|
54
|
+
"PermissionDeniedError",
|
|
55
|
+
"RateLimitError",
|
|
56
|
+
"ServiceUnavailableError",
|
|
57
|
+
"UnprocessableEntityError",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OpencodeError(Exception):
|
|
62
|
+
"""Base class for every error raised by this package."""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class APIError(OpencodeError):
|
|
66
|
+
"""An error that happened while talking to the OpenCode server."""
|
|
67
|
+
|
|
68
|
+
message: str
|
|
69
|
+
request: httpx.Request
|
|
70
|
+
body: object | None
|
|
71
|
+
"""The decoded JSON error body, the raw text when it is not JSON, or ``None``."""
|
|
72
|
+
|
|
73
|
+
def __init__(self, message: str, request: httpx.Request, *, body: object | None) -> None:
|
|
74
|
+
super().__init__(message)
|
|
75
|
+
self.message = message
|
|
76
|
+
self.request = request
|
|
77
|
+
self.body = body
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class APIConnectionError(APIError):
|
|
81
|
+
"""The request could not reach the server, or the connection broke before a response arrived."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, *, request: httpx.Request, message: str = "Connection error.") -> None:
|
|
84
|
+
super().__init__(message, request, body=None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class APITimeoutError(APIConnectionError):
|
|
88
|
+
"""The request timed out."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, request: httpx.Request) -> None:
|
|
91
|
+
super().__init__(request=request, message="Request timed out.")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class APIResponseValidationError(APIError):
|
|
95
|
+
"""The server answered, but the response does not match the expected shape."""
|
|
96
|
+
|
|
97
|
+
response: httpx.Response
|
|
98
|
+
status_code: int
|
|
99
|
+
|
|
100
|
+
def __init__(self, response: httpx.Response, body: object | None, *, message: str | None = None) -> None:
|
|
101
|
+
super().__init__(
|
|
102
|
+
message or "The server response did not match the expected schema.", response.request, body=body
|
|
103
|
+
)
|
|
104
|
+
self.response = response
|
|
105
|
+
self.status_code = response.status_code
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class APIStatusError(APIError):
|
|
109
|
+
"""The server returned a non-success HTTP status."""
|
|
110
|
+
|
|
111
|
+
status: ClassVar[int | None] = None
|
|
112
|
+
"""The HTTP status this class represents, or ``None`` for the generic base."""
|
|
113
|
+
|
|
114
|
+
tag: ClassVar[str | None] = None
|
|
115
|
+
"""The server ``_tag`` this class represents, or ``None`` for status-only classes."""
|
|
116
|
+
|
|
117
|
+
response: httpx.Response
|
|
118
|
+
status_code: int
|
|
119
|
+
error_tag: str | None
|
|
120
|
+
"""The ``_tag`` the server sent, which can be a tag this client version does not know yet."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, message: str, *, response: httpx.Response, body: object | None) -> None:
|
|
123
|
+
super().__init__(message, response.request, body=body)
|
|
124
|
+
self.response = response
|
|
125
|
+
self.status_code = response.status_code
|
|
126
|
+
self.error_tag = _string_field(body, "_tag")
|
|
127
|
+
|
|
128
|
+
def _typed_body(self, model: type[_M]) -> _M:
|
|
129
|
+
raw: object = self.body
|
|
130
|
+
body: dict[str, Any] = cast("dict[str, Any]", raw) if isinstance(raw, dict) else {}
|
|
131
|
+
try:
|
|
132
|
+
return model.model_validate(body)
|
|
133
|
+
except pydantic.ValidationError:
|
|
134
|
+
# Keep the error usable even when the body does not match this client's schema.
|
|
135
|
+
return model.model_construct(**body)
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def request_id(self) -> str | None:
|
|
139
|
+
"""The server's request identifier, when it sends one."""
|
|
140
|
+
value: str | None = self.response.headers.get("x-request-id")
|
|
141
|
+
return value
|
|
142
|
+
|
|
143
|
+
@override
|
|
144
|
+
def __str__(self) -> str:
|
|
145
|
+
detail = f"{self.status_code} {self.error_tag}" if self.error_tag else str(self.status_code)
|
|
146
|
+
return f"{self.message} ({detail})"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class BadRequestError(APIStatusError):
|
|
150
|
+
"""The server rejected the request as malformed (400)."""
|
|
151
|
+
|
|
152
|
+
status = 400
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class AuthenticationError(APIStatusError):
|
|
156
|
+
"""The password was missing or wrong (401)."""
|
|
157
|
+
|
|
158
|
+
status = 401
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class PermissionDeniedError(APIStatusError):
|
|
162
|
+
"""The request is not allowed (403)."""
|
|
163
|
+
|
|
164
|
+
status = 403
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class NotFoundError(APIStatusError):
|
|
168
|
+
"""The requested resource does not exist (404)."""
|
|
169
|
+
|
|
170
|
+
status = 404
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class ConflictError(APIStatusError):
|
|
174
|
+
"""The request conflicts with the current state, for example a busy session (409)."""
|
|
175
|
+
|
|
176
|
+
status = 409
|
|
177
|
+
tag = "ConflictError"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class ContentTooLargeError(APIStatusError):
|
|
181
|
+
"""The request body is too large (413)."""
|
|
182
|
+
|
|
183
|
+
status = 413
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
class UnprocessableEntityError(APIStatusError):
|
|
187
|
+
"""The request was well formed but could not be processed (422)."""
|
|
188
|
+
|
|
189
|
+
status = 422
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class RateLimitError(APIStatusError):
|
|
193
|
+
"""Too many requests (429). Retried automatically, honoring ``Retry-After``."""
|
|
194
|
+
|
|
195
|
+
status = 429
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class InternalServerError(APIStatusError):
|
|
199
|
+
"""The server failed (500, and the base class for every 5xx)."""
|
|
200
|
+
|
|
201
|
+
status = 500
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class NotImplementedServerError(InternalServerError):
|
|
205
|
+
"""The server does not implement this endpoint (501)."""
|
|
206
|
+
|
|
207
|
+
status = 501
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ServiceUnavailableError(InternalServerError):
|
|
211
|
+
"""The server is temporarily unavailable (503). Retried automatically."""
|
|
212
|
+
|
|
213
|
+
status = 503
|
|
214
|
+
tag = "ServiceUnavailableError"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
_STATUS_ERRORS: dict[int, type[APIStatusError]] = {
|
|
218
|
+
400: BadRequestError,
|
|
219
|
+
401: AuthenticationError,
|
|
220
|
+
403: PermissionDeniedError,
|
|
221
|
+
404: NotFoundError,
|
|
222
|
+
409: ConflictError,
|
|
223
|
+
413: ContentTooLargeError,
|
|
224
|
+
422: UnprocessableEntityError,
|
|
225
|
+
429: RateLimitError,
|
|
226
|
+
500: InternalServerError,
|
|
227
|
+
501: NotImplementedServerError,
|
|
228
|
+
503: ServiceUnavailableError,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def status_error_class(
|
|
233
|
+
status_code: int, tag: str | None, tagged: dict[str, type[APIStatusError]]
|
|
234
|
+
) -> type[APIStatusError]:
|
|
235
|
+
"""Picks the most specific error class for a status code and server tag."""
|
|
236
|
+
# The tag is the server's own classification, so it wins over the status code.
|
|
237
|
+
if tag is not None and tag in tagged:
|
|
238
|
+
return tagged[tag]
|
|
239
|
+
if status_code in _STATUS_ERRORS:
|
|
240
|
+
return _STATUS_ERRORS[status_code]
|
|
241
|
+
if status_code >= 500:
|
|
242
|
+
return InternalServerError
|
|
243
|
+
return APIStatusError
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def error_message(body: object | None, status_code: int) -> str:
|
|
247
|
+
"""Extracts a human-readable message from an error body."""
|
|
248
|
+
message = _string_field(body, "message")
|
|
249
|
+
if message is None and isinstance(body, dict):
|
|
250
|
+
message = _string_field(cast("dict[str, Any]", body).get("data"), "message")
|
|
251
|
+
if message:
|
|
252
|
+
return message
|
|
253
|
+
if isinstance(body, str) and body.strip():
|
|
254
|
+
return body.strip()[:500]
|
|
255
|
+
return f"Request failed with status {status_code}"
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def validation_summary(error: pydantic.ValidationError, limit: int = 5) -> str:
|
|
259
|
+
"""Describes a validation failure by location and reason only, never echoing response values."""
|
|
260
|
+
issues = error.errors(include_input=False, include_url=False)
|
|
261
|
+
parts = [f"{'.'.join(str(item) for item in issue['loc']) or '<root>'}: {issue['msg']}" for issue in issues[:limit]]
|
|
262
|
+
more = f" (+{len(issues) - limit} more)" if len(issues) > limit else ""
|
|
263
|
+
return f"Response did not match the expected schema: {'; '.join(parts)}{more}"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _string_field(body: object | None, key: str) -> str | None:
|
|
267
|
+
if isinstance(body, dict):
|
|
268
|
+
value: object = cast("dict[str, Any]", body).get(key)
|
|
269
|
+
if isinstance(value, str):
|
|
270
|
+
return value
|
|
271
|
+
return None
|
opencode/_models.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Base model for every OpenCode API type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
import pydantic
|
|
8
|
+
from pydantic import ConfigDict
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
__all__ = ["BaseModel", "is_none"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_none(value: object) -> bool:
|
|
15
|
+
"""Field ``exclude_if`` predicate: optional fields that are not nullable are left out instead of sent as null."""
|
|
16
|
+
return value is None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BaseModel(pydantic.BaseModel):
|
|
20
|
+
"""Base class for OpenCode API models.
|
|
21
|
+
|
|
22
|
+
Attributes use ``snake_case`` while the wire format uses OpenCode's ``camelCase`` names. Models accept either
|
|
23
|
+
spelling on input and always serialize to the wire names.
|
|
24
|
+
|
|
25
|
+
Unknown fields are kept rather than rejected, so a client keeps working against a newer server. They are
|
|
26
|
+
available through ``model_extra`` and attribute access.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
model_config = ConfigDict(
|
|
30
|
+
extra="allow",
|
|
31
|
+
validate_by_name=True,
|
|
32
|
+
validate_by_alias=True,
|
|
33
|
+
serialize_by_alias=True,
|
|
34
|
+
defer_build=True,
|
|
35
|
+
protected_namespaces=(),
|
|
36
|
+
use_enum_values=True,
|
|
37
|
+
coerce_numbers_to_str=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
__opencode_always_set__: ClassVar[tuple[str, ...]] = ()
|
|
41
|
+
"""Fields serialized even when left at their default, such as a union member's ``type`` tag."""
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
def model_post_init(self, context: Any, /) -> None:
|
|
45
|
+
if self.__opencode_always_set__:
|
|
46
|
+
self.__pydantic_fields_set__.update(self.__opencode_always_set__)
|
|
47
|
+
|
|
48
|
+
def to_dict(self, *, exclude_unset: bool = True) -> dict[str, Any]:
|
|
49
|
+
"""Returns the model as a JSON-compatible dict using wire field names."""
|
|
50
|
+
return self.model_dump(mode="json", by_alias=True, exclude_unset=exclude_unset)
|
|
51
|
+
|
|
52
|
+
def to_json(self, *, indent: int | None = None, exclude_unset: bool = True) -> str:
|
|
53
|
+
"""Returns the model as JSON text using wire field names."""
|
|
54
|
+
return self.model_dump_json(by_alias=True, exclude_unset=exclude_unset, indent=indent)
|
opencode/_pagination.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Cursor pagination.
|
|
2
|
+
|
|
3
|
+
OpenCode list endpoints return ``{"data": [...], "cursor": {"previous": ..., "next": ...}}``. A page iterates over
|
|
4
|
+
every item of every following page, fetching lazily::
|
|
5
|
+
|
|
6
|
+
for message in client.message.list("ses_..."):
|
|
7
|
+
...
|
|
8
|
+
|
|
9
|
+
Use :meth:`SyncCursorPage.iter_pages` or the cursor properties for manual control.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import dataclasses
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Generic, TypeVar
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import AsyncIterator, Generator, Iterator
|
|
19
|
+
|
|
20
|
+
from ._base_client import AsyncAPIClient, RequestOptions, SyncAPIClient
|
|
21
|
+
|
|
22
|
+
__all__ = ["AsyncCursorPage", "AsyncPaginator", "SyncCursorPage"]
|
|
23
|
+
|
|
24
|
+
_T = TypeVar("_T")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _page_parts(payload: Any) -> tuple[list[Any], str | None, str | None]:
|
|
28
|
+
cursor = payload.cursor
|
|
29
|
+
return list(payload.data), getattr(cursor, "next", None), getattr(cursor, "previous", None)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _follow(options: RequestOptions, cursor: str) -> RequestOptions:
|
|
33
|
+
query = {key: value for key, value in options.query.items() if key != "order"}
|
|
34
|
+
# The server rejects `order` together with a cursor: the cursor already encodes the direction.
|
|
35
|
+
query["cursor"] = cursor
|
|
36
|
+
return dataclasses.replace(options, query=query)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _PageBase(Generic[_T]):
|
|
40
|
+
data: list[_T]
|
|
41
|
+
"""Items on this page."""
|
|
42
|
+
next_cursor: str | None
|
|
43
|
+
"""Cursor for the following page, or ``None`` on the last page."""
|
|
44
|
+
previous_cursor: str | None
|
|
45
|
+
"""Cursor for the preceding page, or ``None`` on the first page."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self, *, data: list[_T], next_cursor: str | None, previous_cursor: str | None, options: RequestOptions
|
|
49
|
+
) -> None:
|
|
50
|
+
self.data = data
|
|
51
|
+
self.next_cursor = next_cursor
|
|
52
|
+
self.previous_cursor = previous_cursor
|
|
53
|
+
self._options = options
|
|
54
|
+
|
|
55
|
+
def has_next_page(self) -> bool:
|
|
56
|
+
return bool(self.next_cursor)
|
|
57
|
+
|
|
58
|
+
def __len__(self) -> int:
|
|
59
|
+
return len(self.data)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SyncCursorPage(_PageBase[_T]):
|
|
63
|
+
"""One page of results. Iterating it walks this page and every page after it."""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
client: SyncAPIClient,
|
|
69
|
+
data: list[_T],
|
|
70
|
+
next_cursor: str | None,
|
|
71
|
+
previous_cursor: str | None,
|
|
72
|
+
options: RequestOptions,
|
|
73
|
+
response_type: Any,
|
|
74
|
+
) -> None:
|
|
75
|
+
super().__init__(data=data, next_cursor=next_cursor, previous_cursor=previous_cursor, options=options)
|
|
76
|
+
self._client = client
|
|
77
|
+
self._response_type = response_type
|
|
78
|
+
|
|
79
|
+
@classmethod
|
|
80
|
+
def fetch(cls, client: SyncAPIClient, options: RequestOptions, response_type: Any) -> SyncCursorPage[Any]:
|
|
81
|
+
payload = client.request(options, response_type=response_type)
|
|
82
|
+
data, next_cursor, previous_cursor = _page_parts(payload)
|
|
83
|
+
return cls(
|
|
84
|
+
client=client,
|
|
85
|
+
data=data,
|
|
86
|
+
next_cursor=next_cursor,
|
|
87
|
+
previous_cursor=previous_cursor,
|
|
88
|
+
options=options,
|
|
89
|
+
response_type=response_type,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def next_page(self) -> SyncCursorPage[_T]:
|
|
93
|
+
"""Fetches the following page. Raises ``StopIteration`` on the last page."""
|
|
94
|
+
if not self.next_cursor:
|
|
95
|
+
raise StopIteration("No more pages")
|
|
96
|
+
return SyncCursorPage.fetch(self._client, _follow(self._options, self.next_cursor), self._response_type)
|
|
97
|
+
|
|
98
|
+
def previous_page(self) -> SyncCursorPage[_T]:
|
|
99
|
+
"""Fetches the preceding page. Raises ``StopIteration`` on the first page."""
|
|
100
|
+
if not self.previous_cursor:
|
|
101
|
+
raise StopIteration("No previous page")
|
|
102
|
+
return SyncCursorPage.fetch(self._client, _follow(self._options, self.previous_cursor), self._response_type)
|
|
103
|
+
|
|
104
|
+
def iter_pages(self) -> Generator[SyncCursorPage[_T], None, None]:
|
|
105
|
+
"""Yields this page and then each following page."""
|
|
106
|
+
page: SyncCursorPage[_T] = self
|
|
107
|
+
yield page
|
|
108
|
+
while page.has_next_page():
|
|
109
|
+
page = page.next_page()
|
|
110
|
+
yield page
|
|
111
|
+
|
|
112
|
+
def __iter__(self) -> Iterator[_T]:
|
|
113
|
+
for page in self.iter_pages():
|
|
114
|
+
yield from page.data
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AsyncCursorPage(_PageBase[_T]):
|
|
118
|
+
"""One page of results. ``async for`` walks this page and every page after it."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
client: AsyncAPIClient,
|
|
124
|
+
data: list[_T],
|
|
125
|
+
next_cursor: str | None,
|
|
126
|
+
previous_cursor: str | None,
|
|
127
|
+
options: RequestOptions,
|
|
128
|
+
response_type: Any,
|
|
129
|
+
) -> None:
|
|
130
|
+
super().__init__(data=data, next_cursor=next_cursor, previous_cursor=previous_cursor, options=options)
|
|
131
|
+
self._client = client
|
|
132
|
+
self._response_type = response_type
|
|
133
|
+
|
|
134
|
+
@classmethod
|
|
135
|
+
async def fetch(cls, client: AsyncAPIClient, options: RequestOptions, response_type: Any) -> AsyncCursorPage[Any]:
|
|
136
|
+
payload = await client.request(options, response_type=response_type)
|
|
137
|
+
data, next_cursor, previous_cursor = _page_parts(payload)
|
|
138
|
+
return cls(
|
|
139
|
+
client=client,
|
|
140
|
+
data=data,
|
|
141
|
+
next_cursor=next_cursor,
|
|
142
|
+
previous_cursor=previous_cursor,
|
|
143
|
+
options=options,
|
|
144
|
+
response_type=response_type,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
async def next_page(self) -> AsyncCursorPage[_T]:
|
|
148
|
+
"""Fetches the following page. Raises ``StopAsyncIteration`` on the last page."""
|
|
149
|
+
if not self.next_cursor:
|
|
150
|
+
raise StopAsyncIteration("No more pages")
|
|
151
|
+
return await AsyncCursorPage.fetch(self._client, _follow(self._options, self.next_cursor), self._response_type)
|
|
152
|
+
|
|
153
|
+
async def previous_page(self) -> AsyncCursorPage[_T]:
|
|
154
|
+
"""Fetches the preceding page. Raises ``StopAsyncIteration`` on the first page."""
|
|
155
|
+
if not self.previous_cursor:
|
|
156
|
+
raise StopAsyncIteration("No previous page")
|
|
157
|
+
return await AsyncCursorPage.fetch(
|
|
158
|
+
self._client, _follow(self._options, self.previous_cursor), self._response_type
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def iter_pages(self) -> AsyncIterator[AsyncCursorPage[_T]]:
|
|
162
|
+
"""Yields this page and then each following page."""
|
|
163
|
+
page: AsyncCursorPage[_T] = self
|
|
164
|
+
yield page
|
|
165
|
+
while page.has_next_page():
|
|
166
|
+
page = await page.next_page()
|
|
167
|
+
yield page
|
|
168
|
+
|
|
169
|
+
async def __aiter__(self) -> AsyncIterator[_T]:
|
|
170
|
+
async for page in self.iter_pages():
|
|
171
|
+
for item in page.data:
|
|
172
|
+
yield item
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class AsyncPaginator(Generic[_T]):
|
|
176
|
+
"""Returned by async list methods.
|
|
177
|
+
|
|
178
|
+
``await`` it for the first page, or ``async for`` over it to walk every item across pages.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(self, client: AsyncAPIClient, options: RequestOptions, response_type: Any) -> None:
|
|
182
|
+
self._client = client
|
|
183
|
+
self._options = options
|
|
184
|
+
self._response_type = response_type
|
|
185
|
+
|
|
186
|
+
def __await__(self) -> Generator[Any, None, AsyncCursorPage[_T]]:
|
|
187
|
+
return AsyncCursorPage.fetch(self._client, self._options, self._response_type).__await__()
|
|
188
|
+
|
|
189
|
+
async def __aiter__(self) -> AsyncIterator[_T]:
|
|
190
|
+
page: AsyncCursorPage[_T] = await self
|
|
191
|
+
async for item in page:
|
|
192
|
+
yield item
|