b3os 0.0.1__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.
- b3os/__init__.py +64 -0
- b3os/_async.py +563 -0
- b3os/_base.py +486 -0
- b3os/_client.py +163 -0
- b3os/_errors.py +220 -0
- b3os/_generated/__init__.py +2 -0
- b3os/_generated/async_namespaces.py +1747 -0
- b3os/_generated/clients.py +174 -0
- b3os/_generated/errors.py +317 -0
- b3os/_generated/namespaces.py +1747 -0
- b3os/_generated/ops.py +167 -0
- b3os/_generated/types.py +3887 -0
- b3os/_ops.py +34 -0
- b3os/_options.py +45 -0
- b3os/_pty.py +222 -0
- b3os/_response.py +92 -0
- b3os/_sse.py +93 -0
- b3os/_sync.py +580 -0
- b3os/_timing.py +102 -0
- b3os/_util.py +57 -0
- b3os/_webhooks.py +69 -0
- b3os/py.typed +0 -0
- b3os-0.0.1.dist-info/METADATA +152 -0
- b3os-0.0.1.dist-info/RECORD +26 -0
- b3os-0.0.1.dist-info/WHEEL +4 -0
- b3os-0.0.1.dist-info/licenses/LICENSE +202 -0
b3os/__init__.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""b3os: the Python SDK for B3OS beta.
|
|
2
|
+
|
|
3
|
+
from b3os import B3OS
|
|
4
|
+
b3 = B3OS() # B3OS_API_KEY, or the node socket / sandbox URL
|
|
5
|
+
page = b3.vision.scrape({"url": "https://example.com"})
|
|
6
|
+
sbx = b3.sandboxes.create_sandbox({"template": "base"})
|
|
7
|
+
out = b3.sandboxes.exec(sbx["id"], {"cmd": "python3 -c 'print(2 + 2)'"})
|
|
8
|
+
|
|
9
|
+
Async: `from b3os import AsyncB3OS`, same namespaces with `await`.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from ._async import AsyncEventStream, AsyncJobEventStream, AsyncPage
|
|
13
|
+
from ._base import DEFAULT_BASE_URL, DEFAULT_SOCKET_PATH
|
|
14
|
+
from ._errors import CLIENT_ERROR_CODES, B3OSConnectionError, B3OSError, B3OSJobError, B3OSTimeoutError
|
|
15
|
+
from ._generated import types
|
|
16
|
+
from ._generated.clients import AsyncB3OS, B3OS
|
|
17
|
+
from ._generated.errors import ERROR_CATALOG
|
|
18
|
+
from ._options import CdpEndpoint, RequestOptions, TicketedUrl
|
|
19
|
+
from ._pty import AsyncPtySession, PtyExit, PtySession
|
|
20
|
+
from ._response import ResponseInfo, response_of
|
|
21
|
+
from ._sse import SSEDecoder, SSEMessage
|
|
22
|
+
from ._sync import EventStream, JobEventStream, Page
|
|
23
|
+
from ._timing import ServerTiming, ServerTimingEntry, parse_server_timing
|
|
24
|
+
from ._util import SDK_VERSION
|
|
25
|
+
from ._webhooks import WebhookVerificationError, sign_webhook, verify_webhook
|
|
26
|
+
|
|
27
|
+
__version__ = SDK_VERSION
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"B3OS",
|
|
31
|
+
"AsyncB3OS",
|
|
32
|
+
"B3OSError",
|
|
33
|
+
"B3OSConnectionError",
|
|
34
|
+
"B3OSTimeoutError",
|
|
35
|
+
"B3OSJobError",
|
|
36
|
+
"CLIENT_ERROR_CODES",
|
|
37
|
+
"ERROR_CATALOG",
|
|
38
|
+
"EventStream",
|
|
39
|
+
"JobEventStream",
|
|
40
|
+
"AsyncEventStream",
|
|
41
|
+
"AsyncJobEventStream",
|
|
42
|
+
"Page",
|
|
43
|
+
"AsyncPage",
|
|
44
|
+
"RequestOptions",
|
|
45
|
+
"ResponseInfo",
|
|
46
|
+
"response_of",
|
|
47
|
+
"ServerTiming",
|
|
48
|
+
"ServerTimingEntry",
|
|
49
|
+
"parse_server_timing",
|
|
50
|
+
"SSEDecoder",
|
|
51
|
+
"SSEMessage",
|
|
52
|
+
"TicketedUrl",
|
|
53
|
+
"CdpEndpoint",
|
|
54
|
+
"PtySession",
|
|
55
|
+
"AsyncPtySession",
|
|
56
|
+
"PtyExit",
|
|
57
|
+
"verify_webhook",
|
|
58
|
+
"sign_webhook",
|
|
59
|
+
"WebhookVerificationError",
|
|
60
|
+
"DEFAULT_BASE_URL",
|
|
61
|
+
"DEFAULT_SOCKET_PATH",
|
|
62
|
+
"SDK_VERSION",
|
|
63
|
+
"types",
|
|
64
|
+
]
|
b3os/_async.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""The asynchronous client core (httpx.AsyncClient; asyncio or trio through anyio). Same behavior as _sync.py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Generic, Iterable, Mapping, Optional, Sequence, Tuple, TypeVar, Union
|
|
9
|
+
|
|
10
|
+
import anyio
|
|
11
|
+
import httpx
|
|
12
|
+
|
|
13
|
+
from ._base import (
|
|
14
|
+
TERMINAL_JOB_EVENTS,
|
|
15
|
+
Config,
|
|
16
|
+
Route,
|
|
17
|
+
build_path,
|
|
18
|
+
build_query,
|
|
19
|
+
encode_body,
|
|
20
|
+
error_from_response,
|
|
21
|
+
idempotency_key,
|
|
22
|
+
is_json,
|
|
23
|
+
is_terminal_job,
|
|
24
|
+
job_failure,
|
|
25
|
+
job_id_of,
|
|
26
|
+
job_ops_for,
|
|
27
|
+
per_attempt_timeout,
|
|
28
|
+
request_headers,
|
|
29
|
+
retry_safe,
|
|
30
|
+
should_retry,
|
|
31
|
+
)
|
|
32
|
+
from ._errors import B3OSConnectionError, B3OSError, B3OSJobError, B3OSTimeoutError
|
|
33
|
+
from ._ops import JobOps, Op
|
|
34
|
+
from ._response import ResponseInfo, ResultDict, attach
|
|
35
|
+
from ._sse import SSEDecoder, SSEMessage
|
|
36
|
+
from ._sync import _CONNECT_ERRORS, _connect_refused
|
|
37
|
+
from ._util import backoff, new_idempotency_key, parse_retry_after
|
|
38
|
+
|
|
39
|
+
T = TypeVar("T")
|
|
40
|
+
E = TypeVar("E")
|
|
41
|
+
J = TypeVar("J")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AsyncPage(ResultDict, Generic[T]):
|
|
45
|
+
"""One page of a list (a dict). `async for x in page.auto_paging_iter()` walks every item."""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("_next",)
|
|
48
|
+
_next: Callable[[str], Any]
|
|
49
|
+
|
|
50
|
+
async def auto_paging_iter(self) -> AsyncIterator[T]:
|
|
51
|
+
page: AsyncPage[T] = self
|
|
52
|
+
while True:
|
|
53
|
+
for item in page.get("data", []):
|
|
54
|
+
yield item
|
|
55
|
+
cursor = page.get("next_cursor")
|
|
56
|
+
if not cursor:
|
|
57
|
+
return
|
|
58
|
+
page = await page._next(cursor)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class AsyncEventStream(Generic[E]):
|
|
62
|
+
"""`async for ev in stream: ...`, once. Resumes after drops with Last-Event-ID where the stream allows it."""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
open_fn: Callable[[int, Optional[str], Any], Awaitable[httpx.Response]],
|
|
67
|
+
*,
|
|
68
|
+
first: Optional[httpx.Response] = None,
|
|
69
|
+
is_terminal: Optional[Callable[[Any], bool]] = None,
|
|
70
|
+
resumable: bool,
|
|
71
|
+
reconnect_on_end: bool = False,
|
|
72
|
+
max_reconnects: int = 6,
|
|
73
|
+
done_sentinel: bool = False,
|
|
74
|
+
operation_id: str = "",
|
|
75
|
+
) -> None:
|
|
76
|
+
self._open = open_fn
|
|
77
|
+
self._first = first
|
|
78
|
+
self._is_terminal = is_terminal
|
|
79
|
+
self._resumable = resumable
|
|
80
|
+
self._reconnect_on_end = reconnect_on_end
|
|
81
|
+
self._max_reconnects = max_reconnects
|
|
82
|
+
self._done_sentinel = done_sentinel
|
|
83
|
+
self._op = operation_id
|
|
84
|
+
self._resp: Optional[httpx.Response] = None
|
|
85
|
+
self._closed = False
|
|
86
|
+
self._iterating = False
|
|
87
|
+
self.last_event_id: Optional[str] = None
|
|
88
|
+
self.last_event: Optional[E] = None
|
|
89
|
+
self.completed = False
|
|
90
|
+
|
|
91
|
+
async def aclose(self) -> None:
|
|
92
|
+
self._closed = True
|
|
93
|
+
r = self._resp or self._first
|
|
94
|
+
if r is not None:
|
|
95
|
+
await r.aclose()
|
|
96
|
+
|
|
97
|
+
async def __aenter__(self) -> "AsyncEventStream[E]":
|
|
98
|
+
return self
|
|
99
|
+
|
|
100
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
101
|
+
await self.aclose()
|
|
102
|
+
|
|
103
|
+
def _parse(self, msg: SSEMessage) -> Any:
|
|
104
|
+
try:
|
|
105
|
+
return json.loads(msg.data)
|
|
106
|
+
except ValueError as e:
|
|
107
|
+
raise B3OSError("invalid_response", detail=f"Could not parse the {msg.event} event: {e}", operation_id=self._op)
|
|
108
|
+
|
|
109
|
+
async def __aiter__(self) -> AsyncIterator[E]:
|
|
110
|
+
if self._iterating:
|
|
111
|
+
raise RuntimeError("b3os: an AsyncEventStream can be iterated only once")
|
|
112
|
+
self._iterating = True
|
|
113
|
+
attempt = 0
|
|
114
|
+
failures = 0
|
|
115
|
+
retry_ms: Optional[int] = None
|
|
116
|
+
while not self._closed:
|
|
117
|
+
if attempt == 0 and self._first is not None:
|
|
118
|
+
resp = self._first
|
|
119
|
+
else:
|
|
120
|
+
try:
|
|
121
|
+
resp = await self._open(attempt, self.last_event_id, self.last_event)
|
|
122
|
+
except B3OSError as e:
|
|
123
|
+
if attempt == 0 or not self._resumable or not (e.retryable or e.code == "connection_error"):
|
|
124
|
+
raise
|
|
125
|
+
failures += 1
|
|
126
|
+
if failures > self._max_reconnects:
|
|
127
|
+
raise B3OSError("stream_interrupted", detail=f"Reconnecting failed {failures} times", operation_id=self._op)
|
|
128
|
+
attempt += 1
|
|
129
|
+
await anyio.sleep((retry_ms / 1000) if retry_ms is not None else backoff(failures - 1, 0.5, 10))
|
|
130
|
+
continue
|
|
131
|
+
self._resp = resp
|
|
132
|
+
decoder = SSEDecoder()
|
|
133
|
+
utf8 = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
134
|
+
got = False
|
|
135
|
+
broken: Optional[BaseException] = None
|
|
136
|
+
idle = False
|
|
137
|
+
try:
|
|
138
|
+
async for chunk in resp.aiter_bytes():
|
|
139
|
+
for msg in decoder.feed(utf8.decode(chunk)):
|
|
140
|
+
if msg.retry is not None:
|
|
141
|
+
retry_ms = msg.retry
|
|
142
|
+
if msg.id is not None:
|
|
143
|
+
self.last_event_id = msg.id
|
|
144
|
+
if self._done_sentinel and msg.data.strip() == "[DONE]":
|
|
145
|
+
self.completed = True
|
|
146
|
+
return
|
|
147
|
+
ev = self._parse(msg)
|
|
148
|
+
got = True
|
|
149
|
+
failures = 0
|
|
150
|
+
self.last_event = ev
|
|
151
|
+
terminal = bool(self._is_terminal and self._is_terminal(ev))
|
|
152
|
+
if terminal:
|
|
153
|
+
self.completed = True
|
|
154
|
+
yield ev
|
|
155
|
+
if terminal or self._closed:
|
|
156
|
+
return
|
|
157
|
+
except httpx.ReadTimeout as e:
|
|
158
|
+
idle, broken = True, e
|
|
159
|
+
except (httpx.TransportError, httpx.StreamError) as e:
|
|
160
|
+
broken = e
|
|
161
|
+
finally:
|
|
162
|
+
await resp.aclose()
|
|
163
|
+
if self._closed:
|
|
164
|
+
return
|
|
165
|
+
if broken is not None:
|
|
166
|
+
if not self._resumable:
|
|
167
|
+
raise B3OSError(
|
|
168
|
+
"timeout" if idle else "stream_interrupted",
|
|
169
|
+
detail=("No data within the idle timeout" if idle else f"The event stream broke: {broken}"),
|
|
170
|
+
operation_id=self._op,
|
|
171
|
+
)
|
|
172
|
+
failures += 1
|
|
173
|
+
if failures > self._max_reconnects:
|
|
174
|
+
raise B3OSError("stream_interrupted", detail=f"The event stream broke {failures} times: {broken}", operation_id=self._op)
|
|
175
|
+
else:
|
|
176
|
+
if not self._resumable or not self._reconnect_on_end:
|
|
177
|
+
return
|
|
178
|
+
if not got:
|
|
179
|
+
failures += 1
|
|
180
|
+
if failures > self._max_reconnects:
|
|
181
|
+
raise B3OSError("stream_interrupted", detail="The event stream kept closing without events", operation_id=self._op)
|
|
182
|
+
attempt += 1
|
|
183
|
+
delay = (retry_ms / 1000) if retry_ms is not None else (0.0 if got else backoff(max(0, failures - 1), 0.5, 10))
|
|
184
|
+
if delay:
|
|
185
|
+
await anyio.sleep(delay)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class AsyncJobEventStream(AsyncEventStream[E], Generic[E, J]):
|
|
189
|
+
def __init__(self, *args: Any, final_job: Callable[[Any], Awaitable[J]], **kw: Any) -> None:
|
|
190
|
+
super().__init__(*args, **kw)
|
|
191
|
+
self._final_job = final_job
|
|
192
|
+
self._result: Optional[J] = None
|
|
193
|
+
|
|
194
|
+
async def __aiter__(self) -> AsyncIterator[E]:
|
|
195
|
+
async for ev in super().__aiter__():
|
|
196
|
+
if isinstance(ev, dict) and ev.get("type") in TERMINAL_JOB_EVENTS:
|
|
197
|
+
self._result = await self._final_job(ev)
|
|
198
|
+
yield ev
|
|
199
|
+
|
|
200
|
+
@property
|
|
201
|
+
def result(self) -> Optional[J]:
|
|
202
|
+
return self._result
|
|
203
|
+
|
|
204
|
+
async def final(self) -> J:
|
|
205
|
+
if not self._iterating:
|
|
206
|
+
async for _ in self:
|
|
207
|
+
pass
|
|
208
|
+
if self._result is None:
|
|
209
|
+
raise B3OSError("stream_interrupted", detail="The event stream ended before the job finished", operation_id=self._op)
|
|
210
|
+
return self._result
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class AsyncCore:
|
|
214
|
+
def __init__(self, cfg: Config, on_response: Optional[Callable[[ResponseInfo], None]] = None, http_client: Optional[httpx.AsyncClient] = None) -> None:
|
|
215
|
+
self._cfg = cfg
|
|
216
|
+
self._on_response = on_response
|
|
217
|
+
self._clients: Dict[str, httpx.AsyncClient] = {}
|
|
218
|
+
self._shared = http_client
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def api_key(self) -> Optional[str]:
|
|
222
|
+
return self._cfg.api_key
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def routes(self) -> Dict[str, str]:
|
|
226
|
+
return {"node": self._cfg.node.label, "edge": self._cfg.edge.label, "node_kind": self._cfg.node.kind}
|
|
227
|
+
|
|
228
|
+
def _http(self, route: Route) -> httpx.AsyncClient:
|
|
229
|
+
if self._shared is not None and not route.uds:
|
|
230
|
+
return self._shared
|
|
231
|
+
c = self._clients.get(route.label)
|
|
232
|
+
if c is None:
|
|
233
|
+
transport = httpx.AsyncHTTPTransport(uds=route.uds) if route.uds else None
|
|
234
|
+
c = httpx.AsyncClient(transport=transport, follow_redirects=True, timeout=None)
|
|
235
|
+
self._clients[route.label] = c
|
|
236
|
+
return c
|
|
237
|
+
|
|
238
|
+
async def aclose(self) -> None:
|
|
239
|
+
for c in self._clients.values():
|
|
240
|
+
await c.aclose()
|
|
241
|
+
self._clients.clear()
|
|
242
|
+
|
|
243
|
+
async def _send(
|
|
244
|
+
self,
|
|
245
|
+
op: Op,
|
|
246
|
+
*,
|
|
247
|
+
path: Sequence[Union[str, int]] = (),
|
|
248
|
+
query: Optional[Mapping[str, Any]] = None,
|
|
249
|
+
header_params: Optional[Mapping[str, Any]] = None,
|
|
250
|
+
body: Any = None,
|
|
251
|
+
body_type: Optional[str] = None,
|
|
252
|
+
accept: str = "application/json",
|
|
253
|
+
sse: bool = False,
|
|
254
|
+
extra_query: Optional[Mapping[str, str]] = None,
|
|
255
|
+
options: Optional[Mapping[str, Any]] = None,
|
|
256
|
+
) -> Tuple[httpx.Response, ResponseInfo]:
|
|
257
|
+
opts = dict(options or {})
|
|
258
|
+
path_str = build_path(op, path)
|
|
259
|
+
params = build_query(query, extra_query)
|
|
260
|
+
key = idempotency_key(op, opts)
|
|
261
|
+
streaming_body = hasattr(body, "__aiter__") or hasattr(body, "read")
|
|
262
|
+
safe = retry_safe(op, key, streaming_body)
|
|
263
|
+
max_retries = 0 if streaming_body else int(opts.get("max_retries", self._cfg.max_retries))
|
|
264
|
+
tmo = per_attempt_timeout(self._cfg, op, opts, sse)
|
|
265
|
+
route = self._cfg.route(op)
|
|
266
|
+
attempt = 0
|
|
267
|
+
while True:
|
|
268
|
+
headers = request_headers(self._cfg, route, op, opts, accept, header_params, key)
|
|
269
|
+
content = encode_body(op, body, body_type, headers)
|
|
270
|
+
client = self._http(route)
|
|
271
|
+
req = client.build_request(
|
|
272
|
+
op.method,
|
|
273
|
+
route.base + path_str,
|
|
274
|
+
params=httpx.QueryParams(params) if params else None,
|
|
275
|
+
headers=headers,
|
|
276
|
+
content=content,
|
|
277
|
+
timeout=httpx.Timeout(tmo, connect=min(tmo, 10.0)),
|
|
278
|
+
)
|
|
279
|
+
t0 = time.perf_counter()
|
|
280
|
+
try:
|
|
281
|
+
resp = await client.send(req, stream=True)
|
|
282
|
+
except httpx.TimeoutException as e:
|
|
283
|
+
if isinstance(e, httpx.ConnectTimeout) and attempt < max_retries:
|
|
284
|
+
await anyio.sleep(backoff(attempt))
|
|
285
|
+
attempt += 1
|
|
286
|
+
continue
|
|
287
|
+
raise B3OSTimeoutError(f"{op.id} did not answer within {tmo} s ({route.label})", operation_id=op.id) from e
|
|
288
|
+
except httpx.TransportError as e:
|
|
289
|
+
if route.auto and _connect_refused(e):
|
|
290
|
+
self._cfg.fall_back_to_edge()
|
|
291
|
+
route = self._cfg.edge
|
|
292
|
+
continue
|
|
293
|
+
if attempt < max_retries and (safe or isinstance(e, _CONNECT_ERRORS)):
|
|
294
|
+
await anyio.sleep(backoff(attempt))
|
|
295
|
+
attempt += 1
|
|
296
|
+
continue
|
|
297
|
+
raise B3OSConnectionError(f"Could not reach {route.label}: {e}", operation_id=op.id) from e
|
|
298
|
+
info = ResponseInfo(
|
|
299
|
+
operation_id=op.id,
|
|
300
|
+
method=op.method,
|
|
301
|
+
url=(f"{route.label}{path_str}" if route.uds else str(resp.request.url)),
|
|
302
|
+
status=resp.status_code,
|
|
303
|
+
headers=resp.headers,
|
|
304
|
+
transport="uds" if route.uds else "http",
|
|
305
|
+
attempts=attempt + 1,
|
|
306
|
+
elapsed_ms=(time.perf_counter() - t0) * 1000,
|
|
307
|
+
idempotency_key=key,
|
|
308
|
+
)
|
|
309
|
+
if self._on_response:
|
|
310
|
+
try:
|
|
311
|
+
self._on_response(info)
|
|
312
|
+
except Exception:
|
|
313
|
+
pass
|
|
314
|
+
if 200 <= resp.status_code < 300 or resp.status_code == 101:
|
|
315
|
+
return resp, info
|
|
316
|
+
try:
|
|
317
|
+
text = (await resp.aread()).decode("utf-8", errors="replace")
|
|
318
|
+
except httpx.HTTPError:
|
|
319
|
+
text = ""
|
|
320
|
+
finally:
|
|
321
|
+
await resp.aclose()
|
|
322
|
+
err = error_from_response(self._cfg, route, op, resp.status_code, resp.headers, text)
|
|
323
|
+
if attempt < max_retries and should_retry(err, safe):
|
|
324
|
+
delay = err.retry_after if err.retry_after is not None else backoff(attempt)
|
|
325
|
+
if delay <= self._cfg.max_retry_delay:
|
|
326
|
+
await anyio.sleep(delay)
|
|
327
|
+
attempt += 1
|
|
328
|
+
continue
|
|
329
|
+
raise err
|
|
330
|
+
|
|
331
|
+
async def _read(self, resp: httpx.Response, info: ResponseInfo, op: Op, expect: str) -> Any:
|
|
332
|
+
try:
|
|
333
|
+
if resp.status_code in (204, 101) or op.method == "HEAD":
|
|
334
|
+
return None
|
|
335
|
+
ct = resp.headers.get("content-type", "")
|
|
336
|
+
data = await resp.aread()
|
|
337
|
+
except httpx.TimeoutException as e:
|
|
338
|
+
raise B3OSTimeoutError(f"{op.id} body did not arrive in time", operation_id=op.id) from e
|
|
339
|
+
except httpx.TransportError as e:
|
|
340
|
+
raise B3OSConnectionError(f"The response body of {op.id} broke: {e}", operation_id=op.id) from e
|
|
341
|
+
finally:
|
|
342
|
+
await resp.aclose()
|
|
343
|
+
if expect == "bytes" or (expect == "auto" and not is_json(ct) and not ct.startswith("text/")):
|
|
344
|
+
return attach(bytes(data), info)
|
|
345
|
+
text = data.decode("utf-8", errors="replace")
|
|
346
|
+
if expect == "text" or (expect == "auto" and not is_json(ct)):
|
|
347
|
+
return attach(text, info)
|
|
348
|
+
if not text:
|
|
349
|
+
return None
|
|
350
|
+
try:
|
|
351
|
+
return attach(json.loads(text), info)
|
|
352
|
+
except ValueError as e:
|
|
353
|
+
raise B3OSError(
|
|
354
|
+
"invalid_response",
|
|
355
|
+
status=resp.status_code,
|
|
356
|
+
detail=f"{op.id} answered {resp.status_code} with a body that is not JSON ({ct or 'no content type'})",
|
|
357
|
+
request_id=resp.headers.get("x-request-id"),
|
|
358
|
+
operation_id=op.id,
|
|
359
|
+
) from e
|
|
360
|
+
|
|
361
|
+
async def _call(self, op: Op, *, accept: str = "application/json", expect: str = "auto", **kw: Any) -> Any:
|
|
362
|
+
resp, info = await self._send(op, accept=accept, **kw)
|
|
363
|
+
return await self._read(resp, info, op, expect)
|
|
364
|
+
|
|
365
|
+
async def _list(self, op: Op, **kw: Any) -> AsyncPage[Any]:
|
|
366
|
+
data = await self._call(op, **kw)
|
|
367
|
+
page: AsyncPage[Any] = AsyncPage(data or {})
|
|
368
|
+
page._b3os_response = getattr(data, "_b3os_response", None)
|
|
369
|
+
query = dict(kw.get("query") or {})
|
|
370
|
+
|
|
371
|
+
async def nxt(cursor: str) -> AsyncPage[Any]:
|
|
372
|
+
kw2 = dict(kw)
|
|
373
|
+
kw2["query"] = {**query, "cursor": cursor}
|
|
374
|
+
return await self._list(op, **kw2)
|
|
375
|
+
|
|
376
|
+
page._next = nxt
|
|
377
|
+
return page
|
|
378
|
+
|
|
379
|
+
def _expect_sse(self, resp: httpx.Response, info: ResponseInfo, op: Op) -> httpx.Response:
|
|
380
|
+
ct = resp.headers.get("content-type", "")
|
|
381
|
+
if not ct.startswith("text/event-stream"):
|
|
382
|
+
raise B3OSError(
|
|
383
|
+
"invalid_response",
|
|
384
|
+
status=resp.status_code,
|
|
385
|
+
detail=f"{op.id} answered {resp.status_code} with {ct or 'no content type'} instead of an event stream",
|
|
386
|
+
request_id=info.request_id,
|
|
387
|
+
operation_id=op.id,
|
|
388
|
+
)
|
|
389
|
+
return resp
|
|
390
|
+
|
|
391
|
+
async def _open_get_stream(self, op: Op, kw: Mapping[str, Any], last_event_id: Optional[str], first_attempt: bool) -> httpx.Response:
|
|
392
|
+
opts = dict(kw.get("options") or {})
|
|
393
|
+
headers = dict(opts.get("extra_headers") or {})
|
|
394
|
+
if last_event_id is not None:
|
|
395
|
+
headers["last-event-id"] = last_event_id
|
|
396
|
+
opts["extra_headers"] = headers
|
|
397
|
+
if not first_attempt:
|
|
398
|
+
opts["max_retries"] = 0
|
|
399
|
+
resp, info = await self._send(op, accept="text/event-stream", sse=True, **{**kw, "options": opts})
|
|
400
|
+
try:
|
|
401
|
+
return self._expect_sse(resp, info, op)
|
|
402
|
+
except B3OSError:
|
|
403
|
+
await resp.aclose()
|
|
404
|
+
raise
|
|
405
|
+
|
|
406
|
+
async def _stream(self, op: Op, *, terminal: Optional[Callable[[Any], bool]] = None, reconnect_on_end: bool = False, last_event_id: Optional[str] = None, **kw: Any) -> AsyncEventStream[Any]:
|
|
407
|
+
first = await self._open_get_stream(op, kw, last_event_id, True)
|
|
408
|
+
|
|
409
|
+
async def reopen(attempt: int, lid: Optional[str], _ev: Any) -> httpx.Response:
|
|
410
|
+
return await self._open_get_stream(op, kw, lid, False)
|
|
411
|
+
|
|
412
|
+
return AsyncEventStream(reopen, first=first, is_terminal=terminal, resumable=True, reconnect_on_end=reconnect_on_end, operation_id=op.id)
|
|
413
|
+
|
|
414
|
+
async def _post_stream(self, op: Op, *, terminal: Optional[Callable[[Any], bool]] = None, done_sentinel: bool = False, **kw: Any) -> AsyncEventStream[Any]:
|
|
415
|
+
resp, info = await self._send(op, accept="text/event-stream", sse=True, **kw)
|
|
416
|
+
try:
|
|
417
|
+
first = self._expect_sse(resp, info, op)
|
|
418
|
+
except B3OSError:
|
|
419
|
+
await resp.aclose()
|
|
420
|
+
raise
|
|
421
|
+
|
|
422
|
+
async def reopen(attempt: int, lid: Optional[str], ev: Any) -> httpx.Response:
|
|
423
|
+
raise B3OSError("stream_interrupted", detail=f"{op.id} streams cannot resume", operation_id=op.id)
|
|
424
|
+
|
|
425
|
+
return AsyncEventStream(reopen, first=first, is_terminal=terminal, resumable=False, done_sentinel=done_sentinel, operation_id=op.id)
|
|
426
|
+
|
|
427
|
+
async def _final_job(self, ops: JobOps, ev: Mapping[str, Any]) -> Any:
|
|
428
|
+
job = ev.get("job")
|
|
429
|
+
if is_terminal_job(job):
|
|
430
|
+
return job
|
|
431
|
+
jid = ev.get("job_id")
|
|
432
|
+
if not isinstance(jid, str):
|
|
433
|
+
raise B3OSError("invalid_response", detail="terminal event without a job", operation_id=ops.events.id)
|
|
434
|
+
return await self._call(ops.get, path=[jid])
|
|
435
|
+
|
|
436
|
+
async def _job_events(self, ops: JobOps, job: Any, *, last_event_id: Optional[str] = None, options: Optional[Mapping[str, Any]] = None) -> AsyncJobEventStream[Any, Any]:
|
|
437
|
+
jid = job_id_of(job)
|
|
438
|
+
kw: Dict[str, Any] = {"path": [jid], "options": options or {}}
|
|
439
|
+
first = await self._open_get_stream(ops.events, kw, last_event_id, True)
|
|
440
|
+
|
|
441
|
+
async def reopen(attempt: int, lid: Optional[str], _ev: Any) -> httpx.Response:
|
|
442
|
+
return await self._open_get_stream(ops.events, kw, lid, False)
|
|
443
|
+
|
|
444
|
+
async def final(e: Any) -> Any:
|
|
445
|
+
return await self._final_job(ops, e)
|
|
446
|
+
|
|
447
|
+
return AsyncJobEventStream(
|
|
448
|
+
reopen,
|
|
449
|
+
first=first,
|
|
450
|
+
is_terminal=lambda e: isinstance(e, dict) and e.get("type") in TERMINAL_JOB_EVENTS,
|
|
451
|
+
resumable=True,
|
|
452
|
+
reconnect_on_end=True,
|
|
453
|
+
operation_id=ops.events.id,
|
|
454
|
+
final_job=final,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
def _finish(self, job: Any, raise_on_failure: bool) -> Any:
|
|
458
|
+
if job.get("status") == "succeeded" or not raise_on_failure:
|
|
459
|
+
return job
|
|
460
|
+
raise B3OSJobError(job, **job_failure(job))
|
|
461
|
+
|
|
462
|
+
async def _wait_job(self, ops: JobOps, job: Any, *, timeout: Optional[float] = None, raise_on_failure: bool = True, on_event: Optional[Callable[[Any], None]] = None) -> Any:
|
|
463
|
+
if not isinstance(job, str) and is_terminal_job(job):
|
|
464
|
+
return self._finish(job, raise_on_failure)
|
|
465
|
+
jid = job_id_of(job)
|
|
466
|
+
budget = timeout if timeout is not None else ops.events.timeout_ms / 1000
|
|
467
|
+
deadline = time.monotonic() + budget
|
|
468
|
+
polling = False
|
|
469
|
+
poll_attempt = 0
|
|
470
|
+
while time.monotonic() < deadline:
|
|
471
|
+
if not polling:
|
|
472
|
+
try:
|
|
473
|
+
remaining = max(1.0, deadline - time.monotonic())
|
|
474
|
+
stream = await self._job_events(ops, jid, options={"timeout": min(remaining, 45.0)})
|
|
475
|
+
async for ev in stream:
|
|
476
|
+
if on_event:
|
|
477
|
+
on_event(ev)
|
|
478
|
+
if isinstance(ev, dict) and ev.get("type") in TERMINAL_JOB_EVENTS:
|
|
479
|
+
return self._finish(await self._final_job(ops, ev), raise_on_failure)
|
|
480
|
+
if time.monotonic() >= deadline:
|
|
481
|
+
await stream.aclose()
|
|
482
|
+
break
|
|
483
|
+
except B3OSError as e:
|
|
484
|
+
if e.code in ("route_not_found", "not_implemented", "invalid_response") or e.status in (405, 406):
|
|
485
|
+
polling = True
|
|
486
|
+
elif e.status in (401, 403, 404, 410):
|
|
487
|
+
raise
|
|
488
|
+
elif not e.retryable and e.code not in ("stream_interrupted", "timeout"):
|
|
489
|
+
raise
|
|
490
|
+
resp, info = await self._send(ops.get, path=[jid])
|
|
491
|
+
cur = await self._read(resp, info, ops.get, "json")
|
|
492
|
+
if is_terminal_job(cur):
|
|
493
|
+
return self._finish(cur, raise_on_failure)
|
|
494
|
+
ra = parse_retry_after(info.headers.get("retry-after"))
|
|
495
|
+
await anyio.sleep(max(0.0, min(deadline - time.monotonic(), ra if ra is not None else backoff(poll_attempt, 1.0, 5.0))))
|
|
496
|
+
poll_attempt += 1
|
|
497
|
+
raise B3OSTimeoutError(f"{jid} was not finished after {budget} s; it keeps running on the server", operation_id=ops.get.id)
|
|
498
|
+
|
|
499
|
+
async def _job(self, op: Op, *, wait: Union[bool, float] = False, stream: bool = False, raise_on_failure: bool = True, on_event: Optional[Callable[[Any], None]] = None, **kw: Any) -> Any:
|
|
500
|
+
assert op.job is not None
|
|
501
|
+
ops = op.job
|
|
502
|
+
opts = dict(kw.pop("options", None) or {})
|
|
503
|
+
if op.idem and not opts.get("idempotency_key"):
|
|
504
|
+
opts["idempotency_key"] = new_idempotency_key()
|
|
505
|
+
if stream:
|
|
506
|
+
resp, info = await self._send(op, accept="text/event-stream", sse=True, options=opts, **kw)
|
|
507
|
+
first = self._expect_sse(resp, info, op)
|
|
508
|
+
|
|
509
|
+
async def reopen(attempt: int, lid: Optional[str], ev: Any) -> httpx.Response:
|
|
510
|
+
jid = ev.get("job_id") if isinstance(ev, dict) else None
|
|
511
|
+
if not isinstance(jid, str):
|
|
512
|
+
raise B3OSError("stream_interrupted", detail=f"{op.id}: the stream broke before the first event", operation_id=op.id)
|
|
513
|
+
return await self._open_get_stream(ops.events, {"path": [jid], "options": {}}, lid, False)
|
|
514
|
+
|
|
515
|
+
async def final(e: Any) -> Any:
|
|
516
|
+
return await self._final_job(ops, e)
|
|
517
|
+
|
|
518
|
+
return AsyncJobEventStream(
|
|
519
|
+
reopen,
|
|
520
|
+
first=first,
|
|
521
|
+
is_terminal=lambda e: isinstance(e, dict) and e.get("type") in TERMINAL_JOB_EVENTS,
|
|
522
|
+
resumable=True,
|
|
523
|
+
reconnect_on_end=True,
|
|
524
|
+
operation_id=op.id,
|
|
525
|
+
final_job=final,
|
|
526
|
+
)
|
|
527
|
+
if not wait:
|
|
528
|
+
return await self._call(op, options=opts, **kw)
|
|
529
|
+
budget = float(ops.events.timeout_ms / 1000 if wait is True else wait)
|
|
530
|
+
start = time.monotonic()
|
|
531
|
+
wait_s = int(max(0, min(60, budget)))
|
|
532
|
+
job = await self._call(op, extra_query={"wait": str(wait_s)}, options=opts, **kw)
|
|
533
|
+
remaining = budget - (time.monotonic() - start)
|
|
534
|
+
return await self._wait_job(ops, job, timeout=max(0.0, remaining), raise_on_failure=raise_on_failure, on_event=on_event)
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class AsyncJobs:
|
|
538
|
+
def __init__(
|
|
539
|
+
self,
|
|
540
|
+
core: AsyncCore,
|
|
541
|
+
table: Mapping[str, JobOps],
|
|
542
|
+
kinds: Optional[Mapping[str, JobOps]] = None,
|
|
543
|
+
generic_prefixes: Iterable[str] = (),
|
|
544
|
+
) -> None:
|
|
545
|
+
self._core = core
|
|
546
|
+
self._table = table
|
|
547
|
+
self._kinds = kinds or {}
|
|
548
|
+
self._generic = frozenset(generic_prefixes)
|
|
549
|
+
|
|
550
|
+
def ops_for(self, job: Any) -> JobOps:
|
|
551
|
+
return job_ops_for(self._table, self._kinds, self._generic, job)
|
|
552
|
+
|
|
553
|
+
async def get(self, job: Any) -> Any:
|
|
554
|
+
return await self._core._call(self.ops_for(job).get, path=[job_id_of(job)])
|
|
555
|
+
|
|
556
|
+
async def wait(self, job: Any, *, timeout: Optional[float] = None, raise_on_failure: bool = True, on_event: Optional[Callable[[Any], None]] = None) -> Any:
|
|
557
|
+
return await self._core._wait_job(self.ops_for(job), job, timeout=timeout, raise_on_failure=raise_on_failure, on_event=on_event)
|
|
558
|
+
|
|
559
|
+
async def events(self, job: Any, *, last_event_id: Optional[str] = None) -> AsyncJobEventStream[Any, Any]:
|
|
560
|
+
return await self._core._job_events(self.ops_for(job), job, last_event_id=last_event_id)
|
|
561
|
+
|
|
562
|
+
async def cancel(self, job: Any) -> Any:
|
|
563
|
+
return await self._core._call(self.ops_for(job).cancel, path=[job_id_of(job)])
|