abysshub 0.1.1.dev2__tar.gz → 0.1.1.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/PKG-INFO +6 -4
  2. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/README.md +5 -3
  3. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/pyproject.toml +1 -1
  4. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/src/abysshub/_async_client.py +9 -1
  5. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/src/abysshub/_client.py +41 -7
  6. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/src/abysshub/_error.py +6 -0
  7. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/src/abysshub/_http.py +9 -0
  8. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/tests/test_timeout.py +2 -0
  9. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/.gitignore +0 -0
  10. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/LICENSE +0 -0
  11. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/src/abysshub/__init__.py +0 -0
  12. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/src/abysshub/_files.py +0 -0
  13. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/src/abysshub/_run.py +0 -0
  14. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/src/abysshub/_types.py +0 -0
  15. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/src/abysshub/py.typed +0 -0
  16. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/tests/caller.py +0 -0
  17. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/tests/fake_server.py +0 -0
  18. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/tests/test_client.py +0 -0
  19. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/tests/test_files.py +0 -0
  20. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/tests/test_http.py +0 -0
  21. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/tests/test_package.py +0 -0
  22. {abysshub-0.1.1.dev2 → abysshub-0.1.1.dev3}/tests/test_replay.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: abysshub
3
- Version: 0.1.1.dev2
3
+ Version: 0.1.1.dev3
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
@@ -135,6 +135,7 @@ A refusal, a failed run, a timeout and a lost connection all raise one class, `A
135
135
  | `request_id` | quote it to support, else `None` |
136
136
  | `shortfall` | with `insufficient_funds`: how much Byssium the wallet is short, else `None` |
137
137
  | `price` | with `price_above_max`: what the run costs now, else `None` |
138
+ | `run_id` | the id of the run the call started or read, once the press's `Location` or a run body named it, else `None` |
138
139
  | `run` | the run, for a failed run or a timeout, else `None`; an `AsyncRun` from `AsyncAbyss` |
139
140
 
140
141
  - **Refusals:** `invalid_api_key` (401), `not_found` (404), `invalid_request`, `invalid_input`, `held_input_*`, `input_unreachable` and `idempotency_key_reused` (422), `insufficient_funds` (402), `spend_cap_reached` and `price_above_max` (400), `idempotency_key_in_use` (409), `rate_limited` (429), `server_error` (500).
@@ -161,11 +162,12 @@ There is no timeout by default: `run()` waits as long as the run takes. `timeout
161
162
  try:
162
163
  run = abyss.run("widget_131", input, timeout=60)
163
164
  except AbyssError as error:
164
- if error.code == "timeout":
165
- ... # The run goes on. error.run is the run as last seen, or None when none was seen yet.
165
+ if error.code == "timeout" and error.run_id:
166
+ # The run goes on: wait for it again, without pressing (and paying) twice.
167
+ run = abyss.runs.get(error.run_id, wait=True)
166
168
  ```
167
169
 
168
- It raises `AbyssError` with `code == "timeout"` and `run`, the run as last seen. During the press's first hold the run has not been seen yet, so `run` is `None`; to keep its `id` whatever happens, press with `runs.create()` and wait with `runs.get(id, wait=True, timeout=...)`.
170
+ It raises `AbyssError` with `code == "timeout"`, `run`, the run as last seen or `None`, and `run_id`, the run's id. The press answers its `Location` at once, so `run_id` is set as soon as the press has answered, even while no run has been seen yet; it is `None` only when the waiting stopped before that. Nothing is sent after the waiting stops.
169
171
 
170
172
  ## Async
171
173
 
@@ -118,6 +118,7 @@ A refusal, a failed run, a timeout and a lost connection all raise one class, `A
118
118
  | `request_id` | quote it to support, else `None` |
119
119
  | `shortfall` | with `insufficient_funds`: how much Byssium the wallet is short, else `None` |
120
120
  | `price` | with `price_above_max`: what the run costs now, else `None` |
121
+ | `run_id` | the id of the run the call started or read, once the press's `Location` or a run body named it, else `None` |
121
122
  | `run` | the run, for a failed run or a timeout, else `None`; an `AsyncRun` from `AsyncAbyss` |
122
123
 
123
124
  - **Refusals:** `invalid_api_key` (401), `not_found` (404), `invalid_request`, `invalid_input`, `held_input_*`, `input_unreachable` and `idempotency_key_reused` (422), `insufficient_funds` (402), `spend_cap_reached` and `price_above_max` (400), `idempotency_key_in_use` (409), `rate_limited` (429), `server_error` (500).
@@ -144,11 +145,12 @@ There is no timeout by default: `run()` waits as long as the run takes. `timeout
144
145
  try:
145
146
  run = abyss.run("widget_131", input, timeout=60)
146
147
  except AbyssError as error:
147
- if error.code == "timeout":
148
- ... # The run goes on. error.run is the run as last seen, or None when none was seen yet.
148
+ if error.code == "timeout" and error.run_id:
149
+ # The run goes on: wait for it again, without pressing (and paying) twice.
150
+ run = abyss.runs.get(error.run_id, wait=True)
149
151
  ```
150
152
 
151
- It raises `AbyssError` with `code == "timeout"` and `run`, the run as last seen. During the press's first hold the run has not been seen yet, so `run` is `None`; to keep its `id` whatever happens, press with `runs.create()` and wait with `runs.get(id, wait=True, timeout=...)`.
153
+ It raises `AbyssError` with `code == "timeout"`, `run`, the run as last seen or `None`, and `run_id`, the run's id. The press answers its `Location` at once, so `run_id` is set as soon as the press has answered, even while no run has been seen yet; it is `None` only when the waiting stopped before that. Nothing is sent after the waiting stops.
152
154
 
153
155
  ## Async
154
156
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "abysshub"
7
- version = "0.1.1.dev2"
7
+ version = "0.1.1.dev3"
8
8
  description = "The official Python library for the Abyss API."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -14,6 +14,7 @@ from ._client import (
14
14
  _Call,
15
15
  _expect,
16
16
  _has_ended,
17
+ _heard,
17
18
  _is_key,
18
19
  _is_run,
19
20
  _is_run_list,
@@ -260,7 +261,14 @@ class AsyncAbyss(_Base[AsyncRun]):
260
261
  self._check(call)
261
262
  try:
262
263
  answer = await arequest(
263
- self._http, method, url, sent_headers, content, self._timeouts(call), call.deadline
264
+ self._http,
265
+ method,
266
+ url,
267
+ sent_headers,
268
+ content,
269
+ self._timeouts(call),
270
+ call.deadline,
271
+ lambda headers: _heard(call, headers),
264
272
  )
265
273
  except Stopped:
266
274
  raise self._timed_out(call) from None
@@ -4,6 +4,7 @@ import json
4
4
  import math
5
5
  import os
6
6
  import platform
7
+ import re
7
8
  import time
8
9
  import uuid
9
10
  from collections.abc import Callable, Mapping
@@ -25,6 +26,9 @@ API = "https://api.abysshub.com"
25
26
  DEV_API = "https://api.dev.abysshub.com"
26
27
  DEV_KEY_PREFIX = "abyss_sk_dev_"
27
28
 
29
+ RUN_LOCATION = re.compile(r"/v1/runs/([^/?#]+)/?(?:[?#]|$)")
30
+ """A ``Location`` naming a run, ``…/v1/runs/{id}``, with the id as its group."""
31
+
28
32
  REATTACH_PAUSE = 1.0
29
33
  """Seconds between two re-attaches that both ended without the run's ending."""
30
34
 
@@ -55,16 +59,21 @@ class _Reply:
55
59
  """Whether the connection was cut, or went silent, after the headers."""
56
60
  location: str | None
57
61
  request_id: str | None
62
+ run_id: str | None
63
+ """The id of the call's run, as far as the call knew it when this reply arrived."""
58
64
 
59
65
 
60
66
  @dataclass
61
67
  class _Call:
62
- """One call's waiting: when it stops, and the run as last seen, for a timeout to carry."""
68
+ """One call's waiting: when it stops, and what a timeout carries: the run as last
69
+ seen, the run's id once known, and the latest ``Request-Id``."""
63
70
 
64
71
  timeout: float | None
65
72
  deadline: float | None
66
73
  """A ``time.monotonic()`` reading, or None to wait as long as it takes."""
67
74
  seen: dict[str, Any] | None = None
75
+ run_id: str | None = None
76
+ request_id: str | None = None
68
77
 
69
78
  def left(self) -> float | None:
70
79
  return None if self.deadline is None else self.deadline - time.monotonic()
@@ -182,6 +191,8 @@ class _Base(Generic[R]):
182
191
  return AbyssError(
183
192
  "timeout",
184
193
  f"Stopped waiting after {call.timeout} s. The run goes on.",
194
+ request_id=call.request_id,
195
+ run_id=call.run_id,
185
196
  run=self._run(call.seen) if call.seen is not None else None,
186
197
  )
187
198
 
@@ -268,6 +279,7 @@ class _Base(Generic[R]):
268
279
  "unexpected_response",
269
280
  "The press answered without a Location to read the run at.",
270
281
  request_id=reply.request_id,
282
+ run_id=reply.run_id,
271
283
  )
272
284
  return str(httpx.URL(f"{self.base_url}/").join(location).copy_set_param("wait", "true"))
273
285
 
@@ -290,6 +302,7 @@ class _Base(Generic[R]):
290
302
  raise AbyssError(
291
303
  "connection_error",
292
304
  "The connection dropped, or timed out, before the API answered.",
305
+ run_id=call.run_id,
293
306
  ) from error
294
307
  return _backoff(attempt)
295
308
 
@@ -302,10 +315,12 @@ class _Base(Generic[R]):
302
315
  run = parsed if _is_run(parsed) else None
303
316
  if run is not None:
304
317
  call.seen = run
305
- return _Reply(parsed, run, answer.text is None, answer.headers.get("location"), request_id)
318
+ call.run_id = run["id"]
319
+ location = answer.headers.get("location")
320
+ return _Reply(parsed, run, answer.text is None, location, request_id, call.run_id)
306
321
  pause = _retry_pause(answer, attempt)
307
322
  if pause is None or attempt >= limit:
308
- raise _refusal(answer.status, parsed, request_id)
323
+ raise _refusal(answer.status, parsed, request_id, call.run_id)
309
324
  return pause
310
325
 
311
326
  def _ended(self, reply: _Reply) -> R:
@@ -314,7 +329,7 @@ class _Base(Generic[R]):
314
329
  run = self._run(reply.run)
315
330
  if run.status == "failed":
316
331
  error = run.error or RunError("platform_fault", "The run failed.")
317
- raise AbyssError(error.code, error.message, request_id=reply.request_id, run=run)
332
+ raise AbyssError(error.code, error.message, request_id=reply.request_id, run_id=run.id, run=run)
318
333
  return run
319
334
 
320
335
 
@@ -481,7 +496,16 @@ class Abyss(_Base[Run]):
481
496
  while True:
482
497
  self._check(call)
483
498
  try:
484
- answer = request(self._http, method, url, sent_headers, content, self._timeouts(call), call.deadline)
499
+ answer = request(
500
+ self._http,
501
+ method,
502
+ url,
503
+ sent_headers,
504
+ content,
505
+ self._timeouts(call),
506
+ call.deadline,
507
+ lambda headers: _heard(call, headers),
508
+ )
485
509
  except Stopped:
486
510
  raise self._timed_out(call) from None
487
511
  except httpx.TransportError as error:
@@ -495,6 +519,14 @@ class Abyss(_Base[Run]):
495
519
  attempt += 1
496
520
 
497
521
 
522
+ def _heard(call: _Call, headers: httpx.Headers) -> None:
523
+ """Notes a response's headers as they arrive: its ``Request-Id``, and the run its ``Location`` names."""
524
+ call.request_id = headers.get("request-id", call.request_id)
525
+ match = RUN_LOCATION.search(headers.get("location", ""))
526
+ if match is not None:
527
+ call.run_id = match.group(1)
528
+
529
+
498
530
  def _retry_pause(answer: Answer, attempt: int) -> float | None:
499
531
  """How long to wait before sending a refused request again, or None when it is not retried."""
500
532
  if answer.status in (409, 429):
@@ -516,10 +548,10 @@ def _expect(reply: _Reply, is_: Callable[[Any], bool], message: str) -> Any:
516
548
  """The reply's body when it is what the route answers, else ``unexpected_response``."""
517
549
  if is_(reply.body):
518
550
  return reply.body
519
- raise AbyssError("unexpected_response", message, request_id=reply.request_id)
551
+ raise AbyssError("unexpected_response", message, request_id=reply.request_id, run_id=reply.run_id)
520
552
 
521
553
 
522
- def _refusal(status: int, body: Any, request_id: str | None) -> AbyssError:
554
+ def _refusal(status: int, body: Any, request_id: str | None, run_id: str | None) -> AbyssError:
523
555
  error = body.get("error") if isinstance(body, dict) else None
524
556
  if not isinstance(error, dict) or not isinstance(error.get("code"), str):
525
557
  return AbyssError(
@@ -527,6 +559,7 @@ def _refusal(status: int, body: Any, request_id: str | None) -> AbyssError:
527
559
  f"The API answered {status} without an error body.",
528
560
  status=status,
529
561
  request_id=request_id,
562
+ run_id=run_id,
530
563
  )
531
564
  body_request_id = body.get("request_id")
532
565
  return AbyssError(
@@ -538,6 +571,7 @@ def _refusal(status: int, body: Any, request_id: str | None) -> AbyssError:
538
571
  shortfall=_number(error.get("shortfall")),
539
572
  price=_number(error.get("price")),
540
573
  request_id=body_request_id if isinstance(body_request_id, str) else request_id,
574
+ run_id=run_id,
541
575
  )
542
576
 
543
577
 
@@ -19,6 +19,10 @@ class AbyssError(Exception):
19
19
  param: str | None
20
20
  doc_url: str | None
21
21
  request_id: str | None
22
+ run_id: str | None
23
+ """The id of the run the call started or read, once the call has learned it from a
24
+ ``Location`` header or a run body; else None. A ``timeout`` carries it even when no
25
+ run body has arrived, so ``runs.get(run_id, wait=True)`` finds the run again."""
22
26
  shortfall: float | None
23
27
  """With ``insufficient_funds``: how much Byssium the wallet is short; else None."""
24
28
  price: float | None
@@ -35,6 +39,7 @@ class AbyssError(Exception):
35
39
  param: str | None = None,
36
40
  doc_url: str | None = None,
37
41
  request_id: str | None = None,
42
+ run_id: str | None = None,
38
43
  shortfall: float | None = None,
39
44
  price: float | None = None,
40
45
  run: Run | AsyncRun | None = None,
@@ -46,6 +51,7 @@ class AbyssError(Exception):
46
51
  self.param = param
47
52
  self.doc_url = doc_url
48
53
  self.request_id = request_id
54
+ self.run_id = run_id
49
55
  self.shortfall = shortfall
50
56
  self.price = price
51
57
  self.run = run
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import time
4
+ from collections.abc import Callable
4
5
  from dataclasses import dataclass
5
6
 
6
7
  import httpx
@@ -37,16 +38,21 @@ def request(
37
38
  content: bytes | None = None,
38
39
  timeout: httpx.Timeout | None = None,
39
40
  deadline: float | None = None,
41
+ heard: Callable[[httpx.Headers], None] | None = None,
40
42
  ) -> Answer:
41
43
  """Sends one request and reads its whole body.
42
44
 
43
45
  A failure before the headers raises ``httpx.TransportError``; a cut after them
44
46
  answers ``text=None``, so the caller can re-attach. Past ``deadline`` (a
45
47
  ``time.monotonic()`` reading), a hold that still sends its spaces raises ``Stopped``.
48
+ ``heard`` is handed the headers as soon as they arrive, so a stop during the body
49
+ still leaves them known.
46
50
  """
47
51
  with client.stream(
48
52
  method, url, headers=headers, content=content, timeout=timeout or client.timeout
49
53
  ) as response:
54
+ if heard is not None:
55
+ heard(response.headers)
50
56
  chunks = []
51
57
  try:
52
58
  for chunk in response.iter_bytes():
@@ -66,11 +72,14 @@ async def arequest(
66
72
  content: bytes | None = None,
67
73
  timeout: httpx.Timeout | None = None,
68
74
  deadline: float | None = None,
75
+ heard: Callable[[httpx.Headers], None] | None = None,
69
76
  ) -> Answer:
70
77
  """``request()`` on httpx's async client."""
71
78
  async with client.stream(
72
79
  method, url, headers=headers, content=content, timeout=timeout or client.timeout
73
80
  ) as response:
81
+ if heard is not None:
82
+ heard(response.headers)
74
83
  chunks = []
75
84
  try:
76
85
  async for chunk in response.aiter_bytes():
@@ -33,6 +33,7 @@ def test_the_clients_timeout_applies_to_every_call() -> None:
33
33
  abyss.run(WIDGET, INPUT)
34
34
  assert raised.value.code == "timeout"
35
35
  assert raised.value.run is not None and raised.value.run.id == ID
36
+ assert raised.value.run_id == ID
36
37
 
37
38
  against(work)
38
39
 
@@ -62,5 +63,6 @@ def test_async_the_clients_timeout_applies_and_a_calls_overrides_it(client: floa
62
63
  assert raised.value.code == "timeout"
63
64
  error_run = raised.value.run
64
65
  assert isinstance(error_run, AsyncRun) and error_run.id == ID and error_run.status == "queued"
66
+ assert raised.value.run_id == ID
65
67
 
66
68
  against(lambda base: asyncio.run(run(base)))
File without changes
File without changes