abysshub 0.1.1.dev5__tar.gz → 0.1.2.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 (22) hide show
  1. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/PKG-INFO +26 -8
  2. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/README.md +24 -6
  3. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/pyproject.toml +2 -2
  4. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/src/abysshub/__init__.py +4 -2
  5. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/src/abysshub/_async_client.py +10 -1
  6. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/src/abysshub/_client.py +13 -1
  7. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/src/abysshub/_error.py +7 -1
  8. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/src/abysshub/_run.py +69 -9
  9. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/tests/caller.py +4 -0
  10. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/tests/test_replay.py +35 -11
  11. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/.gitignore +0 -0
  12. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/LICENSE +0 -0
  13. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/src/abysshub/_files.py +0 -0
  14. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/src/abysshub/_http.py +0 -0
  15. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/src/abysshub/_types.py +0 -0
  16. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/src/abysshub/py.typed +0 -0
  17. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/tests/fake_server.py +0 -0
  18. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/tests/test_client.py +0 -0
  19. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/tests/test_files.py +0 -0
  20. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/tests/test_http.py +0 -0
  21. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/tests/test_package.py +0 -0
  22. {abysshub-0.1.1.dev5 → abysshub-0.1.2.dev1}/tests/test_timeout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: abysshub
3
- Version: 0.1.1.dev5
3
+ Version: 0.1.2.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
@@ -9,7 +9,7 @@ License-Expression: MIT
9
9
  License-File: LICENSE
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Classifier: Typing :: Typed
12
- Requires-Python: >=3.10
12
+ Requires-Python: >=3.9
13
13
  Requires-Dist: httpx<1,>=0.27
14
14
  Provides-Extra: dev
15
15
  Requires-Dist: pytest; extra == 'dev'
@@ -35,7 +35,7 @@ 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`. From async code, use `AsyncAbyss` (see [Async](#async)):
38
+ It runs on Python 3.9+, with one dependency, `httpx`. From async code, use `AsyncAbyss` (see [Async](#async)):
39
39
 
40
40
  ```python
41
41
  async with AsyncAbyss() as abyss:
@@ -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__`; and `unexpected_response`, when an answer is not what the route sends.
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
 
@@ -18,7 +18,7 @@ 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`. From async code, use `AsyncAbyss` (see [Async](#async)):
21
+ It runs on Python 3.9+, with one dependency, `httpx`. From async code, use `AsyncAbyss` (see [Async](#async)):
22
22
 
23
23
  ```python
24
24
  async with AsyncAbyss() as abyss:
@@ -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__`; and `unexpected_response`, when an answer is not what the route sends.
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
 
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "abysshub"
7
- version = "0.1.1.dev5"
7
+ version = "0.1.2.dev1"
8
8
  description = "The official Python library for the Abyss API."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
12
- requires-python = ">=3.10"
12
+ requires-python = ">=3.9"
13
13
  dependencies = ["httpx>=0.27,<1"]
14
14
  classifiers = [
15
15
  "Programming Language :: Python :: 3",
@@ -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"AbyssError(code={self.code!r}, message={self.message!r})"
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``."""
@@ -7,19 +7,24 @@ from collections.abc import Awaitable, Callable
7
7
  from concurrent.futures import ThreadPoolExecutor
8
8
  from dataclasses import dataclass, field
9
9
  from pathlib import Path
10
- from typing import Any, Generic, Literal, TypeVar
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
- Download = Callable[[str, str | None], tuple[int, Callable[[], bytes]]]
14
+ # An alias is evaluated at import, where Python 3.9 has no `X | None`: hence Optional.
15
+ Download = Callable[[str, Optional[str]], tuple[int, Callable[[], bytes]]]
15
16
  """Opens a URL in storage, with a ``Range`` header or none: answers its status, and a call
16
17
  that reads its content."""
17
18
 
18
19
  Reread = Callable[[str], dict[str, Any]]
19
20
  """Reads a run again with ``GET /v1/runs/{id}``."""
20
21
 
21
- AsyncDownload = Callable[[str, str | None], Awaitable[tuple[int, Callable[[], Awaitable[bytes]]]]]
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
+
25
+ AsyncDownload = Callable[[str, Optional[str]], Awaitable[tuple[int, Callable[[], Awaitable[bytes]]]]]
22
26
  AsyncReread = Callable[[str], Awaitable[dict[str, Any]]]
27
+ AsyncWake = Callable[[str, Optional[float]], Awaitable["AsyncRun"]]
23
28
 
24
29
  RANGED_FROM = 64 * 1024 * 1024
25
30
  """A file this many bytes or larger downloads as parallel byte ranges: 64 MiB."""
@@ -30,6 +35,11 @@ RANGES = 16
30
35
  RunStatus = Literal["queued", "running", "succeeded", "failed"]
31
36
  """A run's place: ``succeeded`` and ``failed`` are final."""
32
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
+
33
43
 
34
44
  @dataclass
35
45
  class RunError:
@@ -145,6 +155,7 @@ class _RunFields(Generic[F]):
145
155
  created_at: str
146
156
  started_at: str | None
147
157
  ended_at: str | None
158
+ files: RunFiles = "ready"
148
159
 
149
160
  def _targets(self, dir: str | os.PathLike[str]) -> list[tuple[F, Path]]:
150
161
  """Each output file with the path it is saved at under ``dir``, refusing one that leaves it."""
@@ -176,9 +187,21 @@ class Run(_RunFields[RunFile]):
176
187
  """A run, with `/v1`'s names."""
177
188
 
178
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)
179
199
 
180
200
  def save(self, dir: str | os.PathLike[str]) -> list[Path]:
181
- """Saves every output file under ``dir``, at its ``path``, and returns the paths it wrote."""
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)
182
205
  return [output.save(target) for output, target in self._targets(dir)]
183
206
 
184
207
  def _refresh_urls(self) -> None:
@@ -192,9 +215,19 @@ class AsyncRun(_RunFields[AsyncRunFile]):
192
215
  """``Run`` from ``AsyncAbyss``: ``save()`` is awaited."""
193
216
 
194
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)
195
225
 
196
226
  async def save(self, dir: str | os.PathLike[str]) -> list[Path]:
197
- """Saves every output file under ``dir``, at its ``path``, and returns the paths it wrote."""
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)
198
231
  return [await output.save(target) for output, target in self._targets(dir)]
199
232
 
200
233
  async def _refresh_urls(self) -> None:
@@ -203,10 +236,17 @@ class AsyncRun(_RunFields[AsyncRunFile]):
203
236
  self._refreshed(await self._reread(self.id))
204
237
 
205
238
 
206
- def parse_run(data: dict[str, Any], reread: Reread | None = None, download: Download | None = None) -> Run:
207
- """The run `/v1` sent as ``data``. With ``reread`` and ``download``, its output files can be read."""
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."""
208
247
  run = Run(**_fields(data), output_files=[RunFile(**f) for f in _files(data)])
209
248
  run._reread = reread
249
+ run._wake = wake
210
250
  for output in run.output_files:
211
251
  output._download = download
212
252
  output._refresh = run._refresh_urls
@@ -214,11 +254,15 @@ def parse_run(data: dict[str, Any], reread: Reread | None = None, download: Down
214
254
 
215
255
 
216
256
  def parse_async_run(
217
- data: dict[str, Any], reread: AsyncReread | None = None, download: AsyncDownload | None = None
257
+ data: dict[str, Any],
258
+ reread: AsyncReread | None = None,
259
+ download: AsyncDownload | None = None,
260
+ wake: AsyncWake | None = None,
218
261
  ) -> AsyncRun:
219
262
  """``parse_run()`` for ``AsyncAbyss``."""
220
263
  run = AsyncRun(**_fields(data), output_files=[AsyncRunFile(**f) for f in _files(data)])
221
264
  run._reread = reread
265
+ run._wake = wake
222
266
  for output in run.output_files:
223
267
  output._download = download
224
268
  output._refresh = run._refresh_urls
@@ -237,6 +281,7 @@ def _fields(data: dict[str, Any]) -> dict[str, Any]:
237
281
  "created_at": data.get("created_at", ""),
238
282
  "started_at": data.get("started_at"),
239
283
  "ended_at": data.get("ended_at"),
284
+ "files": data.get("files", "ready"),
240
285
  }
241
286
 
242
287
 
@@ -251,6 +296,21 @@ def _no_client(path: str) -> AbyssError:
251
296
  return AbyssError("unexpected_response", f"The output file {path} has no client to download it.")
252
297
 
253
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
+
254
314
  def _downloaded(path: str, status: int, content: bytes) -> bytes:
255
315
  if not 200 <= status < 300:
256
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 and what ``run.save()`` answered."""
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 = value.save(dir / save) if save and isinstance(value, Run) else None
45
- return value, error, saved
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 = await value.save(dir / save) if save and isinstance(value, AsyncRun) else None
57
- return value, error, saved
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