bynn-decisionnode 0.3.1__py3-none-any.whl

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,333 @@
1
+ """The Batch API: many decide requests answered within 24 hours at half the price.
2
+
3
+ ``batches.create`` uploads a draft as JSON Lines, ``batches.add`` adds requests in calls
4
+ of at most 10 MiB, ``batches.finalize`` queues it, ``batches.get`` reads it,
5
+ ``batches.results`` streams its result lines, ``batches.cancel`` stops it, and
6
+ ``run_batch`` does the whole lifecycle.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from dataclasses import dataclass, field
13
+ from typing import Any, AsyncIterator, Dict, Iterator, List, Mapping, NamedTuple, Optional, Sequence, Union
14
+
15
+ import httpx
16
+
17
+ from . import _base
18
+ from ._errors import ConnectionError, DecisionNodeError, ServerError, TimeoutError
19
+ from ._types import Decision, parse_decision
20
+
21
+ __all__ = [
22
+ "Batch",
23
+ "BatchPage",
24
+ "BatchResult",
25
+ "BatchResults",
26
+ "AsyncBatchResults",
27
+ "BatchRun",
28
+ "ADD_BYTES",
29
+ "MIN_POLL_INTERVAL",
30
+ ]
31
+
32
+ ADD_BYTES = 10 * 1024 * 1024
33
+ MIN_POLL_INTERVAL = 10.0
34
+ DEFAULT_POLL_INTERVAL = 60.0
35
+ DEFAULT_RUN_TIMEOUT = 26 * 3600.0
36
+ MAX_CUSTOM_ID_CHARS = 512
37
+ NDJSON = "application/x-ndjson"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class Batch:
42
+ """A batch: every field of the API's batch object.
43
+
44
+ Attributes:
45
+ id: ``batch_`` and 10 characters.
46
+ state: ``draft``, ``queued``, ``running``, ``completed``, ``failed``,
47
+ ``cancelled`` or ``expired``.
48
+ counters: ``requests``, ``queued``, ``in_progress``, ``completed``, ``failed``,
49
+ ``expired`` and ``cancelled``.
50
+ created_at, finalized_at, expires_at, ended_at, results_expire_at: ISO 8601 UTC
51
+ times, or ``None``.
52
+ results_url: Where the results are, once the batch has ended.
53
+ price_factor: The share of the live price a line costs.
54
+ usage: ``input_tokens`` and ``output_tokens`` of the lines answered so far.
55
+ error: For a ``failed`` batch, ``error_type`` and ``message``.
56
+ raw: The batch object as received.
57
+ """
58
+
59
+ id: str
60
+ state: Optional[str]
61
+ counters: Dict[str, int]
62
+ created_at: Optional[str]
63
+ finalized_at: Optional[str]
64
+ expires_at: Optional[str]
65
+ ended_at: Optional[str]
66
+ results_url: Optional[str]
67
+ results_expire_at: Optional[str]
68
+ price_factor: Optional[float]
69
+ usage: Dict[str, Any]
70
+ error: Optional[Dict[str, Any]]
71
+ raw: Dict[str, Any] = field(repr=False)
72
+
73
+ @property
74
+ def ended(self) -> bool:
75
+ """True once the batch is ``completed``, ``failed``, ``cancelled`` or ``expired``."""
76
+ return self.ended_at is not None
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class BatchPage:
81
+ """One page of ``batches.list``, newest first.
82
+
83
+ Attributes:
84
+ batches: The batches of this page.
85
+ offset, limit, total: Where the page starts, its size, and how many batches there are.
86
+ next_offset: The ``offset`` of the next page, or ``None`` on the last.
87
+ raw: The page as received.
88
+ """
89
+
90
+ batches: List[Batch]
91
+ offset: Optional[int]
92
+ limit: Optional[int]
93
+ total: Optional[int]
94
+ next_offset: Optional[int]
95
+ raw: Dict[str, Any] = field(repr=False)
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class BatchResult:
100
+ """One result line, in upload order.
101
+
102
+ Attributes:
103
+ custom_id: The request's ``custom_id``.
104
+ status: The HTTP status the request got (an ``int``), or ``"expired"`` or
105
+ ``"cancelled"`` for a request that never ran.
106
+ decision: For a ``200`` line, the :class:`Decision`, read as ``decide`` reads it.
107
+ error: For an error status, the error the live call would have raised, as a
108
+ value: its class, ``status``, ``error_type``, message and ``reason``.
109
+ headers: The line's response headers, with lower-case names.
110
+ raw: The line as received.
111
+ """
112
+
113
+ custom_id: Optional[str]
114
+ status: Union[int, str, None]
115
+ decision: Optional[Decision]
116
+ error: Optional[DecisionNodeError]
117
+ headers: Dict[str, str]
118
+ raw: Dict[str, Any] = field(repr=False)
119
+
120
+
121
+ class BatchRun(NamedTuple):
122
+ """What ``run_batch`` returns: the ended batch and its results."""
123
+
124
+ batch: Batch
125
+ results: Any
126
+
127
+
128
+ def parse_batch(body: Any) -> Batch:
129
+ raw = dict(body) if isinstance(body, Mapping) else {}
130
+ counters = raw.get("counters")
131
+ usage = raw.get("usage")
132
+ error = raw.get("error")
133
+ return Batch(
134
+ id=raw.get("id", ""),
135
+ state=raw.get("state"),
136
+ counters=dict(counters) if isinstance(counters, Mapping) else {},
137
+ created_at=raw.get("created_at"),
138
+ finalized_at=raw.get("finalized_at"),
139
+ expires_at=raw.get("expires_at"),
140
+ ended_at=raw.get("ended_at"),
141
+ results_url=raw.get("results_url"),
142
+ results_expire_at=raw.get("results_expire_at"),
143
+ price_factor=raw.get("price_factor"),
144
+ usage=dict(usage) if isinstance(usage, Mapping) else {},
145
+ error=dict(error) if isinstance(error, Mapping) else None,
146
+ raw=raw,
147
+ )
148
+
149
+
150
+ def parse_page(body: Any) -> BatchPage:
151
+ raw = dict(body) if isinstance(body, Mapping) else {}
152
+ batches = raw.get("batches")
153
+ return BatchPage(
154
+ batches=[parse_batch(b) for b in batches] if isinstance(batches, list) else [],
155
+ offset=raw.get("offset"),
156
+ limit=raw.get("limit"),
157
+ total=raw.get("total"),
158
+ next_offset=raw.get("next_offset"),
159
+ raw=raw,
160
+ )
161
+
162
+
163
+ def parse_result(raw: Any) -> BatchResult:
164
+ line = dict(raw) if isinstance(raw, Mapping) else {}
165
+ headers_in = line.get("headers")
166
+ headers = {str(k).lower(): str(v) for k, v in headers_in.items()} if isinstance(headers_in, Mapping) else {}
167
+ status = line.get("status")
168
+ body = line.get("body")
169
+ decision: Optional[Decision] = None
170
+ error: Optional[DecisionNodeError] = None
171
+ if status == 200 and isinstance(body, Mapping):
172
+ decision = parse_decision(body, headers)
173
+ elif isinstance(status, int) and not isinstance(status, bool) and status != 200:
174
+ error = _base.make_error(status, headers, json.dumps(body))
175
+ return BatchResult(
176
+ custom_id=line.get("custom_id"),
177
+ status=status,
178
+ decision=decision,
179
+ error=error,
180
+ headers=headers,
181
+ raw=line,
182
+ )
183
+
184
+
185
+ def parse_result_line(text: str) -> BatchResult:
186
+ try:
187
+ raw = json.loads(text)
188
+ except ValueError as exc:
189
+ raise ServerError("a result line is not JSON", status=200, body=text) from exc
190
+ return parse_result(raw)
191
+
192
+
193
+ # --------------------------------------------------------------------------- the lines
194
+
195
+
196
+ def line_bytes(
197
+ req: Mapping[str, Any],
198
+ questions: Mapping[str, Any],
199
+ wires: Sequence[Mapping[str, Any]],
200
+ model: str,
201
+ videos: Sequence[Mapping[str, Any]] = (),
202
+ boosters: Any = None,
203
+ ) -> bytes:
204
+ """One JSON Lines line: ``custom_id``, ``model``, ``state`` (``""`` when absent), the
205
+ questions, the images, the videos, the boosters (``booster_wire``; ``None`` for none),
206
+ and any other field as given."""
207
+ out: Dict[str, Any] = {
208
+ "custom_id": req["custom_id"],
209
+ "model": req.get("model") or model,
210
+ "state": "" if req.get("state") is None else req["state"],
211
+ "questions": dict(questions),
212
+ }
213
+ if wires:
214
+ out["images"] = list(wires)
215
+ if videos:
216
+ out["videos"] = list(videos)
217
+ if boosters is not None:
218
+ out["boosters"] = boosters
219
+ for key, value in req.items():
220
+ if key not in out and key not in ("images", "videos", "boosters"):
221
+ out[key] = value
222
+ return _base.encode(out) + b"\n"
223
+
224
+
225
+ def split_lines(lines: Sequence[bytes], limit: int = ADD_BYTES) -> List[bytes]:
226
+ """The lines in bodies of at most ``limit`` bytes, in order. A line over the limit
227
+ goes alone; the server judges it."""
228
+ bodies: List[bytes] = []
229
+ current: List[bytes] = []
230
+ size = 0
231
+ for line in lines:
232
+ if current and size + len(line) > limit:
233
+ bodies.append(b"".join(current))
234
+ current, size = [], 0
235
+ current.append(line)
236
+ size += len(line)
237
+ if current:
238
+ bodies.append(b"".join(current))
239
+ return bodies
240
+
241
+
242
+ def check_poll(poll_interval: Any, timeout: Any) -> None:
243
+ if (
244
+ isinstance(poll_interval, bool)
245
+ or not isinstance(poll_interval, (int, float))
246
+ or not poll_interval >= MIN_POLL_INTERVAL
247
+ ):
248
+ raise ValueError("poll_interval must be at least %g seconds, got %r" % (MIN_POLL_INTERVAL, poll_interval))
249
+ if isinstance(timeout, bool) or not isinstance(timeout, (int, float)) or not timeout > 0:
250
+ raise ValueError("timeout must be a number of seconds above 0, got %r" % (timeout,))
251
+
252
+
253
+ def poll_wait(poll_interval: float, headers: Optional[Mapping[str, str]]) -> float:
254
+ """The fixed interval, or the server's ``Retry-After`` when that is longer."""
255
+ asked = _base.requested_wait(headers) if headers is not None else None
256
+ return max(poll_interval, asked) if asked is not None else poll_interval
257
+
258
+
259
+ # --------------------------------------------------------------------------- the results
260
+
261
+
262
+ def _stream_error(exc: httpx.TransportError, timeout: float) -> DecisionNodeError:
263
+ if isinstance(exc, httpx.TimeoutException):
264
+ return TimeoutError("the results stopped arriving for %gs" % timeout)
265
+ return ConnectionError("connection error while reading the results: %s" % (exc or type(exc).__name__))
266
+
267
+
268
+ class BatchResults:
269
+ """The result lines of a batch, read from the network one line at a time.
270
+
271
+ Iterate it once; each item is a :class:`BatchResult`. The connection closes when the
272
+ last line is read, on :meth:`close`, or at the end of a ``with`` block.
273
+ """
274
+
275
+ def __init__(self, response: httpx.Response, timeout: float) -> None:
276
+ self._response = response
277
+ self._lines = response.iter_lines()
278
+ self._timeout = timeout
279
+
280
+ def __iter__(self) -> Iterator[BatchResult]:
281
+ return self
282
+
283
+ def __next__(self) -> BatchResult:
284
+ try:
285
+ for line in self._lines:
286
+ if line.strip():
287
+ return parse_result_line(line)
288
+ except httpx.TransportError as exc:
289
+ self.close()
290
+ raise _stream_error(exc, self._timeout) from exc
291
+ self.close()
292
+ raise StopIteration
293
+
294
+ def close(self) -> None:
295
+ self._response.close()
296
+
297
+ def __enter__(self) -> BatchResults:
298
+ return self
299
+
300
+ def __exit__(self, *exc: Any) -> None:
301
+ self.close()
302
+
303
+
304
+ class AsyncBatchResults:
305
+ """The result lines of a batch for the asyncio client: ``async for result in results``."""
306
+
307
+ def __init__(self, response: httpx.Response, timeout: float) -> None:
308
+ self._response = response
309
+ self._lines = response.aiter_lines()
310
+ self._timeout = timeout
311
+
312
+ def __aiter__(self) -> AsyncIterator[BatchResult]:
313
+ return self
314
+
315
+ async def __anext__(self) -> BatchResult:
316
+ try:
317
+ async for line in self._lines:
318
+ if line.strip():
319
+ return parse_result_line(line)
320
+ except httpx.TransportError as exc:
321
+ await self.aclose()
322
+ raise _stream_error(exc, self._timeout) from exc
323
+ await self.aclose()
324
+ raise StopAsyncIteration
325
+
326
+ async def aclose(self) -> None:
327
+ await self._response.aclose()
328
+
329
+ async def __aenter__(self) -> AsyncBatchResults:
330
+ return self
331
+
332
+ async def __aexit__(self, *exc: Any) -> None:
333
+ await self.aclose()