abysshub 0.1.1.dev6__tar.gz → 0.1.2.dev2__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.
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/PKG-INFO +24 -6
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/README.md +23 -5
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/pyproject.toml +1 -1
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/__init__.py +4 -2
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_async_client.py +10 -1
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_client.py +13 -1
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_error.py +7 -1
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_run.py +65 -6
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/caller.py +4 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_replay.py +35 -11
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/.gitignore +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/LICENSE +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_files.py +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_http.py +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_types.py +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/py.typed +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/fake_server.py +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_client.py +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_files.py +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_http.py +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_package.py +0 -0
- {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_timeout.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: abysshub
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2.dev2
|
|
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
|
|
@@ -71,7 +71,7 @@ run = abyss.run(widget, input, max_price=None, idempotency_key=None, timeout=Non
|
|
|
71
71
|
|
|
72
72
|
The press holds until the run ends. When the hold ends early (a deploy cuts it, the network goes down, the API's holds are all taken, or an hour passes), `run()` re-attaches with `GET <Location>?wait=true` until the run ends. Re-attaching has no limit and is not a retry. Connecting has 10 s, and 30 s of silence counts as a cut.
|
|
73
73
|
|
|
74
|
-
A **run** is a dataclass with `/v1`'s fields: `id`, `widget`, `status` (`queued`, `running`, `succeeded` or `failed`), `price`, `result` (the Widget's JSON, a `dict` when it wrote an object), `output_files`, `error`, `created_at`, `started_at` and `ended_at`.
|
|
74
|
+
A **run** is a dataclass with `/v1`'s fields: `id`, `widget`, `status` (`queued`, `running`, `succeeded` or `failed`), `price`, `result` (the Widget's JSON, a `dict` when it wrote an object), `output_files`, `files` (`ready`, `asleep` or `waking`, see [Old big runs](#old-big-runs)), `error`, `created_at`, `started_at` and `ended_at`.
|
|
75
75
|
|
|
76
76
|
## The routes
|
|
77
77
|
|
|
@@ -82,6 +82,7 @@ Under `run()` there is one method per route. Each also takes `timeout=` as a key
|
|
|
82
82
|
| `abyss.runs.create(widget, input, max_price=None, idempotency_key=None)` | `POST /v1/widgets/{widget}/runs` with `"wait": false` | the `Run`, `queued`, at once |
|
|
83
83
|
| `abyss.runs.get(id, wait=False)` | `GET /v1/runs/{id}`, with `?wait=true` when `wait` | the `Run` |
|
|
84
84
|
| `abyss.runs.list(limit=None, starting_after=None)` | `GET /v1/runs?limit=&starting_after=` | a `RunList`: `data`, `has_more`, newest first |
|
|
85
|
+
| `abyss.runs.wake(id)` | `POST /v1/runs/{id}/wake` | the `Run`, `waking` when it was asleep |
|
|
85
86
|
| `abyss.widgets.get(widget)` | `GET /v1/widgets/{widget}` | a `Widget`: `id`, `name`, `description`, `url`, `price`, `free_runs`, `input_schema` |
|
|
86
87
|
| `abyss.uploads.create(widget, field, filename, size=None)` | `POST /v1/widgets/{widget}/uploads` | an `Upload`: `id`, `upload.url`, `upload.fields` |
|
|
87
88
|
| `abyss.key()` | `GET /v1/key` | a `Key`: `name`, `key`, `user`, `spend_cap`, `spent_this_month`, `created_at` |
|
|
@@ -121,9 +122,26 @@ paths = run.save("output") # [Path("output/charts/a.png"), Path("output/output.
|
|
|
121
122
|
- **`run.save(dir)`** writes every output file under `dir` at its `path`, making folders, and returns the paths it wrote.
|
|
122
123
|
- **Each of `run.output_files`** has `f.save(path)` and `f.read()`, which returns `bytes`. An expired URL is refreshed once, by reading the run again with `GET /v1/runs/{id}`. A file of 64 MiB or more downloads as 16 parallel byte ranges of its one URL.
|
|
123
124
|
|
|
125
|
+
## Old big runs
|
|
126
|
+
|
|
127
|
+
A big run (its files total 50 MiB or more) that nobody has opened for six months falls asleep: its larger files sleep in deep storage, and the run reads `files == "asleep"`. Its `result` reads as before, and reading the run never wakes it. Most callers never meet one.
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from abysshub import FilesAsleepError
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
run.save("output")
|
|
134
|
+
except FilesAsleepError as error:
|
|
135
|
+
if error.code == "files_asleep":
|
|
136
|
+
run.wake() # back within 12 hours, with an email
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- **`run.wake()`** (or `abyss.runs.wake(id)`) calls `POST /v1/runs/{id}/wake` and returns the run, `files == "waking"`. Its files are back within 12 hours, when it reads `ready`. Waking a run that is not asleep changes nothing.
|
|
140
|
+
- **`run.save(dir)`** on a run that is `asleep` or `waking` raises `FilesAsleepError`, with `code` `files_asleep` or `files_waking`, before any download.
|
|
141
|
+
|
|
124
142
|
## Errors
|
|
125
143
|
|
|
126
|
-
A refusal, a failed run, a timeout and a lost connection all raise one class, `AbyssError`. There is no class per status: branch on `code`.
|
|
144
|
+
A refusal, a failed run, a timeout and a lost connection all raise one class, `AbyssError`. There is no class per status: branch on `code`. Its one subclass, `FilesAsleepError`, is what `run.save()` raises on an old big run (see [Old big runs](#old-big-runs)).
|
|
127
145
|
|
|
128
146
|
| Field | |
|
|
129
147
|
|---|---|
|
|
@@ -138,9 +156,9 @@ A refusal, a failed run, a timeout and a lost connection all raise one class, `A
|
|
|
138
156
|
| `run_id` | the id of the run the call started or read, once the press's `Location` or a run body named it, else `None` |
|
|
139
157
|
| `run` | the run, for a failed run or a timeout, else `None`; an `AsyncRun` from `AsyncAbyss` |
|
|
140
158
|
|
|
141
|
-
- **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).
|
|
159
|
+
- **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` and `run_files_waking` (409), `rate_limited` (429), `server_error` (500).
|
|
142
160
|
- **A failed run:** `widget_fault`, `platform_fault` or `budget_exceeded`, with the run as `run`.
|
|
143
|
-
- **Library-only codes:** `timeout` (see [Timeouts](#timeouts)); `connection_error`, when the connection still drops before any answer after the retries, with the cause as `__cause__`;
|
|
161
|
+
- **Library-only codes:** `timeout` (see [Timeouts](#timeouts)); `connection_error`, when the connection still drops before any answer after the retries, with the cause as `__cause__`; `unexpected_response`, when an answer is not what the route sends; and `files_asleep` and `files_waking`, raised by `run.save()` as `FilesAsleepError`.
|
|
144
162
|
|
|
145
163
|
## Retries
|
|
146
164
|
|
|
@@ -183,7 +201,7 @@ async with AsyncAbyss() as abyss: # or abyss = AsyncAbyss(), then await abyss.c
|
|
|
183
201
|
run = await abyss.runs.get(queued.id, wait=True)
|
|
184
202
|
```
|
|
185
203
|
|
|
186
|
-
- **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`.
|
|
204
|
+
- **Runs:** it returns an `AsyncRun`, with the same fields as `Run`. Its `save(dir)`, `wake()` 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`.
|
|
187
205
|
- **Files** are uploaded as concurrent tasks, each read from disk in a thread.
|
|
188
206
|
- **`timeout=`** stops the waiting, as with `Abyss`. Cancelling the task stops it too, and raises `CancelledError`, not `AbyssError`.
|
|
189
207
|
|
|
@@ -54,7 +54,7 @@ run = abyss.run(widget, input, max_price=None, idempotency_key=None, timeout=Non
|
|
|
54
54
|
|
|
55
55
|
The press holds until the run ends. When the hold ends early (a deploy cuts it, the network goes down, the API's holds are all taken, or an hour passes), `run()` re-attaches with `GET <Location>?wait=true` until the run ends. Re-attaching has no limit and is not a retry. Connecting has 10 s, and 30 s of silence counts as a cut.
|
|
56
56
|
|
|
57
|
-
A **run** is a dataclass with `/v1`'s fields: `id`, `widget`, `status` (`queued`, `running`, `succeeded` or `failed`), `price`, `result` (the Widget's JSON, a `dict` when it wrote an object), `output_files`, `error`, `created_at`, `started_at` and `ended_at`.
|
|
57
|
+
A **run** is a dataclass with `/v1`'s fields: `id`, `widget`, `status` (`queued`, `running`, `succeeded` or `failed`), `price`, `result` (the Widget's JSON, a `dict` when it wrote an object), `output_files`, `files` (`ready`, `asleep` or `waking`, see [Old big runs](#old-big-runs)), `error`, `created_at`, `started_at` and `ended_at`.
|
|
58
58
|
|
|
59
59
|
## The routes
|
|
60
60
|
|
|
@@ -65,6 +65,7 @@ Under `run()` there is one method per route. Each also takes `timeout=` as a key
|
|
|
65
65
|
| `abyss.runs.create(widget, input, max_price=None, idempotency_key=None)` | `POST /v1/widgets/{widget}/runs` with `"wait": false` | the `Run`, `queued`, at once |
|
|
66
66
|
| `abyss.runs.get(id, wait=False)` | `GET /v1/runs/{id}`, with `?wait=true` when `wait` | the `Run` |
|
|
67
67
|
| `abyss.runs.list(limit=None, starting_after=None)` | `GET /v1/runs?limit=&starting_after=` | a `RunList`: `data`, `has_more`, newest first |
|
|
68
|
+
| `abyss.runs.wake(id)` | `POST /v1/runs/{id}/wake` | the `Run`, `waking` when it was asleep |
|
|
68
69
|
| `abyss.widgets.get(widget)` | `GET /v1/widgets/{widget}` | a `Widget`: `id`, `name`, `description`, `url`, `price`, `free_runs`, `input_schema` |
|
|
69
70
|
| `abyss.uploads.create(widget, field, filename, size=None)` | `POST /v1/widgets/{widget}/uploads` | an `Upload`: `id`, `upload.url`, `upload.fields` |
|
|
70
71
|
| `abyss.key()` | `GET /v1/key` | a `Key`: `name`, `key`, `user`, `spend_cap`, `spent_this_month`, `created_at` |
|
|
@@ -104,9 +105,26 @@ paths = run.save("output") # [Path("output/charts/a.png"), Path("output/output.
|
|
|
104
105
|
- **`run.save(dir)`** writes every output file under `dir` at its `path`, making folders, and returns the paths it wrote.
|
|
105
106
|
- **Each of `run.output_files`** has `f.save(path)` and `f.read()`, which returns `bytes`. An expired URL is refreshed once, by reading the run again with `GET /v1/runs/{id}`. A file of 64 MiB or more downloads as 16 parallel byte ranges of its one URL.
|
|
106
107
|
|
|
108
|
+
## Old big runs
|
|
109
|
+
|
|
110
|
+
A big run (its files total 50 MiB or more) that nobody has opened for six months falls asleep: its larger files sleep in deep storage, and the run reads `files == "asleep"`. Its `result` reads as before, and reading the run never wakes it. Most callers never meet one.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from abysshub import FilesAsleepError
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
run.save("output")
|
|
117
|
+
except FilesAsleepError as error:
|
|
118
|
+
if error.code == "files_asleep":
|
|
119
|
+
run.wake() # back within 12 hours, with an email
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
- **`run.wake()`** (or `abyss.runs.wake(id)`) calls `POST /v1/runs/{id}/wake` and returns the run, `files == "waking"`. Its files are back within 12 hours, when it reads `ready`. Waking a run that is not asleep changes nothing.
|
|
123
|
+
- **`run.save(dir)`** on a run that is `asleep` or `waking` raises `FilesAsleepError`, with `code` `files_asleep` or `files_waking`, before any download.
|
|
124
|
+
|
|
107
125
|
## Errors
|
|
108
126
|
|
|
109
|
-
A refusal, a failed run, a timeout and a lost connection all raise one class, `AbyssError`. There is no class per status: branch on `code`.
|
|
127
|
+
A refusal, a failed run, a timeout and a lost connection all raise one class, `AbyssError`. There is no class per status: branch on `code`. Its one subclass, `FilesAsleepError`, is what `run.save()` raises on an old big run (see [Old big runs](#old-big-runs)).
|
|
110
128
|
|
|
111
129
|
| Field | |
|
|
112
130
|
|---|---|
|
|
@@ -121,9 +139,9 @@ A refusal, a failed run, a timeout and a lost connection all raise one class, `A
|
|
|
121
139
|
| `run_id` | the id of the run the call started or read, once the press's `Location` or a run body named it, else `None` |
|
|
122
140
|
| `run` | the run, for a failed run or a timeout, else `None`; an `AsyncRun` from `AsyncAbyss` |
|
|
123
141
|
|
|
124
|
-
- **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).
|
|
142
|
+
- **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` and `run_files_waking` (409), `rate_limited` (429), `server_error` (500).
|
|
125
143
|
- **A failed run:** `widget_fault`, `platform_fault` or `budget_exceeded`, with the run as `run`.
|
|
126
|
-
- **Library-only codes:** `timeout` (see [Timeouts](#timeouts)); `connection_error`, when the connection still drops before any answer after the retries, with the cause as `__cause__`;
|
|
144
|
+
- **Library-only codes:** `timeout` (see [Timeouts](#timeouts)); `connection_error`, when the connection still drops before any answer after the retries, with the cause as `__cause__`; `unexpected_response`, when an answer is not what the route sends; and `files_asleep` and `files_waking`, raised by `run.save()` as `FilesAsleepError`.
|
|
127
145
|
|
|
128
146
|
## Retries
|
|
129
147
|
|
|
@@ -166,7 +184,7 @@ async with AsyncAbyss() as abyss: # or abyss = AsyncAbyss(), then await abyss.c
|
|
|
166
184
|
run = await abyss.runs.get(queued.id, wait=True)
|
|
167
185
|
```
|
|
168
186
|
|
|
169
|
-
- **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`.
|
|
187
|
+
- **Runs:** it returns an `AsyncRun`, with the same fields as `Run`. Its `save(dir)`, `wake()` 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`.
|
|
170
188
|
- **Files** are uploaded as concurrent tasks, each read from disk in a thread.
|
|
171
189
|
- **`timeout=`** stops the waiting, as with `Abyss`. Cancelling the task stops it too, and raises `CancelledError`, not `AbyssError`.
|
|
172
190
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
"""The official Python library for the Abyss API."""
|
|
2
2
|
from ._async_client import AsyncAbyss, AsyncRuns, AsyncUploads, AsyncWidgets
|
|
3
3
|
from ._client import Abyss, Runs, Uploads, Widgets
|
|
4
|
-
from ._error import AbyssError
|
|
4
|
+
from ._error import AbyssError, FilesAsleepError
|
|
5
5
|
from ._files import FileData, InputFile, file
|
|
6
|
-
from ._run import AsyncRun, AsyncRunFile, Run, RunError, RunFile, RunStatus
|
|
6
|
+
from ._run import AsyncRun, AsyncRunFile, Run, RunError, RunFile, RunFiles, RunStatus
|
|
7
7
|
from ._types import AsyncRunList, FreeRuns, Key, KeyUser, RunList, Upload, UploadForm, Widget
|
|
8
8
|
|
|
9
9
|
__all__ = [
|
|
@@ -17,6 +17,7 @@ __all__ = [
|
|
|
17
17
|
"AsyncUploads",
|
|
18
18
|
"AsyncWidgets",
|
|
19
19
|
"FileData",
|
|
20
|
+
"FilesAsleepError",
|
|
20
21
|
"FreeRuns",
|
|
21
22
|
"InputFile",
|
|
22
23
|
"Key",
|
|
@@ -24,6 +25,7 @@ __all__ = [
|
|
|
24
25
|
"Run",
|
|
25
26
|
"RunError",
|
|
26
27
|
"RunFile",
|
|
28
|
+
"RunFiles",
|
|
27
29
|
"RunList",
|
|
28
30
|
"RunStatus",
|
|
29
31
|
"Runs",
|
|
@@ -69,6 +69,12 @@ class AsyncRuns:
|
|
|
69
69
|
page = await client._get(client._start(timeout), url, _is_run_list, "The run list answered without data.")
|
|
70
70
|
return AsyncRunList(data=[client._run(data) for data in page["data"]], has_more=page["has_more"])
|
|
71
71
|
|
|
72
|
+
async def wake(self, id: str, *, timeout: float | None = None) -> AsyncRun:
|
|
73
|
+
"""Wakes a run whose files are asleep, and returns it, ``waking``. A run that is not asleep is returned as it is."""
|
|
74
|
+
client = self._client
|
|
75
|
+
reply = await client._send(client._start(timeout), "POST", client._wake_url(id))
|
|
76
|
+
return client._run(_expect(reply, _is_run, "The wake answered without a run."))
|
|
77
|
+
|
|
72
78
|
|
|
73
79
|
class AsyncWidgets:
|
|
74
80
|
"""`/v1`'s Widget route, awaited."""
|
|
@@ -241,8 +247,11 @@ class AsyncAbyss(_Base[AsyncRun]):
|
|
|
241
247
|
async def _reread(self, id: str) -> dict[str, Any]:
|
|
242
248
|
return await self._get_run(_Call(None, None), id, False)
|
|
243
249
|
|
|
250
|
+
async def _wake(self, id: str, timeout: float | None) -> AsyncRun:
|
|
251
|
+
return await self.runs.wake(id, timeout=timeout)
|
|
252
|
+
|
|
244
253
|
def _run(self, data: dict[str, Any]) -> AsyncRun:
|
|
245
|
-
return parse_async_run(data, self._reread, self._download)
|
|
254
|
+
return parse_async_run(data, self._reread, self._download, self._wake)
|
|
246
255
|
|
|
247
256
|
async def _get(self, call: _Call, url: str, is_: Callable[[Any], bool], message: str) -> Any:
|
|
248
257
|
"""Reads ``url`` once and returns its body, when it is what the route answers."""
|
|
@@ -118,6 +118,12 @@ class Runs:
|
|
|
118
118
|
page = client._get(client._start(timeout), url, _is_run_list, "The run list answered without data.")
|
|
119
119
|
return RunList(data=[client._run(data) for data in page["data"]], has_more=page["has_more"])
|
|
120
120
|
|
|
121
|
+
def wake(self, id: str, *, timeout: float | None = None) -> Run:
|
|
122
|
+
"""Wakes a run whose files are asleep, and returns it, ``waking``. A run that is not asleep is returned as it is."""
|
|
123
|
+
client = self._client
|
|
124
|
+
reply = client._send(client._start(timeout), "POST", client._wake_url(id))
|
|
125
|
+
return client._run(_expect(reply, _is_run, "The wake answered without a run."))
|
|
126
|
+
|
|
121
127
|
|
|
122
128
|
class Widgets:
|
|
123
129
|
"""`/v1`'s Widget route."""
|
|
@@ -261,6 +267,9 @@ class _Base(Generic[R]):
|
|
|
261
267
|
def _run_url(self, id: str, wait: bool) -> str:
|
|
262
268
|
return f"{self.base_url}/v1/runs/{quote(id, safe='')}" + ("?wait=true" if wait else "")
|
|
263
269
|
|
|
270
|
+
def _wake_url(self, id: str) -> str:
|
|
271
|
+
return f"{self._run_url(id, False)}/wake"
|
|
272
|
+
|
|
264
273
|
def _list_url(self, limit: int | None, starting_after: str | None) -> str:
|
|
265
274
|
params: dict[str, str] = {}
|
|
266
275
|
if limit is not None:
|
|
@@ -472,8 +481,11 @@ class Abyss(_Base[Run]):
|
|
|
472
481
|
def _reread(self, id: str) -> dict[str, Any]:
|
|
473
482
|
return self._get_run(_Call(None, None), id, False)
|
|
474
483
|
|
|
484
|
+
def _wake(self, id: str, timeout: float | None) -> Run:
|
|
485
|
+
return self.runs.wake(id, timeout=timeout)
|
|
486
|
+
|
|
475
487
|
def _run(self, data: dict[str, Any]) -> Run:
|
|
476
|
-
return parse_run(data, self._reread, self._download)
|
|
488
|
+
return parse_run(data, self._reread, self._download, self._wake)
|
|
477
489
|
|
|
478
490
|
def _get(self, call: _Call, url: str, is_: Callable[[Any], bool], message: str) -> Any:
|
|
479
491
|
"""Reads ``url`` once and returns its body, when it is what the route answers."""
|
|
@@ -57,4 +57,10 @@ class AbyssError(Exception):
|
|
|
57
57
|
self.run = run
|
|
58
58
|
|
|
59
59
|
def __repr__(self) -> str:
|
|
60
|
-
return f"
|
|
60
|
+
return f"{type(self).__name__}(code={self.code!r}, message={self.message!r})"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class FilesAsleepError(AbyssError):
|
|
64
|
+
"""What ``run.save()`` raises when the run's files are asleep, with ``code ==
|
|
65
|
+
"files_asleep"``: ``run.wake()`` brings them back within 12 hours. While they come back,
|
|
66
|
+
``files_waking``. It is an ``AbyssError``, carrying the run as ``run``."""
|
|
@@ -9,7 +9,7 @@ from dataclasses import dataclass, field
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from typing import Any, Generic, Literal, Optional, TypeVar
|
|
11
11
|
|
|
12
|
-
from ._error import AbyssError
|
|
12
|
+
from ._error import AbyssError, FilesAsleepError
|
|
13
13
|
|
|
14
14
|
# An alias is evaluated at import, where Python 3.9 has no `X | None`: hence Optional.
|
|
15
15
|
Download = Callable[[str, Optional[str]], tuple[int, Callable[[], bytes]]]
|
|
@@ -19,8 +19,12 @@ that reads its content."""
|
|
|
19
19
|
Reread = Callable[[str], dict[str, Any]]
|
|
20
20
|
"""Reads a run again with ``GET /v1/runs/{id}``."""
|
|
21
21
|
|
|
22
|
+
Wake = Callable[[str, Optional[float]], "Run"]
|
|
23
|
+
"""Wakes a run with ``POST /v1/runs/{id}/wake``, under a ``timeout`` or none: returns the run."""
|
|
24
|
+
|
|
22
25
|
AsyncDownload = Callable[[str, Optional[str]], Awaitable[tuple[int, Callable[[], Awaitable[bytes]]]]]
|
|
23
26
|
AsyncReread = Callable[[str], Awaitable[dict[str, Any]]]
|
|
27
|
+
AsyncWake = Callable[[str, Optional[float]], Awaitable["AsyncRun"]]
|
|
24
28
|
|
|
25
29
|
RANGED_FROM = 64 * 1024 * 1024
|
|
26
30
|
"""A file this many bytes or larger downloads as parallel byte ranges: 64 MiB."""
|
|
@@ -31,6 +35,11 @@ RANGES = 16
|
|
|
31
35
|
RunStatus = Literal["queued", "running", "succeeded", "failed"]
|
|
32
36
|
"""A run's place: ``succeeded`` and ``failed`` are final."""
|
|
33
37
|
|
|
38
|
+
RunFiles = Literal["ready", "asleep", "waking"]
|
|
39
|
+
"""Whether a run's files can be read now. A big run nobody has opened for six months falls
|
|
40
|
+
asleep: its larger files sleep until ``run.wake()``, then read ``waking`` until they are
|
|
41
|
+
back, within 12 hours."""
|
|
42
|
+
|
|
34
43
|
|
|
35
44
|
@dataclass
|
|
36
45
|
class RunError:
|
|
@@ -146,6 +155,7 @@ class _RunFields(Generic[F]):
|
|
|
146
155
|
created_at: str
|
|
147
156
|
started_at: str | None
|
|
148
157
|
ended_at: str | None
|
|
158
|
+
files: RunFiles = "ready"
|
|
149
159
|
|
|
150
160
|
def _targets(self, dir: str | os.PathLike[str]) -> list[tuple[F, Path]]:
|
|
151
161
|
"""Each output file with the path it is saved at under ``dir``, refusing one that leaves it."""
|
|
@@ -177,9 +187,21 @@ class Run(_RunFields[RunFile]):
|
|
|
177
187
|
"""A run, with `/v1`'s names."""
|
|
178
188
|
|
|
179
189
|
_reread: Reread | None = field(default=None, init=False, repr=False, compare=False)
|
|
190
|
+
_wake: Wake | None = field(default=None, init=False, repr=False, compare=False)
|
|
191
|
+
|
|
192
|
+
def wake(self, *, timeout: float | None = None) -> Run:
|
|
193
|
+
"""Wakes the run's files with ``POST /v1/runs/{id}/wake`` and returns the run,
|
|
194
|
+
``waking`` when it was asleep: its files are back within 12 hours. A run that is not
|
|
195
|
+
asleep is returned as it is."""
|
|
196
|
+
if self._wake is None:
|
|
197
|
+
raise _no_client_to_wake(self.id)
|
|
198
|
+
return self._wake(self.id, timeout)
|
|
180
199
|
|
|
181
200
|
def save(self, dir: str | os.PathLike[str]) -> list[Path]:
|
|
182
|
-
"""Saves every output file under ``dir``, at its ``path``, and returns the paths it
|
|
201
|
+
"""Saves every output file under ``dir``, at its ``path``, and returns the paths it
|
|
202
|
+
wrote. A run whose files are asleep or waking raises ``FilesAsleepError`` before any
|
|
203
|
+
download."""
|
|
204
|
+
_check_awake(self)
|
|
183
205
|
return [output.save(target) for output, target in self._targets(dir)]
|
|
184
206
|
|
|
185
207
|
def _refresh_urls(self) -> None:
|
|
@@ -193,9 +215,19 @@ class AsyncRun(_RunFields[AsyncRunFile]):
|
|
|
193
215
|
"""``Run`` from ``AsyncAbyss``: ``save()`` is awaited."""
|
|
194
216
|
|
|
195
217
|
_reread: AsyncReread | None = field(default=None, init=False, repr=False, compare=False)
|
|
218
|
+
_wake: AsyncWake | None = field(default=None, init=False, repr=False, compare=False)
|
|
219
|
+
|
|
220
|
+
async def wake(self, *, timeout: float | None = None) -> AsyncRun:
|
|
221
|
+
"""Wakes the run's files, as ``Run.wake()`` does."""
|
|
222
|
+
if self._wake is None:
|
|
223
|
+
raise _no_client_to_wake(self.id)
|
|
224
|
+
return await self._wake(self.id, timeout)
|
|
196
225
|
|
|
197
226
|
async def save(self, dir: str | os.PathLike[str]) -> list[Path]:
|
|
198
|
-
"""Saves every output file under ``dir``, at its ``path``, and returns the paths it
|
|
227
|
+
"""Saves every output file under ``dir``, at its ``path``, and returns the paths it
|
|
228
|
+
wrote. A run whose files are asleep or waking raises ``FilesAsleepError`` before any
|
|
229
|
+
download."""
|
|
230
|
+
_check_awake(self)
|
|
199
231
|
return [await output.save(target) for output, target in self._targets(dir)]
|
|
200
232
|
|
|
201
233
|
async def _refresh_urls(self) -> None:
|
|
@@ -204,10 +236,17 @@ class AsyncRun(_RunFields[AsyncRunFile]):
|
|
|
204
236
|
self._refreshed(await self._reread(self.id))
|
|
205
237
|
|
|
206
238
|
|
|
207
|
-
def parse_run(
|
|
208
|
-
|
|
239
|
+
def parse_run(
|
|
240
|
+
data: dict[str, Any],
|
|
241
|
+
reread: Reread | None = None,
|
|
242
|
+
download: Download | None = None,
|
|
243
|
+
wake: Wake | None = None,
|
|
244
|
+
) -> Run:
|
|
245
|
+
"""The run `/v1` sent as ``data``. With ``reread`` and ``download``, its output files
|
|
246
|
+
can be read, and with ``wake`` it can be woken."""
|
|
209
247
|
run = Run(**_fields(data), output_files=[RunFile(**f) for f in _files(data)])
|
|
210
248
|
run._reread = reread
|
|
249
|
+
run._wake = wake
|
|
211
250
|
for output in run.output_files:
|
|
212
251
|
output._download = download
|
|
213
252
|
output._refresh = run._refresh_urls
|
|
@@ -215,11 +254,15 @@ def parse_run(data: dict[str, Any], reread: Reread | None = None, download: Down
|
|
|
215
254
|
|
|
216
255
|
|
|
217
256
|
def parse_async_run(
|
|
218
|
-
data: dict[str, Any],
|
|
257
|
+
data: dict[str, Any],
|
|
258
|
+
reread: AsyncReread | None = None,
|
|
259
|
+
download: AsyncDownload | None = None,
|
|
260
|
+
wake: AsyncWake | None = None,
|
|
219
261
|
) -> AsyncRun:
|
|
220
262
|
"""``parse_run()`` for ``AsyncAbyss``."""
|
|
221
263
|
run = AsyncRun(**_fields(data), output_files=[AsyncRunFile(**f) for f in _files(data)])
|
|
222
264
|
run._reread = reread
|
|
265
|
+
run._wake = wake
|
|
223
266
|
for output in run.output_files:
|
|
224
267
|
output._download = download
|
|
225
268
|
output._refresh = run._refresh_urls
|
|
@@ -238,6 +281,7 @@ def _fields(data: dict[str, Any]) -> dict[str, Any]:
|
|
|
238
281
|
"created_at": data.get("created_at", ""),
|
|
239
282
|
"started_at": data.get("started_at"),
|
|
240
283
|
"ended_at": data.get("ended_at"),
|
|
284
|
+
"files": data.get("files", "ready"),
|
|
241
285
|
}
|
|
242
286
|
|
|
243
287
|
|
|
@@ -252,6 +296,21 @@ def _no_client(path: str) -> AbyssError:
|
|
|
252
296
|
return AbyssError("unexpected_response", f"The output file {path} has no client to download it.")
|
|
253
297
|
|
|
254
298
|
|
|
299
|
+
def _check_awake(run: Run | AsyncRun) -> None:
|
|
300
|
+
"""Raises ``FilesAsleepError`` when the run's files are asleep or waking."""
|
|
301
|
+
if run.files == "asleep":
|
|
302
|
+
message = f"The files of run {run.id} are asleep. Call run.wake(): they are back within 12 hours."
|
|
303
|
+
elif run.files == "waking":
|
|
304
|
+
message = f"The files of run {run.id} are waking after run.wake(): they are back within 12 hours."
|
|
305
|
+
else:
|
|
306
|
+
return
|
|
307
|
+
raise FilesAsleepError(f"files_{run.files}", message, run_id=run.id, run=run)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _no_client_to_wake(id: str) -> AbyssError:
|
|
311
|
+
return AbyssError("unexpected_response", f"The run {id} has no client to wake it.", run_id=id)
|
|
312
|
+
|
|
313
|
+
|
|
255
314
|
def _downloaded(path: str, status: int, content: bytes) -> bytes:
|
|
256
315
|
if not 200 <= status < 300:
|
|
257
316
|
raise _answered(path, status)
|
|
@@ -46,6 +46,8 @@ def perform(abyss: Abyss, call: dict[str, Any], input: dict[str, Any]) -> Any:
|
|
|
46
46
|
return abyss.runs.get(call["id"], **options)
|
|
47
47
|
if method == "runs.list":
|
|
48
48
|
return abyss.runs.list(**options)
|
|
49
|
+
if method == "runs.wake":
|
|
50
|
+
return abyss.runs.wake(call["id"], **options)
|
|
49
51
|
if method == "widgets.get":
|
|
50
52
|
return abyss.widgets.get(call["widget"], **options)
|
|
51
53
|
if method == "uploads.create":
|
|
@@ -67,6 +69,8 @@ async def perform_async(abyss: AsyncAbyss, call: dict[str, Any], input: dict[str
|
|
|
67
69
|
return await abyss.runs.get(call["id"], **options)
|
|
68
70
|
if method == "runs.list":
|
|
69
71
|
return await abyss.runs.list(**options)
|
|
72
|
+
if method == "runs.wake":
|
|
73
|
+
return await abyss.runs.wake(call["id"], **options)
|
|
70
74
|
if method == "widgets.get":
|
|
71
75
|
return await abyss.widgets.get(call["widget"], **options)
|
|
72
76
|
if method == "uploads.create":
|
|
@@ -10,9 +10,11 @@ import pytest
|
|
|
10
10
|
from caller import caller_input, perform, perform_async, with_files
|
|
11
11
|
from fake_server import Replay, load_exchanges, pattern
|
|
12
12
|
|
|
13
|
-
from abysshub import Abyss, AbyssError, AsyncAbyss, AsyncRun, Run
|
|
13
|
+
from abysshub import Abyss, AbyssError, AsyncAbyss, AsyncRun, FilesAsleepError, Run
|
|
14
14
|
|
|
15
15
|
KEY = "abyss_sk_fake_for_tests"
|
|
16
|
+
FILES_ASLEEP = {"files_asleep", "files_waking"}
|
|
17
|
+
"""The codes ``run.save()`` raises as ``FilesAsleepError``."""
|
|
16
18
|
RECORDINGS = load_exchanges()
|
|
17
19
|
|
|
18
20
|
|
|
@@ -33,19 +35,26 @@ def assert_fields(actual: Any, expected: Any, where: str) -> None:
|
|
|
33
35
|
assert actual == expected, where
|
|
34
36
|
|
|
35
37
|
|
|
36
|
-
def call_sync(base: str, call: dict[str, Any], dir: Path, files: dict[str, str]) -> tuple[Any, Any, Any]:
|
|
37
|
-
"""The recorded call through ``Abyss``: its value, its error
|
|
38
|
+
def call_sync(base: str, call: dict[str, Any], dir: Path, files: dict[str, str]) -> tuple[Any, Any, Any, Any]:
|
|
39
|
+
"""The recorded call through ``Abyss``: its value, its error, what ``run.save()``
|
|
40
|
+
answered or raised, and what ``run.wake()`` answered."""
|
|
38
41
|
save = call.get("save")
|
|
39
42
|
with Abyss(api_key=KEY, base_url=base, **(call.get("client") or {})) as abyss:
|
|
40
43
|
try:
|
|
41
44
|
value, error = perform(abyss, call, caller_input(call.get("input") or {}, dir, files)), None
|
|
42
45
|
except AbyssError as raised:
|
|
43
46
|
value, error = None, raised
|
|
44
|
-
saved =
|
|
45
|
-
|
|
47
|
+
saved: Any = None
|
|
48
|
+
if save and isinstance(value, Run):
|
|
49
|
+
try:
|
|
50
|
+
saved = value.save(dir / save)
|
|
51
|
+
except AbyssError as raised:
|
|
52
|
+
saved = raised
|
|
53
|
+
woken = value.wake() if call.get("wake") and isinstance(value, Run) else None
|
|
54
|
+
return value, error, saved, woken
|
|
46
55
|
|
|
47
56
|
|
|
48
|
-
async def call_async(base: str, call: dict[str, Any], dir: Path, files: dict[str, str]) -> tuple[Any, Any, Any]:
|
|
57
|
+
async def call_async(base: str, call: dict[str, Any], dir: Path, files: dict[str, str]) -> tuple[Any, Any, Any, Any]:
|
|
49
58
|
"""The recorded call through ``AsyncAbyss``."""
|
|
50
59
|
save = call.get("save")
|
|
51
60
|
async with AsyncAbyss(api_key=KEY, base_url=base, **(call.get("client") or {})) as abyss:
|
|
@@ -53,8 +62,14 @@ async def call_async(base: str, call: dict[str, Any], dir: Path, files: dict[str
|
|
|
53
62
|
value, error = await perform_async(abyss, call, caller_input(call.get("input") or {}, dir, files)), None
|
|
54
63
|
except AbyssError as raised:
|
|
55
64
|
value, error = None, raised
|
|
56
|
-
saved =
|
|
57
|
-
|
|
65
|
+
saved: Any = None
|
|
66
|
+
if save and isinstance(value, AsyncRun):
|
|
67
|
+
try:
|
|
68
|
+
saved = await value.save(dir / save)
|
|
69
|
+
except AbyssError as raised:
|
|
70
|
+
saved = raised
|
|
71
|
+
woken = await value.wake() if call.get("wake") and isinstance(value, AsyncRun) else None
|
|
72
|
+
return value, error, saved, woken
|
|
58
73
|
|
|
59
74
|
|
|
60
75
|
@pytest.mark.parametrize("client", ["Abyss", "AsyncAbyss"])
|
|
@@ -67,13 +82,15 @@ def test_replay(recording: dict[str, Any], client: str) -> None:
|
|
|
67
82
|
try:
|
|
68
83
|
with with_files(files) as dir:
|
|
69
84
|
if client == "Abyss":
|
|
70
|
-
value, error, saved = call_sync(server.base, call, dir, files)
|
|
85
|
+
value, error, saved, woken = call_sync(server.base, call, dir, files)
|
|
71
86
|
else:
|
|
72
|
-
value, error, saved = asyncio.run(call_async(server.base, call, dir, files))
|
|
87
|
+
value, error, saved, woken = asyncio.run(call_async(server.base, call, dir, files))
|
|
88
|
+
outcome = recording["outcome"]
|
|
89
|
+
if isinstance(saved, AbyssError) and "save_error" not in outcome:
|
|
90
|
+
raise saved
|
|
73
91
|
|
|
74
92
|
assert server.problems == []
|
|
75
93
|
assert server.remaining() == 0, "every recorded request was sent"
|
|
76
|
-
outcome = recording["outcome"]
|
|
77
94
|
if "run" in outcome:
|
|
78
95
|
assert isinstance(value, (Run, AsyncRun)), f"expected a run, got {error!r}"
|
|
79
96
|
assert_fields(value, outcome["run"], "run")
|
|
@@ -89,6 +106,13 @@ def test_replay(recording: dict[str, Any], client: str) -> None:
|
|
|
89
106
|
assert error.run is None
|
|
90
107
|
else:
|
|
91
108
|
assert_fields(error.run, run, "error.run")
|
|
109
|
+
if "save_error" in outcome:
|
|
110
|
+
assert isinstance(saved, AbyssError), f"expected run.save() to raise, got {saved!r}"
|
|
111
|
+
assert isinstance(saved, FilesAsleepError) == (outcome["save_error"]["code"] in FILES_ASLEEP)
|
|
112
|
+
assert_fields(saved, outcome["save_error"], "save_error")
|
|
113
|
+
if "woken" in outcome:
|
|
114
|
+
assert isinstance(woken, (Run, AsyncRun)), f"expected run.wake() to return a run, got {woken!r}"
|
|
115
|
+
assert_fields(woken, outcome["woken"], "woken")
|
|
92
116
|
if "saved" in outcome:
|
|
93
117
|
assert saved == [dir / save / path for path in outcome["saved"]], "run.save() returns the paths it wrote"
|
|
94
118
|
# A saved ``pattern`` is a whole file of that many generated bytes.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|