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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: abysshub
3
- Version: 0.0.1.dev8
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`:
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "abysshub"
7
- version = "0.0.1.dev8"
7
+ version = "0.0.1.dev9"
8
8
  description = "The official Python library for the Abyss API."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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
- """Presses a Widget and waits for the run's ending.
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 = parse_run(reply.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
- body: dict[str, Any] = {"input": dict(input)}
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"{self._widget_url(widget)}/runs", headers, json.dumps(body).encode())
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`. Every refusal raises at once."""
136
- try:
137
- answer = request(
138
- self._http,
139
- method,
140
- url,
141
- {"Authorization": f"Bearer {self._api_key}", "Accept": "application/json", **(headers or {})},
142
- content,
143
- )
144
- except httpx.TransportError as error:
145
- raise AbyssError(
146
- "connection_error",
147
- "The connection dropped, or timed out, before the API answered.",
148
- ) from error
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 = 0
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
- index = self.served
99
- if index >= len(self.recording["exchanges"]):
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.served += 1
103
- self.problems.extend(f"#{index + 1}: {problem}" for problem in self._differences(index, req, sent))
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
- client = recording["call"].get("client") or {}
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, recording["call"]), None
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
- assert server.problems == []
54
- assert server.remaining() == 0, "every recorded request was sent"
55
- outcome = recording["outcome"]
56
- if "run" in outcome:
57
- assert isinstance(value, Run), f"expected a run, got {error!r}"
58
- assert_fields(value, outcome["run"], "run")
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
- assert_fields(error.run, run, "error.run")
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