ballotdata 1.0.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.
ballotdata/__init__.py ADDED
@@ -0,0 +1,90 @@
1
+ """The official Python client for the BallotData API: elections, contests,
2
+ endorsers and endorsements for every U.S. ballot, plus a live stream of
3
+ endorsement changes.
4
+
5
+ ::
6
+
7
+ from ballotdata import BallotData
8
+
9
+ client = BallotData() # reads BALLOTDATA_API_KEY
10
+ for endorsement in client.endorsements.list(states="CA", categories="labor"):
11
+ print(endorsement["endorser"]["name"], endorsement["choice"]["label"])
12
+
13
+ Docs: https://github.com/EndorsedByOrg/pylib#readme and https://api.ballotdata.net/docs
14
+ """
15
+
16
+ from ._base import RateLimit, ResponseInfo
17
+ from ._client import AsyncBallotData, BallotData
18
+ from ._version import __version__
19
+ from .errors import (
20
+ APIConnectionError,
21
+ APIError,
22
+ APITimeoutError,
23
+ AuthenticationError,
24
+ BadRequestError,
25
+ BallotDataError,
26
+ ConflictError,
27
+ CursorExpiredError,
28
+ InternalServerError,
29
+ NotFoundError,
30
+ PayloadTooLargeError,
31
+ PermissionDeniedError,
32
+ RateLimitError,
33
+ )
34
+ from .helpers import ContestEndorsements, EndorsementPick, EndorsementPosition, group_by_contest, sort_picks
35
+ from .pagination import AsyncPage, AsyncPaginator, SyncPage
36
+ from .postbacks import (
37
+ SIGNATURE_HEADER,
38
+ PostbackSignatureError,
39
+ construct_postback,
40
+ sign_postback,
41
+ verify_postback_signature,
42
+ )
43
+ from .stream import (
44
+ AsyncEndorsementStream,
45
+ EndorsementMessage,
46
+ EndorsementStream,
47
+ ReadyMessage,
48
+ ReconnectInfo,
49
+ StreamMessage,
50
+ )
51
+
52
+ __all__ = [
53
+ "__version__",
54
+ "BallotData",
55
+ "AsyncBallotData",
56
+ "ResponseInfo",
57
+ "RateLimit",
58
+ "SyncPage",
59
+ "AsyncPage",
60
+ "AsyncPaginator",
61
+ "EndorsementStream",
62
+ "AsyncEndorsementStream",
63
+ "StreamMessage",
64
+ "ReadyMessage",
65
+ "EndorsementMessage",
66
+ "ReconnectInfo",
67
+ "BallotDataError",
68
+ "APIError",
69
+ "BadRequestError",
70
+ "AuthenticationError",
71
+ "PermissionDeniedError",
72
+ "NotFoundError",
73
+ "ConflictError",
74
+ "CursorExpiredError",
75
+ "PayloadTooLargeError",
76
+ "RateLimitError",
77
+ "InternalServerError",
78
+ "APIConnectionError",
79
+ "APITimeoutError",
80
+ "SIGNATURE_HEADER",
81
+ "PostbackSignatureError",
82
+ "verify_postback_signature",
83
+ "construct_postback",
84
+ "sign_postback",
85
+ "group_by_contest",
86
+ "sort_picks",
87
+ "ContestEndorsements",
88
+ "EndorsementPosition",
89
+ "EndorsementPick",
90
+ ]
ballotdata/_base.py ADDED
@@ -0,0 +1,187 @@
1
+ """What the sync and async transports share: settings, query encoding, headers,
2
+ response facts and the retry rules."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import datetime as dt
7
+ import os
8
+ import random
9
+ from collections.abc import Callable, Mapping
10
+ from dataclasses import dataclass
11
+ from typing import Any
12
+ from urllib.parse import quote
13
+
14
+ import httpx
15
+
16
+ from ._version import __version__
17
+
18
+ DEFAULT_BASE_URL = "https://api.ballotdata.net"
19
+ DEFAULT_TIMEOUT = 30.0
20
+ DEFAULT_MAX_RETRIES = 2
21
+ MAX_RETRY_AFTER = 60.0
22
+ """Don't sleep longer than this on a Retry-After; a daily limit can ask for hours."""
23
+
24
+ USER_AGENT = f"ballotdata-python/{__version__}"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class RateLimit:
29
+ """Rate-limit headers from a response. Values are None when the API didn't send them."""
30
+
31
+ limit: int | None
32
+ """Requests allowed per minute on your plan."""
33
+ remaining: int | None
34
+ """Requests left this minute."""
35
+ reset: int | None
36
+ """Seconds until the minute resets."""
37
+ daily_limit: int | None
38
+ """Requests allowed per UTC day, on plans with a daily cap."""
39
+ daily_remaining: int | None
40
+ """Requests left today."""
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class ResponseInfo:
45
+ """Facts about one HTTP response, passed to ``on_response`` and kept on ``client.last_response``."""
46
+
47
+ method: str
48
+ url: str
49
+ status: int
50
+ request_id: str | None
51
+ """The X-Request-Id header. Quote it when you write to BallotData."""
52
+ rate_limit: RateLimit
53
+
54
+
55
+ def _int_header(headers: httpx.Headers, name: str) -> int | None:
56
+ value = headers.get(name)
57
+ if value is None or value == "":
58
+ return None
59
+ try:
60
+ return int(float(value))
61
+ except ValueError:
62
+ return None
63
+
64
+
65
+ def response_info(response: httpx.Response) -> ResponseInfo:
66
+ h = response.headers
67
+ return ResponseInfo(
68
+ method=response.request.method,
69
+ url=str(response.request.url),
70
+ status=response.status_code,
71
+ request_id=h.get("x-request-id"),
72
+ rate_limit=RateLimit(
73
+ limit=_int_header(h, "ratelimit-limit"),
74
+ remaining=_int_header(h, "ratelimit-remaining"),
75
+ reset=_int_header(h, "ratelimit-reset"),
76
+ daily_limit=_int_header(h, "x-daily-limit"),
77
+ daily_remaining=_int_header(h, "x-daily-remaining"),
78
+ ),
79
+ )
80
+
81
+
82
+ def encode_value(value: Any) -> str | None:
83
+ """One query value as the API expects it, or None to leave it out."""
84
+ if value is None or value == "":
85
+ return None
86
+ if isinstance(value, bool):
87
+ return "true" if value else "false"
88
+ if isinstance(value, dt.datetime):
89
+ if value.tzinfo is None:
90
+ value = value.replace(tzinfo=dt.timezone.utc)
91
+ return str(value.astimezone(dt.timezone.utc).isoformat()).replace("+00:00", "Z")
92
+ if isinstance(value, dt.date):
93
+ return value.isoformat()
94
+ if isinstance(value, (list, tuple, set, frozenset)):
95
+ items = [s for s in (encode_value(v) for v in value) if s is not None]
96
+ return ",".join(items) if items else None
97
+ return str(value)
98
+
99
+
100
+ def encode_query(query: Mapping[str, Any] | None) -> dict[str, str]:
101
+ """Query values as the API expects them: lists comma-separated, booleans true/false, times ISO 8601."""
102
+ out: dict[str, str] = {}
103
+ for name, value in (query or {}).items():
104
+ encoded = encode_value(value)
105
+ if encoded is not None:
106
+ out[name] = encoded
107
+ return out
108
+
109
+
110
+ def params(values: Mapping[str, Any], *, rename: Mapping[str, str] | None = None) -> dict[str, Any]:
111
+ """Keyword arguments to query parameters, dropping the ones not given."""
112
+ rename = rename or {}
113
+ return {rename.get(k, k): v for k, v in values.items() if v is not None}
114
+
115
+
116
+ def backoff(attempt: int) -> float:
117
+ """Exponential backoff with jitter: about 0.5s, 1s, 2s … up to 8s."""
118
+ return float(min(8.0, 0.5 * 2**attempt) * (0.75 + random.random() * 0.5))
119
+
120
+
121
+ def retryable_status(status: int, method: str, retry_after: float | None) -> bool:
122
+ """Whether a response with this status is worth retrying."""
123
+ # 429 and 503 mean the request wasn't processed, so any method can retry.
124
+ if status in (429, 503):
125
+ return retry_after is None or retry_after <= MAX_RETRY_AFTER
126
+ return method != "POST" and status in (500, 502, 504)
127
+
128
+
129
+ class BaseSettings:
130
+ """Client settings, read from arguments and the environment."""
131
+
132
+ api_key: str
133
+ base_url: str
134
+ max_retries: int
135
+ timeout: float
136
+ default_headers: dict[str, str]
137
+ on_response: Callable[[ResponseInfo], None] | None
138
+ last_response: ResponseInfo | None
139
+
140
+ def __init__(
141
+ self,
142
+ *,
143
+ api_key: str | None,
144
+ base_url: str | None,
145
+ max_retries: int | None,
146
+ timeout: float | None,
147
+ default_headers: Mapping[str, str] | None,
148
+ on_response: Callable[[ResponseInfo], None] | None,
149
+ ) -> None:
150
+ key = api_key or os.environ.get("BALLOTDATA_API_KEY")
151
+ if not key:
152
+ raise ValueError(
153
+ "No BallotData API key. Pass api_key=... or set BALLOTDATA_API_KEY. "
154
+ "Keys come with a purchase at https://ballotdata.net/pricing."
155
+ )
156
+ self.api_key = key
157
+ self.base_url = (base_url or os.environ.get("BALLOTDATA_API_URL") or DEFAULT_BASE_URL).rstrip("/")
158
+ self.max_retries = DEFAULT_MAX_RETRIES if max_retries is None else max_retries
159
+ self.timeout = DEFAULT_TIMEOUT if timeout is None else timeout
160
+ self.default_headers = dict(default_headers or {})
161
+ self.on_response = on_response
162
+ self.last_response = None
163
+
164
+ def headers(self, extra: Mapping[str, str] | None = None, *, accept: str = "application/json") -> dict[str, str]:
165
+ """Headers for a request."""
166
+ return {
167
+ "Authorization": f"Bearer {self.api_key}",
168
+ "Accept": accept,
169
+ "User-Agent": USER_AGENT,
170
+ **self.default_headers,
171
+ **(extra or {}),
172
+ }
173
+
174
+ def url(self, path: str) -> str:
175
+ return f"{self.base_url}{path}"
176
+
177
+ def observe(self, response: httpx.Response) -> None:
178
+ """Record a response for ``last_response`` and ``on_response``."""
179
+ info = response_info(response)
180
+ self.last_response = info
181
+ if self.on_response is not None:
182
+ self.on_response(info)
183
+
184
+
185
+ def path_segment(value: str | int) -> str:
186
+ """A path segment, escaped."""
187
+ return quote(str(value), safe="")
ballotdata/_client.py ADDED
@@ -0,0 +1,315 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable, Mapping
4
+ from typing import Any, Literal
5
+
6
+ import httpx
7
+
8
+ from ._base import ResponseInfo
9
+ from ._transport import AsyncTransport, RequestOptions, SyncTransport
10
+ from .pagination import AsyncPaginator, SyncPage
11
+ from .resources.contests import AsyncContests, Contests
12
+ from .resources.elections import AsyncElections, Elections
13
+ from .resources.endorsements import AsyncEndorsements, Endorsements
14
+ from .resources.endorsers import AsyncEndorsers, Endorsers
15
+ from .resources.officeholders import AsyncOfficeholders, Officeholders
16
+ from .resources.reference import (
17
+ AsyncCoverageResource,
18
+ AsyncEndorserCategories,
19
+ AsyncStates,
20
+ CoverageResource,
21
+ EndorserCategories,
22
+ States,
23
+ )
24
+ from .resources.subscriptions import AsyncSubscriptions, Subscriptions
25
+ from .stream import AsyncEvents, AsyncStream, Events, Stream
26
+
27
+ HTTPMethod = Literal["GET", "POST", "PUT", "DELETE"]
28
+
29
+
30
+ class BallotData:
31
+ """A client for the BallotData API.
32
+
33
+ ::
34
+
35
+ from ballotdata import BallotData
36
+
37
+ client = BallotData() # reads BALLOTDATA_API_KEY
38
+ contest = client.contests.get(4410, categories="labor")
39
+
40
+ Every method also takes ``timeout``, ``max_retries`` and ``extra_headers``
41
+ keyword arguments to override the client's settings for that call. Use the
42
+ client as a context manager, or call :meth:`close`, to let go of its
43
+ connections.
44
+
45
+ :param api_key: Your API key (``ebk_…``). Defaults to the ``BALLOTDATA_API_KEY``
46
+ environment variable. Keys come with a purchase at https://ballotdata.net/pricing
47
+ and are created in the console at https://api.ballotdata.net/console/keys.
48
+ Keep keys on servers, never in a browser or mobile app.
49
+ :param base_url: API origin. Defaults to ``BALLOTDATA_API_URL``, then ``https://api.ballotdata.net``.
50
+ :param max_retries: How many times to retry a request that failed in a way worth
51
+ retrying: 429 and 503 (after Retry-After), connection errors and timeouts,
52
+ and 500, 502 and 504 on GET, PUT and DELETE. Default 2. Set 0 to turn retries off.
53
+ :param timeout: Seconds to wait for each response. Default 30.
54
+ :param http_client: An ``httpx.Client`` to send requests with (for proxies, custom
55
+ TLS, or tests). You close it yourself.
56
+ :param default_headers: Extra headers to send on every request.
57
+ :param on_response: Called after every HTTP response (including ones that are
58
+ retried) with a :class:`ResponseInfo`. Handy for logging and watching rate limits.
59
+ :raises ValueError: when there is no API key.
60
+ """
61
+
62
+ states: States
63
+ """``GET /v1/states``."""
64
+ coverage: CoverageResource
65
+ """``GET /v1/coverage``."""
66
+ elections: Elections
67
+ """``GET /v1/elections``."""
68
+ contests: Contests
69
+ """``GET /v1/contests``, ``GET /v1/contests/{id}``."""
70
+ endorser_categories: EndorserCategories
71
+ """``GET /v1/endorser-categories``."""
72
+ endorsers: Endorsers
73
+ """``GET /v1/endorsers``, ``GET /v1/endorsers/{slug}``."""
74
+ endorsements: Endorsements
75
+ """``GET /v1/endorsements``."""
76
+ officeholders: Officeholders
77
+ """``GET /v1/officeholders``, ``GET /v1/officeholders/{id}``."""
78
+ stream: Stream
79
+ """``GET /v1/stream`` (Server-Sent Events)."""
80
+ events: Events
81
+ """``GET /v1/events`` (polling)."""
82
+ subscriptions: Subscriptions
83
+ """``/v1/subscriptions``."""
84
+
85
+ def __init__(
86
+ self,
87
+ *,
88
+ api_key: str | None = None,
89
+ base_url: str | None = None,
90
+ max_retries: int | None = None,
91
+ timeout: float | None = None,
92
+ http_client: httpx.Client | None = None,
93
+ default_headers: Mapping[str, str] | None = None,
94
+ on_response: Callable[[ResponseInfo], None] | None = None,
95
+ ) -> None:
96
+ t = self._transport = SyncTransport(
97
+ http_client=http_client,
98
+ api_key=api_key,
99
+ base_url=base_url,
100
+ max_retries=max_retries,
101
+ timeout=timeout,
102
+ default_headers=default_headers,
103
+ on_response=on_response,
104
+ )
105
+ self.states = States(t)
106
+ self.coverage = CoverageResource(t)
107
+ self.elections = Elections(t)
108
+ self.contests = Contests(t)
109
+ self.endorser_categories = EndorserCategories(t)
110
+ self.endorsers = Endorsers(t)
111
+ self.endorsements = Endorsements(t)
112
+ self.officeholders = Officeholders(t)
113
+ self.stream = Stream(t)
114
+ self.events = Events(t)
115
+ self.subscriptions = Subscriptions(t)
116
+
117
+ @property
118
+ def base_url(self) -> str:
119
+ """The API origin requests go to."""
120
+ return self._transport.base_url
121
+
122
+ @property
123
+ def last_response(self) -> ResponseInfo | None:
124
+ """Status, request id and rate-limit headers of the most recent response, or None before the first."""
125
+ return self._transport.last_response
126
+
127
+ def request(
128
+ self,
129
+ method: HTTPMethod,
130
+ path: str,
131
+ *,
132
+ query: Mapping[str, Any] | None = None,
133
+ body: Any = None,
134
+ timeout: float | None = None,
135
+ max_retries: int | None = None,
136
+ extra_headers: Mapping[str, str] | None = None,
137
+ ) -> Any:
138
+ """Call any endpoint directly, with the client's key, retries and errors.
139
+ For endpoints added to the API after this version of the library.
140
+ Returns the parsed JSON body (None for 204).
141
+
142
+ Example::
143
+
144
+ body = client.request("GET", "/v1/states")
145
+ """
146
+ return self._transport.request(method, path, query=query, body=body, options=RequestOptions(timeout, max_retries, extra_headers))
147
+
148
+ def paginate(
149
+ self,
150
+ path: str,
151
+ query: Mapping[str, Any] | None = None,
152
+ *,
153
+ timeout: float | None = None,
154
+ max_retries: int | None = None,
155
+ extra_headers: Mapping[str, str] | None = None,
156
+ ) -> SyncPage[Any]:
157
+ """Walk any paged list endpoint (``{"data": [...], "next_cursor": ...}``).
158
+
159
+ Example::
160
+
161
+ for item in client.paginate("/v1/endorsements", {"states": "CA"}):
162
+ ...
163
+ """
164
+ opts = RequestOptions(timeout, max_retries, extra_headers)
165
+ query = dict(query or {})
166
+
167
+ def fetch(cursor: str | None) -> Any:
168
+ return self._transport.request("GET", path, query={**query, "cursor": cursor}, options=opts)
169
+
170
+ return SyncPage(fetch(query.get("cursor")), fetch)
171
+
172
+ def close(self) -> None:
173
+ """Close the client's connections (not an ``http_client`` you passed in)."""
174
+ self._transport.close()
175
+
176
+ def __enter__(self) -> BallotData:
177
+ return self
178
+
179
+ def __exit__(self, *exc: object) -> None:
180
+ self.close()
181
+
182
+ def __repr__(self) -> str:
183
+ return f"BallotData(base_url={self.base_url!r})"
184
+
185
+
186
+ class AsyncBallotData:
187
+ """A client for the BallotData API, for asyncio.
188
+
189
+ ::
190
+
191
+ from ballotdata import AsyncBallotData
192
+
193
+ async with AsyncBallotData() as client: # reads BALLOTDATA_API_KEY
194
+ contest = await client.contests.get(4410, categories="labor")
195
+ async for endorsement in client.endorsements.list(states="CA"):
196
+ ...
197
+
198
+ Takes the same arguments as :class:`BallotData`, with an ``httpx.AsyncClient``
199
+ as ``http_client``.
200
+ """
201
+
202
+ states: AsyncStates
203
+ """``GET /v1/states``."""
204
+ coverage: AsyncCoverageResource
205
+ """``GET /v1/coverage``."""
206
+ elections: AsyncElections
207
+ """``GET /v1/elections``."""
208
+ contests: AsyncContests
209
+ """``GET /v1/contests``, ``GET /v1/contests/{id}``."""
210
+ endorser_categories: AsyncEndorserCategories
211
+ """``GET /v1/endorser-categories``."""
212
+ endorsers: AsyncEndorsers
213
+ """``GET /v1/endorsers``, ``GET /v1/endorsers/{slug}``."""
214
+ endorsements: AsyncEndorsements
215
+ """``GET /v1/endorsements``."""
216
+ officeholders: AsyncOfficeholders
217
+ """``GET /v1/officeholders``, ``GET /v1/officeholders/{id}``."""
218
+ stream: AsyncStream
219
+ """``GET /v1/stream`` (Server-Sent Events)."""
220
+ events: AsyncEvents
221
+ """``GET /v1/events`` (polling)."""
222
+ subscriptions: AsyncSubscriptions
223
+ """``/v1/subscriptions``."""
224
+
225
+ def __init__(
226
+ self,
227
+ *,
228
+ api_key: str | None = None,
229
+ base_url: str | None = None,
230
+ max_retries: int | None = None,
231
+ timeout: float | None = None,
232
+ http_client: httpx.AsyncClient | None = None,
233
+ default_headers: Mapping[str, str] | None = None,
234
+ on_response: Callable[[ResponseInfo], None] | None = None,
235
+ ) -> None:
236
+ t = self._transport = AsyncTransport(
237
+ http_client=http_client,
238
+ api_key=api_key,
239
+ base_url=base_url,
240
+ max_retries=max_retries,
241
+ timeout=timeout,
242
+ default_headers=default_headers,
243
+ on_response=on_response,
244
+ )
245
+ self.states = AsyncStates(t)
246
+ self.coverage = AsyncCoverageResource(t)
247
+ self.elections = AsyncElections(t)
248
+ self.contests = AsyncContests(t)
249
+ self.endorser_categories = AsyncEndorserCategories(t)
250
+ self.endorsers = AsyncEndorsers(t)
251
+ self.endorsements = AsyncEndorsements(t)
252
+ self.officeholders = AsyncOfficeholders(t)
253
+ self.stream = AsyncStream(t)
254
+ self.events = AsyncEvents(t)
255
+ self.subscriptions = AsyncSubscriptions(t)
256
+
257
+ @property
258
+ def base_url(self) -> str:
259
+ """The API origin requests go to."""
260
+ return self._transport.base_url
261
+
262
+ @property
263
+ def last_response(self) -> ResponseInfo | None:
264
+ """Status, request id and rate-limit headers of the most recent response, or None before the first."""
265
+ return self._transport.last_response
266
+
267
+ async def request(
268
+ self,
269
+ method: HTTPMethod,
270
+ path: str,
271
+ *,
272
+ query: Mapping[str, Any] | None = None,
273
+ body: Any = None,
274
+ timeout: float | None = None,
275
+ max_retries: int | None = None,
276
+ extra_headers: Mapping[str, str] | None = None,
277
+ ) -> Any:
278
+ """Call any endpoint directly. See :meth:`BallotData.request`."""
279
+ return await self._transport.request(
280
+ method, path, query=query, body=body, options=RequestOptions(timeout, max_retries, extra_headers)
281
+ )
282
+
283
+ def paginate(
284
+ self,
285
+ path: str,
286
+ query: Mapping[str, Any] | None = None,
287
+ *,
288
+ timeout: float | None = None,
289
+ max_retries: int | None = None,
290
+ extra_headers: Mapping[str, str] | None = None,
291
+ ) -> AsyncPaginator[Any]:
292
+ """Walk any paged list endpoint. Await for the first page, or ``async for`` every item."""
293
+ opts = RequestOptions(timeout, max_retries, extra_headers)
294
+ query = dict(query or {})
295
+
296
+ async def fetch(cursor: str | None) -> Any:
297
+ return await self._transport.request("GET", path, query={**query, "cursor": cursor}, options=opts)
298
+
299
+ return AsyncPaginator(fetch, query.get("cursor"))
300
+
301
+ async def close(self) -> None:
302
+ """Close the client's connections (not an ``http_client`` you passed in)."""
303
+ await self._transport.close()
304
+
305
+ async def __aenter__(self) -> AsyncBallotData:
306
+ return self
307
+
308
+ async def __aexit__(self, *exc: object) -> None:
309
+ await self.close()
310
+
311
+ def __repr__(self) -> str:
312
+ return f"AsyncBallotData(base_url={self.base_url!r})"
313
+
314
+
315
+ __all__ = ["BallotData", "AsyncBallotData"]
ballotdata/_sse.py ADDED
@@ -0,0 +1,95 @@
1
+ """A Server-Sent Events parser, following the WHATWG rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import codecs
6
+ import re
7
+ from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator
8
+ from dataclasses import dataclass
9
+
10
+ _LINE_END = re.compile(r"\r\n|\n|\r")
11
+ _DIGITS = re.compile(r"^\d+$")
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SSEMessage:
16
+ """One parsed SSE message."""
17
+
18
+ event: str
19
+ """The ``event:`` field; ``message`` when there was none. Empty for a block that only set ``id`` or ``retry``."""
20
+ data: str
21
+ """The ``data:`` lines joined with newlines."""
22
+ id: str | None = None
23
+ """The ``id:`` field, when the block had one."""
24
+ retry: int | None = None
25
+ """The ``retry:`` field in milliseconds, when the block had one."""
26
+
27
+
28
+ class SSEDecoder:
29
+ """Feed it bytes; it hands back complete messages. Comment lines (the API's
30
+ ``: ping`` heartbeat) are skipped. A block left unfinished when the stream
31
+ ends is dropped, as the SSE rules require."""
32
+
33
+ def __init__(self) -> None:
34
+ self._decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
35
+ self._buffer = ""
36
+ self._event = ""
37
+ self._data: list[str] = []
38
+ self._id: str | None = None
39
+ self._retry: int | None = None
40
+
41
+ def feed(self, chunk: bytes) -> list[SSEMessage]:
42
+ self._buffer += self._decoder.decode(chunk)
43
+ out: list[SSEMessage] = []
44
+ while True:
45
+ match = _LINE_END.search(self._buffer)
46
+ if match is None:
47
+ break
48
+ # A lone \r at the end of what we have may be the first half of \r\n.
49
+ if match.group() == "\r" and match.start() == len(self._buffer) - 1:
50
+ break
51
+ text = self._buffer[: match.start()]
52
+ self._buffer = self._buffer[match.end() :]
53
+ message = self._line(text)
54
+ if message is not None:
55
+ out.append(message)
56
+ return out
57
+
58
+ def _line(self, text: str) -> SSEMessage | None:
59
+ if text == "":
60
+ message = None
61
+ if self._data or self._event:
62
+ message = SSEMessage(self._event or "message", "\n".join(self._data), self._id, self._retry)
63
+ elif self._id is not None or self._retry is not None:
64
+ message = SSEMessage("", "", self._id, self._retry)
65
+ self._event, self._data, self._id, self._retry = "", [], None, None
66
+ return message
67
+ if text.startswith(":"):
68
+ return None
69
+ field, colon, value = text.partition(":")
70
+ if colon and value.startswith(" "):
71
+ value = value[1:]
72
+ if field == "event":
73
+ self._event = value
74
+ elif field == "data":
75
+ self._data.append(value)
76
+ elif field == "id" and "\0" not in value:
77
+ self._id = value
78
+ elif field == "retry" and _DIGITS.match(value):
79
+ self._retry = int(value)
80
+ return None
81
+
82
+
83
+ def iter_sse(chunks: Iterable[bytes]) -> Iterator[SSEMessage]:
84
+ """Parse an SSE byte stream into messages."""
85
+ decoder = SSEDecoder()
86
+ for chunk in chunks:
87
+ yield from decoder.feed(chunk)
88
+
89
+
90
+ async def aiter_sse(chunks: AsyncIterable[bytes]) -> AsyncIterator[SSEMessage]:
91
+ """Parse an async SSE byte stream into messages."""
92
+ decoder = SSEDecoder()
93
+ async for chunk in chunks:
94
+ for message in decoder.feed(chunk):
95
+ yield message