abysshub 0.0.1.dev8__tar.gz → 0.0.1.dev9__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.0.1.dev8 → abysshub-0.0.1.dev9}/PKG-INFO +19 -1
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/README.md +18 -0
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/pyproject.toml +1 -1
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/src/abysshub/__init__.py +2 -1
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/src/abysshub/_client.py +91 -18
- abysshub-0.0.1.dev9/src/abysshub/_files.py +94 -0
- abysshub-0.0.1.dev9/src/abysshub/_run.py +130 -0
- abysshub-0.0.1.dev9/tests/caller.py +43 -0
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/tests/fake_server.py +54 -6
- abysshub-0.0.1.dev9/tests/test_files.py +117 -0
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/tests/test_replay.py +26 -27
- abysshub-0.0.1.dev8/src/abysshub/_run.py +0 -61
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/.gitignore +0 -0
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/LICENSE +0 -0
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/src/abysshub/_error.py +0 -0
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/src/abysshub/_http.py +0 -0
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/src/abysshub/py.typed +0 -0
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/tests/test_client.py +0 -0
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/tests/test_http.py +0 -0
- {abysshub-0.0.1.dev8 → abysshub-0.0.1.dev9}/tests/test_package.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: abysshub
|
|
3
|
-
Version: 0.0.1.
|
|
3
|
+
Version: 0.0.1.dev9
|
|
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
|
|
@@ -63,6 +63,24 @@ The press holds until the run ends. When the hold ends early (a deploy cuts it,
|
|
|
63
63
|
|
|
64
64
|
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`.
|
|
65
65
|
|
|
66
|
+
## Files
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from abysshub import Abyss, file
|
|
70
|
+
|
|
71
|
+
run = abyss.run("widget_1660", {
|
|
72
|
+
"report": file("report.pdf"),
|
|
73
|
+
"data": [file("jan.csv"), file(csv_bytes, filename="feb.csv"), "https://example.com/sales/mar.csv"],
|
|
74
|
+
})
|
|
75
|
+
paths = run.save("output") # [Path("output/charts/a.png"), Path("output/output.json")]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- **`file(path)`** is a file on disk, read when it is uploaded. A file Field also takes a `Path`, an open file (`open("report.pdf", "rb")`) or `bytes`. `file(data, filename=...)` names data. A file without a name uploads under its Field's name.
|
|
79
|
+
- **A string passes through** as an `https` URL or an upload id. A multi-file Field takes a list, which may mix all three kinds.
|
|
80
|
+
- **Before the press,** each file is uploaded with `POST /v1/widgets/{widget}/uploads`, then sent to storage, in parallel, and its id goes in the input. An upload refusal (`held_input_*`) raises before any press. A press that drops before any headers is sent again with the same ids; it never uploads again.
|
|
81
|
+
- **`run.save(dir)`** writes every output file under `dir` at its `path`, making folders, and returns the paths it wrote.
|
|
82
|
+
- **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}`.
|
|
83
|
+
|
|
66
84
|
## Errors
|
|
67
85
|
|
|
68
86
|
A refusal or a failed run raises one `AbyssError`. Branch on `code`:
|
|
@@ -46,6 +46,24 @@ The press holds until the run ends. When the hold ends early (a deploy cuts it,
|
|
|
46
46
|
|
|
47
47
|
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`.
|
|
48
48
|
|
|
49
|
+
## Files
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
from abysshub import Abyss, file
|
|
53
|
+
|
|
54
|
+
run = abyss.run("widget_1660", {
|
|
55
|
+
"report": file("report.pdf"),
|
|
56
|
+
"data": [file("jan.csv"), file(csv_bytes, filename="feb.csv"), "https://example.com/sales/mar.csv"],
|
|
57
|
+
})
|
|
58
|
+
paths = run.save("output") # [Path("output/charts/a.png"), Path("output/output.json")]
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- **`file(path)`** is a file on disk, read when it is uploaded. A file Field also takes a `Path`, an open file (`open("report.pdf", "rb")`) or `bytes`. `file(data, filename=...)` names data. A file without a name uploads under its Field's name.
|
|
62
|
+
- **A string passes through** as an `https` URL or an upload id. A multi-file Field takes a list, which may mix all three kinds.
|
|
63
|
+
- **Before the press,** each file is uploaded with `POST /v1/widgets/{widget}/uploads`, then sent to storage, in parallel, and its id goes in the input. An upload refusal (`held_input_*`) raises before any press. A press that drops before any headers is sent again with the same ids; it never uploads again.
|
|
64
|
+
- **`run.save(dir)`** writes every output file under `dir` at its `path`, making folders, and returns the paths it wrote.
|
|
65
|
+
- **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}`.
|
|
66
|
+
|
|
49
67
|
## Errors
|
|
50
68
|
|
|
51
69
|
A refusal or a failed run raises one `AbyssError`. Branch on `code`:
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""The official Python library for the Abyss API."""
|
|
2
2
|
from ._client import Abyss
|
|
3
3
|
from ._error import AbyssError
|
|
4
|
+
from ._files import FileData, InputFile, file
|
|
4
5
|
from ._run import Run, RunError, RunFile, RunStatus
|
|
5
6
|
|
|
6
|
-
__all__ = ["Abyss", "AbyssError", "Run", "RunError", "RunFile", "RunStatus"]
|
|
7
|
+
__all__ = ["Abyss", "AbyssError", "FileData", "InputFile", "Run", "RunError", "RunFile", "RunStatus", "file"]
|
|
@@ -13,6 +13,7 @@ from urllib.parse import quote
|
|
|
13
13
|
import httpx
|
|
14
14
|
|
|
15
15
|
from ._error import AbyssError
|
|
16
|
+
from ._files import upload_files
|
|
16
17
|
from ._http import request, timeouts
|
|
17
18
|
from ._run import Run, RunError, parse_run
|
|
18
19
|
|
|
@@ -23,6 +24,9 @@ DEV_KEY_PREFIX = "abyss_sk_dev_"
|
|
|
23
24
|
REATTACH_PAUSE = 1.0
|
|
24
25
|
"""Seconds between two re-attaches that both ended without the run's ending."""
|
|
25
26
|
|
|
27
|
+
RETRY_PAUSE = 0.5
|
|
28
|
+
"""Seconds before the first retry after a drop; the pause doubles with each retry."""
|
|
29
|
+
|
|
26
30
|
|
|
27
31
|
@dataclass
|
|
28
32
|
class _Reply:
|
|
@@ -80,15 +84,17 @@ class Abyss:
|
|
|
80
84
|
max_price: float | None = None,
|
|
81
85
|
idempotency_key: str | None = None,
|
|
82
86
|
) -> Run:
|
|
83
|
-
"""
|
|
87
|
+
"""Uploads the input's files, presses a Widget and waits for the run's ending.
|
|
84
88
|
|
|
89
|
+
A file Field takes ``file(path)``, a ``Path``, an open file or ``bytes``, or a
|
|
90
|
+
list of them; a string passes through as an ``https`` URL or an upload id.
|
|
85
91
|
Re-attaches through ``Location`` whenever the hold ends early. Returns the
|
|
86
92
|
succeeded run; raises ``AbyssError`` on a refusal or a failed run.
|
|
87
93
|
"""
|
|
88
94
|
press = self._press(widget, input, max_price, idempotency_key)
|
|
89
95
|
reply = press if _has_ended(press) else self._hold(self._wait_url(press), _has_ended)
|
|
90
96
|
assert reply.run is not None
|
|
91
|
-
run =
|
|
97
|
+
run = self._run(reply.run)
|
|
92
98
|
if run.status == "failed":
|
|
93
99
|
error = run.error or RunError("platform_fault", "The run failed.")
|
|
94
100
|
raise AbyssError(error.code, error.message, request_id=reply.request_id, run=run)
|
|
@@ -101,14 +107,72 @@ class Abyss:
|
|
|
101
107
|
max_price: float | None,
|
|
102
108
|
idempotency_key: str | None,
|
|
103
109
|
) -> _Reply:
|
|
104
|
-
|
|
110
|
+
"""Uploads the input's files and sends the press. Its retries send the same body
|
|
111
|
+
and ``Idempotency-Key``, so the uploads are never redone."""
|
|
112
|
+
widget_url = self._widget_url(widget)
|
|
113
|
+
uploaded = upload_files(input, lambda field, filename, data: self._upload(widget_url, field, filename, data))
|
|
114
|
+
body: dict[str, Any] = {"input": uploaded}
|
|
105
115
|
if max_price is not None:
|
|
106
116
|
body["max_price"] = max_price
|
|
107
117
|
headers = {
|
|
108
118
|
"Content-Type": "application/json",
|
|
109
119
|
"Idempotency-Key": idempotency_key if idempotency_key is not None else str(uuid.uuid4()),
|
|
110
120
|
}
|
|
111
|
-
return self._send("POST", f"{
|
|
121
|
+
return self._send("POST", f"{widget_url}/runs", headers, json.dumps(body).encode())
|
|
122
|
+
|
|
123
|
+
def _upload(self, widget_url: str, field: str, filename: str, data: bytes) -> str:
|
|
124
|
+
"""Asks for an upload, sends the file to storage and answers the upload's id."""
|
|
125
|
+
grant = self._send(
|
|
126
|
+
"POST",
|
|
127
|
+
f"{widget_url}/uploads",
|
|
128
|
+
{"Content-Type": "application/json"},
|
|
129
|
+
json.dumps({"field": field, "filename": filename, "size": len(data)}).encode(),
|
|
130
|
+
)
|
|
131
|
+
if not _is_upload(grant.body):
|
|
132
|
+
raise AbyssError(
|
|
133
|
+
"unexpected_response",
|
|
134
|
+
"The upload answered without an id and a form.",
|
|
135
|
+
request_id=grant.request_id,
|
|
136
|
+
)
|
|
137
|
+
upload = grant.body["upload"]
|
|
138
|
+
try:
|
|
139
|
+
stored = self._http.post(
|
|
140
|
+
upload["url"],
|
|
141
|
+
data={name: str(value) for name, value in upload["fields"].items()},
|
|
142
|
+
files={"file": (filename, data)},
|
|
143
|
+
)
|
|
144
|
+
except httpx.TransportError as error:
|
|
145
|
+
raise AbyssError(
|
|
146
|
+
"connection_error",
|
|
147
|
+
f"The connection to storage dropped during the upload of {filename}.",
|
|
148
|
+
param=field,
|
|
149
|
+
) from error
|
|
150
|
+
if not stored.is_success:
|
|
151
|
+
raise AbyssError(
|
|
152
|
+
"unexpected_response",
|
|
153
|
+
f"Storage answered {stored.status_code} to the upload of {filename}.",
|
|
154
|
+
status=stored.status_code,
|
|
155
|
+
param=field,
|
|
156
|
+
)
|
|
157
|
+
return str(grant.body["id"])
|
|
158
|
+
|
|
159
|
+
def _download(self, url: str) -> tuple[int, bytes]:
|
|
160
|
+
"""Downloads an output file from storage, without the API key."""
|
|
161
|
+
try:
|
|
162
|
+
response = self._http.get(url)
|
|
163
|
+
except httpx.TransportError as error:
|
|
164
|
+
raise AbyssError("connection_error", "The connection to storage dropped during a download.") from error
|
|
165
|
+
return response.status_code, response.content
|
|
166
|
+
|
|
167
|
+
def _reread(self, id: str) -> dict[str, Any]:
|
|
168
|
+
"""Reads a run with ``GET /v1/runs/{id}``. A cut read is read again."""
|
|
169
|
+
reply = self._hold(f"{self.base_url}/v1/runs/{quote(id, safe='')}", lambda reply: not reply.cut)
|
|
170
|
+
if reply.run is None:
|
|
171
|
+
raise AbyssError("unexpected_response", "The read answered without a run.", request_id=reply.request_id)
|
|
172
|
+
return reply.run
|
|
173
|
+
|
|
174
|
+
def _run(self, data: dict[str, Any]) -> Run:
|
|
175
|
+
return parse_run(data, self._reread, self._download)
|
|
112
176
|
|
|
113
177
|
def _hold(self, url: str, done: Callable[[_Reply], bool]) -> _Reply:
|
|
114
178
|
"""Reads ``url`` until ``done``. Re-attaching has no limit and is not a retry."""
|
|
@@ -132,20 +196,22 @@ class Abyss:
|
|
|
132
196
|
return str(httpx.URL(f"{self.base_url}/").join(location).copy_set_param("wait", "true"))
|
|
133
197
|
|
|
134
198
|
def _send(self, method: str, url: str, headers: dict[str, str] | None = None, content: bytes | None = None) -> _Reply:
|
|
135
|
-
"""Sends one request to `/v1`.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
199
|
+
"""Sends one request to `/v1`. A drop before any headers is sent again, at most
|
|
200
|
+
``max_retries`` times; every refusal raises at once."""
|
|
201
|
+
sent_headers = {"Authorization": f"Bearer {self._api_key}", "Accept": "application/json", **(headers or {})}
|
|
202
|
+
attempt = 0
|
|
203
|
+
while True:
|
|
204
|
+
try:
|
|
205
|
+
answer = request(self._http, method, url, sent_headers, content)
|
|
206
|
+
break
|
|
207
|
+
except httpx.TransportError as error:
|
|
208
|
+
if attempt >= self.max_retries:
|
|
209
|
+
raise AbyssError(
|
|
210
|
+
"connection_error",
|
|
211
|
+
"The connection dropped, or timed out, before the API answered.",
|
|
212
|
+
) from error
|
|
213
|
+
time.sleep(RETRY_PAUSE * 2**attempt)
|
|
214
|
+
attempt += 1
|
|
149
215
|
request_id = answer.headers.get("request-id")
|
|
150
216
|
parsed = _parse(answer.text)
|
|
151
217
|
if answer.status in (200, 202):
|
|
@@ -191,6 +257,13 @@ def _is_run(value: Any) -> bool:
|
|
|
191
257
|
return isinstance(value, dict) and isinstance(value.get("id"), str) and isinstance(value.get("status"), str)
|
|
192
258
|
|
|
193
259
|
|
|
260
|
+
def _is_upload(value: Any) -> bool:
|
|
261
|
+
if not isinstance(value, dict) or not isinstance(value.get("id"), str):
|
|
262
|
+
return False
|
|
263
|
+
upload = value.get("upload")
|
|
264
|
+
return isinstance(upload, dict) and isinstance(upload.get("url"), str) and isinstance(upload.get("fields"), dict)
|
|
265
|
+
|
|
266
|
+
|
|
194
267
|
def _has_ended(reply: _Reply) -> bool:
|
|
195
268
|
"""Whether the reply carries a run that has succeeded or failed."""
|
|
196
269
|
return reply.run is not None and reply.run["status"] in ("succeeded", "failed")
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Callable, Mapping
|
|
5
|
+
from concurrent.futures import Future, ThreadPoolExecutor
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import IO, Any, Union
|
|
8
|
+
|
|
9
|
+
FileData = Union[bytes, bytearray, memoryview, os.PathLike[str], IO[bytes], IO[str]]
|
|
10
|
+
"""File data: bytes in memory, a ``Path``, or an open file."""
|
|
11
|
+
|
|
12
|
+
MAX_PARALLEL_UPLOADS = 8
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InputFile:
|
|
16
|
+
"""A file for a file Field: a path, read when it is uploaded, or data. Made by ``file()``."""
|
|
17
|
+
|
|
18
|
+
source: str | FileData
|
|
19
|
+
filename: str | None
|
|
20
|
+
"""The name to upload under. Defaults to the path's or the open file's name, else the Field's name."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, source: str | FileData, filename: str | None = None) -> None:
|
|
23
|
+
self.source = source
|
|
24
|
+
self.filename = filename
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
return f"file({self.source!r})"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def file(source: str | FileData, *, filename: str | None = None) -> InputFile:
|
|
31
|
+
"""A file for a file Field. A string is a path, read when the file is uploaded."""
|
|
32
|
+
return InputFile(source, filename)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
Uploader = Callable[[str, str, bytes], str]
|
|
36
|
+
"""Uploads one file for a Field, by its name and content, and answers the upload's id."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def upload_files(input: Mapping[str, Any], upload: Uploader) -> dict[str, Any]:
|
|
40
|
+
"""The input with every file value uploaded, in parallel, and replaced by its upload's id.
|
|
41
|
+
|
|
42
|
+
Strings pass through as an ``https`` URL or an upload id, and a list may mix all
|
|
43
|
+
kinds. The caller's input is never changed.
|
|
44
|
+
"""
|
|
45
|
+
uploaded = dict(input)
|
|
46
|
+
jobs: list[tuple[Callable[[str], None], str, InputFile | FileData]] = []
|
|
47
|
+
for field, value in input.items():
|
|
48
|
+
if is_file(value):
|
|
49
|
+
jobs.append((_setter(uploaded, field), field, value))
|
|
50
|
+
elif isinstance(value, list) and any(is_file(item) for item in value):
|
|
51
|
+
items = list(value)
|
|
52
|
+
uploaded[field] = items
|
|
53
|
+
jobs.extend((_setter(items, index), field, item) for index, item in enumerate(value) if is_file(item))
|
|
54
|
+
if not jobs:
|
|
55
|
+
return uploaded
|
|
56
|
+
|
|
57
|
+
def upload_one(field: str, value: InputFile | FileData) -> str:
|
|
58
|
+
filename, data = _load(value, field)
|
|
59
|
+
return upload(field, filename, data)
|
|
60
|
+
|
|
61
|
+
with ThreadPoolExecutor(max_workers=min(len(jobs), MAX_PARALLEL_UPLOADS)) as pool:
|
|
62
|
+
futures: list[Future[str]] = [pool.submit(upload_one, field, value) for _, field, value in jobs]
|
|
63
|
+
for (put, _, _), future in zip(jobs, futures):
|
|
64
|
+
put(future.result())
|
|
65
|
+
return uploaded
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def is_file(value: Any) -> bool:
|
|
69
|
+
"""Whether the value is a file to upload, rather than a Field's plain value."""
|
|
70
|
+
return isinstance(value, (InputFile, bytes, bytearray, memoryview, os.PathLike)) or callable(
|
|
71
|
+
getattr(value, "read", None)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _setter(target: Any, at: Any) -> Callable[[str], None]:
|
|
76
|
+
def put(upload_id: str) -> None:
|
|
77
|
+
target[at] = upload_id
|
|
78
|
+
|
|
79
|
+
return put
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _load(value: InputFile | FileData, field: str) -> tuple[str, bytes]:
|
|
83
|
+
"""The file's name and content."""
|
|
84
|
+
given = value if isinstance(value, InputFile) else InputFile(value)
|
|
85
|
+
source = given.source
|
|
86
|
+
if isinstance(source, (str, os.PathLike)):
|
|
87
|
+
path = Path(source)
|
|
88
|
+
return given.filename or path.name, path.read_bytes()
|
|
89
|
+
if isinstance(source, (bytes, bytearray, memoryview)):
|
|
90
|
+
return given.filename or field, bytes(source)
|
|
91
|
+
content = source.read()
|
|
92
|
+
data = content.encode("utf-8") if isinstance(content, str) else bytes(content)
|
|
93
|
+
name = getattr(source, "name", None)
|
|
94
|
+
return given.filename or (Path(name).name if isinstance(name, str) and name else field), data
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from ._error import AbyssError
|
|
10
|
+
|
|
11
|
+
Download = Callable[[str], tuple[int, bytes]]
|
|
12
|
+
"""Downloads a URL from storage: answers its status and content."""
|
|
13
|
+
|
|
14
|
+
Reread = Callable[[str], dict[str, Any]]
|
|
15
|
+
"""Reads a run again with ``GET /v1/runs/{id}``."""
|
|
16
|
+
|
|
17
|
+
RunStatus = Literal["queued", "running", "succeeded", "failed"]
|
|
18
|
+
"""A run's place: ``succeeded`` and ``failed`` are final."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class RunError:
|
|
23
|
+
"""Why a run failed: ``widget_fault``, ``platform_fault`` or ``budget_exceeded``."""
|
|
24
|
+
|
|
25
|
+
code: str
|
|
26
|
+
message: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class RunFile:
|
|
31
|
+
"""One file a run wrote. Its ``url`` is signed fresh on every read of the run, and expires."""
|
|
32
|
+
|
|
33
|
+
path: str
|
|
34
|
+
size: int
|
|
35
|
+
content_type: str
|
|
36
|
+
url: str
|
|
37
|
+
_download: Download | None = field(default=None, init=False, repr=False, compare=False)
|
|
38
|
+
_refresh: Callable[[], None] | None = field(default=None, init=False, repr=False, compare=False)
|
|
39
|
+
|
|
40
|
+
def read(self) -> bytes:
|
|
41
|
+
"""Downloads the file. An expired ``url`` is refreshed once, by reading the run again."""
|
|
42
|
+
if self._download is None:
|
|
43
|
+
raise AbyssError("unexpected_response", f"The output file {self.path} has no client to download it.")
|
|
44
|
+
status, content = self._download(self.url)
|
|
45
|
+
if status == 403 and self._refresh is not None:
|
|
46
|
+
self._refresh()
|
|
47
|
+
status, content = self._download(self.url)
|
|
48
|
+
if not 200 <= status < 300:
|
|
49
|
+
raise AbyssError(
|
|
50
|
+
"unexpected_response",
|
|
51
|
+
f"The download of {self.path} answered {status}.",
|
|
52
|
+
status=status,
|
|
53
|
+
)
|
|
54
|
+
return content
|
|
55
|
+
|
|
56
|
+
def save(self, path: str | os.PathLike[str]) -> Path:
|
|
57
|
+
"""Downloads the file to ``path``, making its folders, and returns ``path``."""
|
|
58
|
+
target = Path(path)
|
|
59
|
+
content = self.read()
|
|
60
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
target.write_bytes(content)
|
|
62
|
+
return target
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class Run:
|
|
67
|
+
"""A run, with `/v1`'s names."""
|
|
68
|
+
|
|
69
|
+
id: str
|
|
70
|
+
widget: str
|
|
71
|
+
status: RunStatus
|
|
72
|
+
price: float
|
|
73
|
+
result: Any
|
|
74
|
+
output_files: list[RunFile]
|
|
75
|
+
error: RunError | None
|
|
76
|
+
created_at: str
|
|
77
|
+
started_at: str | None
|
|
78
|
+
ended_at: str | None
|
|
79
|
+
_reread: Reread | None = field(default=None, init=False, repr=False, compare=False)
|
|
80
|
+
|
|
81
|
+
def save(self, dir: str | os.PathLike[str]) -> list[Path]:
|
|
82
|
+
"""Saves every output file under ``dir``, at its ``path``, and returns the paths it wrote."""
|
|
83
|
+
folder = Path(dir)
|
|
84
|
+
root = folder.resolve()
|
|
85
|
+
saved = []
|
|
86
|
+
for output in self.output_files:
|
|
87
|
+
target = folder / output.path
|
|
88
|
+
resolved = target.resolve()
|
|
89
|
+
if resolved == root or not resolved.is_relative_to(root):
|
|
90
|
+
raise AbyssError(
|
|
91
|
+
"unexpected_response",
|
|
92
|
+
f"The output file {output.path} would be saved outside {folder}.",
|
|
93
|
+
)
|
|
94
|
+
saved.append(output.save(target))
|
|
95
|
+
return saved
|
|
96
|
+
|
|
97
|
+
def _refresh_urls(self) -> None:
|
|
98
|
+
"""Reads the run again and gives every output file its freshly signed ``url``."""
|
|
99
|
+
if self._reread is None:
|
|
100
|
+
return
|
|
101
|
+
fresh = {f.get("path"): f.get("url") for f in self._reread(self.id).get("output_files") or []}
|
|
102
|
+
for output in self.output_files:
|
|
103
|
+
url = fresh.get(output.path)
|
|
104
|
+
if isinstance(url, str):
|
|
105
|
+
output.url = url
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def parse_run(data: dict[str, Any], reread: Reread | None = None, download: Download | None = None) -> Run:
|
|
109
|
+
"""The run `/v1` sent as ``data``. With ``reread`` and ``download``, its output files can be read."""
|
|
110
|
+
error = data.get("error")
|
|
111
|
+
run = Run(
|
|
112
|
+
id=data["id"],
|
|
113
|
+
widget=data.get("widget", ""),
|
|
114
|
+
status=data["status"],
|
|
115
|
+
price=data.get("price", 0),
|
|
116
|
+
result=data.get("result"),
|
|
117
|
+
output_files=[
|
|
118
|
+
RunFile(path=f["path"], size=f["size"], content_type=f["content_type"], url=f["url"])
|
|
119
|
+
for f in data.get("output_files") or []
|
|
120
|
+
],
|
|
121
|
+
error=RunError(code=error["code"], message=error["message"]) if isinstance(error, dict) else None,
|
|
122
|
+
created_at=data.get("created_at", ""),
|
|
123
|
+
started_at=data.get("started_at"),
|
|
124
|
+
ended_at=data.get("ended_at"),
|
|
125
|
+
)
|
|
126
|
+
run._reread = reread
|
|
127
|
+
for output in run.output_files:
|
|
128
|
+
output._download = download
|
|
129
|
+
output._refresh = run._refresh_urls
|
|
130
|
+
return run
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""What a recorded call's caller holds: its files on disk, and its input as a caller writes it."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections.abc import Iterator
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from tempfile import TemporaryDirectory
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from abysshub import Abyss, file
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def caller_input(input: dict[str, Any], dir: Path, files: dict[str, str]) -> dict[str, Any]:
|
|
14
|
+
"""The caller's input: ``{"$file": name}`` is ``file(path)``, and ``{"$bytes": name}``
|
|
15
|
+
its content in memory, given with its name."""
|
|
16
|
+
|
|
17
|
+
def value(item: Any) -> Any:
|
|
18
|
+
if isinstance(item, dict) and "$file" in item:
|
|
19
|
+
return file(str(dir / item["$file"]))
|
|
20
|
+
if isinstance(item, dict) and "$bytes" in item:
|
|
21
|
+
return file(files[item["$bytes"]].encode(), filename=item["$bytes"])
|
|
22
|
+
return item
|
|
23
|
+
|
|
24
|
+
return {field: [value(i) for i in item] if isinstance(item, list) else value(item) for field, item in input.items()}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def with_files(files: dict[str, str]) -> Iterator[Path]:
|
|
29
|
+
"""A folder holding the caller's files, removed afterwards."""
|
|
30
|
+
with TemporaryDirectory(prefix="abysshub-") as name:
|
|
31
|
+
dir = Path(name)
|
|
32
|
+
for path, content in files.items():
|
|
33
|
+
(dir / path).write_bytes(content.encode())
|
|
34
|
+
yield dir
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def perform(abyss: Abyss, call: dict[str, Any], input: dict[str, Any]) -> Any:
|
|
38
|
+
"""The recorded call, made through the method it names."""
|
|
39
|
+
method = call.get("method", "run")
|
|
40
|
+
options = call.get("options") or {}
|
|
41
|
+
if method == "run":
|
|
42
|
+
return abyss.run(call["widget"], input, **options)
|
|
43
|
+
raise AssertionError(f"no such method: {method}")
|
|
@@ -8,6 +8,8 @@ import socket
|
|
|
8
8
|
import threading
|
|
9
9
|
import time
|
|
10
10
|
from collections.abc import Callable
|
|
11
|
+
from email.message import Message
|
|
12
|
+
from email.parser import BytesParser
|
|
11
13
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Any
|
|
@@ -39,7 +41,7 @@ class Replay:
|
|
|
39
41
|
self.recording = recording
|
|
40
42
|
self.key = key
|
|
41
43
|
self.problems: list[str] = []
|
|
42
|
-
self.served =
|
|
44
|
+
self.served: set[int] = set()
|
|
43
45
|
self._uuid: str | None = None
|
|
44
46
|
self._lock = threading.Lock()
|
|
45
47
|
self._closing = threading.Event()
|
|
@@ -63,7 +65,7 @@ class Replay:
|
|
|
63
65
|
self._thread.start()
|
|
64
66
|
|
|
65
67
|
def remaining(self) -> int:
|
|
66
|
-
return len(self.recording["exchanges"]) - self.served
|
|
68
|
+
return len(self.recording["exchanges"]) - len(self.served)
|
|
67
69
|
|
|
68
70
|
def close(self) -> None:
|
|
69
71
|
self._closing.set()
|
|
@@ -73,6 +75,23 @@ class Replay:
|
|
|
73
75
|
def _fill(self, value: str) -> str:
|
|
74
76
|
return value.replace("{base}", self.base).replace("{key}", self.key)
|
|
75
77
|
|
|
78
|
+
def _candidates(self) -> list[int]:
|
|
79
|
+
"""The exchanges the next request may answer: the first one not yet served, or
|
|
80
|
+
every unserved one in its block when it is marked ``parallel``."""
|
|
81
|
+
exchanges = self.recording["exchanges"]
|
|
82
|
+
first = next((index for index in range(len(exchanges)) if index not in self.served), None)
|
|
83
|
+
if first is None:
|
|
84
|
+
return []
|
|
85
|
+
if not exchanges[first].get("parallel"):
|
|
86
|
+
return [first]
|
|
87
|
+
block = []
|
|
88
|
+
index = first
|
|
89
|
+
while index < len(exchanges) and exchanges[index].get("parallel"):
|
|
90
|
+
if index not in self.served:
|
|
91
|
+
block.append(index)
|
|
92
|
+
index += 1
|
|
93
|
+
return block
|
|
94
|
+
|
|
76
95
|
def _differences(self, index: int, req: BaseHTTPRequestHandler, sent: bytes) -> list[str]:
|
|
77
96
|
exchange = self.recording["exchanges"][index]
|
|
78
97
|
request = exchange["request"]
|
|
@@ -91,16 +110,23 @@ class Replay:
|
|
|
91
110
|
found.append("storage was sent the API key")
|
|
92
111
|
if "body" in request and _parse_or_text(sent.decode("utf-8")) != request["body"]:
|
|
93
112
|
found.append(f"body is {sent.decode('utf-8')}")
|
|
113
|
+
if "form" in request:
|
|
114
|
+
form = _read_form(req.headers.get("Content-Type"), sent)
|
|
115
|
+
if form != request["form"]:
|
|
116
|
+
found.append(f"form is {json.dumps(form)}")
|
|
94
117
|
return found
|
|
95
118
|
|
|
96
119
|
def _choose(self, req: BaseHTTPRequestHandler, sent: bytes) -> int | None:
|
|
120
|
+
"""The candidate the request matches, else the first one, noting why."""
|
|
97
121
|
with self._lock:
|
|
98
|
-
|
|
99
|
-
if
|
|
122
|
+
candidates = self._candidates()
|
|
123
|
+
if not candidates:
|
|
100
124
|
self.problems.append(f"unexpected {req.command} {req.path}")
|
|
101
125
|
return None
|
|
102
|
-
self.
|
|
103
|
-
|
|
126
|
+
index = next((other for other in candidates if not self._differences(other, req, sent)), candidates[0])
|
|
127
|
+
found = self._differences(index, req, sent)
|
|
128
|
+
self.served.add(index)
|
|
129
|
+
self.problems.extend(f"#{index + 1}: {problem}" for problem in found)
|
|
104
130
|
for name, value in (self.recording["exchanges"][index]["request"].get("headers") or {}).items():
|
|
105
131
|
if value == "{uuid}" and self._uuid is None:
|
|
106
132
|
self._uuid = req.headers.get(name)
|
|
@@ -125,6 +151,10 @@ class Replay:
|
|
|
125
151
|
req.send_header("Content-Type", "application/json")
|
|
126
152
|
for name, value in (response.get("headers") or {}).items():
|
|
127
153
|
req.send_header(name, self._fill(value))
|
|
154
|
+
if response["status"] == 204:
|
|
155
|
+
req.end_headers()
|
|
156
|
+
req.wfile.flush()
|
|
157
|
+
return
|
|
128
158
|
# Chunked, as Node sends it, so a cut is told apart from the body's end.
|
|
129
159
|
req.send_header("Transfer-Encoding", "chunked")
|
|
130
160
|
req.end_headers()
|
|
@@ -154,6 +184,24 @@ def _write_chunk(req: BaseHTTPRequestHandler, data: bytes) -> None:
|
|
|
154
184
|
req.wfile.flush()
|
|
155
185
|
|
|
156
186
|
|
|
187
|
+
def _read_form(content_type: str | None, sent: bytes) -> dict[str, Any]:
|
|
188
|
+
"""A storage upload form, as the recordings write it: each field's text, with the
|
|
189
|
+
file (which must come last) as its content."""
|
|
190
|
+
message = BytesParser().parsebytes(b"Content-Type: %s\r\n\r\n%s" % ((content_type or "").encode(), sent))
|
|
191
|
+
if not message.is_multipart():
|
|
192
|
+
return {"(not a form)": content_type}
|
|
193
|
+
form: dict[str, Any] = {}
|
|
194
|
+
name = None
|
|
195
|
+
for part in message.get_payload():
|
|
196
|
+
assert isinstance(part, Message)
|
|
197
|
+
name = part.get_param("name", header="content-disposition")
|
|
198
|
+
content = part.get_payload(decode=True)
|
|
199
|
+
form[str(name)] = content.decode("utf-8") if isinstance(content, bytes) else content
|
|
200
|
+
if name != "file":
|
|
201
|
+
form["(the file is not last)"] = True
|
|
202
|
+
return form
|
|
203
|
+
|
|
204
|
+
|
|
157
205
|
def _parse_or_text(text: str) -> Any:
|
|
158
206
|
try:
|
|
159
207
|
return json.loads(text)
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Files in and out, beyond the recorded calls: every kind of file a caller may pass,
|
|
2
|
+
and each output file's save() and read()."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from caller import with_files
|
|
13
|
+
from fake_server import Replay, load_exchanges
|
|
14
|
+
|
|
15
|
+
from abysshub import Abyss, AbyssError, file
|
|
16
|
+
|
|
17
|
+
KEY = "abyss_sk_fake_for_tests"
|
|
18
|
+
RECORDINGS = {recording["name"]: recording for recording in load_exchanges("files-*.json")}
|
|
19
|
+
UPLOAD = RECORDINGS["files-upload-press.json"]
|
|
20
|
+
EXPIRED = RECORDINGS["files-output-expired.json"]
|
|
21
|
+
CONTENT = UPLOAD["call"]["files"]["report.pdf"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@contextmanager
|
|
25
|
+
def against(recording: dict[str, Any]) -> Iterator[tuple[Abyss, Path]]:
|
|
26
|
+
server = Replay(recording, KEY)
|
|
27
|
+
try:
|
|
28
|
+
with with_files(recording["call"].get("files") or {}) as dir, Abyss(api_key=KEY, base_url=server.base) as abyss:
|
|
29
|
+
yield abyss, dir
|
|
30
|
+
assert server.problems == []
|
|
31
|
+
assert server.remaining() == 0, "every recorded request was sent"
|
|
32
|
+
finally:
|
|
33
|
+
server.close()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
SOURCES: dict[str, Callable[[Path], Any]] = {
|
|
37
|
+
"file(path)": lambda dir: file(str(dir / "report.pdf")),
|
|
38
|
+
"file(Path)": lambda dir: file(dir / "report.pdf"),
|
|
39
|
+
"a Path": lambda dir: dir / "report.pdf",
|
|
40
|
+
"file(bytes)": lambda dir: file(CONTENT.encode(), filename="report.pdf"),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@pytest.mark.parametrize("kind", SOURCES)
|
|
45
|
+
def test_every_kind_of_file_uploads_like_file_path(kind: str) -> None:
|
|
46
|
+
with against(UPLOAD) as (abyss, dir):
|
|
47
|
+
run = abyss.run(UPLOAD["call"]["widget"], {"report": SOURCES[kind](dir)})
|
|
48
|
+
assert run.status == "succeeded"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_an_open_file_uploads_under_its_name() -> None:
|
|
52
|
+
with against(UPLOAD) as (abyss, dir), open(dir / "report.pdf", "rb") as report:
|
|
53
|
+
run = abyss.run("widget_1660", {"report": report})
|
|
54
|
+
assert run.status == "succeeded"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_bytes_upload_under_the_fields_name() -> None:
|
|
58
|
+
recording = copy.deepcopy(UPLOAD)
|
|
59
|
+
recording["exchanges"][0]["request"]["body"]["filename"] = "report"
|
|
60
|
+
with against(recording) as (abyss, _):
|
|
61
|
+
assert abyss.run("widget_1660", {"report": CONTENT.encode()}).status == "succeeded"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_the_callers_input_is_never_changed() -> None:
|
|
65
|
+
with against(UPLOAD) as (abyss, dir):
|
|
66
|
+
report = file(str(dir / "report.pdf"))
|
|
67
|
+
input = {"report": report}
|
|
68
|
+
abyss.run("widget_1660", input)
|
|
69
|
+
assert input == {"report": report}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_a_wrong_upload_form_is_noted() -> None:
|
|
73
|
+
recording = copy.deepcopy(UPLOAD)
|
|
74
|
+
recording["exchanges"][1]["request"]["form"]["file"] = "another report"
|
|
75
|
+
server = Replay(recording, KEY)
|
|
76
|
+
try:
|
|
77
|
+
with with_files(recording["call"]["files"]) as dir, Abyss(api_key=KEY, base_url=server.base) as abyss:
|
|
78
|
+
abyss.run("widget_1660", {"report": file(dir / "report.pdf")})
|
|
79
|
+
assert len(server.problems) == 1
|
|
80
|
+
assert server.problems[0].startswith("#2: form is ")
|
|
81
|
+
finally:
|
|
82
|
+
server.close()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_each_output_file_has_save_and_read_refreshing_an_expired_url_once() -> None:
|
|
86
|
+
with against(EXPIRED) as (abyss, dir):
|
|
87
|
+
run = abyss.run(EXPIRED["call"]["widget"], EXPIRED["call"]["input"])
|
|
88
|
+
chart, output = run.output_files
|
|
89
|
+
path = dir / "deep" / "chart.png"
|
|
90
|
+
assert chart.save(path) == path
|
|
91
|
+
assert path.read_text("utf-8") == EXPIRED["outcome"]["saved"]["charts/a.png"]
|
|
92
|
+
content = output.read()
|
|
93
|
+
assert isinstance(content, bytes)
|
|
94
|
+
assert content.decode("utf-8") == EXPIRED["outcome"]["saved"]["output.json"]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_a_url_still_refused_after_the_refresh_raises() -> None:
|
|
98
|
+
recording = copy.deepcopy(EXPIRED)
|
|
99
|
+
recording["exchanges"] = recording["exchanges"][:3]
|
|
100
|
+
refused = copy.deepcopy(recording["exchanges"][1])
|
|
101
|
+
refused["request"]["path"] = refused["request"]["path"].replace("expired", "fresh")
|
|
102
|
+
recording["exchanges"].append(refused)
|
|
103
|
+
with against(recording) as (abyss, _):
|
|
104
|
+
run = abyss.run(EXPIRED["call"]["widget"], EXPIRED["call"]["input"])
|
|
105
|
+
with pytest.raises(AbyssError) as raised:
|
|
106
|
+
run.output_files[0].read()
|
|
107
|
+
assert raised.value.status == 403
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_run_save_refuses_an_output_path_that_leaves_its_folder() -> None:
|
|
111
|
+
recording = copy.deepcopy(EXPIRED)
|
|
112
|
+
recording["exchanges"] = recording["exchanges"][:1]
|
|
113
|
+
recording["exchanges"][0]["response"]["body"]["output_files"][0]["path"] = "../escape.png"
|
|
114
|
+
with against(recording) as (abyss, dir):
|
|
115
|
+
run = abyss.run(EXPIRED["call"]["widget"], EXPIRED["call"]["input"])
|
|
116
|
+
with pytest.raises(AbyssError):
|
|
117
|
+
run.save(dir / "output")
|
|
@@ -5,12 +5,13 @@ from __future__ import annotations
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
7
|
import pytest
|
|
8
|
+
from caller import caller_input, perform, with_files
|
|
8
9
|
from fake_server import Replay, load_exchanges
|
|
9
10
|
|
|
10
11
|
from abysshub import Abyss, AbyssError, Run
|
|
11
12
|
|
|
12
13
|
KEY = "abyss_sk_fake_for_tests"
|
|
13
|
-
RECORDINGS = load_exchanges("run-*.json")
|
|
14
|
+
RECORDINGS = load_exchanges("run-*.json") + load_exchanges("files-*.json")
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def assert_fields(actual: Any, expected: Any, where: str) -> None:
|
|
@@ -30,41 +31,39 @@ def assert_fields(actual: Any, expected: Any, where: str) -> None:
|
|
|
30
31
|
assert actual == expected, where
|
|
31
32
|
|
|
32
33
|
|
|
33
|
-
def perform(abyss: Abyss, call: dict[str, Any]) -> Any:
|
|
34
|
-
"""The recorded call, made through the method it names."""
|
|
35
|
-
method = call.get("method", "run")
|
|
36
|
-
options = call.get("options") or {}
|
|
37
|
-
if method == "run":
|
|
38
|
-
return abyss.run(call["widget"], call.get("input") or {}, **options)
|
|
39
|
-
raise AssertionError(f"no such method: {method}")
|
|
40
|
-
|
|
41
|
-
|
|
42
34
|
@pytest.mark.parametrize("recording", RECORDINGS, ids=[r["name"] for r in RECORDINGS])
|
|
43
35
|
def test_replay(recording: dict[str, Any]) -> None:
|
|
36
|
+
call = recording["call"]
|
|
37
|
+
files = call.get("files") or {}
|
|
38
|
+
save = call.get("save")
|
|
44
39
|
server = Replay(recording, KEY)
|
|
45
40
|
try:
|
|
46
|
-
|
|
47
|
-
with Abyss(api_key=KEY, base_url=server.base, **client) as abyss:
|
|
41
|
+
with with_files(files) as dir, Abyss(api_key=KEY, base_url=server.base, **(call.get("client") or {})) as abyss:
|
|
48
42
|
try:
|
|
49
|
-
value, error = perform(abyss,
|
|
43
|
+
value, error = perform(abyss, call, caller_input(call.get("input") or {}, dir, files)), None
|
|
50
44
|
except AbyssError as raised:
|
|
51
45
|
value, error = None, raised
|
|
46
|
+
saved = value.save(dir / save) if save and isinstance(value, Run) else None
|
|
52
47
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
else:
|
|
60
|
-
assert error is not None, f"expected an AbyssError, got {value!r}"
|
|
61
|
-
fields = dict(outcome["error"])
|
|
62
|
-
run = fields.pop("run")
|
|
63
|
-
assert_fields(error, fields, "error")
|
|
64
|
-
if run is None:
|
|
65
|
-
assert error.run is None
|
|
48
|
+
assert server.problems == []
|
|
49
|
+
assert server.remaining() == 0, "every recorded request was sent"
|
|
50
|
+
outcome = recording["outcome"]
|
|
51
|
+
if "run" in outcome:
|
|
52
|
+
assert isinstance(value, Run), f"expected a run, got {error!r}"
|
|
53
|
+
assert_fields(value, outcome["run"], "run")
|
|
66
54
|
else:
|
|
67
|
-
|
|
55
|
+
assert error is not None, f"expected an AbyssError, got {value!r}"
|
|
56
|
+
fields = dict(outcome["error"])
|
|
57
|
+
run = fields.pop("run")
|
|
58
|
+
assert_fields(error, fields, "error")
|
|
59
|
+
if run is None:
|
|
60
|
+
assert error.run is None
|
|
61
|
+
else:
|
|
62
|
+
assert_fields(error.run, run, "error.run")
|
|
63
|
+
if "saved" in outcome:
|
|
64
|
+
assert saved == [dir / save / path for path in outcome["saved"]], "run.save() returns the paths it wrote"
|
|
65
|
+
for path, content in outcome["saved"].items():
|
|
66
|
+
assert (dir / save / path).read_text("utf-8") == content, f"saved {path}"
|
|
68
67
|
finally:
|
|
69
68
|
server.close()
|
|
70
69
|
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
from dataclasses import dataclass
|
|
4
|
-
from typing import Any, Literal
|
|
5
|
-
|
|
6
|
-
RunStatus = Literal["queued", "running", "succeeded", "failed"]
|
|
7
|
-
"""A run's place: ``succeeded`` and ``failed`` are final."""
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
@dataclass
|
|
11
|
-
class RunError:
|
|
12
|
-
"""Why a run failed: ``widget_fault``, ``platform_fault`` or ``budget_exceeded``."""
|
|
13
|
-
|
|
14
|
-
code: str
|
|
15
|
-
message: str
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@dataclass
|
|
19
|
-
class RunFile:
|
|
20
|
-
"""One file a run wrote. Its ``url`` is signed fresh on every read of the run, and expires."""
|
|
21
|
-
|
|
22
|
-
path: str
|
|
23
|
-
size: int
|
|
24
|
-
content_type: str
|
|
25
|
-
url: str
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
@dataclass
|
|
29
|
-
class Run:
|
|
30
|
-
"""A run, with `/v1`'s names."""
|
|
31
|
-
|
|
32
|
-
id: str
|
|
33
|
-
widget: str
|
|
34
|
-
status: RunStatus
|
|
35
|
-
price: float
|
|
36
|
-
result: Any
|
|
37
|
-
output_files: list[RunFile]
|
|
38
|
-
error: RunError | None
|
|
39
|
-
created_at: str
|
|
40
|
-
started_at: str | None
|
|
41
|
-
ended_at: str | None
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def parse_run(data: dict[str, Any]) -> Run:
|
|
45
|
-
"""The run `/v1` sent as ``data``."""
|
|
46
|
-
error = data.get("error")
|
|
47
|
-
return Run(
|
|
48
|
-
id=data["id"],
|
|
49
|
-
widget=data.get("widget", ""),
|
|
50
|
-
status=data["status"],
|
|
51
|
-
price=data.get("price", 0),
|
|
52
|
-
result=data.get("result"),
|
|
53
|
-
output_files=[
|
|
54
|
-
RunFile(path=f["path"], size=f["size"], content_type=f["content_type"], url=f["url"])
|
|
55
|
-
for f in data.get("output_files") or []
|
|
56
|
-
],
|
|
57
|
-
error=RunError(code=error["code"], message=error["message"]) if isinstance(error, dict) else None,
|
|
58
|
-
created_at=data.get("created_at", ""),
|
|
59
|
-
started_at=data.get("started_at"),
|
|
60
|
-
ended_at=data.get("ended_at"),
|
|
61
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|