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.
- bynn_decisionnode-0.3.1.dist-info/METADATA +939 -0
- bynn_decisionnode-0.3.1.dist-info/RECORD +19 -0
- bynn_decisionnode-0.3.1.dist-info/WHEEL +4 -0
- bynn_decisionnode-0.3.1.dist-info/licenses/LICENSE +21 -0
- decisionnode/__init__.py +195 -0
- decisionnode/_base.py +548 -0
- decisionnode/_batches.py +333 -0
- decisionnode/_boosters.py +553 -0
- decisionnode/_client.py +2269 -0
- decisionnode/_errors.py +218 -0
- decisionnode/_images.py +283 -0
- decisionnode/_questions.py +262 -0
- decisionnode/_sessions.py +711 -0
- decisionnode/_types.py +764 -0
- decisionnode/_uploads.py +265 -0
- decisionnode/_validate.py +934 -0
- decisionnode/_version.py +5 -0
- decisionnode/_videos.py +197 -0
- decisionnode/py.typed +0 -0
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"""Sessions: one long-lived question set answered for every frame on a WebSocket.
|
|
2
|
+
|
|
3
|
+
``open_session`` sends ``POST /v1/sessions``; :meth:`Session.connect` opens the stream
|
|
4
|
+
(the key travels in the handshake's ``Authorization`` header, never in the URL);
|
|
5
|
+
:meth:`Session.send` sends one frame; :meth:`Session.replies` yields what comes back.
|
|
6
|
+
Replies and frame errors are values, never exceptions. The stream ends with a
|
|
7
|
+
:class:`SessionClosed`, which says by ``reopen`` whether opening a new session with the
|
|
8
|
+
same body is the right next step.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import inspect
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import random
|
|
18
|
+
import time
|
|
19
|
+
from typing import (
|
|
20
|
+
TYPE_CHECKING,
|
|
21
|
+
Any,
|
|
22
|
+
AsyncIterator,
|
|
23
|
+
Awaitable,
|
|
24
|
+
Callable,
|
|
25
|
+
Dict,
|
|
26
|
+
Iterable,
|
|
27
|
+
Iterator,
|
|
28
|
+
List,
|
|
29
|
+
Mapping,
|
|
30
|
+
Optional,
|
|
31
|
+
Union,
|
|
32
|
+
)
|
|
33
|
+
from urllib.parse import quote, urlsplit
|
|
34
|
+
|
|
35
|
+
from . import _base
|
|
36
|
+
from ._errors import ConnectionError, DecisionNodeError, NotFoundError, TimeoutError
|
|
37
|
+
from ._images import Image
|
|
38
|
+
from ._types import AnyAnswer, Safety, Usage, parse_answer, parse_cost
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from ._client import AsyncDecisionNode, DecisionNode
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"Session",
|
|
45
|
+
"AsyncSession",
|
|
46
|
+
"Reply",
|
|
47
|
+
"FrameError",
|
|
48
|
+
"SessionClosed",
|
|
49
|
+
"SessionEvent",
|
|
50
|
+
"REOPEN_CODES",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger("decisionnode")
|
|
54
|
+
|
|
55
|
+
# Close codes after which opening a new session with the same body is the right step.
|
|
56
|
+
REOPEN_CODES = frozenset({1001, 1006, 1011, 4404, 4408, 4409, 4410})
|
|
57
|
+
# The server's socket cap: the session is kept and the client connects to it again.
|
|
58
|
+
TRY_AGAIN = 1013
|
|
59
|
+
# Codes reopened without a wait: the idle close (on the next reading) and the TTL close.
|
|
60
|
+
REOPEN_AT_ONCE = frozenset({4408, 4410})
|
|
61
|
+
# The largest message the server reads is 16 MiB; replies are far smaller.
|
|
62
|
+
MAX_MESSAGE = 16 * 1024 * 1024
|
|
63
|
+
RECONNECT_BASE = 1.0
|
|
64
|
+
RECONNECT_CAP = 30.0
|
|
65
|
+
LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SessionClosed(DecisionNodeError):
|
|
69
|
+
"""The stream closed. Also raised by :meth:`Session.connect` for a refused handshake.
|
|
70
|
+
|
|
71
|
+
Attributes:
|
|
72
|
+
code: The WebSocket close code; ``1006`` when the connection was lost without one.
|
|
73
|
+
reason: The close reason as sent, for logs only.
|
|
74
|
+
reopen: True when a new session with the same body is the right next step
|
|
75
|
+
(``1001``, ``1006``, ``1011``, ``4404``, ``4408``, ``4409``, ``4410``). False for
|
|
76
|
+
every other code, ``1013`` included: there the session is kept and the
|
|
77
|
+
client connects to it again after a wait.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, code: int, reason: str = "") -> None:
|
|
81
|
+
super().__init__("session closed with %d%s" % (code, (": " + reason) if reason else ""), reason=reason)
|
|
82
|
+
self.code = code
|
|
83
|
+
self.reopen = code in REOPEN_CODES
|
|
84
|
+
|
|
85
|
+
def __repr__(self) -> str:
|
|
86
|
+
return "SessionClosed(code=%d, reopen=%s)" % (self.code, self.reopen)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _safety(value: Any) -> Optional[Safety]:
|
|
90
|
+
if not isinstance(value, Mapping) or not isinstance(value.get("checked"), bool):
|
|
91
|
+
return None
|
|
92
|
+
if not value["checked"]:
|
|
93
|
+
reason = value.get("reason")
|
|
94
|
+
return Safety(checked=False, reason=reason if isinstance(reason, str) else None)
|
|
95
|
+
categories = {
|
|
96
|
+
k: (v if isinstance(v, (int, float)) and not isinstance(v, bool) else None)
|
|
97
|
+
for k, v in value.items()
|
|
98
|
+
if k not in ("checked", "flagged", "blocked", "reason")
|
|
99
|
+
}
|
|
100
|
+
flagged = value.get("flagged")
|
|
101
|
+
return Safety(
|
|
102
|
+
checked=True,
|
|
103
|
+
harm=categories.get("harm"),
|
|
104
|
+
self_harm=categories.get("self_harm"),
|
|
105
|
+
flagged=[str(f) for f in flagged] if isinstance(flagged, list) else [],
|
|
106
|
+
categories=categories,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _usage(value: Any) -> Optional[Usage]:
|
|
111
|
+
if not isinstance(value, Mapping):
|
|
112
|
+
return None
|
|
113
|
+
return Usage(
|
|
114
|
+
input_tokens=value.get("input_tokens"),
|
|
115
|
+
output_tokens=value.get("output_tokens"),
|
|
116
|
+
cost=parse_cost(value.get("cost")),
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class Reply:
|
|
121
|
+
"""The answers to one frame.
|
|
122
|
+
|
|
123
|
+
Attributes:
|
|
124
|
+
seq: The ``seq`` of the frame answered.
|
|
125
|
+
answers: The answers by question id, typed as in a :class:`Decision`.
|
|
126
|
+
dropped: The ``seq`` of each frame superseded by a newer one since the previous
|
|
127
|
+
reply (latest wins), or ``None``.
|
|
128
|
+
usage: What the frame billed.
|
|
129
|
+
latency_ms: The server's time for the frame.
|
|
130
|
+
safety: The safety check on the frame, or ``None``.
|
|
131
|
+
raw: The message as received.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
__slots__ = ("seq", "answers", "dropped", "usage", "latency_ms", "safety", "raw")
|
|
135
|
+
|
|
136
|
+
def __init__(self, raw: Dict[str, Any]) -> None:
|
|
137
|
+
self.raw = raw
|
|
138
|
+
self.seq: Optional[int] = raw.get("seq")
|
|
139
|
+
answers = raw.get("answers")
|
|
140
|
+
self.answers: Dict[str, AnyAnswer] = (
|
|
141
|
+
{str(k): parse_answer(v) for k, v in answers.items()} if isinstance(answers, Mapping) else {}
|
|
142
|
+
)
|
|
143
|
+
dropped = raw.get("dropped")
|
|
144
|
+
self.dropped: Optional[List[int]] = list(dropped) if isinstance(dropped, list) else None
|
|
145
|
+
self.usage = _usage(raw.get("usage"))
|
|
146
|
+
self.latency_ms: Optional[Union[int, float]] = raw.get("latency_ms")
|
|
147
|
+
self.safety = _safety(raw.get("safety"))
|
|
148
|
+
|
|
149
|
+
def __getitem__(self, question_id: str) -> Any:
|
|
150
|
+
return self.answers[question_id]
|
|
151
|
+
|
|
152
|
+
def __repr__(self) -> str:
|
|
153
|
+
return "Reply(seq=%r, answers=%r, dropped=%r)" % (self.seq, self.answers, self.dropped)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class FrameError:
|
|
157
|
+
"""A frame the server could not answer, as a value. The session stays open.
|
|
158
|
+
|
|
159
|
+
Attributes:
|
|
160
|
+
seq: The frame's ``seq``, or ``None`` when the message could not be read as a frame.
|
|
161
|
+
error_type: ``api_usage_error``, ``rate_limit_error``, ``overloaded_error``,
|
|
162
|
+
``api_error``, ``refusal_error``, or an upload error such as ``upload_not_found``.
|
|
163
|
+
message: The server's message.
|
|
164
|
+
category: For ``refusal_error``: ``harm`` or ``self_harm``.
|
|
165
|
+
retry_after_ms: For ``rate_limit_error`` and ``overloaded_error``: when to send again.
|
|
166
|
+
usage: For ``refusal_error``: what the refused frame billed.
|
|
167
|
+
safety: For ``refusal_error``: the safety check.
|
|
168
|
+
raw: The message as received.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
__slots__ = ("seq", "error_type", "message", "category", "retry_after_ms", "usage", "safety", "raw")
|
|
172
|
+
|
|
173
|
+
def __init__(self, raw: Dict[str, Any]) -> None:
|
|
174
|
+
self.raw = raw
|
|
175
|
+
found = raw.get("error")
|
|
176
|
+
err: Mapping[str, Any] = found if isinstance(found, Mapping) else {}
|
|
177
|
+
self.seq: Optional[int] = raw.get("seq")
|
|
178
|
+
self.error_type: Optional[str] = err.get("error_type")
|
|
179
|
+
message = err.get("message")
|
|
180
|
+
self.message: str = message if isinstance(message, str) else ""
|
|
181
|
+
self.category: Optional[str] = err.get("category")
|
|
182
|
+
self.retry_after_ms: Optional[int] = raw.get("retry_after_ms")
|
|
183
|
+
self.usage = _usage(raw.get("usage"))
|
|
184
|
+
self.safety = _safety(raw.get("safety"))
|
|
185
|
+
|
|
186
|
+
def __repr__(self) -> str:
|
|
187
|
+
return "FrameError(seq=%r, error_type=%r, message=%r)" % (self.seq, self.error_type, self.message)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
SessionEvent = Union[Reply, FrameError, SessionClosed]
|
|
191
|
+
"""What :meth:`Session.replies` yields: replies and frame errors, then the close."""
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def parse_event(text: Union[str, bytes]) -> Optional[Union[Reply, FrameError]]:
|
|
195
|
+
try:
|
|
196
|
+
raw = json.loads(text)
|
|
197
|
+
except ValueError:
|
|
198
|
+
logger.debug("session: a message that is not JSON was skipped")
|
|
199
|
+
return None
|
|
200
|
+
if not isinstance(raw, dict):
|
|
201
|
+
return None
|
|
202
|
+
return FrameError(raw) if "error" in raw else Reply(raw)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def check_stream_url(url: Any) -> str:
|
|
206
|
+
"""``wss`` only; ``ws`` is accepted for a loopback host alone, so the key never
|
|
207
|
+
travels unencrypted."""
|
|
208
|
+
if not isinstance(url, str):
|
|
209
|
+
raise DecisionNodeError("the session has no stream_url")
|
|
210
|
+
parts = urlsplit(url)
|
|
211
|
+
scheme = parts.scheme.lower()
|
|
212
|
+
host = (parts.hostname or "").lower()
|
|
213
|
+
if scheme == "wss" and host:
|
|
214
|
+
return url
|
|
215
|
+
if scheme == "ws" and host in LOOPBACK_HOSTS:
|
|
216
|
+
return url
|
|
217
|
+
raise DecisionNodeError("the session's stream_url %r is not wss; the key is never sent over it" % url)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def frame_message(seq: int, frame: Any, image: Any, questions: Any, reset: Any) -> Dict[str, Any]:
|
|
221
|
+
msg: Dict[str, Any] = {"seq": seq}
|
|
222
|
+
if frame is not None:
|
|
223
|
+
msg["frame"] = frame
|
|
224
|
+
if image is not None:
|
|
225
|
+
msg["image"] = image
|
|
226
|
+
if questions is not None:
|
|
227
|
+
if isinstance(questions, (str, bytes)) or not isinstance(questions, Iterable):
|
|
228
|
+
raise ValueError("questions must be a list of the session's question ids")
|
|
229
|
+
msg["questions"] = list(questions)
|
|
230
|
+
if reset is not None:
|
|
231
|
+
msg["reset"] = reset
|
|
232
|
+
return msg
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def reconnect_wait(failures: int) -> float:
|
|
236
|
+
"""Capped backoff from 1 s to 30 s with jitter."""
|
|
237
|
+
return min(RECONNECT_CAP, RECONNECT_BASE * float(2**failures)) * (0.5 + random.random() * 0.5)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _closed_from(exc: BaseException) -> SessionClosed:
|
|
241
|
+
rcvd = getattr(exc, "rcvd", None)
|
|
242
|
+
if rcvd is None:
|
|
243
|
+
return SessionClosed(1006, "")
|
|
244
|
+
return SessionClosed(int(rcvd.code), str(rcvd.reason or ""))
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _handshake_error(exc: BaseException, timeout: float) -> DecisionNodeError:
|
|
248
|
+
response = getattr(exc, "response", None)
|
|
249
|
+
if response is not None and getattr(response, "status_code", None) is not None:
|
|
250
|
+
headers = {k.lower(): v for k, v in response.headers.raw_items()}
|
|
251
|
+
body = response.body.decode("utf-8", errors="replace") if response.body else ""
|
|
252
|
+
return _base.make_error(int(response.status_code), headers, body)
|
|
253
|
+
if isinstance(exc, (asyncio.TimeoutError, TimeoutError)) or type(exc).__name__ == "TimeoutError":
|
|
254
|
+
return TimeoutError("the stream did not open within %gs" % timeout)
|
|
255
|
+
return ConnectionError("the stream could not be opened: %s" % (exc or type(exc).__name__))
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
class _SessionInfo:
|
|
259
|
+
"""Every field of the open response."""
|
|
260
|
+
|
|
261
|
+
_client: Any
|
|
262
|
+
|
|
263
|
+
def __init__(self, raw: Dict[str, Any], headers: Mapping[str, str]) -> None:
|
|
264
|
+
self.raw = raw
|
|
265
|
+
self.session_id: str = raw.get("session_id", "")
|
|
266
|
+
self.model: Optional[str] = raw.get("model")
|
|
267
|
+
self.stream_url: Optional[str] = raw.get("stream_url")
|
|
268
|
+
self.window: Optional[int] = raw.get("window")
|
|
269
|
+
self.ttl_seconds: Optional[int] = raw.get("ttl_seconds")
|
|
270
|
+
self.expires_at: Optional[str] = raw.get("expires_at")
|
|
271
|
+
self.limits: Dict[str, Any] = dict(raw.get("limits") or {})
|
|
272
|
+
self.questions: List[str] = list(raw.get("questions") or [])
|
|
273
|
+
self.prefix_tokens: Optional[int] = raw.get("prefix_tokens")
|
|
274
|
+
self.usage = _usage(raw.get("usage")) or Usage()
|
|
275
|
+
answers = raw.get("answers")
|
|
276
|
+
self.answers: Dict[str, AnyAnswer] = (
|
|
277
|
+
{str(k): parse_answer(v) for k, v in answers.items()} if isinstance(answers, Mapping) else {}
|
|
278
|
+
)
|
|
279
|
+
self.safety = _safety(raw.get("safety"))
|
|
280
|
+
self.request_id: Optional[str] = headers.get("x-request-id")
|
|
281
|
+
self._seq = 0
|
|
282
|
+
self._closed: Optional[SessionClosed] = None
|
|
283
|
+
|
|
284
|
+
def _next_seq(self, seq: Optional[int]) -> int:
|
|
285
|
+
if seq is None:
|
|
286
|
+
self._seq += 1
|
|
287
|
+
return self._seq
|
|
288
|
+
if isinstance(seq, bool) or not isinstance(seq, int) or seq < 0:
|
|
289
|
+
raise ValueError("seq must be a whole number, 0 or more, got %r" % (seq,))
|
|
290
|
+
return seq
|
|
291
|
+
|
|
292
|
+
def check_frame(
|
|
293
|
+
self,
|
|
294
|
+
frame: Any = None,
|
|
295
|
+
image: Union[Image, Mapping[str, Any], None] = None,
|
|
296
|
+
questions: Optional[Iterable[str]] = None,
|
|
297
|
+
reset: Optional[bool] = None,
|
|
298
|
+
seq: Optional[int] = None,
|
|
299
|
+
) -> None:
|
|
300
|
+
"""Run every local check :meth:`send` would run on this frame and raise the same
|
|
301
|
+
error; send and upload nothing, and use no ``seq``. Returns ``None`` when the frame
|
|
302
|
+
would be sent. Needs no connection, and is not a coroutine on either client."""
|
|
303
|
+
self._client._check_frame(self, frame, image, questions, reset, seq)
|
|
304
|
+
|
|
305
|
+
def _end_path(self) -> str:
|
|
306
|
+
return "%s/%s" % (_base.SESSIONS_PATH, quote(self.session_id, safe=""))
|
|
307
|
+
|
|
308
|
+
def __repr__(self) -> str:
|
|
309
|
+
return "%s(session_id=%r, model=%r)" % (type(self).__name__, self.session_id, self.model)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
class Session(_SessionInfo):
|
|
313
|
+
"""An open session of the blocking client. Made by :meth:`DecisionNode.open_session`.
|
|
314
|
+
|
|
315
|
+
Every field of the open response is an attribute (``session_id``, ``model``,
|
|
316
|
+
``stream_url``, ``window``, ``ttl_seconds``, ``expires_at``, ``limits``,
|
|
317
|
+
``questions``, ``prefix_tokens``, ``usage``, ``answers``, ``safety``), and ``raw``
|
|
318
|
+
holds it as received. Use it as a context manager to end it on exit.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
def __init__(self, client: DecisionNode, raw: Dict[str, Any], headers: Mapping[str, str]) -> None:
|
|
322
|
+
super().__init__(raw, headers)
|
|
323
|
+
self._client = client
|
|
324
|
+
self._ws: Any = None
|
|
325
|
+
|
|
326
|
+
def connect(self) -> Session:
|
|
327
|
+
"""Open the stream. A handshake the server refuses with an HTTP status raises the
|
|
328
|
+
error class of that status; a close at connect arrives as the first event."""
|
|
329
|
+
from websockets.exceptions import InvalidHandshake
|
|
330
|
+
from websockets.sync.client import connect
|
|
331
|
+
|
|
332
|
+
settings = self._client._settings
|
|
333
|
+
url = check_stream_url(self.stream_url)
|
|
334
|
+
try:
|
|
335
|
+
self._ws = connect(
|
|
336
|
+
url,
|
|
337
|
+
additional_headers=settings.stream_headers(),
|
|
338
|
+
user_agent_header=_base.USER_AGENT,
|
|
339
|
+
compression=None,
|
|
340
|
+
open_timeout=settings.timeout,
|
|
341
|
+
max_size=MAX_MESSAGE,
|
|
342
|
+
**self._client._stream_options(url),
|
|
343
|
+
)
|
|
344
|
+
except (InvalidHandshake, OSError, TimeoutError) as exc:
|
|
345
|
+
raise _handshake_error(exc, settings.timeout) from exc
|
|
346
|
+
self._closed = None
|
|
347
|
+
return self
|
|
348
|
+
|
|
349
|
+
def send(
|
|
350
|
+
self,
|
|
351
|
+
frame: Any = None,
|
|
352
|
+
image: Union[Image, Mapping[str, Any], None] = None,
|
|
353
|
+
questions: Optional[Iterable[str]] = None,
|
|
354
|
+
reset: Optional[bool] = None,
|
|
355
|
+
seq: Optional[int] = None,
|
|
356
|
+
) -> int:
|
|
357
|
+
"""Send one frame and return its ``seq``. Never waits for the reply.
|
|
358
|
+
|
|
359
|
+
``seq`` counts from 1 when not given. An ``image`` is checked first; a small one
|
|
360
|
+
goes inline, a large or already uploaded one by its ``upload_id``.
|
|
361
|
+
"""
|
|
362
|
+
if self._ws is None:
|
|
363
|
+
raise ValueError("connect() the session before sending")
|
|
364
|
+
# Every fixed rule of the frame first: a refused frame writes nothing and takes no seq.
|
|
365
|
+
questions = self._client._check_frame(self, frame, image, questions, reset, seq)
|
|
366
|
+
wire = self._client._frame_image(image) if image is not None else None
|
|
367
|
+
number = self._next_seq(seq)
|
|
368
|
+
msg = frame_message(number, frame, wire, questions, reset)
|
|
369
|
+
try:
|
|
370
|
+
self._ws.send(_base.encode(msg).decode("utf-8"))
|
|
371
|
+
except Exception as exc: # noqa: BLE001 - a closed socket shows as the next event
|
|
372
|
+
if type(exc).__name__.startswith("ConnectionClosed"):
|
|
373
|
+
self._closed = self._closed or _closed_from(exc)
|
|
374
|
+
else:
|
|
375
|
+
raise
|
|
376
|
+
return number
|
|
377
|
+
|
|
378
|
+
def next_event(self, timeout: Optional[float] = None) -> Optional[SessionEvent]:
|
|
379
|
+
"""The next :class:`Reply`, :class:`FrameError` or :class:`SessionClosed` (the last
|
|
380
|
+
event, returned again on every later call); ``None`` when nothing arrived
|
|
381
|
+
within ``timeout`` seconds."""
|
|
382
|
+
if self._closed is not None:
|
|
383
|
+
return self._closed
|
|
384
|
+
if self._ws is None:
|
|
385
|
+
raise ValueError("connect() the session before reading")
|
|
386
|
+
while True:
|
|
387
|
+
try:
|
|
388
|
+
text = self._ws.recv(timeout=timeout)
|
|
389
|
+
except TimeoutError:
|
|
390
|
+
return None
|
|
391
|
+
except Exception as exc: # noqa: BLE001
|
|
392
|
+
if type(exc).__name__.startswith("ConnectionClosed"):
|
|
393
|
+
self._closed = _closed_from(exc)
|
|
394
|
+
return self._closed
|
|
395
|
+
raise
|
|
396
|
+
event = parse_event(text)
|
|
397
|
+
if event is not None:
|
|
398
|
+
return event
|
|
399
|
+
|
|
400
|
+
def replies(self, timeout: Optional[float] = None) -> Iterator[SessionEvent]:
|
|
401
|
+
"""Yield replies and frame errors as they come, then the :class:`SessionClosed`.
|
|
402
|
+
|
|
403
|
+
With ``timeout``, stop early when nothing arrives for that many seconds.
|
|
404
|
+
"""
|
|
405
|
+
while True:
|
|
406
|
+
event = self.next_event(timeout)
|
|
407
|
+
if event is None:
|
|
408
|
+
return
|
|
409
|
+
yield event
|
|
410
|
+
if isinstance(event, SessionClosed):
|
|
411
|
+
return
|
|
412
|
+
|
|
413
|
+
def end(self) -> None:
|
|
414
|
+
"""End the session (``DELETE``). A session that has already ended counts as ended."""
|
|
415
|
+
try:
|
|
416
|
+
self._client._send("DELETE", self._end_path(), None)
|
|
417
|
+
except NotFoundError:
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
def close(self) -> None:
|
|
421
|
+
"""Close the stream with ``1000``. The session ends with it."""
|
|
422
|
+
if self._ws is not None:
|
|
423
|
+
try:
|
|
424
|
+
self._ws.close()
|
|
425
|
+
except Exception: # noqa: BLE001 - closing a broken socket
|
|
426
|
+
pass
|
|
427
|
+
|
|
428
|
+
def __enter__(self) -> Session:
|
|
429
|
+
return self
|
|
430
|
+
|
|
431
|
+
def __exit__(self, *exc: Any) -> None:
|
|
432
|
+
try:
|
|
433
|
+
self.end()
|
|
434
|
+
finally:
|
|
435
|
+
self.close()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class AsyncSession(_SessionInfo):
|
|
439
|
+
"""An open session of the asyncio client. Same fields and methods as :class:`Session`;
|
|
440
|
+
every method is a coroutine, and :meth:`replies` is an async iterator."""
|
|
441
|
+
|
|
442
|
+
def __init__(self, client: AsyncDecisionNode, raw: Dict[str, Any], headers: Mapping[str, str]) -> None:
|
|
443
|
+
super().__init__(raw, headers)
|
|
444
|
+
self._client = client
|
|
445
|
+
self._ws: Any = None
|
|
446
|
+
|
|
447
|
+
async def connect(self) -> AsyncSession:
|
|
448
|
+
"""Open the stream. See :meth:`Session.connect`."""
|
|
449
|
+
from websockets.asyncio.client import connect
|
|
450
|
+
from websockets.exceptions import InvalidHandshake
|
|
451
|
+
|
|
452
|
+
settings = self._client._settings
|
|
453
|
+
url = check_stream_url(self.stream_url)
|
|
454
|
+
try:
|
|
455
|
+
self._ws = await connect(
|
|
456
|
+
url,
|
|
457
|
+
additional_headers=settings.stream_headers(),
|
|
458
|
+
user_agent_header=_base.USER_AGENT,
|
|
459
|
+
compression=None,
|
|
460
|
+
open_timeout=settings.timeout,
|
|
461
|
+
max_size=MAX_MESSAGE,
|
|
462
|
+
**self._client._stream_options(url),
|
|
463
|
+
)
|
|
464
|
+
except (InvalidHandshake, OSError, asyncio.TimeoutError) as exc:
|
|
465
|
+
raise _handshake_error(exc, settings.timeout) from exc
|
|
466
|
+
self._closed = None
|
|
467
|
+
return self
|
|
468
|
+
|
|
469
|
+
async def send(
|
|
470
|
+
self,
|
|
471
|
+
frame: Any = None,
|
|
472
|
+
image: Union[Image, Mapping[str, Any], None] = None,
|
|
473
|
+
questions: Optional[Iterable[str]] = None,
|
|
474
|
+
reset: Optional[bool] = None,
|
|
475
|
+
seq: Optional[int] = None,
|
|
476
|
+
) -> int:
|
|
477
|
+
"""Send one frame and return its ``seq``. See :meth:`Session.send`."""
|
|
478
|
+
if self._ws is None:
|
|
479
|
+
raise ValueError("connect() the session before sending")
|
|
480
|
+
questions = self._client._check_frame(self, frame, image, questions, reset, seq)
|
|
481
|
+
wire = await self._client._frame_image(image) if image is not None else None
|
|
482
|
+
number = self._next_seq(seq)
|
|
483
|
+
msg = frame_message(number, frame, wire, questions, reset)
|
|
484
|
+
try:
|
|
485
|
+
await self._ws.send(_base.encode(msg).decode("utf-8"))
|
|
486
|
+
except Exception as exc: # noqa: BLE001
|
|
487
|
+
if type(exc).__name__.startswith("ConnectionClosed"):
|
|
488
|
+
self._closed = self._closed or _closed_from(exc)
|
|
489
|
+
else:
|
|
490
|
+
raise
|
|
491
|
+
return number
|
|
492
|
+
|
|
493
|
+
async def next_event(self, timeout: Optional[float] = None) -> Optional[SessionEvent]:
|
|
494
|
+
"""The next event, or ``None`` after ``timeout`` seconds. See :meth:`Session.next_event`."""
|
|
495
|
+
if self._closed is not None:
|
|
496
|
+
return self._closed
|
|
497
|
+
if self._ws is None:
|
|
498
|
+
raise ValueError("connect() the session before reading")
|
|
499
|
+
while True:
|
|
500
|
+
try:
|
|
501
|
+
if timeout is None:
|
|
502
|
+
text = await self._ws.recv()
|
|
503
|
+
else:
|
|
504
|
+
# recv() is safe to cancel; a buffered message needs one turn of the loop.
|
|
505
|
+
text = await asyncio.wait_for(self._ws.recv(), max(timeout, 0.001))
|
|
506
|
+
except asyncio.TimeoutError:
|
|
507
|
+
return None
|
|
508
|
+
except Exception as exc: # noqa: BLE001
|
|
509
|
+
if type(exc).__name__.startswith("ConnectionClosed"):
|
|
510
|
+
self._closed = _closed_from(exc)
|
|
511
|
+
return self._closed
|
|
512
|
+
raise
|
|
513
|
+
event = parse_event(text)
|
|
514
|
+
if event is not None:
|
|
515
|
+
return event
|
|
516
|
+
|
|
517
|
+
async def replies(self, timeout: Optional[float] = None) -> AsyncIterator[SessionEvent]:
|
|
518
|
+
"""Yield replies and frame errors as they come, then the :class:`SessionClosed`."""
|
|
519
|
+
while True:
|
|
520
|
+
event = await self.next_event(timeout)
|
|
521
|
+
if event is None:
|
|
522
|
+
return
|
|
523
|
+
yield event
|
|
524
|
+
if isinstance(event, SessionClosed):
|
|
525
|
+
return
|
|
526
|
+
|
|
527
|
+
async def end(self) -> None:
|
|
528
|
+
"""End the session (``DELETE``). A session that has already ended counts as ended."""
|
|
529
|
+
try:
|
|
530
|
+
await self._client._send("DELETE", self._end_path(), None)
|
|
531
|
+
except NotFoundError:
|
|
532
|
+
pass
|
|
533
|
+
|
|
534
|
+
async def close(self) -> None:
|
|
535
|
+
"""Close the stream with ``1000``."""
|
|
536
|
+
if self._ws is not None:
|
|
537
|
+
try:
|
|
538
|
+
await self._ws.close()
|
|
539
|
+
except Exception: # noqa: BLE001
|
|
540
|
+
pass
|
|
541
|
+
|
|
542
|
+
async def __aenter__(self) -> AsyncSession:
|
|
543
|
+
return self
|
|
544
|
+
|
|
545
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
546
|
+
try:
|
|
547
|
+
await self.end()
|
|
548
|
+
finally:
|
|
549
|
+
await self.close()
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# --------------------------------------------------------------------------- supervise
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
class _Supervisor:
|
|
556
|
+
"""The bookkeeping shared by both forms of ``supervise``."""
|
|
557
|
+
|
|
558
|
+
def __init__(self) -> None:
|
|
559
|
+
self.owed = 0 # frames sent and not yet answered, failed or dropped
|
|
560
|
+
self.failures = 0 # reopens in a row without an answered frame
|
|
561
|
+
self.answered = 0 # replies on the current session
|
|
562
|
+
self.last: Optional[SessionClosed] = None # the last close seen, returned at the end
|
|
563
|
+
self.cause: Optional[SessionClosed] = None # the close the next open answers
|
|
564
|
+
|
|
565
|
+
def seen(self, event: Union[Reply, FrameError]) -> None:
|
|
566
|
+
dropped = event.dropped if isinstance(event, Reply) else None
|
|
567
|
+
self.owed = max(0, self.owed - 1 - len(dropped or ()))
|
|
568
|
+
if isinstance(event, Reply):
|
|
569
|
+
self.answered += 1
|
|
570
|
+
|
|
571
|
+
def closed(self, event: SessionClosed) -> None:
|
|
572
|
+
self.last = event
|
|
573
|
+
self.cause = event
|
|
574
|
+
self.owed = 0
|
|
575
|
+
|
|
576
|
+
def stop(self) -> bool:
|
|
577
|
+
"""Stop on every close code that is neither a reopen code nor 1013."""
|
|
578
|
+
cause = self.cause
|
|
579
|
+
return cause is not None and not cause.reopen and cause.code != TRY_AGAIN
|
|
580
|
+
|
|
581
|
+
def wait(self) -> Optional[float]:
|
|
582
|
+
"""The wait before opening (or connecting) again, or ``None`` for at once."""
|
|
583
|
+
cause = self.cause
|
|
584
|
+
if cause is None or cause.code in REOPEN_AT_ONCE:
|
|
585
|
+
return None
|
|
586
|
+
if self.answered:
|
|
587
|
+
self.failures = 0
|
|
588
|
+
wait = reconnect_wait(self.failures)
|
|
589
|
+
self.failures += 1
|
|
590
|
+
return wait
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def supervise(
|
|
594
|
+
client: DecisionNode,
|
|
595
|
+
body: Mapping[str, Any],
|
|
596
|
+
feed: Iterable[Mapping[str, Any]],
|
|
597
|
+
on_reply: Callable[[Union[Reply, FrameError]], Any],
|
|
598
|
+
drain_timeout: float,
|
|
599
|
+
) -> Optional[SessionClosed]:
|
|
600
|
+
sup = _Supervisor()
|
|
601
|
+
session: Optional[Session] = None
|
|
602
|
+
kept: Optional[Session] = None # closed with 1013: connect to it again
|
|
603
|
+
|
|
604
|
+
def drain(block: float) -> None:
|
|
605
|
+
nonlocal session, kept
|
|
606
|
+
deadline = time.monotonic() + block
|
|
607
|
+
while session is not None:
|
|
608
|
+
if block > 0 and sup.owed == 0:
|
|
609
|
+
return
|
|
610
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
611
|
+
event = session.next_event(timeout=remaining)
|
|
612
|
+
if event is None:
|
|
613
|
+
return
|
|
614
|
+
if isinstance(event, SessionClosed):
|
|
615
|
+
sup.closed(event)
|
|
616
|
+
session.close()
|
|
617
|
+
kept = session if event.code == TRY_AGAIN else None
|
|
618
|
+
session = None
|
|
619
|
+
return
|
|
620
|
+
sup.seen(event)
|
|
621
|
+
on_reply(event)
|
|
622
|
+
|
|
623
|
+
for reading in feed:
|
|
624
|
+
drain(0.0)
|
|
625
|
+
if sup.stop():
|
|
626
|
+
return sup.last
|
|
627
|
+
if session is None:
|
|
628
|
+
wait = sup.wait()
|
|
629
|
+
if wait is not None:
|
|
630
|
+
client._sleep(wait)
|
|
631
|
+
if kept is not None:
|
|
632
|
+
session, kept = kept, None
|
|
633
|
+
else:
|
|
634
|
+
session = client.open_session(**dict(body))
|
|
635
|
+
sup.answered = 0
|
|
636
|
+
session.connect()
|
|
637
|
+
sup.cause = None
|
|
638
|
+
session.send(**dict(reading))
|
|
639
|
+
sup.owed += 1
|
|
640
|
+
drain(drain_timeout)
|
|
641
|
+
if session is not None:
|
|
642
|
+
session.end()
|
|
643
|
+
session.close()
|
|
644
|
+
return sup.last
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
async def asupervise(
|
|
648
|
+
client: AsyncDecisionNode,
|
|
649
|
+
body: Mapping[str, Any],
|
|
650
|
+
feed: Union[Iterable[Mapping[str, Any]], AsyncIterator[Mapping[str, Any]]],
|
|
651
|
+
on_reply: Callable[[Union[Reply, FrameError]], Union[Any, Awaitable[Any]]],
|
|
652
|
+
drain_timeout: float,
|
|
653
|
+
) -> Optional[SessionClosed]:
|
|
654
|
+
sup = _Supervisor()
|
|
655
|
+
session: Optional[AsyncSession] = None
|
|
656
|
+
kept: Optional[AsyncSession] = None
|
|
657
|
+
|
|
658
|
+
async def deliver(event: Union[Reply, FrameError]) -> None:
|
|
659
|
+
result = on_reply(event)
|
|
660
|
+
if inspect.isawaitable(result):
|
|
661
|
+
await result
|
|
662
|
+
|
|
663
|
+
async def drain(block: float) -> None:
|
|
664
|
+
nonlocal session, kept
|
|
665
|
+
deadline = time.monotonic() + block
|
|
666
|
+
while session is not None:
|
|
667
|
+
if block > 0 and sup.owed == 0:
|
|
668
|
+
return
|
|
669
|
+
remaining = max(0.0, deadline - time.monotonic())
|
|
670
|
+
event = await session.next_event(timeout=remaining)
|
|
671
|
+
if event is None:
|
|
672
|
+
return
|
|
673
|
+
if isinstance(event, SessionClosed):
|
|
674
|
+
sup.closed(event)
|
|
675
|
+
await session.close()
|
|
676
|
+
kept = session if event.code == TRY_AGAIN else None
|
|
677
|
+
session = None
|
|
678
|
+
return
|
|
679
|
+
sup.seen(event)
|
|
680
|
+
await deliver(event)
|
|
681
|
+
|
|
682
|
+
async def readings() -> AsyncIterator[Mapping[str, Any]]:
|
|
683
|
+
if hasattr(feed, "__aiter__"):
|
|
684
|
+
async for item in feed:
|
|
685
|
+
yield item
|
|
686
|
+
else:
|
|
687
|
+
for item in feed:
|
|
688
|
+
yield item
|
|
689
|
+
|
|
690
|
+
async for reading in readings():
|
|
691
|
+
await drain(0.0)
|
|
692
|
+
if sup.stop():
|
|
693
|
+
return sup.last
|
|
694
|
+
if session is None:
|
|
695
|
+
wait = sup.wait()
|
|
696
|
+
if wait is not None:
|
|
697
|
+
await client._sleep(wait)
|
|
698
|
+
if kept is not None:
|
|
699
|
+
session, kept = kept, None
|
|
700
|
+
else:
|
|
701
|
+
session = await client.open_session(**dict(body))
|
|
702
|
+
sup.answered = 0
|
|
703
|
+
await session.connect()
|
|
704
|
+
sup.cause = None
|
|
705
|
+
await session.send(**dict(reading))
|
|
706
|
+
sup.owed += 1
|
|
707
|
+
await drain(drain_timeout)
|
|
708
|
+
if session is not None:
|
|
709
|
+
await session.end()
|
|
710
|
+
await session.close()
|
|
711
|
+
return sup.last
|