abysshub 0.1.1.dev1__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.dev1 → abysshub-0.1.1.dev3}/PKG-INFO +8 -4
  2. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/README.md +7 -3
  3. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/pyproject.toml +1 -1
  4. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/src/abysshub/_async_client.py +9 -1
  5. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/src/abysshub/_client.py +49 -7
  6. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/src/abysshub/_error.py +14 -0
  7. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/src/abysshub/_http.py +9 -0
  8. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/tests/test_client.py +14 -0
  9. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/tests/test_timeout.py +2 -0
  10. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/.gitignore +0 -0
  11. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/LICENSE +0 -0
  12. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/src/abysshub/__init__.py +0 -0
  13. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/src/abysshub/_files.py +0 -0
  14. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/src/abysshub/_run.py +0 -0
  15. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/src/abysshub/_types.py +0 -0
  16. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/src/abysshub/py.typed +0 -0
  17. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/tests/caller.py +0 -0
  18. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/tests/fake_server.py +0 -0
  19. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/tests/test_files.py +0 -0
  20. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/tests/test_http.py +0 -0
  21. {abysshub-0.1.1.dev1 → abysshub-0.1.1.dev3}/tests/test_package.py +0 -0
  22. {abysshub-0.1.1.dev1 → 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.dev1
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
@@ -133,6 +133,9 @@ A refusal, a failed run, a timeout and a lost connection all raise one class, `A
133
133
  | `param` | the Field, body key, query key or header at fault, else `None` |
134
134
  | `doc_url` | the code on the API access page, else `None` |
135
135
  | `request_id` | quote it to support, else `None` |
136
+ | `shortfall` | with `insufficient_funds`: how much Byssium the wallet is short, else `None` |
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` |
136
139
  | `run` | the run, for a failed run or a timeout, else `None`; an `AsyncRun` from `AsyncAbyss` |
137
140
 
138
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).
@@ -159,11 +162,12 @@ There is no timeout by default: `run()` waits as long as the run takes. `timeout
159
162
  try:
160
163
  run = abyss.run("widget_131", input, timeout=60)
161
164
  except AbyssError as error:
162
- if error.code == "timeout":
163
- ... # 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)
164
168
  ```
165
169
 
166
- 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.
167
171
 
168
172
  ## Async
169
173
 
@@ -116,6 +116,9 @@ A refusal, a failed run, a timeout and a lost connection all raise one class, `A
116
116
  | `param` | the Field, body key, query key or header at fault, else `None` |
117
117
  | `doc_url` | the code on the API access page, else `None` |
118
118
  | `request_id` | quote it to support, else `None` |
119
+ | `shortfall` | with `insufficient_funds`: how much Byssium the wallet is short, else `None` |
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` |
119
122
  | `run` | the run, for a failed run or a timeout, else `None`; an `AsyncRun` from `AsyncAbyss` |
120
123
 
121
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).
@@ -142,11 +145,12 @@ There is no timeout by default: `run()` waits as long as the run takes. `timeout
142
145
  try:
143
146
  run = abyss.run("widget_131", input, timeout=60)
144
147
  except AbyssError as error:
145
- if error.code == "timeout":
146
- ... # 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)
147
151
  ```
148
152
 
149
- 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.
150
154
 
151
155
  ## Async
152
156
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "abysshub"
7
- version = "0.1.1.dev1"
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(
@@ -535,7 +568,10 @@ def _refusal(status: int, body: Any, request_id: str | None) -> AbyssError:
535
568
  status=status,
536
569
  param=_text(error.get("param")),
537
570
  doc_url=_text(error.get("doc_url")),
571
+ shortfall=_number(error.get("shortfall")),
572
+ price=_number(error.get("price")),
538
573
  request_id=body_request_id if isinstance(body_request_id, str) else request_id,
574
+ run_id=run_id,
539
575
  )
540
576
 
541
577
 
@@ -543,6 +579,12 @@ def _text(value: Any) -> str | None:
543
579
  return value if isinstance(value, str) else None
544
580
 
545
581
 
582
+ def _number(value: Any) -> float | None:
583
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
584
+ return None
585
+ return float(value)
586
+
587
+
546
588
  def _parse(text: str | None) -> Any:
547
589
  if text is None:
548
590
  return None
@@ -19,6 +19,14 @@ 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."""
26
+ shortfall: float | None
27
+ """With ``insufficient_funds``: how much Byssium the wallet is short; else None."""
28
+ price: float | None
29
+ """With ``price_above_max``: what the run costs now; else None."""
22
30
  run: Run | AsyncRun | None
23
31
  """The run, for a failed run or a timeout: an ``AsyncRun`` from ``AsyncAbyss``."""
24
32
 
@@ -31,6 +39,9 @@ class AbyssError(Exception):
31
39
  param: str | None = None,
32
40
  doc_url: str | None = None,
33
41
  request_id: str | None = None,
42
+ run_id: str | None = None,
43
+ shortfall: float | None = None,
44
+ price: float | None = None,
34
45
  run: Run | AsyncRun | None = None,
35
46
  ) -> None:
36
47
  super().__init__(message)
@@ -40,6 +51,9 @@ class AbyssError(Exception):
40
51
  self.param = param
41
52
  self.doc_url = doc_url
42
53
  self.request_id = request_id
54
+ self.run_id = run_id
55
+ self.shortfall = shortfall
56
+ self.price = price
43
57
  self.run = run
44
58
 
45
59
  def __repr__(self) -> str:
@@ -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():
@@ -67,3 +67,17 @@ def test_every_request_sends_the_user_agent() -> None:
67
67
  assert server.problems == []
68
68
  finally:
69
69
  server.close()
70
+
71
+
72
+ def test_a_refusal_without_shortfall_or_price_carries_none_for_both() -> None:
73
+ recording = load_exchanges("run-refused-invalid-input.json")[0]
74
+ server = Replay(recording, "abyss_sk_x")
75
+ try:
76
+ with Abyss(api_key="abyss_sk_x", base_url=server.base) as abyss, pytest.raises(AbyssError) as raised:
77
+ abyss.run(recording["call"]["widget"], recording["call"]["input"])
78
+ assert raised.value.code == "invalid_input"
79
+ assert raised.value.shortfall is None
80
+ assert raised.value.price is None
81
+ assert server.problems == []
82
+ finally:
83
+ server.close()
@@ -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