abysshub 0.0.1.dev6__tar.gz → 0.0.1.dev8__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.
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.5
2
+ Name: abysshub
3
+ Version: 0.0.1.dev8
4
+ Summary: The official Python library for the Abyss API.
5
+ Project-URL: Homepage, https://abysshub.com
6
+ Project-URL: Source, https://github.com/Abysshub/abysshub-sdk
7
+ Project-URL: Issues, https://github.com/Abysshub/abysshub-sdk/issues
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Typing :: Typed
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: httpx<1,>=0.27
14
+ Provides-Extra: dev
15
+ Requires-Dist: pytest; extra == 'dev'
16
+ Description-Content-Type: text/markdown
17
+
18
+ # abysshub
19
+
20
+ The official Python library for the [Abyss](https://abysshub.com) API.
21
+
22
+ Pre-release: the Abyss API opens soon.
23
+
24
+ ```python
25
+ from abysshub import Abyss, AbyssError
26
+
27
+ abyss = Abyss() # reads ABYSS_API_KEY
28
+
29
+ try:
30
+ run = abyss.run("widget_131", {"amount": 250000})
31
+ print(run.result)
32
+ except AbyssError as error:
33
+ print(error.code, error.param, error.request_id, error.run)
34
+ ```
35
+
36
+ `run()` presses the Widget and waits for the run to end, however long it takes, then returns the succeeded run. A refusal or a failed run raises `AbyssError`.
37
+
38
+ It runs on Python 3.10+, with one dependency, `httpx`.
39
+
40
+ ## The client
41
+
42
+ ```python
43
+ abyss = Abyss(api_key=None, base_url=None, max_retries=2)
44
+ ```
45
+
46
+ - **`api_key`:** defaults to `ABYSS_API_KEY`. Without a key, `Abyss()` raises `AbyssError` with `code == "invalid_api_key"`.
47
+ - **`base_url`:** defaults to `ABYSS_BASE_URL`, else `https://api.abysshub.com`. A key starting `abyss_sk_dev_` goes to `https://api.dev.abysshub.com`.
48
+
49
+ `abyss.close()` closes its connections; `with Abyss() as abyss:` does it for you.
50
+
51
+ ## Running a Widget
52
+
53
+ ```python
54
+ run = abyss.run(widget, input, max_price=None, idempotency_key=None)
55
+ ```
56
+
57
+ - **`widget`** is `widget_<id>`, or an unlisted Widget's private token.
58
+ - **`input`** is the Widget's Fields by name, as a `dict`.
59
+ - **`max_price`:** the most the run may cost, in Byssium. A higher price is refused with `price_above_max`, and nothing is charged.
60
+ - **`idempotency_key`:** each call presses with a fresh UUID `Idempotency-Key` unless you give one.
61
+
62
+ The press holds until the run ends. When the hold ends early (a deploy cuts it, the API's holds are all taken, or an hour passes), `run()` re-attaches with `GET <Location>?wait=true` until the run ends. Re-attaching has no limit and is not a retry. Connecting has 10 s, and 30 s of silence counts as a cut.
63
+
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
+
66
+ ## Errors
67
+
68
+ A refusal or a failed run raises one `AbyssError`. Branch on `code`:
69
+
70
+ - **`code`, `message`, `param`, `doc_url`, `request_id`:** from `/v1`'s error body. A failed run's `code` is `widget_fault`, `platform_fault` or `budget_exceeded`.
71
+ - **`status`:** the HTTP status of a refusal; `None` for a failed run.
72
+ - **`run`:** the failed run; `None` for a refusal.
73
+
74
+ The library's own codes: `invalid_api_key` (no key), `connection_error` (the connection dropped before the API answered) and `unexpected_response` (an answer that is not `/v1`'s).
@@ -0,0 +1,57 @@
1
+ # abysshub
2
+
3
+ The official Python library for the [Abyss](https://abysshub.com) API.
4
+
5
+ Pre-release: the Abyss API opens soon.
6
+
7
+ ```python
8
+ from abysshub import Abyss, AbyssError
9
+
10
+ abyss = Abyss() # reads ABYSS_API_KEY
11
+
12
+ try:
13
+ run = abyss.run("widget_131", {"amount": 250000})
14
+ print(run.result)
15
+ except AbyssError as error:
16
+ print(error.code, error.param, error.request_id, error.run)
17
+ ```
18
+
19
+ `run()` presses the Widget and waits for the run to end, however long it takes, then returns the succeeded run. A refusal or a failed run raises `AbyssError`.
20
+
21
+ It runs on Python 3.10+, with one dependency, `httpx`.
22
+
23
+ ## The client
24
+
25
+ ```python
26
+ abyss = Abyss(api_key=None, base_url=None, max_retries=2)
27
+ ```
28
+
29
+ - **`api_key`:** defaults to `ABYSS_API_KEY`. Without a key, `Abyss()` raises `AbyssError` with `code == "invalid_api_key"`.
30
+ - **`base_url`:** defaults to `ABYSS_BASE_URL`, else `https://api.abysshub.com`. A key starting `abyss_sk_dev_` goes to `https://api.dev.abysshub.com`.
31
+
32
+ `abyss.close()` closes its connections; `with Abyss() as abyss:` does it for you.
33
+
34
+ ## Running a Widget
35
+
36
+ ```python
37
+ run = abyss.run(widget, input, max_price=None, idempotency_key=None)
38
+ ```
39
+
40
+ - **`widget`** is `widget_<id>`, or an unlisted Widget's private token.
41
+ - **`input`** is the Widget's Fields by name, as a `dict`.
42
+ - **`max_price`:** the most the run may cost, in Byssium. A higher price is refused with `price_above_max`, and nothing is charged.
43
+ - **`idempotency_key`:** each call presses with a fresh UUID `Idempotency-Key` unless you give one.
44
+
45
+ The press holds until the run ends. When the hold ends early (a deploy cuts it, the API's holds are all taken, or an hour passes), `run()` re-attaches with `GET <Location>?wait=true` until the run ends. Re-attaching has no limit and is not a retry. Connecting has 10 s, and 30 s of silence counts as a cut.
46
+
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
+
49
+ ## Errors
50
+
51
+ A refusal or a failed run raises one `AbyssError`. Branch on `code`:
52
+
53
+ - **`code`, `message`, `param`, `doc_url`, `request_id`:** from `/v1`'s error body. A failed run's `code` is `widget_fault`, `platform_fault` or `budget_exceeded`.
54
+ - **`status`:** the HTTP status of a refusal; `None` for a failed run.
55
+ - **`run`:** the failed run; `None` for a refusal.
56
+
57
+ The library's own codes: `invalid_api_key` (no key), `connection_error` (the connection dropped before the API answered) and `unexpected_response` (an answer that is not `/v1`'s).
@@ -4,13 +4,13 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "abysshub"
7
- version = "0.0.1.dev6"
7
+ version = "0.0.1.dev8"
8
8
  description = "The official Python library for the Abyss API."
9
9
  readme = "README.md"
10
10
  license = "MIT"
11
11
  license-files = ["LICENSE"]
12
12
  requires-python = ">=3.10"
13
- dependencies = []
13
+ dependencies = ["httpx>=0.27,<1"]
14
14
  classifiers = [
15
15
  "Programming Language :: Python :: 3",
16
16
  "Typing :: Typed",
@@ -0,0 +1,6 @@
1
+ """The official Python library for the Abyss API."""
2
+ from ._client import Abyss
3
+ from ._error import AbyssError
4
+ from ._run import Run, RunError, RunFile, RunStatus
5
+
6
+ __all__ = ["Abyss", "AbyssError", "Run", "RunError", "RunFile", "RunStatus"]
@@ -0,0 +1,196 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ import uuid
7
+ from collections.abc import Callable, Mapping
8
+ from dataclasses import dataclass
9
+ from types import TracebackType
10
+ from typing import Any
11
+ from urllib.parse import quote
12
+
13
+ import httpx
14
+
15
+ from ._error import AbyssError
16
+ from ._http import request, timeouts
17
+ from ._run import Run, RunError, parse_run
18
+
19
+ API = "https://api.abysshub.com"
20
+ DEV_API = "https://api.dev.abysshub.com"
21
+ DEV_KEY_PREFIX = "abyss_sk_dev_"
22
+
23
+ REATTACH_PAUSE = 1.0
24
+ """Seconds between two re-attaches that both ended without the run's ending."""
25
+
26
+
27
+ @dataclass
28
+ class _Reply:
29
+ body: Any
30
+ run: dict[str, Any] | None
31
+ cut: bool
32
+ """Whether the connection was cut, or went silent, after the headers."""
33
+ location: str | None
34
+ request_id: str | None
35
+
36
+
37
+ class Abyss:
38
+ """A client for the Abyss API.
39
+
40
+ The key defaults to ``ABYSS_API_KEY``, and the address to ``ABYSS_BASE_URL``,
41
+ else the address the key belongs to.
42
+ """
43
+
44
+ base_url: str
45
+ max_retries: int
46
+ """How many times a request is sent again after a drop before any headers, a 429, a 409 or a 5xx."""
47
+
48
+ def __init__(self, api_key: str | None = None, base_url: str | None = None, max_retries: int = 2) -> None:
49
+ if api_key is None:
50
+ api_key = os.environ.get("ABYSS_API_KEY")
51
+ if not api_key:
52
+ raise AbyssError("invalid_api_key", "No Abyss API key: pass api_key, or set ABYSS_API_KEY.")
53
+ self._api_key = api_key
54
+ if base_url is None:
55
+ base_url = os.environ.get("ABYSS_BASE_URL") or (DEV_API if api_key.startswith(DEV_KEY_PREFIX) else API)
56
+ self.base_url = base_url.rstrip("/")
57
+ self.max_retries = max_retries
58
+ self._http = httpx.Client(timeout=timeouts())
59
+
60
+ def close(self) -> None:
61
+ """Closes the client's connections."""
62
+ self._http.close()
63
+
64
+ def __enter__(self) -> Abyss:
65
+ return self
66
+
67
+ def __exit__(
68
+ self,
69
+ exc_type: type[BaseException] | None,
70
+ exc: BaseException | None,
71
+ tb: TracebackType | None,
72
+ ) -> None:
73
+ self.close()
74
+
75
+ def run(
76
+ self,
77
+ widget: str,
78
+ input: Mapping[str, Any],
79
+ *,
80
+ max_price: float | None = None,
81
+ idempotency_key: str | None = None,
82
+ ) -> Run:
83
+ """Presses a Widget and waits for the run's ending.
84
+
85
+ Re-attaches through ``Location`` whenever the hold ends early. Returns the
86
+ succeeded run; raises ``AbyssError`` on a refusal or a failed run.
87
+ """
88
+ press = self._press(widget, input, max_price, idempotency_key)
89
+ reply = press if _has_ended(press) else self._hold(self._wait_url(press), _has_ended)
90
+ assert reply.run is not None
91
+ run = parse_run(reply.run)
92
+ if run.status == "failed":
93
+ error = run.error or RunError("platform_fault", "The run failed.")
94
+ raise AbyssError(error.code, error.message, request_id=reply.request_id, run=run)
95
+ return run
96
+
97
+ def _press(
98
+ self,
99
+ widget: str,
100
+ input: Mapping[str, Any],
101
+ max_price: float | None,
102
+ idempotency_key: str | None,
103
+ ) -> _Reply:
104
+ body: dict[str, Any] = {"input": dict(input)}
105
+ if max_price is not None:
106
+ body["max_price"] = max_price
107
+ headers = {
108
+ "Content-Type": "application/json",
109
+ "Idempotency-Key": idempotency_key if idempotency_key is not None else str(uuid.uuid4()),
110
+ }
111
+ return self._send("POST", f"{self._widget_url(widget)}/runs", headers, json.dumps(body).encode())
112
+
113
+ def _hold(self, url: str, done: Callable[[_Reply], bool]) -> _Reply:
114
+ """Reads ``url`` until ``done``. Re-attaching has no limit and is not a retry."""
115
+ reply = self._send("GET", url)
116
+ while not done(reply):
117
+ time.sleep(REATTACH_PAUSE)
118
+ reply = self._send("GET", url)
119
+ return reply
120
+
121
+ def _widget_url(self, widget: str) -> str:
122
+ return f"{self.base_url}/v1/widgets/{quote(widget, safe='')}"
123
+
124
+ def _wait_url(self, reply: _Reply) -> str:
125
+ location = reply.location or (f"/v1/runs/{quote(reply.run['id'], safe='')}" if reply.run else None)
126
+ if not location:
127
+ raise AbyssError(
128
+ "unexpected_response",
129
+ "The press answered without a Location to read the run at.",
130
+ request_id=reply.request_id,
131
+ )
132
+ return str(httpx.URL(f"{self.base_url}/").join(location).copy_set_param("wait", "true"))
133
+
134
+ 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
149
+ request_id = answer.headers.get("request-id")
150
+ parsed = _parse(answer.text)
151
+ if answer.status in (200, 202):
152
+ run = parsed if _is_run(parsed) else None
153
+ return _Reply(parsed, run, answer.text is None, answer.headers.get("location"), request_id)
154
+ raise _refusal(answer.status, parsed, request_id)
155
+
156
+
157
+ def _refusal(status: int, body: Any, request_id: str | None) -> AbyssError:
158
+ error = body.get("error") if isinstance(body, dict) else None
159
+ if not isinstance(error, dict) or not isinstance(error.get("code"), str):
160
+ return AbyssError(
161
+ "unexpected_response",
162
+ f"The API answered {status} without an error body.",
163
+ status=status,
164
+ request_id=request_id,
165
+ )
166
+ body_request_id = body.get("request_id")
167
+ return AbyssError(
168
+ error["code"],
169
+ _text(error.get("message")) or error["code"],
170
+ status=status,
171
+ param=_text(error.get("param")),
172
+ doc_url=_text(error.get("doc_url")),
173
+ request_id=body_request_id if isinstance(body_request_id, str) else request_id,
174
+ )
175
+
176
+
177
+ def _text(value: Any) -> str | None:
178
+ return value if isinstance(value, str) else None
179
+
180
+
181
+ def _parse(text: str | None) -> Any:
182
+ if text is None:
183
+ return None
184
+ try:
185
+ return json.loads(text)
186
+ except ValueError:
187
+ return None
188
+
189
+
190
+ def _is_run(value: Any) -> bool:
191
+ return isinstance(value, dict) and isinstance(value.get("id"), str) and isinstance(value.get("status"), str)
192
+
193
+
194
+ def _has_ended(reply: _Reply) -> bool:
195
+ """Whether the reply carries a run that has succeeded or failed."""
196
+ return reply.run is not None and reply.run["status"] in ("succeeded", "failed")
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ if TYPE_CHECKING:
6
+ from ._run import Run
7
+
8
+
9
+ class AbyssError(Exception):
10
+ """The one error the library raises: a refusal from `/v1`, or a run that failed.
11
+
12
+ Branch on ``code``. A failed run carries the run as ``run``.
13
+ """
14
+
15
+ code: str
16
+ message: str
17
+ status: int | None
18
+ """The HTTP status of a refusal; None for a failed run."""
19
+ param: str | None
20
+ doc_url: str | None
21
+ request_id: str | None
22
+ run: Run | None
23
+
24
+ def __init__(
25
+ self,
26
+ code: str,
27
+ message: str,
28
+ *,
29
+ status: int | None = None,
30
+ param: str | None = None,
31
+ doc_url: str | None = None,
32
+ request_id: str | None = None,
33
+ run: Run | None = None,
34
+ ) -> None:
35
+ super().__init__(message)
36
+ self.code = code
37
+ self.message = message
38
+ self.status = status
39
+ self.param = param
40
+ self.doc_url = doc_url
41
+ self.request_id = request_id
42
+ self.run = run
43
+
44
+ def __repr__(self) -> str:
45
+ return f"AbyssError(code={self.code!r}, message={self.message!r})"
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ import httpx
6
+
7
+ CONNECT_TIMEOUT = 10.0
8
+ """Seconds to connect."""
9
+
10
+ SILENCE_TIMEOUT = 30.0
11
+ """Seconds a response may stay silent before it counts as a cut."""
12
+
13
+
14
+ def timeouts(connect: float = CONNECT_TIMEOUT, silence: float = SILENCE_TIMEOUT) -> httpx.Timeout:
15
+ """httpx's timeouts: ``connect`` to connect, and ``silence`` between any two reads."""
16
+ return httpx.Timeout(silence, connect=connect)
17
+
18
+
19
+ @dataclass
20
+ class Answer:
21
+ status: int
22
+ headers: httpx.Headers
23
+ text: str | None
24
+ """The whole body, or None when the connection was cut or went silent after the headers."""
25
+
26
+
27
+ def request(
28
+ client: httpx.Client,
29
+ method: str,
30
+ url: str,
31
+ headers: dict[str, str],
32
+ content: bytes | None = None,
33
+ ) -> Answer:
34
+ """Sends one request and reads its whole body.
35
+
36
+ A failure before the headers raises ``httpx.TransportError``; a cut after them
37
+ answers ``text=None``, so the caller can re-attach.
38
+ """
39
+ with client.stream(method, url, headers=headers, content=content) as response:
40
+ try:
41
+ body = b"".join(response.iter_bytes())
42
+ except httpx.TransportError:
43
+ return Answer(response.status_code, response.headers, None)
44
+ return Answer(response.status_code, response.headers, body.decode("utf-8", errors="replace"))
@@ -0,0 +1,61 @@
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
+ )
@@ -0,0 +1,161 @@
1
+ """The tiny fake server: it replays one recorded exchange file from exchanges/ and
2
+ notes every way the library's requests differ from the recorded ones."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ import socket
8
+ import threading
9
+ import time
10
+ from collections.abc import Callable
11
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ EXCHANGES = Path(__file__).resolve().parents[2] / "exchanges"
16
+ UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE)
17
+ CHUNK_PAUSE = 0.005
18
+
19
+
20
+ def load_exchanges(pattern: str = "*.json") -> list[dict[str, Any]]:
21
+ return [{"name": path.name, **json.loads(path.read_text("utf-8"))} for path in sorted(EXCHANGES.glob(pattern))]
22
+
23
+
24
+ def fill_every(value: Any, fill: Callable[[str], str]) -> Any:
25
+ """The value with ``fill`` applied to every string in it, however deep."""
26
+ if isinstance(value, str):
27
+ return fill(value)
28
+ if isinstance(value, list):
29
+ return [fill_every(item, fill) for item in value]
30
+ if isinstance(value, dict):
31
+ return {name: fill_every(item, fill) for name, item in value.items()}
32
+ return value
33
+
34
+
35
+ class Replay:
36
+ """Serves one recording's exchanges in order, on 127.0.0.1."""
37
+
38
+ def __init__(self, recording: dict[str, Any], key: str) -> None:
39
+ self.recording = recording
40
+ self.key = key
41
+ self.problems: list[str] = []
42
+ self.served = 0
43
+ self._uuid: str | None = None
44
+ self._lock = threading.Lock()
45
+ self._closing = threading.Event()
46
+ replay = self
47
+
48
+ class Handler(BaseHTTPRequestHandler):
49
+ protocol_version = "HTTP/1.1"
50
+
51
+ def log_message(self, format: str, *args: Any) -> None:
52
+ pass
53
+
54
+ def do_GET(self) -> None:
55
+ replay._serve(self)
56
+
57
+ do_POST = do_GET
58
+
59
+ self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
60
+ self._server.daemon_threads = True
61
+ self.base = f"http://127.0.0.1:{self._server.server_address[1]}"
62
+ self._thread = threading.Thread(target=self._server.serve_forever, args=(0.01,), daemon=True)
63
+ self._thread.start()
64
+
65
+ def remaining(self) -> int:
66
+ return len(self.recording["exchanges"]) - self.served
67
+
68
+ def close(self) -> None:
69
+ self._closing.set()
70
+ self._server.shutdown()
71
+ self._server.server_close()
72
+
73
+ def _fill(self, value: str) -> str:
74
+ return value.replace("{base}", self.base).replace("{key}", self.key)
75
+
76
+ def _differences(self, index: int, req: BaseHTTPRequestHandler, sent: bytes) -> list[str]:
77
+ exchange = self.recording["exchanges"][index]
78
+ request = exchange["request"]
79
+ found = []
80
+ if req.command != request["method"] or req.path != request["path"]:
81
+ found.append(f"expected {request['method']} {request['path']}, got {req.command} {req.path}")
82
+ for name, value in (request.get("headers") or {}).items():
83
+ got = req.headers.get(name)
84
+ if value == "{uuid}":
85
+ ok = bool(UUID.match(got or "")) and (self._uuid is None or got == self._uuid)
86
+ else:
87
+ ok = got == self._fill(value)
88
+ if not ok:
89
+ found.append(f"header {name} is {got!r}")
90
+ if exchange.get("storage") and req.headers.get("Authorization") is not None:
91
+ found.append("storage was sent the API key")
92
+ if "body" in request and _parse_or_text(sent.decode("utf-8")) != request["body"]:
93
+ found.append(f"body is {sent.decode('utf-8')}")
94
+ return found
95
+
96
+ def _choose(self, req: BaseHTTPRequestHandler, sent: bytes) -> int | None:
97
+ with self._lock:
98
+ index = self.served
99
+ if index >= len(self.recording["exchanges"]):
100
+ self.problems.append(f"unexpected {req.command} {req.path}")
101
+ return None
102
+ self.served += 1
103
+ self.problems.extend(f"#{index + 1}: {problem}" for problem in self._differences(index, req, sent))
104
+ for name, value in (self.recording["exchanges"][index]["request"].get("headers") or {}).items():
105
+ if value == "{uuid}" and self._uuid is None:
106
+ self._uuid = req.headers.get(name)
107
+ return index
108
+
109
+ def _serve(self, req: BaseHTTPRequestHandler) -> None:
110
+ sent = req.rfile.read(int(req.headers.get("Content-Length") or 0))
111
+ index = self._choose(req, sent)
112
+ if index is None:
113
+ req.send_response(500)
114
+ req.send_header("Content-Length", "0")
115
+ req.end_headers()
116
+ return
117
+ response = self.recording["exchanges"][index]["response"]
118
+
119
+ if response.get("drop"):
120
+ req.close_connection = True
121
+ req.connection.shutdown(socket.SHUT_RDWR)
122
+ return
123
+ req.send_response(response["status"])
124
+ if "body" in response:
125
+ req.send_header("Content-Type", "application/json")
126
+ for name, value in (response.get("headers") or {}).items():
127
+ req.send_header(name, self._fill(value))
128
+ # Chunked, as Node sends it, so a cut is told apart from the body's end.
129
+ req.send_header("Transfer-Encoding", "chunked")
130
+ req.end_headers()
131
+ req.wfile.flush()
132
+ for chunk in response.get("chunks") or []:
133
+ time.sleep(CHUNK_PAUSE)
134
+ _write_chunk(req, chunk.encode())
135
+ if response.get("cut"):
136
+ time.sleep(CHUNK_PAUSE)
137
+ req.close_connection = True
138
+ req.connection.shutdown(socket.SHUT_RDWR)
139
+ return
140
+ if response.get("hold"):
141
+ self._closing.wait()
142
+ req.close_connection = True
143
+ return
144
+ if "body" in response:
145
+ _write_chunk(req, json.dumps(fill_every(response["body"], self._fill)).encode())
146
+ elif response.get("text"):
147
+ _write_chunk(req, response["text"].encode())
148
+ req.wfile.write(b"0\r\n\r\n")
149
+ req.wfile.flush()
150
+
151
+
152
+ def _write_chunk(req: BaseHTTPRequestHandler, data: bytes) -> None:
153
+ req.wfile.write(b"%x\r\n%s\r\n" % (len(data), data))
154
+ req.wfile.flush()
155
+
156
+
157
+ def _parse_or_text(text: str) -> Any:
158
+ try:
159
+ return json.loads(text)
160
+ except ValueError:
161
+ return text
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from abysshub import Abyss, AbyssError
6
+
7
+
8
+ @pytest.fixture(autouse=True)
9
+ def no_env(monkeypatch: pytest.MonkeyPatch) -> None:
10
+ monkeypatch.delenv("ABYSS_API_KEY", raising=False)
11
+ monkeypatch.delenv("ABYSS_BASE_URL", raising=False)
12
+
13
+
14
+ def test_a_key_goes_to_the_apis_address() -> None:
15
+ assert Abyss(api_key="abyss_sk_x").base_url == "https://api.abysshub.com"
16
+
17
+
18
+ def test_an_abyss_sk_dev_key_goes_to_devs_address() -> None:
19
+ assert Abyss(api_key="abyss_sk_dev_x").base_url == "https://api.dev.abysshub.com"
20
+
21
+
22
+ def test_abyss_base_url_overrides_the_keys_address_and_base_url_overrides_both(monkeypatch: pytest.MonkeyPatch) -> None:
23
+ monkeypatch.setenv("ABYSS_BASE_URL", "http://localhost:8000/")
24
+ assert Abyss(api_key="abyss_sk_dev_x").base_url == "http://localhost:8000"
25
+ assert Abyss(api_key="abyss_sk_dev_x", base_url="http://other").base_url == "http://other"
26
+
27
+
28
+ def test_the_key_comes_from_abyss_api_key(monkeypatch: pytest.MonkeyPatch) -> None:
29
+ monkeypatch.setenv("ABYSS_API_KEY", "abyss_sk_dev_x")
30
+ assert Abyss().base_url == "https://api.dev.abysshub.com"
31
+
32
+
33
+ def test_no_key_raises_abyss_error() -> None:
34
+ with pytest.raises(AbyssError) as raised:
35
+ Abyss()
36
+ assert raised.value.code == "invalid_api_key"
37
+
38
+
39
+ def test_max_retries_defaults_to_2() -> None:
40
+ assert Abyss(api_key="abyss_sk_x").max_retries == 2
41
+ assert Abyss(api_key="abyss_sk_x", max_retries=0).max_retries == 0
@@ -0,0 +1,59 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+ import pytest
7
+ from fake_server import Replay
8
+
9
+ from abysshub._http import request, timeouts
10
+
11
+
12
+ def answering(response: dict[str, Any]) -> Replay:
13
+ return Replay({"exchanges": [{"request": {"method": "GET", "path": "/"}, "response": response}]}, "abyss_sk_x")
14
+
15
+
16
+ def get(server: Replay, silence: float = 1.0) -> Any:
17
+ with httpx.Client(timeout=timeouts(connect=1.0, silence=silence)) as client:
18
+ return request(client, "GET", f"{server.base}/", {})
19
+
20
+
21
+ def test_the_timeouts_are_10_s_to_connect_and_30_s_of_silence() -> None:
22
+ assert timeouts() == httpx.Timeout(30.0, connect=10.0)
23
+
24
+
25
+ def test_silence_after_the_headers_counts_as_a_cut() -> None:
26
+ server = answering({"status": 200, "chunks": [" "], "hold": True})
27
+ try:
28
+ answer = get(server, silence=0.1)
29
+ assert answer.status == 200
30
+ assert answer.text is None
31
+ finally:
32
+ server.close()
33
+
34
+
35
+ def test_a_cut_after_the_headers_answers_no_text() -> None:
36
+ server = answering({"status": 200, "headers": {"Location": "{base}/v1/runs/run_x"}, "chunks": [" ", " "], "cut": True})
37
+ try:
38
+ answer = get(server)
39
+ assert answer.text is None
40
+ assert answer.headers["location"] == f"{server.base}/v1/runs/run_x"
41
+ finally:
42
+ server.close()
43
+
44
+
45
+ def test_a_drop_before_any_headers_raises() -> None:
46
+ server = answering({"drop": True})
47
+ try:
48
+ with pytest.raises(httpx.TransportError):
49
+ get(server)
50
+ finally:
51
+ server.close()
52
+
53
+
54
+ def test_a_whole_body_keeps_its_leading_spaces_for_the_caller_to_skip() -> None:
55
+ server = answering({"status": 200, "chunks": [" "], "body": {"ok": True}})
56
+ try:
57
+ assert get(server).text == ' {"ok": true}'
58
+ finally:
59
+ server.close()
@@ -0,0 +1,81 @@
1
+ """Every recorded exchange the Python library covers, replayed through the fake server.
2
+ Each must reach the same outcome as in JS."""
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import pytest
8
+ from fake_server import Replay, load_exchanges
9
+
10
+ from abysshub import Abyss, AbyssError, Run
11
+
12
+ KEY = "abyss_sk_fake_for_tests"
13
+ RECORDINGS = load_exchanges("run-*.json")
14
+
15
+
16
+ def assert_fields(actual: Any, expected: Any, where: str) -> None:
17
+ """The fields ``expected`` names, however deep: a list must have its length, and an
18
+ object at least the fields it names."""
19
+ if isinstance(expected, list):
20
+ assert isinstance(actual, list), f"{where} is a list"
21
+ assert len(actual) == len(expected), f"{where} length"
22
+ for index, item in enumerate(expected):
23
+ assert_fields(actual[index], item, f"{where}[{index}]")
24
+ elif isinstance(expected, dict):
25
+ assert actual is not None, f"{where} is an object"
26
+ for name, value in expected.items():
27
+ field = actual[name] if isinstance(actual, dict) else getattr(actual, name)
28
+ assert_fields(field, value, f"{where}.{name}")
29
+ else:
30
+ assert actual == expected, where
31
+
32
+
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
+ @pytest.mark.parametrize("recording", RECORDINGS, ids=[r["name"] for r in RECORDINGS])
43
+ def test_replay(recording: dict[str, Any]) -> None:
44
+ server = Replay(recording, KEY)
45
+ try:
46
+ client = recording["call"].get("client") or {}
47
+ with Abyss(api_key=KEY, base_url=server.base, **client) as abyss:
48
+ try:
49
+ value, error = perform(abyss, recording["call"]), None
50
+ except AbyssError as raised:
51
+ value, error = None, raised
52
+
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
66
+ else:
67
+ assert_fields(error.run, run, "error.run")
68
+ finally:
69
+ server.close()
70
+
71
+
72
+ def test_result_is_a_dict() -> None:
73
+ recording = next(r for r in RECORDINGS if r["name"] == "run-held-success.json")
74
+ server = Replay(recording, KEY)
75
+ try:
76
+ with Abyss(api_key=KEY, base_url=server.base) as abyss:
77
+ run = abyss.run("widget_131", {"amount": 250000})
78
+ assert run.result == {"approved": True, "rate": 0.042}
79
+ assert isinstance(run.result, dict)
80
+ finally:
81
+ server.close()
@@ -1,21 +0,0 @@
1
- Metadata-Version: 2.5
2
- Name: abysshub
3
- Version: 0.0.1.dev6
4
- Summary: The official Python library for the Abyss API.
5
- Project-URL: Homepage, https://abysshub.com
6
- Project-URL: Source, https://github.com/Abysshub/abysshub-sdk
7
- Project-URL: Issues, https://github.com/Abysshub/abysshub-sdk/issues
8
- License-Expression: MIT
9
- License-File: LICENSE
10
- Classifier: Programming Language :: Python :: 3
11
- Classifier: Typing :: Typed
12
- Requires-Python: >=3.10
13
- Provides-Extra: dev
14
- Requires-Dist: pytest; extra == 'dev'
15
- Description-Content-Type: text/markdown
16
-
17
- # abysshub
18
-
19
- The official Python library for the [Abyss](https://abysshub.com) API.
20
-
21
- Pre-release: the Abyss API opens soon.
@@ -1,5 +0,0 @@
1
- # abysshub
2
-
3
- The official Python library for the [Abyss](https://abysshub.com) API.
4
-
5
- Pre-release: the Abyss API opens soon.
@@ -1 +0,0 @@
1
- """The official Python library for the Abyss API."""
File without changes
File without changes