axio-sse 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,14 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ *.pyo
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .pytest_cache/
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ _build/
12
+ .DS_Store
13
+ *.sqlite
14
+ *.db
@@ -0,0 +1,297 @@
1
+ Metadata-Version: 2.5
2
+ Name: axio-sse
3
+ Version: 0.1.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
+ [![PyPI](https://img.shields.io/pypi/v/axio-sse)](https://pypi.org/project/axio-sse/)
16
+ [![Python](https://img.shields.io/pypi/pyversions/axio-sse)](https://pypi.org/project/axio-sse/)
17
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](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,285 @@
1
+ # axio-sse
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/axio-sse)](https://pypi.org/project/axio-sse/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/axio-sse)](https://pypi.org/project/axio-sse/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
6
+
7
+ Read `text/event-stream`: a decoder you feed, and a reader for what its payloads mean.
8
+
9
+ The package knows nothing about HTTP and imports no client. It has no dependencies, not even on
10
+ [axio](https://github.com/mosquito/axio-agent).
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install axio-sse
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ ### `payloads(chunks, *, until="")` — the JSON object of every event
21
+
22
+ All a stream with no discriminator needs. Comments, keep-alives and junk never arrive. `until` names
23
+ the one data payload that closes the stream, so a sentinel that is not JSON never reaches you.
24
+
25
+ <!-- name: test_readme_payloads -->
26
+ ```python
27
+ import asyncio
28
+ from axio_sse import payloads
29
+
30
+ async def chunks():
31
+ yield b': keep-alive\n\n'
32
+ yield b'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n'
33
+ yield b'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n'
34
+ yield b"data: [DONE]"
35
+
36
+ async def main() -> None:
37
+ got = [p["choices"][0]["delta"]["content"] async for p in payloads(chunks(), until="[DONE]")]
38
+ assert got == ["Hel", "lo"]
39
+
40
+ asyncio.run(main())
41
+ ```
42
+
43
+ Feed it whatever the transport hands you. A chunk may end mid-field, mid-terminator, or mid-UTF-8
44
+ sequence. The result does not depend on where it was cut. Chunks must still carry their line
45
+ terminators, so `aiter_lines()` will not do. It strips them, and nothing ever dispatches.
46
+
47
+ A stream that stops without its final blank line still yields what it collected. The example above
48
+ ends on `data: [DONE]` with no newline after it. That is how these streams really end.
49
+
50
+ ### `events(chunks, *, until="")` — the wire events themselves
51
+
52
+ <!-- name: test_readme_events -->
53
+ ```python
54
+ import asyncio
55
+ from axio_sse import Event, events
56
+
57
+ async def chunks():
58
+ yield b'data: {"first":\ndata: true}\n\n'
59
+ yield b"event: named\r\ndata: sec"
60
+ yield b"ond\r\n\r\n"
61
+
62
+ async def main() -> None:
63
+ assert [e async for e in events(chunks())] == [
64
+ Event(data='{"first":\ntrue}'),
65
+ Event(data="second", event="named"),
66
+ ]
67
+
68
+ asyncio.run(main())
69
+ ```
70
+
71
+ `Event` carries the four fields the format defines — `data`, `event`, `id`, `retry`. An empty
72
+ `event` means unnamed, which the format reads as `"message"`. `Event.payload()` gives the JSON
73
+ object, or `None` where the event carries none.
74
+
75
+ `events()` suspends nowhere of its own accord, so it needs no async framework: asyncio, trio and
76
+ anyio all drive it. A `yield` in an async generator does not reach the event loop. A caller that
77
+ must stay fair to other tasks — a TUI redrawing, a queue being served — therefore says so itself,
78
+ with `await asyncio.sleep(0)` in its own loop where it knows what else is waiting.
79
+
80
+ ### `Decoder` — the format, with no loop
81
+
82
+ `Decoder` is the format and nothing else. It is synchronous and holds no connection. Every wire case
83
+ is therefore testable without a loop. A thread or a non-asyncio caller can drive it too. Same shape
84
+ as `codecs.IncrementalDecoder`, because the problem is the same: input cut at arbitrary points,
85
+ output that only sometimes completes.
86
+
87
+ <!-- name: test_readme_decoder -->
88
+ ```python
89
+ from axio_sse import Decoder, Event
90
+
91
+ decoder = Decoder()
92
+ assert decoder.decode(b"data: hel") == []
93
+ assert decoder.decode(b"lo\n\ndata: wor") == [Event(data="hello")]
94
+ assert decoder.decode(b"ld\n\n", final=True) == [Event(data="world")]
95
+ ```
96
+
97
+ `final=True` closes the stream. What is still pending is discarded, which the format requires: an
98
+ event that never reached its blank line is not dispatched. Dispatched anyway, a connection cut
99
+ between a frame and the blank line after it makes a truncated turn read as a finished one.
100
+
101
+ The package takes chunks and never lines for a reason. `aiohttp`'s `readuntil` raises `LineTooLong`
102
+ past 131072 bytes. `LineTooLong` is not a `ClientError`. One large reasoning event kills a turn with
103
+ no answer.
104
+
105
+ ### `Wire` — a payload shape
106
+
107
+ Declare the fields you read. Each is read by its declared name and type. A misspelled key is
108
+ therefore a type error at the place that uses it, rather than a default quietly standing in for the
109
+ value.
110
+
111
+ <!-- name: test_readme_reader -->
112
+ ```python
113
+ from dataclasses import dataclass, field
114
+ from axio_sse import Payload, Wire
115
+
116
+ @dataclass(frozen=True, slots=True)
117
+ class Usage(Wire):
118
+ """Nested, and never dispatched to: it has no name of its own."""
119
+ output_tokens: int = 0
120
+
121
+ @dataclass(frozen=True, slots=True)
122
+ class ResponseObject(Wire):
123
+ usage: Usage = field(default_factory=Usage)
124
+
125
+ @dataclass(frozen=True, slots=True)
126
+ class OutputTextDelta(Wire, name="response.output_text.delta"):
127
+ delta: str = ""
128
+
129
+ @dataclass(frozen=True, slots=True)
130
+ class Completed(Wire, name="response.completed"):
131
+ response: ResponseObject = field(default_factory=ResponseObject)
132
+ ```
133
+
134
+ A field the provider did not send, sent as null, or sent as the wrong type takes its default. That
135
+ is what an optional provider field is. One bad field must not lose the whole event. A nested object
136
+ is another `Wire`. A list of them is `list[ThatWire]`. Declare a field `raw: Payload` and it
137
+ receives the whole payload, for a shape that varies too much to declare whole.
138
+
139
+ ### `Reader` — one method per event
140
+
141
+ A stream that says what each event is subclasses `Reader` and writes one `@on(...)` method per
142
+ event. That class body is one endpoint's whole vocabulary. `by` on the class line names the payload
143
+ key that holds the event's name. It defaults to `"type"`.
144
+
145
+ Give `@on` a shape and the method is handed that shape. Give it names and the method is handed the
146
+ `Payload` itself. That is what a method that only forwards an event wants. Declaring a shape for a
147
+ payload nobody reads a field of would be a schema written for nothing.
148
+
149
+ One instance reads one stream. The turn's running totals and id maps live on `self` instead of
150
+ travelling through a call. Construct one per response.
151
+
152
+ <!-- name: test_readme_reader -->
153
+ ```python
154
+ import asyncio
155
+ from collections.abc import Iterator
156
+ from axio_sse import Reader, on
157
+
158
+ class Responses(Reader[str]):
159
+ """What the Responses API sends, and what each event becomes."""
160
+
161
+ def __init__(self) -> None:
162
+ self.output_tokens = 0
163
+
164
+ @on(OutputTextDelta)
165
+ def _text(self, wire: OutputTextDelta) -> Iterator[str]:
166
+ yield wire.delta
167
+
168
+ @on(Completed)
169
+ def _completed(self, wire: Completed) -> None:
170
+ self.output_tokens = wire.response.usage.output_tokens
171
+
172
+ @on("response.created", "response.in_progress", "response.output_text.done")
173
+ def _expected(self, payload: Payload) -> None:
174
+ """The bookkeeping around the deltas. Named so strict fires only on something new."""
175
+
176
+ async def chunks():
177
+ yield b'data: {"type":"response.created"}\n\n'
178
+ yield b'data: {"type":"response.output_text.delta","delta":"Hi"}\n\n'
179
+ yield b'data: {"type":"response.completed","response":{"usage":{"output_tokens":7}}}\n\n'
180
+
181
+ async def main() -> None:
182
+ turn = Responses()
183
+ assert [made async for made in turn.over(chunks())] == ["Hi"]
184
+ assert turn.output_tokens == 7
185
+
186
+ asyncio.run(main())
187
+ ```
188
+
189
+ A handler returns what the event became — an iterable, or `None` where the event only moved that
190
+ state. Several names on one method is how a stream that sends one thing under two names is written.
191
+ It is also how a group of events that means nothing here is written: a method with only a docstring.
192
+ Both stay in the class body, so no second list exists to keep in step with the first.
193
+
194
+ ### `strict` — failing on the day the provider sends something new
195
+
196
+ An event no method claims is skipped and logged at DEBUG. Read with `strict=True` and it raises
197
+ instead. That is what a test holds against the provider's own published list.
198
+
199
+ <!-- name: test_readme_reader -->
200
+ ```python
201
+ import pytest
202
+ from axio_sse import Event, UnknownEvent
203
+
204
+ assert Responses.names() == {
205
+ "response.output_text.delta",
206
+ "response.completed",
207
+ "response.created",
208
+ "response.in_progress",
209
+ "response.output_text.done",
210
+ }
211
+
212
+ with pytest.raises(UnknownEvent, match="response.refusal.delta"):
213
+ Responses().read(Event(data='{"type":"response.refusal.delta"}'), strict=True)
214
+ ```
215
+
216
+ `strict` belongs to the call, not to the reader. A policy that outlived one call would leave a CI
217
+ test's strictness set for the next caller.
218
+
219
+ ### `EVENT_NAME` — dispatching on the format's own field
220
+
221
+ Some streams name the event in the SSE `event:` field rather than in the payload.
222
+
223
+ <!-- name: test_readme_event_name -->
224
+ ```python
225
+ import asyncio
226
+ from collections.abc import Iterator
227
+ from dataclasses import dataclass, field
228
+ from axio_sse import EVENT_NAME, Reader, Wire, on
229
+
230
+ @dataclass(frozen=True, slots=True)
231
+ class BlockDelta(Wire):
232
+ text: str = ""
233
+
234
+ @dataclass(frozen=True, slots=True)
235
+ class ContentBlockDelta(Wire, name="content_block_delta"):
236
+ delta: BlockDelta = field(default_factory=BlockDelta)
237
+
238
+ class Messages(Reader[str], by=EVENT_NAME):
239
+ @on(ContentBlockDelta)
240
+ def _delta(self, wire: ContentBlockDelta) -> Iterator[str]:
241
+ yield wire.delta.text
242
+
243
+ async def chunks():
244
+ yield b'event: content_block_delta\ndata: {"delta":{"text":"Hi"}}\n\n'
245
+ yield b"event: ping\ndata: {}\n\n"
246
+
247
+ async def main() -> None:
248
+ assert [made async for made in Messages().over(chunks())] == ["Hi"]
249
+
250
+ asyncio.run(main())
251
+ ```
252
+
253
+ ### `Payload` — reading by path
254
+
255
+ `Payload` is a `dict`, so `payload["x"]`, `in` and `json.dumps` all still work. The four readers
256
+ exist so a handler carries no `Any` and no chain of `.get({})`. Each walks the path and gives the
257
+ default wherever a step is missing, null, or the wrong type. That is what an optional provider field
258
+ is.
259
+
260
+ <!-- name: test_readme_payload -->
261
+ ```python
262
+ from axio_sse import Payload
263
+
264
+ payload = Payload({"message": {"usage": {"input_tokens": 7}}, "output": [{"type": "function_call"}]})
265
+
266
+ assert payload.number("message", "usage", "input_tokens") == 7
267
+ assert payload.number("message", "usage", "output_tokens") == 0
268
+ assert payload.number("message", "usage", "output_tokens", default=3) == 3
269
+ assert payload.string("message", "role") == ""
270
+ assert payload.obj("message", "usage") == {"input_tokens": 7}
271
+ assert payload.objs("output") == [{"type": "function_call"}]
272
+ assert payload.objs("nothing") == []
273
+ ```
274
+
275
+ `number()` never reads a `true` as `1`. `bool` is an `int` in Python, so a flag would otherwise read
276
+ as a count and stay unnoticed:
277
+
278
+ <!-- name: test_readme_payload -->
279
+ ```python
280
+ assert Payload({"flag": True}).number("flag") == 0
281
+ ```
282
+
283
+ ## License
284
+
285
+ MIT
@@ -0,0 +1,49 @@
1
+ [project]
2
+ name = "axio-sse"
3
+ version = "0.1.0"
4
+ description = "Server-sent events, read from a stream of chunks"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = {text = "MIT"}
8
+ keywords = ["sse", "server-sent-events", "event-stream", "streaming", "async"]
9
+ # Nothing. It takes an async iterable of bytes and yields events; whoever produced the bytes is
10
+ # not its business, which is the whole reason it is its own distribution.
11
+ dependencies = []
12
+
13
+ [project.urls]
14
+ Documentation = "https://docs.axio-agent.com"
15
+ Homepage = "https://github.com/mosquito/axio-agent"
16
+ Repository = "https://github.com/mosquito/axio-agent"
17
+
18
+ [build-system]
19
+ requires = ["hatchling"]
20
+ build-backend = "hatchling.build"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/axio_sse"]
24
+
25
+ [tool.pytest.ini_options]
26
+ asyncio_mode = "auto"
27
+ testpaths = ["tests", "README.md"]
28
+
29
+ [tool.ruff]
30
+ line-length = 119
31
+ output-format = "concise"
32
+ target-version = "py312"
33
+
34
+ [tool.ruff.lint]
35
+ select = ["E", "F", "I", "UP"]
36
+
37
+ [tool.mypy]
38
+ strict = true
39
+ python_version = "3.12"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "pytest>=8",
44
+ "pytest-asyncio>=0.24",
45
+ "mypy>=1.14",
46
+ "ruff>=0.9",
47
+ "pytest-cov>=7.1.0",
48
+ "markdown-pytest>=0.6.0",
49
+ ]
@@ -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
+ ]