abysshub 0.0.1.dev16__tar.gz → 0.1.1.dev1__tar.gz

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 (23) hide show
  1. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/PKG-INFO +23 -4
  2. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/README.md +22 -3
  3. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/pyproject.toml +1 -1
  4. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/src/abysshub/__init__.py +10 -2
  5. abysshub-0.1.1.dev1/src/abysshub/_async_client.py +275 -0
  6. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/src/abysshub/_client.py +199 -127
  7. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/src/abysshub/_error.py +4 -3
  8. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/src/abysshub/_files.py +38 -10
  9. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/src/abysshub/_http.py +24 -0
  10. abysshub-0.1.1.dev1/src/abysshub/_run.py +214 -0
  11. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/src/abysshub/_types.py +10 -1
  12. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/tests/caller.py +22 -1
  13. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/tests/fake_server.py +5 -1
  14. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/tests/test_client.py +11 -1
  15. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/tests/test_files.py +36 -4
  16. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/tests/test_replay.py +57 -12
  17. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/tests/test_timeout.py +16 -1
  18. abysshub-0.0.1.dev16/src/abysshub/_run.py +0 -130
  19. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/.gitignore +0 -0
  20. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/LICENSE +0 -0
  21. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/src/abysshub/py.typed +0 -0
  22. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/tests/test_http.py +0 -0
  23. {abysshub-0.0.1.dev16 → abysshub-0.1.1.dev1}/tests/test_package.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: abysshub
3
- Version: 0.0.1.dev16
3
+ Version: 0.1.1.dev1
4
4
  Summary: The official Python library for the Abyss API.
5
5
  Project-URL: Homepage, https://abysshub.com
6
6
  Project-URL: Source, https://github.com/Abysshub/abysshub-sdk
@@ -35,10 +35,11 @@ except AbyssError as error:
35
35
 
36
36
  `run()` presses the Widget and waits for the run to end, however long it takes, then returns the succeeded run. A refusal or a failed run raises `AbyssError`.
37
37
 
38
- It runs on Python 3.10+, with one dependency, `httpx`. The client is synchronous; from async code, run it in a thread:
38
+ It runs on Python 3.10+, with one dependency, `httpx`. From async code, use `AsyncAbyss` (see [Async](#async)):
39
39
 
40
40
  ```python
41
- run = await asyncio.to_thread(abyss.run, "widget_131", {"amount": 250000})
41
+ async with AsyncAbyss() as abyss:
42
+ run = await abyss.run("widget_131", {"amount": 250000})
42
43
  ```
43
44
 
44
45
  ## The client
@@ -132,7 +133,7 @@ A refusal, a failed run, a timeout and a lost connection all raise one class, `A
132
133
  | `param` | the Field, body key, query key or header at fault, else `None` |
133
134
  | `doc_url` | the code on the API access page, else `None` |
134
135
  | `request_id` | quote it to support, else `None` |
135
- | `run` | the run, for a failed run or a timeout, else `None` |
136
+ | `run` | the run, for a failed run or a timeout, else `None`; an `AsyncRun` from `AsyncAbyss` |
136
137
 
137
138
  - **Refusals:** `invalid_api_key` (401), `not_found` (404), `invalid_request`, `invalid_input`, `held_input_*`, `input_unreachable` and `idempotency_key_reused` (422), `insufficient_funds` (402), `spend_cap_reached` and `price_above_max` (400), `idempotency_key_in_use` (409), `rate_limited` (429), `server_error` (500).
138
139
  - **A failed run:** `widget_fault`, `platform_fault` or `budget_exceeded`, with the run as `run`.
@@ -164,6 +165,24 @@ except AbyssError as error:
164
165
 
165
166
  It raises `AbyssError` with `code == "timeout"` and `run`, the run as last seen. During the press's first hold the run has not been seen yet, so `run` is `None`; to keep its `id` whatever happens, press with `runs.create()` and wait with `runs.get(id, wait=True, timeout=...)`.
166
167
 
168
+ ## Async
169
+
170
+ `AsyncAbyss` is `Abyss` on httpx's async client. It takes the same options and has the same methods, each awaited; its retries, re-attaching and `timeout` behave the same.
171
+
172
+ ```python
173
+ from abysshub import AsyncAbyss, file
174
+
175
+ async with AsyncAbyss() as abyss: # or abyss = AsyncAbyss(), then await abyss.close()
176
+ run = await abyss.run("widget_1660", {"report": file("report.pdf")})
177
+ paths = await run.save("output")
178
+ queued = await abyss.runs.create("widget_131", {"amount": 250000})
179
+ run = await abyss.runs.get(queued.id, wait=True)
180
+ ```
181
+
182
+ - **Runs:** it returns an `AsyncRun`, with the same fields as `Run`. Its `save(dir)` and each of its `output_files`' `save(path)` and `read()` are awaited. `runs.list()` returns an `AsyncRunList`, and an `AbyssError` raised by `AsyncAbyss` carries an `AsyncRun` as `run`.
183
+ - **Files** are uploaded as concurrent tasks, each read from disk in a thread.
184
+ - **`timeout=`** stops the waiting, as with `Abyss`. Cancelling the task stops it too, and raises `CancelledError`, not `AbyssError`.
185
+
167
186
  ## Names
168
187
 
169
188
  Returned data keeps `/v1`'s names: `output_files`, `has_more`, `request_id`. The library's own options are snake_case: `max_price`, `starting_after`. A Field's name is never converted: `input` is sent as you write it.
@@ -18,10 +18,11 @@ except AbyssError as error:
18
18
 
19
19
  `run()` presses the Widget and waits for the run to end, however long it takes, then returns the succeeded run. A refusal or a failed run raises `AbyssError`.
20
20
 
21
- It runs on Python 3.10+, with one dependency, `httpx`. The client is synchronous; from async code, run it in a thread:
21
+ It runs on Python 3.10+, with one dependency, `httpx`. From async code, use `AsyncAbyss` (see [Async](#async)):
22
22
 
23
23
  ```python
24
- run = await asyncio.to_thread(abyss.run, "widget_131", {"amount": 250000})
24
+ async with AsyncAbyss() as abyss:
25
+ run = await abyss.run("widget_131", {"amount": 250000})
25
26
  ```
26
27
 
27
28
  ## The client
@@ -115,7 +116,7 @@ A refusal, a failed run, a timeout and a lost connection all raise one class, `A
115
116
  | `param` | the Field, body key, query key or header at fault, else `None` |
116
117
  | `doc_url` | the code on the API access page, else `None` |
117
118
  | `request_id` | quote it to support, else `None` |
118
- | `run` | the run, for a failed run or a timeout, else `None` |
119
+ | `run` | the run, for a failed run or a timeout, else `None`; an `AsyncRun` from `AsyncAbyss` |
119
120
 
120
121
  - **Refusals:** `invalid_api_key` (401), `not_found` (404), `invalid_request`, `invalid_input`, `held_input_*`, `input_unreachable` and `idempotency_key_reused` (422), `insufficient_funds` (402), `spend_cap_reached` and `price_above_max` (400), `idempotency_key_in_use` (409), `rate_limited` (429), `server_error` (500).
121
122
  - **A failed run:** `widget_fault`, `platform_fault` or `budget_exceeded`, with the run as `run`.
@@ -147,6 +148,24 @@ except AbyssError as error:
147
148
 
148
149
  It raises `AbyssError` with `code == "timeout"` and `run`, the run as last seen. During the press's first hold the run has not been seen yet, so `run` is `None`; to keep its `id` whatever happens, press with `runs.create()` and wait with `runs.get(id, wait=True, timeout=...)`.
149
150
 
151
+ ## Async
152
+
153
+ `AsyncAbyss` is `Abyss` on httpx's async client. It takes the same options and has the same methods, each awaited; its retries, re-attaching and `timeout` behave the same.
154
+
155
+ ```python
156
+ from abysshub import AsyncAbyss, file
157
+
158
+ async with AsyncAbyss() as abyss: # or abyss = AsyncAbyss(), then await abyss.close()
159
+ run = await abyss.run("widget_1660", {"report": file("report.pdf")})
160
+ paths = await run.save("output")
161
+ queued = await abyss.runs.create("widget_131", {"amount": 250000})
162
+ run = await abyss.runs.get(queued.id, wait=True)
163
+ ```
164
+
165
+ - **Runs:** it returns an `AsyncRun`, with the same fields as `Run`. Its `save(dir)` and each of its `output_files`' `save(path)` and `read()` are awaited. `runs.list()` returns an `AsyncRunList`, and an `AbyssError` raised by `AsyncAbyss` carries an `AsyncRun` as `run`.
166
+ - **Files** are uploaded as concurrent tasks, each read from disk in a thread.
167
+ - **`timeout=`** stops the waiting, as with `Abyss`. Cancelling the task stops it too, and raises `CancelledError`, not `AbyssError`.
168
+
150
169
  ## Names
151
170
 
152
171
  Returned data keeps `/v1`'s names: `output_files`, `has_more`, `request_id`. The library's own options are snake_case: `max_price`, `starting_after`. A Field's name is never converted: `input` is sent as you write it.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "abysshub"
7
- version = "0.0.1.dev16"
7
+ version = "0.1.1.dev1"
8
8
  description = "The official Python library for the Abyss API."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,13 +1,21 @@
1
1
  """The official Python library for the Abyss API."""
2
+ from ._async_client import AsyncAbyss, AsyncRuns, AsyncUploads, AsyncWidgets
2
3
  from ._client import Abyss, Runs, Uploads, Widgets
3
4
  from ._error import AbyssError
4
5
  from ._files import FileData, InputFile, file
5
- from ._run import Run, RunError, RunFile, RunStatus
6
- from ._types import FreeRuns, Key, KeyUser, RunList, Upload, UploadForm, Widget
6
+ from ._run import AsyncRun, AsyncRunFile, Run, RunError, RunFile, RunStatus
7
+ from ._types import AsyncRunList, FreeRuns, Key, KeyUser, RunList, Upload, UploadForm, Widget
7
8
 
8
9
  __all__ = [
9
10
  "Abyss",
10
11
  "AbyssError",
12
+ "AsyncAbyss",
13
+ "AsyncRun",
14
+ "AsyncRunFile",
15
+ "AsyncRunList",
16
+ "AsyncRuns",
17
+ "AsyncUploads",
18
+ "AsyncWidgets",
11
19
  "FileData",
12
20
  "FreeRuns",
13
21
  "InputFile",
@@ -0,0 +1,275 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import math
5
+ from collections.abc import Callable, Mapping
6
+ from types import TracebackType
7
+ from typing import Any
8
+
9
+ import httpx
10
+
11
+ from ._client import (
12
+ REATTACH_PAUSE,
13
+ _Base,
14
+ _Call,
15
+ _expect,
16
+ _has_ended,
17
+ _is_key,
18
+ _is_run,
19
+ _is_run_list,
20
+ _is_upload,
21
+ _is_widget,
22
+ _Reply,
23
+ )
24
+ from ._error import AbyssError
25
+ from ._files import upload_files_async
26
+ from ._http import Stopped, arequest, timeouts
27
+ from ._run import AsyncRun, parse_async_run
28
+ from ._types import AsyncRunList, Key, Upload, Widget, parse_key, parse_upload, parse_widget
29
+
30
+
31
+ class AsyncRuns:
32
+ """`/v1`'s run routes, one method each, awaited."""
33
+
34
+ def __init__(self, client: AsyncAbyss) -> None:
35
+ self._client = client
36
+
37
+ async def create(
38
+ self,
39
+ widget: str,
40
+ input: Mapping[str, Any],
41
+ *,
42
+ max_price: float | None = None,
43
+ idempotency_key: str | None = None,
44
+ timeout: float | None = None,
45
+ ) -> AsyncRun:
46
+ """Uploads the input's files and presses with ``"wait": false``: returns the run at once, ``queued``."""
47
+ client = self._client
48
+ call = client._start(timeout)
49
+ press = await client._press(call, widget, input, max_price, idempotency_key, hold=False)
50
+ return client._run(_expect(press, _is_run, "The press answered without a run."))
51
+
52
+ async def get(self, id: str, wait: bool = False, *, timeout: float | None = None) -> AsyncRun:
53
+ """Reads a run as it is, or with ``wait`` holds until it ends. A failed run is returned, not raised."""
54
+ client = self._client
55
+ return client._run(await client._get_run(client._start(timeout), id, wait))
56
+
57
+ async def list(
58
+ self,
59
+ limit: int | None = None,
60
+ starting_after: str | None = None,
61
+ *,
62
+ timeout: float | None = None,
63
+ ) -> AsyncRunList:
64
+ """Returns a page of runs, newest first."""
65
+ client = self._client
66
+ url = client._list_url(limit, starting_after)
67
+ page = await client._get(client._start(timeout), url, _is_run_list, "The run list answered without data.")
68
+ return AsyncRunList(data=[client._run(data) for data in page["data"]], has_more=page["has_more"])
69
+
70
+
71
+ class AsyncWidgets:
72
+ """`/v1`'s Widget route, awaited."""
73
+
74
+ def __init__(self, client: AsyncAbyss) -> None:
75
+ self._client = client
76
+
77
+ async def get(self, widget: str, *, timeout: float | None = None) -> Widget:
78
+ """Reads a Widget: its price, Free Runs and ``input_schema``."""
79
+ client = self._client
80
+ data = await client._get(
81
+ client._start(timeout), client._widget_url(widget), _is_widget, "The Widget answered without an id."
82
+ )
83
+ return parse_widget(data)
84
+
85
+
86
+ class AsyncUploads:
87
+ """`/v1`'s upload route, awaited."""
88
+
89
+ def __init__(self, client: AsyncAbyss) -> None:
90
+ self._client = client
91
+
92
+ async def create(
93
+ self,
94
+ widget: str,
95
+ field: str,
96
+ filename: str,
97
+ size: int | None = None,
98
+ *,
99
+ timeout: float | None = None,
100
+ ) -> Upload:
101
+ """Asks for an upload's grant. ``run()`` and ``runs.create()`` upload files by themselves."""
102
+ client = self._client
103
+ return await client._grant(client._start(timeout), client._widget_url(widget), field, filename, size)
104
+
105
+
106
+ class AsyncAbyss(_Base[AsyncRun]):
107
+ """``Abyss`` for async code, on httpx's async client: every method is awaited.
108
+
109
+ The key defaults to ``ABYSS_API_KEY``, and the address to ``ABYSS_BASE_URL``,
110
+ else the address the key belongs to.
111
+ """
112
+
113
+ runs: AsyncRuns
114
+ widgets: AsyncWidgets
115
+ uploads: AsyncUploads
116
+
117
+ def __init__(
118
+ self,
119
+ api_key: str | None = None,
120
+ base_url: str | None = None,
121
+ max_retries: int = 2,
122
+ timeout: float | None = None,
123
+ ) -> None:
124
+ super().__init__(api_key, base_url, max_retries, timeout)
125
+ self.runs = AsyncRuns(self)
126
+ self.widgets = AsyncWidgets(self)
127
+ self.uploads = AsyncUploads(self)
128
+ self._http = httpx.AsyncClient(timeout=timeouts())
129
+
130
+ async def close(self) -> None:
131
+ """Closes the client's connections."""
132
+ await self._http.aclose()
133
+
134
+ async def __aenter__(self) -> AsyncAbyss:
135
+ return self
136
+
137
+ async def __aexit__(
138
+ self,
139
+ exc_type: type[BaseException] | None,
140
+ exc: BaseException | None,
141
+ tb: TracebackType | None,
142
+ ) -> None:
143
+ await self.close()
144
+
145
+ async def run(
146
+ self,
147
+ widget: str,
148
+ input: Mapping[str, Any],
149
+ *,
150
+ max_price: float | None = None,
151
+ idempotency_key: str | None = None,
152
+ timeout: float | None = None,
153
+ ) -> AsyncRun:
154
+ """Uploads the input's files, presses a Widget and waits for the run's ending.
155
+
156
+ The same as ``Abyss.run()``, awaited: returns the succeeded run; raises
157
+ ``AbyssError`` on a refusal or a failed run, and with ``code == "timeout"``
158
+ once ``timeout`` seconds have passed.
159
+ """
160
+ call = self._start(timeout)
161
+ press = await self._press(call, widget, input, max_price, idempotency_key, hold=True)
162
+ return self._ended(press if _has_ended(press) else await self._hold(call, self._wait_url(press), _has_ended))
163
+
164
+ async def key(self, *, timeout: float | None = None) -> Key:
165
+ """Returns the API Key making the call, with ``GET /v1/key``."""
166
+ url = f"{self.base_url}/v1/key"
167
+ return parse_key(await self._get(self._start(timeout), url, _is_key, "The key answered without a key."))
168
+
169
+ async def _sleep(self, call: _Call, seconds: float) -> None:
170
+ """Waits ``seconds``, or raises ``timeout`` at the call's deadline."""
171
+ pause, stops = self._pause(call, seconds)
172
+ await asyncio.sleep(pause)
173
+ if stops:
174
+ raise self._timed_out(call)
175
+
176
+ async def _press(
177
+ self,
178
+ call: _Call,
179
+ widget: str,
180
+ input: Mapping[str, Any],
181
+ max_price: float | None,
182
+ idempotency_key: str | None,
183
+ hold: bool,
184
+ ) -> _Reply:
185
+ """Uploads the input's files and sends the press. Its retries never redo the uploads."""
186
+ widget_url = self._widget_url(widget)
187
+ uploaded = await upload_files_async(
188
+ input, lambda field, filename, data: self._upload(call, widget_url, field, filename, data)
189
+ )
190
+ headers, body = self._press_request(uploaded, max_price, idempotency_key, hold)
191
+ return await self._send(call, "POST", f"{widget_url}/runs", headers, body)
192
+
193
+ async def _grant(self, call: _Call, widget_url: str, field: str, filename: str, size: int | None) -> Upload:
194
+ """Asks for an upload with ``POST /v1/widgets/{widget}/uploads``."""
195
+ body = self._grant_body(field, filename, size)
196
+ grant = await self._send(call, "POST", f"{widget_url}/uploads", {"Content-Type": "application/json"}, body)
197
+ return parse_upload(_expect(grant, _is_upload, "The upload answered without an id and a form."))
198
+
199
+ async def _upload(self, call: _Call, widget_url: str, field: str, filename: str, data: bytes) -> str:
200
+ """Asks for an upload, sends the file to storage and answers the upload's id."""
201
+ grant = await self._grant(call, widget_url, field, filename, len(data))
202
+ stored: httpx.Response | httpx.TransportError
203
+ try:
204
+ stored = await self._http.post(
205
+ grant.upload.url,
206
+ data=grant.upload.fields,
207
+ files={"file": (filename, data)},
208
+ timeout=self._timeouts(call),
209
+ )
210
+ except httpx.TransportError as error:
211
+ stored = error
212
+ self._stored(call, field, filename, stored)
213
+ return grant.id
214
+
215
+ async def _download(self, url: str) -> tuple[int, bytes]:
216
+ """Downloads an output file from storage, without the API key."""
217
+ try:
218
+ response = await self._http.get(url)
219
+ except httpx.TransportError as error:
220
+ raise AbyssError("connection_error", "The connection to storage dropped during a download.") from error
221
+ return response.status_code, response.content
222
+
223
+ async def _get_run(self, call: _Call, id: str, wait: bool) -> dict[str, Any]:
224
+ """Reads a run with ``GET /v1/runs/{id}``, holding with ``?wait=true``. A cut hold is read again."""
225
+ reply = await self._hold(call, self._run_url(id, wait), lambda reply: not reply.cut)
226
+ return _expect(reply, _is_run, "The read answered without a run.")
227
+
228
+ async def _reread(self, id: str) -> dict[str, Any]:
229
+ return await self._get_run(_Call(None, None), id, False)
230
+
231
+ def _run(self, data: dict[str, Any]) -> AsyncRun:
232
+ return parse_async_run(data, self._reread, self._download)
233
+
234
+ async def _get(self, call: _Call, url: str, is_: Callable[[Any], bool], message: str) -> Any:
235
+ """Reads ``url`` once and returns its body, when it is what the route answers."""
236
+ return _expect(await self._send(call, "GET", url), is_, message)
237
+
238
+ async def _hold(self, call: _Call, url: str, done: Callable[[_Reply], bool]) -> _Reply:
239
+ """Reads ``url`` until ``done``, re-attaching with no limit, as ``Abyss`` does."""
240
+ reply = await self._send(call, "GET", url, retries=math.inf)
241
+ while not done(reply):
242
+ await self._sleep(call, REATTACH_PAUSE)
243
+ reply = await self._send(call, "GET", url, retries=math.inf)
244
+ return reply
245
+
246
+ async def _send(
247
+ self,
248
+ call: _Call,
249
+ method: str,
250
+ url: str,
251
+ headers: dict[str, str] | None = None,
252
+ content: bytes | None = None,
253
+ retries: float | None = None,
254
+ ) -> _Reply:
255
+ """Sends one request to `/v1`, with ``Abyss``'s retries."""
256
+ limit = self._limit(retries)
257
+ sent_headers = self._headers(headers)
258
+ attempt = 0
259
+ while True:
260
+ self._check(call)
261
+ try:
262
+ answer = await arequest(
263
+ self._http, method, url, sent_headers, content, self._timeouts(call), call.deadline
264
+ )
265
+ except Stopped:
266
+ raise self._timed_out(call) from None
267
+ except httpx.TransportError as error:
268
+ pause = self._dropped(call, error, attempt, limit)
269
+ else:
270
+ reply = self._answered(call, answer, attempt, limit)
271
+ if isinstance(reply, _Reply):
272
+ return reply
273
+ pause = reply
274
+ await self._sleep(call, pause)
275
+ attempt += 1