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.
Files changed (22) hide show
  1. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/PKG-INFO +2 -2
  2. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/README.md +1 -1
  3. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/pyproject.toml +1 -1
  4. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_async_client.py +18 -6
  5. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_client.py +20 -5
  6. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_run.py +93 -12
  7. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/fake_server.py +14 -1
  8. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/test_files.py +51 -1
  9. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/test_replay.py +6 -2
  10. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/.gitignore +0 -0
  11. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/LICENSE +0 -0
  12. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/__init__.py +0 -0
  13. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_error.py +0 -0
  14. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_files.py +0 -0
  15. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_http.py +0 -0
  16. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/_types.py +0 -0
  17. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/src/abysshub/py.typed +0 -0
  18. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/caller.py +0 -0
  19. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/test_client.py +0 -0
  20. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/test_http.py +0 -0
  21. {abysshub-0.1.1.dev4 → abysshub-0.1.1.dev5}/tests/test_package.py +0 -0
  22. {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.dev4
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
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "abysshub"
7
- version = "0.1.1.dev4"
7
+ version = "0.1.1.dev5"
8
8
  description = "The official Python library for the Abyss API."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- """Downloads an output file from storage, without the API key."""
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.get(url)
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 AbyssError("connection_error", "The connection to storage dropped during a download.") from error
222
- return response.status_code, response.content
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
- """Downloads an output file from storage, without the API key."""
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.get(url)
453
+ response = self._http.send(self._http.build_request("GET", url, headers=headers), stream=True)
452
454
  except httpx.TransportError as error:
453
- raise AbyssError("connection_error", "The connection to storage dropped during a download.") from error
454
- return response.status_code, response.content
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
- """Downloads a URL from storage: answers its status and content."""
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
- status, content = self._download(self.url)
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, content = self._download(self.url)
55
- return _downloaded(self.path, status, content)
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
- status, content = await self._download(self.url)
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, content = await self._download(self.url)
77
- return _downloaded(self.path, status, content)
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 AbyssError("unexpected_response", f"The download of {path} answered {status}.", status=status)
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 == "{uuid}":
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
- assert (dir / save / path).read_text("utf-8") == content, f"saved {path}"
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