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.
Files changed (22) hide show
  1. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/PKG-INFO +24 -6
  2. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/README.md +23 -5
  3. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/pyproject.toml +1 -1
  4. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/__init__.py +4 -2
  5. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_async_client.py +10 -1
  6. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_client.py +13 -1
  7. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_error.py +7 -1
  8. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_run.py +65 -6
  9. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/caller.py +4 -0
  10. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_replay.py +35 -11
  11. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/.gitignore +0 -0
  12. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/LICENSE +0 -0
  13. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_files.py +0 -0
  14. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_http.py +0 -0
  15. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/_types.py +0 -0
  16. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/src/abysshub/py.typed +0 -0
  17. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/fake_server.py +0 -0
  18. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_client.py +0 -0
  19. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_files.py +0 -0
  20. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_http.py +0 -0
  21. {abysshub-0.1.1.dev6 → abysshub-0.1.2.dev2}/tests/test_package.py +0 -0
  22. {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.1.dev6
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__`; 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
 
@@ -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,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "abysshub"
7
- version = "0.1.1.dev6"
7
+ version = "0.1.2.dev2"
8
8
  description = "The official Python library for the Abyss API."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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``."""
@@ -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 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)
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 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)
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(data: dict[str, Any], reread: Reread | None = None, download: Download | None = None) -> Run:
208
- """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."""
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], 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,
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 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