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.
- abysshub-0.0.1.dev8/PKG-INFO +74 -0
- abysshub-0.0.1.dev8/README.md +57 -0
- {abysshub-0.0.1.dev6 → abysshub-0.0.1.dev8}/pyproject.toml +2 -2
- abysshub-0.0.1.dev8/src/abysshub/__init__.py +6 -0
- abysshub-0.0.1.dev8/src/abysshub/_client.py +196 -0
- abysshub-0.0.1.dev8/src/abysshub/_error.py +45 -0
- abysshub-0.0.1.dev8/src/abysshub/_http.py +44 -0
- abysshub-0.0.1.dev8/src/abysshub/_run.py +61 -0
- abysshub-0.0.1.dev8/tests/fake_server.py +161 -0
- abysshub-0.0.1.dev8/tests/test_client.py +41 -0
- abysshub-0.0.1.dev8/tests/test_http.py +59 -0
- abysshub-0.0.1.dev8/tests/test_replay.py +81 -0
- abysshub-0.0.1.dev6/PKG-INFO +0 -21
- abysshub-0.0.1.dev6/README.md +0 -5
- abysshub-0.0.1.dev6/src/abysshub/__init__.py +0 -1
- {abysshub-0.0.1.dev6 → abysshub-0.0.1.dev8}/.gitignore +0 -0
- {abysshub-0.0.1.dev6 → abysshub-0.0.1.dev8}/LICENSE +0 -0
- {abysshub-0.0.1.dev6 → abysshub-0.0.1.dev8}/src/abysshub/py.typed +0 -0
- {abysshub-0.0.1.dev6 → abysshub-0.0.1.dev8}/tests/test_package.py +0 -0
|
@@ -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.
|
|
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,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()
|
abysshub-0.0.1.dev6/PKG-INFO
DELETED
|
@@ -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.
|
abysshub-0.0.1.dev6/README.md
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""The official Python library for the Abyss API."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|