abysshub 0.1.1.dev4__tar.gz → 0.1.1.dev5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/PKG-INFO +2 -2
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/README.md +1 -1
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/pyproject.toml +1 -1
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_async_client.py +18 -6
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_client.py +20 -5
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_run.py +93 -12
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/fake_server.py +14 -1
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/test_files.py +51 -1
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/test_replay.py +6 -2
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/.gitignore +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/LICENSE +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/__init__.py +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_error.py +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_files.py +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_http.py +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_types.py +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/py.typed +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/caller.py +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/test_client.py +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/test_http.py +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/test_package.py +0 -0
- {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/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.
|
|
3
|
+
Version: 0.1.1.dev5
|
|
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
|
|
@@ -119,7 +119,7 @@ paths = run.save("output") # [Path("output/charts/a.png"), Path("output/output.
|
|
|
119
119
|
- **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.
|
|
120
120
|
- **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.
|
|
121
121
|
- **`run.save(dir)`** writes every output file under `dir` at its `path`, making folders, and returns the paths it wrote.
|
|
122
|
-
- **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}`.
|
|
122
|
+
- **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
123
|
|
|
124
124
|
## Errors
|
|
125
125
|
|
|
@@ -102,7 +102,7 @@ paths = run.save("output") # [Path("output/charts/a.png"), Path("output/output.
|
|
|
102
102
|
- **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.
|
|
103
103
|
- **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.
|
|
104
104
|
- **`run.save(dir)`** writes every output file under `dir` at its `path`, making folders, and returns the paths it wrote.
|
|
105
|
-
- **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}`.
|
|
105
|
+
- **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
106
|
|
|
107
107
|
## Errors
|
|
108
108
|
|
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import asyncio
|
|
4
4
|
import math
|
|
5
|
-
from collections.abc import Callable, Mapping
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
6
6
|
from types import TracebackType
|
|
7
7
|
from typing import Any
|
|
8
8
|
|
|
@@ -12,6 +12,7 @@ from ._client import (
|
|
|
12
12
|
REATTACH_PAUSE,
|
|
13
13
|
_Base,
|
|
14
14
|
_Call,
|
|
15
|
+
_dropped_download,
|
|
15
16
|
_expect,
|
|
16
17
|
_has_ended,
|
|
17
18
|
_heard,
|
|
@@ -213,13 +214,24 @@ class AsyncAbyss(_Base[AsyncRun]):
|
|
|
213
214
|
self._stored(call, field, filename, stored)
|
|
214
215
|
return grant.id
|
|
215
216
|
|
|
216
|
-
async def _download(self, url: str) -> tuple[int, bytes]:
|
|
217
|
-
"""
|
|
217
|
+
async def _download(self, url: str, range_header: str | None) -> tuple[int, Callable[[], Awaitable[bytes]]]:
|
|
218
|
+
"""Opens an output file in storage, without the API key, with a ``Range`` header or
|
|
219
|
+
none: returns its status, and a call that reads its content and closes it."""
|
|
220
|
+
headers = {"Range": range_header} if range_header else None
|
|
218
221
|
try:
|
|
219
|
-
response = await self._http.
|
|
222
|
+
response = await self._http.send(self._http.build_request("GET", url, headers=headers), stream=True)
|
|
220
223
|
except httpx.TransportError as error:
|
|
221
|
-
raise
|
|
222
|
-
|
|
224
|
+
raise _dropped_download() from error
|
|
225
|
+
|
|
226
|
+
async def read() -> bytes:
|
|
227
|
+
try:
|
|
228
|
+
return await response.aread()
|
|
229
|
+
except httpx.TransportError as error:
|
|
230
|
+
raise _dropped_download() from error
|
|
231
|
+
finally:
|
|
232
|
+
await response.aclose()
|
|
233
|
+
|
|
234
|
+
return response.status_code, read
|
|
223
235
|
|
|
224
236
|
async def _get_run(self, call: _Call, id: str, wait: bool) -> dict[str, Any]:
|
|
225
237
|
"""Reads a run with ``GET /v1/runs/{id}``, holding with ``?wait=true``. A cut hold is read again."""
|
|
@@ -445,13 +445,24 @@ class Abyss(_Base[Run]):
|
|
|
445
445
|
self._stored(call, field, filename, stored)
|
|
446
446
|
return grant.id
|
|
447
447
|
|
|
448
|
-
def _download(self, url: str) -> tuple[int, bytes]:
|
|
449
|
-
"""
|
|
448
|
+
def _download(self, url: str, range_header: str | None) -> tuple[int, Callable[[], bytes]]:
|
|
449
|
+
"""Opens an output file in storage, without the API key, with a ``Range`` header or
|
|
450
|
+
none: returns its status, and a call that reads its content and closes it."""
|
|
451
|
+
headers = {"Range": range_header} if range_header else None
|
|
450
452
|
try:
|
|
451
|
-
response = self._http.
|
|
453
|
+
response = self._http.send(self._http.build_request("GET", url, headers=headers), stream=True)
|
|
452
454
|
except httpx.TransportError as error:
|
|
453
|
-
raise
|
|
454
|
-
|
|
455
|
+
raise _dropped_download() from error
|
|
456
|
+
|
|
457
|
+
def read() -> bytes:
|
|
458
|
+
try:
|
|
459
|
+
return response.read()
|
|
460
|
+
except httpx.TransportError as error:
|
|
461
|
+
raise _dropped_download() from error
|
|
462
|
+
finally:
|
|
463
|
+
response.close()
|
|
464
|
+
|
|
465
|
+
return response.status_code, read
|
|
455
466
|
|
|
456
467
|
def _get_run(self, call: _Call, id: str, wait: bool) -> dict[str, Any]:
|
|
457
468
|
"""Reads a run with ``GET /v1/runs/{id}``, holding with ``?wait=true``. A cut hold is read again."""
|
|
@@ -544,6 +555,10 @@ def _backoff(attempt: int) -> float:
|
|
|
544
555
|
return min(RETRY_PAUSE * 2 ** min(attempt, 16), RETRY_PAUSE_CEILING)
|
|
545
556
|
|
|
546
557
|
|
|
558
|
+
def _dropped_download() -> AbyssError:
|
|
559
|
+
return AbyssError("connection_error", "The connection to storage dropped during a download.")
|
|
560
|
+
|
|
561
|
+
|
|
547
562
|
def _expect(reply: _Reply, is_: Callable[[Any], bool], message: str) -> Any:
|
|
548
563
|
"""The reply's body when it is what the route answers, else ``unexpected_response``."""
|
|
549
564
|
if is_(reply.body):
|
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import math
|
|
3
5
|
import os
|
|
4
6
|
from collections.abc import Awaitable, Callable
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
8
|
from dataclasses import dataclass, field
|
|
6
9
|
from pathlib import Path
|
|
7
10
|
from typing import Any, Generic, Literal, TypeVar
|
|
8
11
|
|
|
9
12
|
from ._error import AbyssError
|
|
10
13
|
|
|
11
|
-
Download = Callable[[str], tuple[int, bytes]]
|
|
12
|
-
"""
|
|
14
|
+
Download = Callable[[str, str | None], tuple[int, Callable[[], bytes]]]
|
|
15
|
+
"""Opens a URL in storage, with a ``Range`` header or none: answers its status, and a call
|
|
16
|
+
that reads its content."""
|
|
13
17
|
|
|
14
18
|
Reread = Callable[[str], dict[str, Any]]
|
|
15
19
|
"""Reads a run again with ``GET /v1/runs/{id}``."""
|
|
16
20
|
|
|
17
|
-
AsyncDownload = Callable[[str], Awaitable[tuple[int, bytes]]]
|
|
21
|
+
AsyncDownload = Callable[[str, str | None], Awaitable[tuple[int, Callable[[], Awaitable[bytes]]]]]
|
|
18
22
|
AsyncReread = Callable[[str], Awaitable[dict[str, Any]]]
|
|
19
23
|
|
|
24
|
+
RANGED_FROM = 64 * 1024 * 1024
|
|
25
|
+
"""A file this many bytes or larger downloads as parallel byte ranges: 64 MiB."""
|
|
26
|
+
|
|
27
|
+
RANGES = 16
|
|
28
|
+
"""How many parallel byte ranges a large file downloads as."""
|
|
29
|
+
|
|
20
30
|
RunStatus = Literal["queued", "running", "succeeded", "failed"]
|
|
21
31
|
"""A run's place: ``succeeded`` and ``failed`` are final."""
|
|
22
32
|
|
|
@@ -45,14 +55,32 @@ class RunFile(_RunFileFields):
|
|
|
45
55
|
_refresh: Callable[[], None] | None = field(default=None, init=False, repr=False, compare=False)
|
|
46
56
|
|
|
47
57
|
def read(self) -> bytes:
|
|
48
|
-
"""Downloads the file. An expired ``url`` is refreshed once, by reading the run again.
|
|
58
|
+
"""Downloads the file. An expired ``url`` is refreshed once, by reading the run again.
|
|
59
|
+
A file of ``RANGED_FROM`` bytes or more downloads as ``RANGES`` parallel byte ranges
|
|
60
|
+
of its one ``url``: the first goes alone, and once it answers 206 the others go
|
|
61
|
+
together. A storage that ignores ``Range`` answers the whole file to the first."""
|
|
49
62
|
if self._download is None:
|
|
50
63
|
raise _no_client(self.path)
|
|
51
|
-
|
|
64
|
+
download = self._download
|
|
65
|
+
parts = _ranges(self.size)
|
|
66
|
+
first = _range(parts[0]) if parts else None
|
|
67
|
+
status, body = download(self.url, first)
|
|
52
68
|
if status == 403 and self._refresh is not None:
|
|
69
|
+
body()
|
|
53
70
|
self._refresh()
|
|
54
|
-
status,
|
|
55
|
-
|
|
71
|
+
status, body = download(self.url, first)
|
|
72
|
+
if not parts or status != 206:
|
|
73
|
+
return _downloaded(self.path, status, body())
|
|
74
|
+
|
|
75
|
+
def part(index: int) -> bytes:
|
|
76
|
+
if index == 0:
|
|
77
|
+
return _part(self.path, parts[0], status, body())
|
|
78
|
+
part_status, part_body = download(self.url, _range(parts[index]))
|
|
79
|
+
return _part(self.path, parts[index], part_status, part_body())
|
|
80
|
+
|
|
81
|
+
with ThreadPoolExecutor(len(parts)) as pool:
|
|
82
|
+
reads = [pool.submit(part, index) for index in range(len(parts))]
|
|
83
|
+
return b"".join(read.result() for read in reads)
|
|
56
84
|
|
|
57
85
|
def save(self, path: str | os.PathLike[str]) -> Path:
|
|
58
86
|
"""Downloads the file to ``path``, making its folders, and returns ``path``."""
|
|
@@ -67,14 +95,35 @@ class AsyncRunFile(_RunFileFields):
|
|
|
67
95
|
_refresh: Callable[[], Awaitable[None]] | None = field(default=None, init=False, repr=False, compare=False)
|
|
68
96
|
|
|
69
97
|
async def read(self) -> bytes:
|
|
70
|
-
"""Downloads the file. An expired ``url`` is refreshed once, by reading the run again.
|
|
98
|
+
"""Downloads the file. An expired ``url`` is refreshed once, by reading the run again.
|
|
99
|
+
A large file downloads as parallel byte ranges, as ``RunFile.read()`` does."""
|
|
71
100
|
if self._download is None:
|
|
72
101
|
raise _no_client(self.path)
|
|
73
|
-
|
|
102
|
+
download = self._download
|
|
103
|
+
parts = _ranges(self.size)
|
|
104
|
+
first = _range(parts[0]) if parts else None
|
|
105
|
+
status, body = await download(self.url, first)
|
|
74
106
|
if status == 403 and self._refresh is not None:
|
|
107
|
+
await body()
|
|
75
108
|
await self._refresh()
|
|
76
|
-
status,
|
|
77
|
-
|
|
109
|
+
status, body = await download(self.url, first)
|
|
110
|
+
if not parts or status != 206:
|
|
111
|
+
return _downloaded(self.path, status, await body())
|
|
112
|
+
|
|
113
|
+
async def part(index: int) -> bytes:
|
|
114
|
+
if index == 0:
|
|
115
|
+
return _part(self.path, parts[0], status, await body())
|
|
116
|
+
part_status, part_body = await download(self.url, _range(parts[index]))
|
|
117
|
+
return _part(self.path, parts[index], part_status, await part_body())
|
|
118
|
+
|
|
119
|
+
reads = [asyncio.ensure_future(part(index)) for index in range(len(parts))]
|
|
120
|
+
try:
|
|
121
|
+
return b"".join(await asyncio.gather(*reads))
|
|
122
|
+
except BaseException:
|
|
123
|
+
for read in reads:
|
|
124
|
+
read.cancel()
|
|
125
|
+
await asyncio.gather(*reads, return_exceptions=True)
|
|
126
|
+
raise
|
|
78
127
|
|
|
79
128
|
async def save(self, path: str | os.PathLike[str]) -> Path:
|
|
80
129
|
"""Downloads the file to ``path``, making its folders, and returns ``path``."""
|
|
@@ -204,10 +253,42 @@ def _no_client(path: str) -> AbyssError:
|
|
|
204
253
|
|
|
205
254
|
def _downloaded(path: str, status: int, content: bytes) -> bytes:
|
|
206
255
|
if not 200 <= status < 300:
|
|
207
|
-
raise
|
|
256
|
+
raise _answered(path, status)
|
|
208
257
|
return content
|
|
209
258
|
|
|
210
259
|
|
|
260
|
+
def _ranges(size: int) -> list[tuple[int, int]] | None:
|
|
261
|
+
"""The byte ranges, first and last byte included, a file of ``size`` bytes downloads
|
|
262
|
+
as, or None when it downloads whole."""
|
|
263
|
+
if size < RANGED_FROM:
|
|
264
|
+
return None
|
|
265
|
+
step = math.ceil(size / RANGES)
|
|
266
|
+
return [(first, min(first + step, size) - 1) for first in range(0, size, step)]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _range(part: tuple[int, int]) -> str:
|
|
270
|
+
"""The ``Range`` header naming ``part``."""
|
|
271
|
+
return f"bytes={part[0]}-{part[1]}"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _part(path: str, part: tuple[int, int], status: int, content: bytes) -> bytes:
|
|
275
|
+
"""The bytes of one range, which must answer 206 with exactly the range's length."""
|
|
276
|
+
if status != 206:
|
|
277
|
+
raise _answered(path, status)
|
|
278
|
+
first, last = part
|
|
279
|
+
if len(content) != last - first + 1:
|
|
280
|
+
raise AbyssError(
|
|
281
|
+
"unexpected_response",
|
|
282
|
+
f"The download of {path} answered {len(content)} bytes for the range {first}-{last}.",
|
|
283
|
+
status=status,
|
|
284
|
+
)
|
|
285
|
+
return content
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _answered(path: str, status: int) -> AbyssError:
|
|
289
|
+
return AbyssError("unexpected_response", f"The download of {path} answered {status}.", status=status)
|
|
290
|
+
|
|
291
|
+
|
|
211
292
|
def _write(target: Path, content: bytes) -> Path:
|
|
212
293
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
213
294
|
target.write_bytes(content)
|
|
@@ -104,7 +104,9 @@ class Replay:
|
|
|
104
104
|
found.append(f"expected {request['method']} {request['path']}, got {req.command} {req.path}")
|
|
105
105
|
for name, value in (request.get("headers") or {}).items():
|
|
106
106
|
got = req.headers.get(name)
|
|
107
|
-
if value
|
|
107
|
+
if value is None:
|
|
108
|
+
ok = got is None
|
|
109
|
+
elif value == "{uuid}":
|
|
108
110
|
ok = bool(UUID.match(got or "")) and (self._uuid is None or got == self._uuid)
|
|
109
111
|
else:
|
|
110
112
|
ok = got == self._fill(value)
|
|
@@ -177,12 +179,23 @@ class Replay:
|
|
|
177
179
|
return
|
|
178
180
|
if "body" in response:
|
|
179
181
|
_write_chunk(req, json.dumps(fill_every(response["body"], self._fill)).encode())
|
|
182
|
+
elif "pattern" in response:
|
|
183
|
+
_write_chunk(req, pattern(*response["pattern"]))
|
|
180
184
|
elif response.get("text"):
|
|
181
185
|
_write_chunk(req, response["text"].encode())
|
|
182
186
|
req.wfile.write(b"0\r\n\r\n")
|
|
183
187
|
req.wfile.flush()
|
|
184
188
|
|
|
185
189
|
|
|
190
|
+
def pattern(first: int, last: int) -> bytes:
|
|
191
|
+
"""The generated bytes ``first`` to ``last``, byte n being n % 251: a large file a
|
|
192
|
+
recording names in a few characters."""
|
|
193
|
+
start = first % 251
|
|
194
|
+
cycle = bytes(range(start, 251)) + bytes(range(start))
|
|
195
|
+
size = last - first + 1
|
|
196
|
+
return (cycle * (size // 251 + 1))[:size]
|
|
197
|
+
|
|
198
|
+
|
|
186
199
|
def _write_chunk(req: BaseHTTPRequestHandler, data: bytes) -> None:
|
|
187
200
|
req.wfile.write(b"%x\r\n%s\r\n" % (len(data), data))
|
|
188
201
|
req.wfile.flush()
|
|
@@ -11,7 +11,7 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
import pytest
|
|
13
13
|
from caller import with_files
|
|
14
|
-
from fake_server import Replay, load_exchanges
|
|
14
|
+
from fake_server import Replay, load_exchanges, pattern
|
|
15
15
|
|
|
16
16
|
from abysshub import Abyss, AbyssError, AsyncAbyss, file
|
|
17
17
|
|
|
@@ -19,6 +19,7 @@ KEY = "abyss_sk_fake_for_tests"
|
|
|
19
19
|
RECORDINGS = {recording["name"]: recording for recording in load_exchanges("files-*.json")}
|
|
20
20
|
UPLOAD = RECORDINGS["files-upload-press.json"]
|
|
21
21
|
EXPIRED = RECORDINGS["files-output-expired.json"]
|
|
22
|
+
RANGED = RECORDINGS["files-output-ranged.json"]
|
|
22
23
|
CONTENT = UPLOAD["call"]["files"]["report.pdf"]
|
|
23
24
|
|
|
24
25
|
|
|
@@ -147,3 +148,52 @@ def test_async_each_output_file_has_save_and_read_refreshing_an_expired_url_once
|
|
|
147
148
|
|
|
148
149
|
with serving(EXPIRED) as (base, dir):
|
|
149
150
|
asyncio.run(work(base, dir))
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def test_a_file_of_64_mib_or_more_reads_as_its_16_ranges_byte_for_byte() -> None:
|
|
154
|
+
with against(RANGED) as (abyss, _):
|
|
155
|
+
run = abyss.run(RANGED["call"]["widget"], RANGED["call"]["input"])
|
|
156
|
+
assert run.output_files[0].read() == pattern(0, RANGED["outcome"]["saved"]["frames.bin"]["pattern"] - 1)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
FAILURES: dict[str, tuple[dict[str, Any], str, int | None]] = {
|
|
160
|
+
"an error status": (
|
|
161
|
+
{"status": 503, "headers": {"Content-Type": "application/xml"}, "text": "<Error><Code>SlowDown</Code></Error>"},
|
|
162
|
+
"unexpected_response",
|
|
163
|
+
503,
|
|
164
|
+
),
|
|
165
|
+
"a drop": ({"drop": True}, "connection_error", None),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.parametrize("client", ["Abyss", "AsyncAbyss"])
|
|
170
|
+
@pytest.mark.parametrize("failure", FAILURES)
|
|
171
|
+
def test_a_range_that_fails_ends_the_download_with_the_error_a_whole_download_raises(
|
|
172
|
+
failure: str, client: str
|
|
173
|
+
) -> None:
|
|
174
|
+
response, code, status = FAILURES[failure]
|
|
175
|
+
recording = copy.deepcopy(RANGED)
|
|
176
|
+
recording["exchanges"][9]["response"] = response
|
|
177
|
+
|
|
178
|
+
async def read_async(base: str) -> bytes:
|
|
179
|
+
async with AsyncAbyss(api_key=KEY, base_url=base) as abyss:
|
|
180
|
+
run = await abyss.run(RANGED["call"]["widget"], RANGED["call"]["input"])
|
|
181
|
+
return await run.output_files[0].read()
|
|
182
|
+
|
|
183
|
+
# The other ranges may be stopped before they reach the server, so not every
|
|
184
|
+
# recorded request need be sent.
|
|
185
|
+
server = Replay(recording, KEY)
|
|
186
|
+
try:
|
|
187
|
+
with pytest.raises(AbyssError) as raised:
|
|
188
|
+
if client == "Abyss":
|
|
189
|
+
with Abyss(api_key=KEY, base_url=server.base) as abyss:
|
|
190
|
+
abyss.run(RANGED["call"]["widget"], RANGED["call"]["input"]).output_files[0].read()
|
|
191
|
+
else:
|
|
192
|
+
asyncio.run(read_async(server.base))
|
|
193
|
+
assert raised.value.code == code
|
|
194
|
+
assert raised.value.status == status
|
|
195
|
+
if status is not None:
|
|
196
|
+
assert raised.value.message == f"The download of frames.bin answered {status}."
|
|
197
|
+
assert server.problems == []
|
|
198
|
+
finally:
|
|
199
|
+
server.close()
|
|
@@ -8,7 +8,7 @@ from typing import Any
|
|
|
8
8
|
|
|
9
9
|
import pytest
|
|
10
10
|
from caller import caller_input, perform, perform_async, with_files
|
|
11
|
-
from fake_server import Replay, load_exchanges
|
|
11
|
+
from fake_server import Replay, load_exchanges, pattern
|
|
12
12
|
|
|
13
13
|
from abysshub import Abyss, AbyssError, AsyncAbyss, AsyncRun, Run
|
|
14
14
|
|
|
@@ -91,8 +91,12 @@ def test_replay(recording: dict[str, Any], client: str) -> None:
|
|
|
91
91
|
assert_fields(error.run, run, "error.run")
|
|
92
92
|
if "saved" in outcome:
|
|
93
93
|
assert saved == [dir / save / path for path in outcome["saved"]], "run.save() returns the paths it wrote"
|
|
94
|
+
# A saved ``pattern`` is a whole file of that many generated bytes.
|
|
94
95
|
for path, content in outcome["saved"].items():
|
|
95
|
-
|
|
96
|
+
if isinstance(content, dict):
|
|
97
|
+
assert (dir / save / path).read_bytes() == pattern(0, content["pattern"] - 1), f"saved {path}"
|
|
98
|
+
else:
|
|
99
|
+
assert (dir / save / path).read_text("utf-8") == content, f"saved {path}"
|
|
96
100
|
finally:
|
|
97
101
|
server.close()
|
|
98
102
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|