axio-sse 0.11.0__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.
- axio_sse/__init__.py +43 -0
- axio_sse/decoder.py +263 -0
- axio_sse/event.py +104 -0
- axio_sse/py.typed +0 -0
- axio_sse/reader.py +200 -0
- axio_sse/stream.py +37 -0
- axio_sse/wire.py +132 -0
- axio_sse-0.11.0.dist-info/METADATA +297 -0
- axio_sse-0.11.0.dist-info/RECORD +10 -0
- axio_sse-0.11.0.dist-info/WHEEL +4 -0
axio_sse/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Read ``text/event-stream``: a decoder you feed, and a reader for what its payloads mean.
|
|
2
|
+
|
|
3
|
+
``Decoder`` is the format and nothing else. Feed it chunks — bytes or text, cut anywhere — and take
|
|
4
|
+
the events they completed. It is synchronous and holds no connection, so every wire case is
|
|
5
|
+
testable without a loop, and a thread or a non-asyncio caller can drive it. ``events()`` and
|
|
6
|
+
``payloads()`` are the async skin over it: chunks in, ``Event`` or ``Payload`` out. Chunks must
|
|
7
|
+
carry their line terminators, so an iterator of lines will not do.
|
|
8
|
+
|
|
9
|
+
A stream whose events are all one shape needs nothing above ``payloads()``: every JSON object the
|
|
10
|
+
stream carries, and nothing more to learn. ``until`` names the one data payload that closes the
|
|
11
|
+
stream — ``until="[DONE]"`` — so a sentinel that is not JSON never reaches a caller.
|
|
12
|
+
|
|
13
|
+
A stream that says what each event is subclasses ``Reader`` and writes one ``@on(...)`` method per
|
|
14
|
+
event. ``by`` on the class line names the payload key that holds the name, or ``EVENT_NAME`` for
|
|
15
|
+
the format's own ``event:`` field. That class body is one endpoint's whole vocabulary, the events
|
|
16
|
+
it deliberately drops included. An event no method claims is skipped and logged at DEBUG. It
|
|
17
|
+
raises ``UnknownEvent`` when the caller reads with ``strict=True``, which is how a test fails on
|
|
18
|
+
the day the provider sends something new.
|
|
19
|
+
|
|
20
|
+
This module knows nothing about HTTP and imports no client.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from .decoder import Decoder, EventTooLarge
|
|
24
|
+
from .event import Event, MalformedPayload, Payload
|
|
25
|
+
from .reader import EVENT_NAME, Handled, Reader, UnknownEvent, on
|
|
26
|
+
from .stream import events, payloads
|
|
27
|
+
from .wire import Wire
|
|
28
|
+
|
|
29
|
+
__all__ = [
|
|
30
|
+
"EVENT_NAME",
|
|
31
|
+
"Decoder",
|
|
32
|
+
"Event",
|
|
33
|
+
"EventTooLarge",
|
|
34
|
+
"Handled",
|
|
35
|
+
"MalformedPayload",
|
|
36
|
+
"Payload",
|
|
37
|
+
"Reader",
|
|
38
|
+
"UnknownEvent",
|
|
39
|
+
"Wire",
|
|
40
|
+
"events",
|
|
41
|
+
"on",
|
|
42
|
+
"payloads",
|
|
43
|
+
]
|
axio_sse/decoder.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""The format as a state machine, with no I/O and no loop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import codecs
|
|
6
|
+
from typing import Final
|
|
7
|
+
|
|
8
|
+
from .event import Event
|
|
9
|
+
|
|
10
|
+
#: The only line endings the format allows. ``str.splitlines`` breaks on more than these.
|
|
11
|
+
#: ``_take`` hard-codes them; changing this tuple alone changes nothing.
|
|
12
|
+
ENDINGS: Final = ("\r\n", "\n", "\r")
|
|
13
|
+
|
|
14
|
+
#: How large a held piece grows before the next chunk starts a new one. Bounds the number of
|
|
15
|
+
#: string headers a fragmented event costs, at no measurable cost to an ordinary read buffer.
|
|
16
|
+
_MIN_PIECE = 4096
|
|
17
|
+
|
|
18
|
+
#: How long a ``retry:`` value may be. ``str.isdigit`` is true for 128 characters ``int()``
|
|
19
|
+
#: refuses, and CPython refuses to parse past 4300 digits.
|
|
20
|
+
_RETRY_DIGITS: Final = 18
|
|
21
|
+
|
|
22
|
+
#: How large one event may grow before the decoder refuses it, in characters. Nothing in the
|
|
23
|
+
#: format ends an event but a blank line, so an endpoint that never sends one — or a line that
|
|
24
|
+
#: never terminates — is held in full until the process runs out of memory. Generous enough that
|
|
25
|
+
#: no real event meets it: an inline base64 image is the largest thing any of these streams carry.
|
|
26
|
+
MAX_EVENT: Final = 32 * 1024 * 1024
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EventTooLarge(ValueError):
|
|
30
|
+
"""One event grew past the decoder's limit, and the stream was refused rather than held."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Decoder:
|
|
34
|
+
"""The format as a state machine: feed it chunks, take the events they completed.
|
|
35
|
+
|
|
36
|
+
Same shape as ``codecs.IncrementalDecoder``: ``decode(chunk, final)`` and ``reset()``. The
|
|
37
|
+
problem is the same one. Input is cut at arbitrary points, and output only sometimes
|
|
38
|
+
completes. It takes chunks and never lines. ``aiohttp``'s ``readuntil`` raises ``LineTooLong``
|
|
39
|
+
past 131072 bytes, and ``LineTooLong`` is not a ``ClientError``. A large reasoning event killed
|
|
40
|
+
a turn with no answer.
|
|
41
|
+
|
|
42
|
+
Held text costs time for its size, and never for its square. Chunks with no terminator wait
|
|
43
|
+
in a list. A read line is left behind rather than sliced out. A scanned tail is never scanned
|
|
44
|
+
twice.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
__slots__ = (
|
|
48
|
+
"_limit",
|
|
49
|
+
"_pending",
|
|
50
|
+
"_data_size",
|
|
51
|
+
"_text",
|
|
52
|
+
"_held",
|
|
53
|
+
"_parts",
|
|
54
|
+
"_start",
|
|
55
|
+
"_scan",
|
|
56
|
+
"_trailing_cr",
|
|
57
|
+
"_opened",
|
|
58
|
+
"_data",
|
|
59
|
+
"_event",
|
|
60
|
+
"_id",
|
|
61
|
+
"_retry",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def __init__(self, limit: int = MAX_EVENT) -> None:
|
|
65
|
+
self._limit = limit
|
|
66
|
+
self.reset()
|
|
67
|
+
|
|
68
|
+
def reset(self) -> None:
|
|
69
|
+
"""Forget the half-read event and the half-read character, ready for another stream."""
|
|
70
|
+
# What has arrived. ``utf-8-sig`` strips a leading byte order mark, which the format
|
|
71
|
+
# requires. ``_parts`` holds chunks apart until a terminator arrives, because joining each
|
|
72
|
+
# one into the buffer copies the whole held event again.
|
|
73
|
+
#
|
|
74
|
+
# ``strict``: this stream carries the base64 of a signature, which a provider refuses
|
|
75
|
+
# on replay if one character changed. A U+FFFD there shows up a request later.
|
|
76
|
+
self._text = codecs.getincrementaldecoder("utf-8-sig")(errors="strict")
|
|
77
|
+
self._parts: list[str] = []
|
|
78
|
+
self._held = ""
|
|
79
|
+
#: Characters held that no line has taken yet, against ``_limit``.
|
|
80
|
+
self._pending = 0
|
|
81
|
+
self._start = 0
|
|
82
|
+
self._scan = 0
|
|
83
|
+
self._opened = False
|
|
84
|
+
self._trailing_cr = False
|
|
85
|
+
|
|
86
|
+
# What the lines read so far have collected, for the next dispatch.
|
|
87
|
+
self._data: list[str] = []
|
|
88
|
+
self._data_size = 0
|
|
89
|
+
self._event = ""
|
|
90
|
+
self._id = ""
|
|
91
|
+
self._retry: int | None = None
|
|
92
|
+
|
|
93
|
+
def decode(self, chunk: bytes | str = b"", final: bool = False) -> list[Event]:
|
|
94
|
+
"""Every event this chunk completed.
|
|
95
|
+
|
|
96
|
+
``final=True`` closes the stream: what is still pending is discarded, which the format
|
|
97
|
+
requires of an event that never reached its blank line. Without that last call a stream
|
|
98
|
+
cut mid-character keeps the half character instead of replacing it.
|
|
99
|
+
"""
|
|
100
|
+
text = self._text_of(chunk, final)
|
|
101
|
+
self._hold(text)
|
|
102
|
+
if not final and "\n" not in text and "\r" not in text:
|
|
103
|
+
self._bounded()
|
|
104
|
+
return []
|
|
105
|
+
self._join()
|
|
106
|
+
|
|
107
|
+
made: list[Event] = []
|
|
108
|
+
while (line := self._take()) is not None:
|
|
109
|
+
if (event := self._read(line)) is not None:
|
|
110
|
+
made.append(event)
|
|
111
|
+
if final:
|
|
112
|
+
self._forget()
|
|
113
|
+
elif self._start and self._start * 2 >= len(self._held):
|
|
114
|
+
self._compact()
|
|
115
|
+
self._bounded()
|
|
116
|
+
return made
|
|
117
|
+
|
|
118
|
+
def _text_of(self, chunk: bytes | str, final: bool) -> str:
|
|
119
|
+
"""This chunk as text, with the byte order mark and a split terminator dealt with."""
|
|
120
|
+
if isinstance(chunk, bytes):
|
|
121
|
+
text = self._text.decode(chunk)
|
|
122
|
+
else:
|
|
123
|
+
# Half a character whose other half arrived as text, so nothing can complete it:
|
|
124
|
+
# refused, like every other byte that will not decode. A final flush keeps a partial
|
|
125
|
+
# mark, so the state is cleared as well as read.
|
|
126
|
+
if pending := self._text.getstate()[0]:
|
|
127
|
+
self._text.setstate((b"", 0))
|
|
128
|
+
text = pending.decode("utf-8") + chunk
|
|
129
|
+
if not self._opened:
|
|
130
|
+
# The byte decoder took one mark already, and a second is data. Stripped here as well,
|
|
131
|
+
# two marks would read differently as bytes than as text.
|
|
132
|
+
if not isinstance(chunk, bytes):
|
|
133
|
+
text = text.removeprefix("\ufeff")
|
|
134
|
+
if text or final:
|
|
135
|
+
self._opened = True
|
|
136
|
+
# Flag 0 means no mark is expected, which the byte decoder cannot know on its own.
|
|
137
|
+
# The pending bytes stay: they are half a character, not a mark.
|
|
138
|
+
self._text.setstate((self._text.getstate()[0], 0))
|
|
139
|
+
if final:
|
|
140
|
+
try:
|
|
141
|
+
text += self._text.decode(b"", True)
|
|
142
|
+
except UnicodeDecodeError:
|
|
143
|
+
# The stream stopped mid-character. Whatever it was carrying never reached its
|
|
144
|
+
# blank line either, and `_forget` drops that; half a character is the same loss.
|
|
145
|
+
pass
|
|
146
|
+
if self._trailing_cr:
|
|
147
|
+
text, self._trailing_cr = "\r" + text, False
|
|
148
|
+
if text.endswith("\r") and not final:
|
|
149
|
+
# A chunk can end mid-terminator. Hold the ``\r`` until the next chunk says whether a
|
|
150
|
+
# ``\n`` follows, or it invents a blank line and dispatches half an event.
|
|
151
|
+
text, self._trailing_cr = text[:-1], True
|
|
152
|
+
return text
|
|
153
|
+
|
|
154
|
+
def _hold(self, text: str) -> None:
|
|
155
|
+
"""Keep this text until a terminator arrives."""
|
|
156
|
+
if not text:
|
|
157
|
+
return
|
|
158
|
+
# Every piece costs a string header, so a byte at a time held forty times its size.
|
|
159
|
+
if self._parts and len(self._parts[-1]) < _MIN_PIECE:
|
|
160
|
+
self._parts[-1] += text
|
|
161
|
+
else:
|
|
162
|
+
self._parts.append(text)
|
|
163
|
+
self._pending += len(text)
|
|
164
|
+
|
|
165
|
+
def _bounded(self) -> None:
|
|
166
|
+
"""Refuse a line that never ends, measured once the complete ones are gone.
|
|
167
|
+
|
|
168
|
+
Checked as the chunk arrives instead, ``_pending`` was still the whole unread buffer, so
|
|
169
|
+
one read holding many small complete events tripped a limit about a single line and named
|
|
170
|
+
a cause that had not happened.
|
|
171
|
+
"""
|
|
172
|
+
if self._pending > self._limit:
|
|
173
|
+
raise EventTooLarge(f"a line ran past {self._limit} characters with no terminator")
|
|
174
|
+
|
|
175
|
+
def _compact(self) -> None:
|
|
176
|
+
"""Drop the lines already read, once they outweigh what is left."""
|
|
177
|
+
self._held = self._held[self._start :]
|
|
178
|
+
self._scan -= self._start
|
|
179
|
+
self._start = 0
|
|
180
|
+
|
|
181
|
+
def _forget(self) -> None:
|
|
182
|
+
"""Discard what never completed, which is what end of file means for this format.
|
|
183
|
+
|
|
184
|
+
Dispatched instead, a connection cut between a frame and the blank line after it makes a
|
|
185
|
+
truncated turn read as a finished one.
|
|
186
|
+
"""
|
|
187
|
+
self._held, self._start, self._scan, self._pending = "", 0, 0, 0
|
|
188
|
+
self._data, self._data_size, self._event, self._retry = [], 0, "", None
|
|
189
|
+
|
|
190
|
+
def _join(self) -> None:
|
|
191
|
+
"""Make the held text one string again, and drop the lines already read."""
|
|
192
|
+
self._parts.insert(0, self._held[self._start :])
|
|
193
|
+
self._held = "".join(self._parts)
|
|
194
|
+
self._parts.clear()
|
|
195
|
+
self._scan -= self._start
|
|
196
|
+
self._start = 0
|
|
197
|
+
|
|
198
|
+
def _take(self) -> str | None:
|
|
199
|
+
"""The next complete line, or None while none is complete.
|
|
200
|
+
|
|
201
|
+
At the same position take the longest ending: splitting ``\\r`` out of ``\\r\\n`` leaves a
|
|
202
|
+
``\\n`` that reads as a blank line, which dispatches.
|
|
203
|
+
"""
|
|
204
|
+
held = self._held
|
|
205
|
+
nl = held.find("\n", self._scan)
|
|
206
|
+
# Look for a ``\r`` only before that ``\n``. A search for the two-character ``\r\n`` runs to
|
|
207
|
+
# the end of an LF-only buffer, at a fraction of the speed of a one-character search.
|
|
208
|
+
cr = held.find("\r", self._scan, len(held) if nl == -1 else nl)
|
|
209
|
+
if cr != -1:
|
|
210
|
+
at, after = cr, cr + 2 if cr + 1 == nl else cr + 1
|
|
211
|
+
elif nl != -1:
|
|
212
|
+
at, after = nl, nl + 1
|
|
213
|
+
else:
|
|
214
|
+
# The tail carries no terminator, so no later chunk scans it again.
|
|
215
|
+
self._scan = len(held)
|
|
216
|
+
return None
|
|
217
|
+
line = held[self._start : at]
|
|
218
|
+
self._pending -= after - self._start
|
|
219
|
+
self._start = self._scan = after
|
|
220
|
+
return line
|
|
221
|
+
|
|
222
|
+
def _collected(self) -> bool:
|
|
223
|
+
"""Whether a blank line here fires anything.
|
|
224
|
+
|
|
225
|
+
The format dispatches on the data buffer, and on nothing else. A name alone fires nothing,
|
|
226
|
+
and so does a ``retry:``, which sets the stream's reconnection time rather than sending
|
|
227
|
+
anything. ``Event.retry`` still reports the value where data arrived beside it.
|
|
228
|
+
"""
|
|
229
|
+
return bool(self._data)
|
|
230
|
+
|
|
231
|
+
def _dispatch(self) -> Event:
|
|
232
|
+
made = Event(data="\n".join(self._data), event=self._event, id=self._id, retry=self._retry)
|
|
233
|
+
# The id survives dispatch, per the format: it is the stream's position, not this event's.
|
|
234
|
+
self._data, self._data_size, self._event, self._retry = [], 0, "", None
|
|
235
|
+
return made
|
|
236
|
+
|
|
237
|
+
def _read(self, line: str) -> Event | None:
|
|
238
|
+
if not line:
|
|
239
|
+
# A blank line dispatches, but only if something was collected: a stream of keep-alives
|
|
240
|
+
# must not become a stream of empty events.
|
|
241
|
+
if self._collected():
|
|
242
|
+
return self._dispatch()
|
|
243
|
+
# `_retry` as well: the format sets the reconnection time from the field, and a
|
|
244
|
+
# value left behind here rode out on whatever event dispatched next.
|
|
245
|
+
self._data, self._data_size, self._event, self._retry = [], 0, "", None
|
|
246
|
+
return None
|
|
247
|
+
if line.startswith(":"):
|
|
248
|
+
return None # comment line
|
|
249
|
+
name, _, value = line.partition(":")
|
|
250
|
+
value = value.removeprefix(" ") # exactly one space, per the format
|
|
251
|
+
if name == "data":
|
|
252
|
+
self._data.append(value)
|
|
253
|
+
self._data_size += len(value) + 1
|
|
254
|
+
if self._data_size > self._limit:
|
|
255
|
+
raise EventTooLarge(f"an event collected more than {self._limit} characters of data")
|
|
256
|
+
elif name == "event":
|
|
257
|
+
self._event = value
|
|
258
|
+
elif name == "id" and "\0" not in value:
|
|
259
|
+
self._id = value
|
|
260
|
+
elif name == "retry" and value.isascii() and value.isdigit() and len(value) <= _RETRY_DIGITS:
|
|
261
|
+
self._retry = int(value)
|
|
262
|
+
# Any other field is ignored, which the format requires: it is how it is extended.
|
|
263
|
+
return None
|
axio_sse/event.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""One event and the JSON object inside it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("axio.sse")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Payload(dict[str, Any]):
|
|
14
|
+
"""The JSON object inside one event, read by path.
|
|
15
|
+
|
|
16
|
+
``payload.number("message", "usage", "input_tokens")`` walks the path and gives the default
|
|
17
|
+
wherever a step is missing, null, or the wrong type — which is what an optional provider field
|
|
18
|
+
is. It is a ``dict``, so ``payload["x"]``, ``in``, and ``json.dumps`` all still work. The four
|
|
19
|
+
readers exist so a handler carries no ``Any`` and no chain of ``.get({})``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__slots__ = ()
|
|
23
|
+
|
|
24
|
+
def _at(self, keys: tuple[str, ...]) -> Any:
|
|
25
|
+
found: Any = self
|
|
26
|
+
for key in keys:
|
|
27
|
+
if not isinstance(found, dict):
|
|
28
|
+
return None
|
|
29
|
+
found = found.get(key)
|
|
30
|
+
return found
|
|
31
|
+
|
|
32
|
+
def string(self, *keys: str, default: str = "") -> str:
|
|
33
|
+
"""The string at this path, or the default where the provider sent none."""
|
|
34
|
+
found = self._at(keys)
|
|
35
|
+
return found if isinstance(found, str) else default
|
|
36
|
+
|
|
37
|
+
def number(self, *keys: str, default: int = 0) -> int:
|
|
38
|
+
"""The whole number at this path, or the default where the provider sent none."""
|
|
39
|
+
found = self._at(keys)
|
|
40
|
+
# bool is an int in Python. A true/false field must not read here as 1 or 0.
|
|
41
|
+
return found if isinstance(found, int) and not isinstance(found, bool) else default
|
|
42
|
+
|
|
43
|
+
def obj(self, *keys: str) -> Payload:
|
|
44
|
+
"""The object at this path, empty where there is none, so a path can be walked in steps."""
|
|
45
|
+
found = self._at(keys)
|
|
46
|
+
return Payload(found) if isinstance(found, dict) else Payload()
|
|
47
|
+
|
|
48
|
+
def objs(self, *keys: str) -> list[Payload]:
|
|
49
|
+
"""Every object in the list at this path. A missing list reads as no objects."""
|
|
50
|
+
found = self._at(keys)
|
|
51
|
+
if not isinstance(found, list):
|
|
52
|
+
return []
|
|
53
|
+
return [Payload(one) for one in found if isinstance(one, dict)]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class MalformedPayload(ValueError):
|
|
57
|
+
"""An event that carried data no reader can act on.
|
|
58
|
+
|
|
59
|
+
Raised rather than skipped: the stream said this event mattered, and there is no way to
|
|
60
|
+
continue reading it that does not report a partial turn as a whole one.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True, slots=True)
|
|
65
|
+
class Event:
|
|
66
|
+
"""One dispatched event, with the four fields the format defines."""
|
|
67
|
+
|
|
68
|
+
data: str = ""
|
|
69
|
+
#: What the ``event:`` field carried, empty where the stream sent none.
|
|
70
|
+
event: str = ""
|
|
71
|
+
#: The stream position for a client that reconnects, not an id of this event.
|
|
72
|
+
id: str = ""
|
|
73
|
+
retry: int | None = None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def name(self) -> str:
|
|
77
|
+
"""The event's type. An unnamed event is of type ``message``, which the format defines.
|
|
78
|
+
|
|
79
|
+
Dispatched on the raw field instead, an ``@on("message")`` handler never runs for the
|
|
80
|
+
ordinary unnamed event, and a strict read rejects it as unknown.
|
|
81
|
+
"""
|
|
82
|
+
return self.event or "message"
|
|
83
|
+
|
|
84
|
+
def payload(self) -> Payload | None:
|
|
85
|
+
"""This event's JSON object, or None where the event carries no data at all.
|
|
86
|
+
|
|
87
|
+
Data that will not parse raises. Skipped instead, a text or tool-call event whose JSON
|
|
88
|
+
arrived corrupt was dropped, the completion event after it still reported success, and the
|
|
89
|
+
caller got a short answer or half a tool's arguments with nothing saying anything was lost.
|
|
90
|
+
|
|
91
|
+
A sentinel such as ``[DONE]`` is data too, and reaches here as junk. Name it in ``until``,
|
|
92
|
+
which ends the stream before it is read.
|
|
93
|
+
"""
|
|
94
|
+
if not self.data:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
got = json.loads(self.data)
|
|
98
|
+
except json.JSONDecodeError as exc:
|
|
99
|
+
log.error("payload is not JSON: %.80s", self.data)
|
|
100
|
+
raise MalformedPayload(f"event {self.name!r} carries data that is not JSON: {exc}") from exc
|
|
101
|
+
if not isinstance(got, dict):
|
|
102
|
+
log.error("payload is not an object: %.80s", self.data)
|
|
103
|
+
raise MalformedPayload(f"event {self.name!r} carries {type(got).__name__} and not an object")
|
|
104
|
+
return Payload(got)
|
axio_sse/py.typed
ADDED
|
File without changes
|
axio_sse/reader.py
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""What one endpoint sends, as one method per event."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import AsyncIterable, AsyncIterator, Callable, Iterable, Mapping
|
|
7
|
+
from types import MappingProxyType
|
|
8
|
+
from typing import Any, ClassVar, Final, cast
|
|
9
|
+
|
|
10
|
+
from .event import Event, Payload
|
|
11
|
+
from .stream import events
|
|
12
|
+
from .wire import Wire
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger("axio.sse")
|
|
15
|
+
|
|
16
|
+
#: ``by=EVENT_NAME`` dispatches on the format's own ``event:`` field. Any other ``by`` names
|
|
17
|
+
#: a key in the payload, and no provider puts a colon in a key.
|
|
18
|
+
EVENT_NAME: Final = "event:"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class UnknownEvent(LookupError):
|
|
22
|
+
"""A name no method of this reader claims, met while the reader reads strictly."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _made[T](result: Handled[T]) -> list[T]:
|
|
26
|
+
"""What a handler returned, as a list.
|
|
27
|
+
|
|
28
|
+
A ``str`` is one result, never its letters. It satisfies ``Iterable[str]``, so a ``Reader[str]``
|
|
29
|
+
handler returning "hello" gave the caller five events.
|
|
30
|
+
"""
|
|
31
|
+
if result is None:
|
|
32
|
+
return []
|
|
33
|
+
if isinstance(result, (str, bytes)):
|
|
34
|
+
return [cast(T, result)]
|
|
35
|
+
return list(result)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
#: What a handler returns: what it made, or nothing. A ``str`` or ``bytes`` counts as one result,
|
|
39
|
+
#: never as a sequence of its parts.
|
|
40
|
+
type Handled[T] = Iterable[T] | None
|
|
41
|
+
|
|
42
|
+
type _Handler[R, P, T] = Callable[[R, P], Handled[T]]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def on[R, P, T](*claimed: str | type[Wire]) -> Callable[[_Handler[R, P, T]], _Handler[R, P, T]]:
|
|
46
|
+
"""Give a ``Reader`` method the payloads it reads.
|
|
47
|
+
|
|
48
|
+
Give it a ``Wire`` shape and the method is handed that shape, its fields read by declared name
|
|
49
|
+
and type. Give it wire names and the method is handed the ``Payload`` itself, which is what a
|
|
50
|
+
method that only forwards an event wants. Declaring a shape for a payload nobody reads a field
|
|
51
|
+
of would be a schema written for nothing.
|
|
52
|
+
|
|
53
|
+
Several names on one method is how a stream that sends one thing under two names is written.
|
|
54
|
+
Both stay in the class body, so ``strict`` has nothing to fire on and no second list exists to
|
|
55
|
+
keep in step with the first.
|
|
56
|
+
"""
|
|
57
|
+
shapes = [one for one in claimed if isinstance(one, type)]
|
|
58
|
+
if len(shapes) > 1:
|
|
59
|
+
raise ValueError("on() takes one shape, or names; a method reads one shape at a time")
|
|
60
|
+
if shapes and len(claimed) > 1:
|
|
61
|
+
raise ValueError(f"on({shapes[0].__name__}) already carries its names; do not repeat them")
|
|
62
|
+
|
|
63
|
+
if shapes:
|
|
64
|
+
shape = shapes[0]
|
|
65
|
+
if not issubclass(shape, Wire):
|
|
66
|
+
raise TypeError(f"{shape.__name__} is not a Wire, so it cannot say what it reads")
|
|
67
|
+
if not shape.names:
|
|
68
|
+
raise ValueError(f"{shape.__name__} has no name to read under — give it name= on the class line")
|
|
69
|
+
names: tuple[str, ...] = shape.names
|
|
70
|
+
else:
|
|
71
|
+
names = tuple(one for one in claimed if isinstance(one, str))
|
|
72
|
+
shape = None
|
|
73
|
+
if not names or not all(names):
|
|
74
|
+
raise ValueError("on() takes at least one event name, and no name may be empty")
|
|
75
|
+
|
|
76
|
+
def tag(method: _Handler[R, P, T]) -> _Handler[R, P, T]:
|
|
77
|
+
# setattr, not an assignment: a type checker refuses a new attribute on a Callable.
|
|
78
|
+
setattr(method, "_sse_names", names)
|
|
79
|
+
setattr(method, "_sse_shape", shape)
|
|
80
|
+
return method
|
|
81
|
+
|
|
82
|
+
return tag
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _redecorated(klass: type, attribute: str) -> bool:
|
|
86
|
+
"""Whether this class gives that attribute names of its own."""
|
|
87
|
+
return bool(getattr(vars(klass).get(attribute), "_sse_names", ()))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class Reader[T]:
|
|
91
|
+
"""What one endpoint sends, as one method per event.
|
|
92
|
+
|
|
93
|
+
class Messages(Reader[StreamEvent], by=EVENT_NAME):
|
|
94
|
+
@on("content_block_delta")
|
|
95
|
+
def _delta(self, payload: Payload) -> Iterator[StreamEvent]: ...
|
|
96
|
+
|
|
97
|
+
One instance reads one stream. The turn's running totals and id maps live on ``self`` instead
|
|
98
|
+
of travelling through a call. A reader used for a second response would carry the first one's
|
|
99
|
+
state into it. Construct one per response. Being that state, a reader must not be frozen.
|
|
100
|
+
``read`` latches the caller's ``strict`` on ``self``, and ``@dataclass(frozen=True)`` refuses
|
|
101
|
+
that assignment. With ``slots=True`` as well, the refusal comes from inside the rebuilt class
|
|
102
|
+
and says only that ``super()`` got the wrong type. ``@dataclass(slots=True)`` alone is fine.
|
|
103
|
+
|
|
104
|
+
A handler returns what the event became — an iterable, or None where the event only moved that
|
|
105
|
+
state. ``by`` names the payload key that holds the event's name, or ``EVENT_NAME`` for the
|
|
106
|
+
format's own ``event:`` field. A subclass that does not give ``by`` inherits it.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
_by: ClassVar[str] = "type"
|
|
110
|
+
#: Wire name to the method name that reads it and the shape it reads it as. Keyed by the
|
|
111
|
+
#: function, a subclass that overrides a handler without repeating ``@on`` never runs.
|
|
112
|
+
_handlers: ClassVar[Mapping[str, tuple[str, type[Wire] | None]]] = MappingProxyType({})
|
|
113
|
+
#: What the running read was asked for, so ``unknown()`` obeys it from inside a handler.
|
|
114
|
+
_strict: bool = False
|
|
115
|
+
|
|
116
|
+
def __init_subclass__(cls, *, by: str | None = None, **rest: object) -> None:
|
|
117
|
+
super().__init_subclass__(**rest)
|
|
118
|
+
if by is not None:
|
|
119
|
+
cls._by = by
|
|
120
|
+
found: dict[str, tuple[str, type[Wire] | None]] = {}
|
|
121
|
+
# Base first, so a subclass replaces only the names it claims again.
|
|
122
|
+
for klass in reversed(cls.__mro__):
|
|
123
|
+
here: dict[str, tuple[str, type[Wire] | None]] = {}
|
|
124
|
+
# Redecorating an inherited name replaces what the parent read, not just how.
|
|
125
|
+
for stale in [n for n, (attribute, _) in found.items() if _redecorated(klass, attribute)]:
|
|
126
|
+
del found[stale]
|
|
127
|
+
for attribute, method in vars(klass).items():
|
|
128
|
+
shape = cast("type[Wire] | None", getattr(method, "_sse_shape", None))
|
|
129
|
+
for name in cast(tuple[str, ...], getattr(method, "_sse_names", ())):
|
|
130
|
+
if name in here:
|
|
131
|
+
# Definition order would silently leave one of the two never called.
|
|
132
|
+
taken = here[name][0]
|
|
133
|
+
raise ValueError(f"{klass.__qualname__} reads {name!r} twice: {taken} and {attribute}")
|
|
134
|
+
here[name] = (attribute, shape)
|
|
135
|
+
found.update(here)
|
|
136
|
+
cls._handlers = MappingProxyType(found)
|
|
137
|
+
|
|
138
|
+
@classmethod
|
|
139
|
+
def names(cls) -> frozenset[str]:
|
|
140
|
+
"""Every name this reader claims, for a test to hold against the provider's own list."""
|
|
141
|
+
return frozenset(cls._handlers)
|
|
142
|
+
|
|
143
|
+
def unknown(self, name: str) -> None:
|
|
144
|
+
"""The one policy for a name nothing here reads: DEBUG, or refuse under ``strict``.
|
|
145
|
+
|
|
146
|
+
A handler calls it for a second discriminator inside one event, such as a block that names
|
|
147
|
+
the kind of its own chunks. A nested name nobody read then fails the same replay a new
|
|
148
|
+
event fails, instead of disappearing.
|
|
149
|
+
"""
|
|
150
|
+
log.debug("%s does not read %r", type(self).__name__, name)
|
|
151
|
+
if self._strict:
|
|
152
|
+
raise UnknownEvent(
|
|
153
|
+
f"{type(self).__name__} does not read {name!r}; it reads {', '.join(sorted(self.names()))}"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def unmatched(self, name: str, payload: Payload) -> Handled[T]:
|
|
157
|
+
"""What a payload no method claims becomes. Nothing, unless a reader says otherwise.
|
|
158
|
+
|
|
159
|
+
Override it to forward instead of drop. That is what a stream whose vocabulary grows on its
|
|
160
|
+
own needs. An endpoint that runs tools names an event per tool. That set is a function
|
|
161
|
+
of which tools exist and which were asked for, not of the protocol. Naming them one by one
|
|
162
|
+
makes the reader stale the day a tool is added. It also reports a new tool as news about
|
|
163
|
+
the protocol, when it is news about the tools.
|
|
164
|
+
|
|
165
|
+
Name here only what this reader interprets. ``strict`` still refuses anything unnamed. A test can
|
|
166
|
+
therefore hold the interpreted set against the schema, and the reader carries no list it
|
|
167
|
+
cannot keep true.
|
|
168
|
+
"""
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def read(self, event: Event, *, strict: bool = False) -> list[T]:
|
|
172
|
+
"""Everything this one event became, empty where it became nothing."""
|
|
173
|
+
# Latched first, so a nested unknown obeys the same policy as a top-level one, and so a
|
|
174
|
+
# read that raises before its handler runs has not left the last read's policy on self.
|
|
175
|
+
self._strict = strict
|
|
176
|
+
payload = event.payload()
|
|
177
|
+
if payload is None:
|
|
178
|
+
return []
|
|
179
|
+
name = event.name if self._by == EVENT_NAME else payload.string(self._by)
|
|
180
|
+
claimed = self._handlers.get(name)
|
|
181
|
+
if claimed is None:
|
|
182
|
+
# unknown() first: under strict it raises, so a reader that forwards still fails a replay.
|
|
183
|
+
self.unknown(name)
|
|
184
|
+
return _made(self.unmatched(name, payload))
|
|
185
|
+
attribute, shape = claimed
|
|
186
|
+
handler = cast(Callable[[Any], Handled[T]], getattr(self, attribute))
|
|
187
|
+
made = handler(payload if shape is None else shape.read(payload))
|
|
188
|
+
return _made(made)
|
|
189
|
+
|
|
190
|
+
async def over(
|
|
191
|
+
self, chunks: AsyncIterable[bytes | str], *, strict: bool = False, until: str = ""
|
|
192
|
+
) -> AsyncIterator[T]:
|
|
193
|
+
"""Read a whole stream of chunks, yielding what each event became.
|
|
194
|
+
|
|
195
|
+
An event that becomes nothing yields nothing, so the outputs do not line up with the
|
|
196
|
+
events.
|
|
197
|
+
"""
|
|
198
|
+
async for event in events(chunks, until=until):
|
|
199
|
+
for made in self.read(event, strict=strict):
|
|
200
|
+
yield made
|
axio_sse/stream.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The async skin over the decoder: chunks in, events or payloads out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import AsyncIterable, AsyncIterator
|
|
6
|
+
|
|
7
|
+
from .decoder import Decoder
|
|
8
|
+
from .event import Event, Payload
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def events(chunks: AsyncIterable[bytes | str], *, until: str = "") -> AsyncIterator[Event]:
|
|
12
|
+
"""Every event in this stream, as the chunks arrive.
|
|
13
|
+
|
|
14
|
+
Chunks must carry their line terminators: ``aiter_lines()`` strips them, so nothing dispatches.
|
|
15
|
+
A stream that stops without its final blank line still yields what it collected. ``until``
|
|
16
|
+
names the data payload that closes the stream, and is not yielded.
|
|
17
|
+
"""
|
|
18
|
+
decoder = Decoder()
|
|
19
|
+
async for chunk in chunks:
|
|
20
|
+
for event in decoder.decode(chunk):
|
|
21
|
+
if until and event.data == until:
|
|
22
|
+
return
|
|
23
|
+
yield event
|
|
24
|
+
for event in decoder.decode(final=True):
|
|
25
|
+
if until and event.data == until:
|
|
26
|
+
return
|
|
27
|
+
yield event
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def payloads(chunks: AsyncIterable[bytes | str], *, until: str = "") -> AsyncIterator[Payload]:
|
|
31
|
+
"""The JSON object of every event in this stream. Comments, keep-alives and junk do not arrive.
|
|
32
|
+
|
|
33
|
+
All a stream with no discriminator needs: its events are one shape, read field by field.
|
|
34
|
+
"""
|
|
35
|
+
async for event in events(chunks, until=until):
|
|
36
|
+
if (payload := event.payload()) is not None:
|
|
37
|
+
yield payload
|
axio_sse/wire.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Payload shapes: one class per wire name, read into declared fields."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Iterable, Mapping
|
|
5
|
+
from dataclasses import fields, is_dataclass
|
|
6
|
+
from functools import cache
|
|
7
|
+
from types import UnionType
|
|
8
|
+
from typing import Any, ClassVar, Literal, Self, Union, get_args, get_origin, get_type_hints
|
|
9
|
+
|
|
10
|
+
from .event import Payload
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Wire:
|
|
16
|
+
"""One payload shape, named by the wire name it arrives under::
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class OutputTextDelta(Wire, name="response.output_text.delta"):
|
|
20
|
+
delta: str = ""
|
|
21
|
+
output_index: int = 0
|
|
22
|
+
|
|
23
|
+
Every field is read by its declared name and type, so a misspelled key is a type error at the
|
|
24
|
+
place that uses it rather than a default quietly standing in for the value. A field the
|
|
25
|
+
provider did not send, sent as null, or sent as the wrong type takes its default. That is what
|
|
26
|
+
an optional provider field is, and one bad field must not lose the whole event.
|
|
27
|
+
|
|
28
|
+
A nested object is another ``Wire``; a list of them is ``list[ThatWire]``. Give a shape no
|
|
29
|
+
``name=`` and it is only ever nested, never dispatched to.
|
|
30
|
+
|
|
31
|
+
Declare a field ``raw: Payload`` and it receives the whole payload, for a shape that varies too
|
|
32
|
+
much to declare whole. A citation arrives under five shapes and each names its span
|
|
33
|
+
differently, so the fields worth reading are declared and the rest travels beside them.
|
|
34
|
+
|
|
35
|
+
Declaring a shape registers it nowhere. A ``Reader`` claims it with ``@on(ThatShape)``.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
#: Every name this shape arrives under, from ``name=`` and ``also=`` on the class line.
|
|
39
|
+
names: ClassVar[tuple[str, ...]] = ()
|
|
40
|
+
|
|
41
|
+
def __init_subclass__(cls, *, name: str = "", also: str | Iterable[str] = (), **rest: object) -> None:
|
|
42
|
+
super().__init_subclass__(**rest)
|
|
43
|
+
if also and not name:
|
|
44
|
+
raise ValueError(f"{cls.__name__} gives also= without name=; a shape names itself whole")
|
|
45
|
+
if name:
|
|
46
|
+
# Replaces rather than extends: a renamed subclass must not keep its parent's names.
|
|
47
|
+
cls.names = (name, *((also,) if isinstance(also, str) else also))
|
|
48
|
+
if not all(cls.names):
|
|
49
|
+
raise ValueError(f"{cls.__name__} claims an empty name, which would capture every payload")
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def read(cls, payload: Payload) -> Self:
|
|
53
|
+
"""This payload as this shape. Extra keys are ignored, missing ones take their defaults."""
|
|
54
|
+
if not is_dataclass(cls):
|
|
55
|
+
raise TypeError(f"{cls.__name__} is not a dataclass, so it has no fields to read into")
|
|
56
|
+
hints = _hints(cls)
|
|
57
|
+
made: dict[str, Any] = {}
|
|
58
|
+
for field in fields(cls):
|
|
59
|
+
if field.name == "raw" and hints[field.name] is Payload:
|
|
60
|
+
made[field.name] = payload
|
|
61
|
+
continue
|
|
62
|
+
if field.name not in payload:
|
|
63
|
+
continue
|
|
64
|
+
value = _as(hints[field.name], payload[field.name])
|
|
65
|
+
if value is not None:
|
|
66
|
+
made[field.name] = value
|
|
67
|
+
return cls(**made)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@cache
|
|
71
|
+
def _hints(cls: type) -> Mapping[str, Any]:
|
|
72
|
+
"""The declared types of one shape, worked out once.
|
|
73
|
+
|
|
74
|
+
Annotations do not change, and every transport uses ``from __future__ import annotations``, so
|
|
75
|
+
without this each event re-evaluates every annotation from its string form. Measured on a real
|
|
76
|
+
text delta that was nine tenths of the cost of reading the event.
|
|
77
|
+
"""
|
|
78
|
+
return get_type_hints(cls)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _as(kind: Any, raw: Any) -> Any:
|
|
82
|
+
"""``raw`` as this declared type, or None where it is not that and the default should stand."""
|
|
83
|
+
origin = get_origin(kind)
|
|
84
|
+
if origin is UnionType or origin is Union:
|
|
85
|
+
rest = [arg for arg in get_args(kind) if arg is not type(None)]
|
|
86
|
+
for member in rest:
|
|
87
|
+
if (read := _as(member, raw)) is not None:
|
|
88
|
+
return read
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
if isinstance(kind, type) and issubclass(kind, Wire):
|
|
92
|
+
return kind.read(Payload(raw)) if isinstance(raw, dict) else None
|
|
93
|
+
if origin is list:
|
|
94
|
+
if not isinstance(raw, list):
|
|
95
|
+
return None
|
|
96
|
+
args = get_args(kind)
|
|
97
|
+
if not args:
|
|
98
|
+
return list(raw)
|
|
99
|
+
read = [_as(args[0], one) for one in raw]
|
|
100
|
+
return [one for one in read if one is not None]
|
|
101
|
+
if kind is str:
|
|
102
|
+
return raw if isinstance(raw, str) else None
|
|
103
|
+
# bool is an int in Python, so each has to refuse the other.
|
|
104
|
+
if kind is bool:
|
|
105
|
+
return raw if isinstance(raw, bool) else None
|
|
106
|
+
if kind is int:
|
|
107
|
+
return raw if isinstance(raw, int) and not isinstance(raw, bool) else None
|
|
108
|
+
if kind is float:
|
|
109
|
+
if not isinstance(raw, (int, float)) or isinstance(raw, bool):
|
|
110
|
+
return None
|
|
111
|
+
try:
|
|
112
|
+
return float(raw)
|
|
113
|
+
except OverflowError:
|
|
114
|
+
# A JSON integer is unbounded and float() is not, so a value the caller cannot represent
|
|
115
|
+
# takes its default.
|
|
116
|
+
return None
|
|
117
|
+
if kind is Payload or kind is dict or origin is dict:
|
|
118
|
+
return Payload(raw) if isinstance(raw, dict) else None
|
|
119
|
+
if origin is Literal:
|
|
120
|
+
return raw if raw in get_args(kind) else None
|
|
121
|
+
if origin is tuple:
|
|
122
|
+
if not isinstance(raw, list):
|
|
123
|
+
return None
|
|
124
|
+
inner = [a for a in get_args(kind) if a is not Ellipsis]
|
|
125
|
+
items: list[Any] = [_as(inner[0], one) for one in raw] if inner else list(raw)
|
|
126
|
+
return tuple(one for one in items if one is not None)
|
|
127
|
+
if kind is Any:
|
|
128
|
+
return raw
|
|
129
|
+
# An annotation the ladder cannot read takes its default rather than whatever arrived. Passed
|
|
130
|
+
# through, a declared field held a value of any shape and the class's own rule said otherwise.
|
|
131
|
+
logger.debug("No rule for %r, so the field takes its default", kind)
|
|
132
|
+
return None
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: axio-sse
|
|
3
|
+
Version: 0.11.0
|
|
4
|
+
Summary: Server-sent events, read from a stream of chunks
|
|
5
|
+
Project-URL: Documentation, https://docs.axio-agent.com
|
|
6
|
+
Project-URL: Homepage, https://github.com/mosquito/axio-agent
|
|
7
|
+
Project-URL: Repository, https://github.com/mosquito/axio-agent
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: async,event-stream,server-sent-events,sse,streaming
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# axio-sse
|
|
14
|
+
|
|
15
|
+
[](https://pypi.org/project/axio-sse/)
|
|
16
|
+
[](https://pypi.org/project/axio-sse/)
|
|
17
|
+
[](LICENSE)
|
|
18
|
+
|
|
19
|
+
Read `text/event-stream`: a decoder you feed, and a reader for what its payloads mean.
|
|
20
|
+
|
|
21
|
+
The package knows nothing about HTTP and imports no client. It has no dependencies, not even on
|
|
22
|
+
[axio](https://github.com/mosquito/axio-agent).
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install axio-sse
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### `payloads(chunks, *, until="")` — the JSON object of every event
|
|
33
|
+
|
|
34
|
+
All a stream with no discriminator needs. Comments, keep-alives and junk never arrive. `until` names
|
|
35
|
+
the one data payload that closes the stream, so a sentinel that is not JSON never reaches you.
|
|
36
|
+
|
|
37
|
+
<!-- name: test_readme_payloads -->
|
|
38
|
+
```python
|
|
39
|
+
import asyncio
|
|
40
|
+
from axio_sse import payloads
|
|
41
|
+
|
|
42
|
+
async def chunks():
|
|
43
|
+
yield b': keep-alive\n\n'
|
|
44
|
+
yield b'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n'
|
|
45
|
+
yield b'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n'
|
|
46
|
+
yield b"data: [DONE]"
|
|
47
|
+
|
|
48
|
+
async def main() -> None:
|
|
49
|
+
got = [p["choices"][0]["delta"]["content"] async for p in payloads(chunks(), until="[DONE]")]
|
|
50
|
+
assert got == ["Hel", "lo"]
|
|
51
|
+
|
|
52
|
+
asyncio.run(main())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Feed it whatever the transport hands you. A chunk may end mid-field, mid-terminator, or mid-UTF-8
|
|
56
|
+
sequence. The result does not depend on where it was cut. Chunks must still carry their line
|
|
57
|
+
terminators, so `aiter_lines()` will not do. It strips them, and nothing ever dispatches.
|
|
58
|
+
|
|
59
|
+
A stream that stops without its final blank line still yields what it collected. The example above
|
|
60
|
+
ends on `data: [DONE]` with no newline after it. That is how these streams really end.
|
|
61
|
+
|
|
62
|
+
### `events(chunks, *, until="")` — the wire events themselves
|
|
63
|
+
|
|
64
|
+
<!-- name: test_readme_events -->
|
|
65
|
+
```python
|
|
66
|
+
import asyncio
|
|
67
|
+
from axio_sse import Event, events
|
|
68
|
+
|
|
69
|
+
async def chunks():
|
|
70
|
+
yield b'data: {"first":\ndata: true}\n\n'
|
|
71
|
+
yield b"event: named\r\ndata: sec"
|
|
72
|
+
yield b"ond\r\n\r\n"
|
|
73
|
+
|
|
74
|
+
async def main() -> None:
|
|
75
|
+
assert [e async for e in events(chunks())] == [
|
|
76
|
+
Event(data='{"first":\ntrue}'),
|
|
77
|
+
Event(data="second", event="named"),
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
asyncio.run(main())
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`Event` carries the four fields the format defines — `data`, `event`, `id`, `retry`. An empty
|
|
84
|
+
`event` means unnamed, which the format reads as `"message"`. `Event.payload()` gives the JSON
|
|
85
|
+
object, or `None` where the event carries none.
|
|
86
|
+
|
|
87
|
+
`events()` suspends nowhere of its own accord, so it needs no async framework: asyncio, trio and
|
|
88
|
+
anyio all drive it. A `yield` in an async generator does not reach the event loop. A caller that
|
|
89
|
+
must stay fair to other tasks — a TUI redrawing, a queue being served — therefore says so itself,
|
|
90
|
+
with `await asyncio.sleep(0)` in its own loop where it knows what else is waiting.
|
|
91
|
+
|
|
92
|
+
### `Decoder` — the format, with no loop
|
|
93
|
+
|
|
94
|
+
`Decoder` is the format and nothing else. It is synchronous and holds no connection. Every wire case
|
|
95
|
+
is therefore testable without a loop. A thread or a non-asyncio caller can drive it too. Same shape
|
|
96
|
+
as `codecs.IncrementalDecoder`, because the problem is the same: input cut at arbitrary points,
|
|
97
|
+
output that only sometimes completes.
|
|
98
|
+
|
|
99
|
+
<!-- name: test_readme_decoder -->
|
|
100
|
+
```python
|
|
101
|
+
from axio_sse import Decoder, Event
|
|
102
|
+
|
|
103
|
+
decoder = Decoder()
|
|
104
|
+
assert decoder.decode(b"data: hel") == []
|
|
105
|
+
assert decoder.decode(b"lo\n\ndata: wor") == [Event(data="hello")]
|
|
106
|
+
assert decoder.decode(b"ld\n\n", final=True) == [Event(data="world")]
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`final=True` closes the stream. What is still pending is discarded, which the format requires: an
|
|
110
|
+
event that never reached its blank line is not dispatched. Dispatched anyway, a connection cut
|
|
111
|
+
between a frame and the blank line after it makes a truncated turn read as a finished one.
|
|
112
|
+
|
|
113
|
+
The package takes chunks and never lines for a reason. `aiohttp`'s `readuntil` raises `LineTooLong`
|
|
114
|
+
past 131072 bytes. `LineTooLong` is not a `ClientError`. One large reasoning event kills a turn with
|
|
115
|
+
no answer.
|
|
116
|
+
|
|
117
|
+
### `Wire` — a payload shape
|
|
118
|
+
|
|
119
|
+
Declare the fields you read. Each is read by its declared name and type. A misspelled key is
|
|
120
|
+
therefore a type error at the place that uses it, rather than a default quietly standing in for the
|
|
121
|
+
value.
|
|
122
|
+
|
|
123
|
+
<!-- name: test_readme_reader -->
|
|
124
|
+
```python
|
|
125
|
+
from dataclasses import dataclass, field
|
|
126
|
+
from axio_sse import Payload, Wire
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True, slots=True)
|
|
129
|
+
class Usage(Wire):
|
|
130
|
+
"""Nested, and never dispatched to: it has no name of its own."""
|
|
131
|
+
output_tokens: int = 0
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class ResponseObject(Wire):
|
|
135
|
+
usage: Usage = field(default_factory=Usage)
|
|
136
|
+
|
|
137
|
+
@dataclass(frozen=True, slots=True)
|
|
138
|
+
class OutputTextDelta(Wire, name="response.output_text.delta"):
|
|
139
|
+
delta: str = ""
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True)
|
|
142
|
+
class Completed(Wire, name="response.completed"):
|
|
143
|
+
response: ResponseObject = field(default_factory=ResponseObject)
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
A field the provider did not send, sent as null, or sent as the wrong type takes its default. That
|
|
147
|
+
is what an optional provider field is. One bad field must not lose the whole event. A nested object
|
|
148
|
+
is another `Wire`. A list of them is `list[ThatWire]`. Declare a field `raw: Payload` and it
|
|
149
|
+
receives the whole payload, for a shape that varies too much to declare whole.
|
|
150
|
+
|
|
151
|
+
### `Reader` — one method per event
|
|
152
|
+
|
|
153
|
+
A stream that says what each event is subclasses `Reader` and writes one `@on(...)` method per
|
|
154
|
+
event. That class body is one endpoint's whole vocabulary. `by` on the class line names the payload
|
|
155
|
+
key that holds the event's name. It defaults to `"type"`.
|
|
156
|
+
|
|
157
|
+
Give `@on` a shape and the method is handed that shape. Give it names and the method is handed the
|
|
158
|
+
`Payload` itself. That is what a method that only forwards an event wants. Declaring a shape for a
|
|
159
|
+
payload nobody reads a field of would be a schema written for nothing.
|
|
160
|
+
|
|
161
|
+
One instance reads one stream. The turn's running totals and id maps live on `self` instead of
|
|
162
|
+
travelling through a call. Construct one per response.
|
|
163
|
+
|
|
164
|
+
<!-- name: test_readme_reader -->
|
|
165
|
+
```python
|
|
166
|
+
import asyncio
|
|
167
|
+
from collections.abc import Iterator
|
|
168
|
+
from axio_sse import Reader, on
|
|
169
|
+
|
|
170
|
+
class Responses(Reader[str]):
|
|
171
|
+
"""What the Responses API sends, and what each event becomes."""
|
|
172
|
+
|
|
173
|
+
def __init__(self) -> None:
|
|
174
|
+
self.output_tokens = 0
|
|
175
|
+
|
|
176
|
+
@on(OutputTextDelta)
|
|
177
|
+
def _text(self, wire: OutputTextDelta) -> Iterator[str]:
|
|
178
|
+
yield wire.delta
|
|
179
|
+
|
|
180
|
+
@on(Completed)
|
|
181
|
+
def _completed(self, wire: Completed) -> None:
|
|
182
|
+
self.output_tokens = wire.response.usage.output_tokens
|
|
183
|
+
|
|
184
|
+
@on("response.created", "response.in_progress", "response.output_text.done")
|
|
185
|
+
def _expected(self, payload: Payload) -> None:
|
|
186
|
+
"""The bookkeeping around the deltas. Named so strict fires only on something new."""
|
|
187
|
+
|
|
188
|
+
async def chunks():
|
|
189
|
+
yield b'data: {"type":"response.created"}\n\n'
|
|
190
|
+
yield b'data: {"type":"response.output_text.delta","delta":"Hi"}\n\n'
|
|
191
|
+
yield b'data: {"type":"response.completed","response":{"usage":{"output_tokens":7}}}\n\n'
|
|
192
|
+
|
|
193
|
+
async def main() -> None:
|
|
194
|
+
turn = Responses()
|
|
195
|
+
assert [made async for made in turn.over(chunks())] == ["Hi"]
|
|
196
|
+
assert turn.output_tokens == 7
|
|
197
|
+
|
|
198
|
+
asyncio.run(main())
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
A handler returns what the event became — an iterable, or `None` where the event only moved that
|
|
202
|
+
state. Several names on one method is how a stream that sends one thing under two names is written.
|
|
203
|
+
It is also how a group of events that means nothing here is written: a method with only a docstring.
|
|
204
|
+
Both stay in the class body, so no second list exists to keep in step with the first.
|
|
205
|
+
|
|
206
|
+
### `strict` — failing on the day the provider sends something new
|
|
207
|
+
|
|
208
|
+
An event no method claims is skipped and logged at DEBUG. Read with `strict=True` and it raises
|
|
209
|
+
instead. That is what a test holds against the provider's own published list.
|
|
210
|
+
|
|
211
|
+
<!-- name: test_readme_reader -->
|
|
212
|
+
```python
|
|
213
|
+
import pytest
|
|
214
|
+
from axio_sse import Event, UnknownEvent
|
|
215
|
+
|
|
216
|
+
assert Responses.names() == {
|
|
217
|
+
"response.output_text.delta",
|
|
218
|
+
"response.completed",
|
|
219
|
+
"response.created",
|
|
220
|
+
"response.in_progress",
|
|
221
|
+
"response.output_text.done",
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
with pytest.raises(UnknownEvent, match="response.refusal.delta"):
|
|
225
|
+
Responses().read(Event(data='{"type":"response.refusal.delta"}'), strict=True)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`strict` belongs to the call, not to the reader. A policy that outlived one call would leave a CI
|
|
229
|
+
test's strictness set for the next caller.
|
|
230
|
+
|
|
231
|
+
### `EVENT_NAME` — dispatching on the format's own field
|
|
232
|
+
|
|
233
|
+
Some streams name the event in the SSE `event:` field rather than in the payload.
|
|
234
|
+
|
|
235
|
+
<!-- name: test_readme_event_name -->
|
|
236
|
+
```python
|
|
237
|
+
import asyncio
|
|
238
|
+
from collections.abc import Iterator
|
|
239
|
+
from dataclasses import dataclass, field
|
|
240
|
+
from axio_sse import EVENT_NAME, Reader, Wire, on
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True, slots=True)
|
|
243
|
+
class BlockDelta(Wire):
|
|
244
|
+
text: str = ""
|
|
245
|
+
|
|
246
|
+
@dataclass(frozen=True, slots=True)
|
|
247
|
+
class ContentBlockDelta(Wire, name="content_block_delta"):
|
|
248
|
+
delta: BlockDelta = field(default_factory=BlockDelta)
|
|
249
|
+
|
|
250
|
+
class Messages(Reader[str], by=EVENT_NAME):
|
|
251
|
+
@on(ContentBlockDelta)
|
|
252
|
+
def _delta(self, wire: ContentBlockDelta) -> Iterator[str]:
|
|
253
|
+
yield wire.delta.text
|
|
254
|
+
|
|
255
|
+
async def chunks():
|
|
256
|
+
yield b'event: content_block_delta\ndata: {"delta":{"text":"Hi"}}\n\n'
|
|
257
|
+
yield b"event: ping\ndata: {}\n\n"
|
|
258
|
+
|
|
259
|
+
async def main() -> None:
|
|
260
|
+
assert [made async for made in Messages().over(chunks())] == ["Hi"]
|
|
261
|
+
|
|
262
|
+
asyncio.run(main())
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### `Payload` — reading by path
|
|
266
|
+
|
|
267
|
+
`Payload` is a `dict`, so `payload["x"]`, `in` and `json.dumps` all still work. The four readers
|
|
268
|
+
exist so a handler carries no `Any` and no chain of `.get({})`. Each walks the path and gives the
|
|
269
|
+
default wherever a step is missing, null, or the wrong type. That is what an optional provider field
|
|
270
|
+
is.
|
|
271
|
+
|
|
272
|
+
<!-- name: test_readme_payload -->
|
|
273
|
+
```python
|
|
274
|
+
from axio_sse import Payload
|
|
275
|
+
|
|
276
|
+
payload = Payload({"message": {"usage": {"input_tokens": 7}}, "output": [{"type": "function_call"}]})
|
|
277
|
+
|
|
278
|
+
assert payload.number("message", "usage", "input_tokens") == 7
|
|
279
|
+
assert payload.number("message", "usage", "output_tokens") == 0
|
|
280
|
+
assert payload.number("message", "usage", "output_tokens", default=3) == 3
|
|
281
|
+
assert payload.string("message", "role") == ""
|
|
282
|
+
assert payload.obj("message", "usage") == {"input_tokens": 7}
|
|
283
|
+
assert payload.objs("output") == [{"type": "function_call"}]
|
|
284
|
+
assert payload.objs("nothing") == []
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`number()` never reads a `true` as `1`. `bool` is an `int` in Python, so a flag would otherwise read
|
|
288
|
+
as a count and stay unnoticed:
|
|
289
|
+
|
|
290
|
+
<!-- name: test_readme_payload -->
|
|
291
|
+
```python
|
|
292
|
+
assert Payload({"flag": True}).number("flag") == 0
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
## License
|
|
296
|
+
|
|
297
|
+
MIT
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
axio_sse/__init__.py,sha256=x_xZlv9NuQY2eD5RjXHHXgA1jxSrz53PoCnaAZevLMg,1866
|
|
2
|
+
axio_sse/decoder.py,sha256=KJ7S84fcAgLadADjGXoy7i_ZrQhsUC41PTKeOir1GGs,11619
|
|
3
|
+
axio_sse/event.py,sha256=ISOKLSuj25Kr1draFWZWWLQXee1GdTbXpg6CPgyjrHM,4205
|
|
4
|
+
axio_sse/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
axio_sse/reader.py,sha256=_mkFhQrYktuetIwsjHFe0JOOQge6i8AgVbrHrqWr6TE,9502
|
|
6
|
+
axio_sse/stream.py,sha256=pAyjAZpIzkWVg1yxiW259OfzE6Q-jy9jWgqpaMTRJyw,1419
|
|
7
|
+
axio_sse/wire.py,sha256=pLsWyEikavGuokQZCmMRVyM5xX3cgi-8OHkQOs62VVY,5818
|
|
8
|
+
axio_sse-0.11.0.dist-info/METADATA,sha256=ZXolPzWiaUVOnYjgeefQjh1XvNfS5O6nPEYHcXitYjk,11037
|
|
9
|
+
axio_sse-0.11.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
axio_sse-0.11.0.dist-info/RECORD,,
|