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 +90 -0
- ballotdata/_base.py +187 -0
- ballotdata/_client.py +315 -0
- ballotdata/_sse.py +95 -0
- ballotdata/_transport.py +170 -0
- ballotdata/_version.py +1 -0
- ballotdata/errors.py +207 -0
- ballotdata/helpers.py +88 -0
- ballotdata/pagination.py +152 -0
- ballotdata/postbacks.py +141 -0
- ballotdata/py.typed +0 -0
- ballotdata/resources/__init__.py +1 -0
- ballotdata/resources/_resource.py +45 -0
- ballotdata/resources/contests.py +153 -0
- ballotdata/resources/elections.py +94 -0
- ballotdata/resources/endorsements.py +158 -0
- ballotdata/resources/endorsers.py +109 -0
- ballotdata/resources/officeholders.py +127 -0
- ballotdata/resources/reference.py +136 -0
- ballotdata/resources/subscriptions.py +194 -0
- ballotdata/stream.py +853 -0
- ballotdata/types.py +750 -0
- ballotdata-1.0.0.dist-info/METADATA +455 -0
- ballotdata-1.0.0.dist-info/RECORD +26 -0
- ballotdata-1.0.0.dist-info/WHEEL +4 -0
- ballotdata-1.0.0.dist-info/licenses/LICENSE +21 -0
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
|