ballotdata 1.0.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.
- ballotdata-1.0.0/.gitignore +13 -0
- ballotdata-1.0.0/CHANGELOG.md +17 -0
- ballotdata-1.0.0/LICENSE +21 -0
- ballotdata-1.0.0/PKG-INFO +455 -0
- ballotdata-1.0.0/README.md +427 -0
- ballotdata-1.0.0/docs/errors.md +71 -0
- ballotdata-1.0.0/docs/postbacks.md +144 -0
- ballotdata-1.0.0/docs/reference.md +403 -0
- ballotdata-1.0.0/docs/stream.md +182 -0
- ballotdata-1.0.0/pyproject.toml +64 -0
- ballotdata-1.0.0/src/ballotdata/__init__.py +90 -0
- ballotdata-1.0.0/src/ballotdata/_base.py +187 -0
- ballotdata-1.0.0/src/ballotdata/_client.py +315 -0
- ballotdata-1.0.0/src/ballotdata/_sse.py +95 -0
- ballotdata-1.0.0/src/ballotdata/_transport.py +170 -0
- ballotdata-1.0.0/src/ballotdata/_version.py +1 -0
- ballotdata-1.0.0/src/ballotdata/errors.py +207 -0
- ballotdata-1.0.0/src/ballotdata/helpers.py +88 -0
- ballotdata-1.0.0/src/ballotdata/pagination.py +152 -0
- ballotdata-1.0.0/src/ballotdata/postbacks.py +141 -0
- ballotdata-1.0.0/src/ballotdata/py.typed +0 -0
- ballotdata-1.0.0/src/ballotdata/resources/__init__.py +1 -0
- ballotdata-1.0.0/src/ballotdata/resources/_resource.py +45 -0
- ballotdata-1.0.0/src/ballotdata/resources/contests.py +153 -0
- ballotdata-1.0.0/src/ballotdata/resources/elections.py +94 -0
- ballotdata-1.0.0/src/ballotdata/resources/endorsements.py +158 -0
- ballotdata-1.0.0/src/ballotdata/resources/endorsers.py +109 -0
- ballotdata-1.0.0/src/ballotdata/resources/officeholders.py +127 -0
- ballotdata-1.0.0/src/ballotdata/resources/reference.py +136 -0
- ballotdata-1.0.0/src/ballotdata/resources/subscriptions.py +194 -0
- ballotdata-1.0.0/src/ballotdata/stream.py +853 -0
- ballotdata-1.0.0/src/ballotdata/types.py +750 -0
- ballotdata-1.0.0/tests/__init__.py +0 -0
- ballotdata-1.0.0/tests/conftest.py +28 -0
- ballotdata-1.0.0/tests/fake_api.py +409 -0
- ballotdata-1.0.0/tests/openapi.json +1 -0
- ballotdata-1.0.0/tests/test_async.py +139 -0
- ballotdata-1.0.0/tests/test_client.py +249 -0
- ballotdata-1.0.0/tests/test_coverage.py +94 -0
- ballotdata-1.0.0/tests/test_postbacks.py +55 -0
- ballotdata-1.0.0/tests/test_stream.py +166 -0
- ballotdata-1.0.0/tests/typing_usage.py +74 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.0 (unreleased)
|
|
4
|
+
|
|
5
|
+
First release, for BallotData API 1.2.0.
|
|
6
|
+
|
|
7
|
+
- `BallotData` and `AsyncBallotData` clients covering every endpoint of the BallotData API v1:
|
|
8
|
+
states, coverage, elections, contests, endorser categories, endorsers, endorsements,
|
|
9
|
+
officeholders, the stream, events and subscriptions. `endorsements.list()` returns the basics of
|
|
10
|
+
each endorsement; `endorsements.get(id)` returns the full record.
|
|
11
|
+
- Paged lists that return the first page and iterate every item.
|
|
12
|
+
- Retries with `Retry-After`, timeouts, and typed errors.
|
|
13
|
+
- `client.stream.connect()`: the Endorsement Stream over Server-Sent Events, with cursors and
|
|
14
|
+
automatic reconnecting. `close()` is safe from another thread.
|
|
15
|
+
- `client.events.list()` and `client.events.follow()` for polling.
|
|
16
|
+
- `verify_postback_signature()`, `construct_postback()` and `sign_postback()` for signed postbacks.
|
|
17
|
+
- Typed responses (`TypedDict`s) checked with mypy strict. One dependency, `httpx`.
|
ballotdata-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BallotData LLC
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ballotdata
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The official Python client for the BallotData API: elections, contests, endorsers, endorsements and a live stream of endorsement changes for every U.S. ballot.
|
|
5
|
+
Project-URL: Homepage, https://ballotdata.net
|
|
6
|
+
Project-URL: Documentation, https://github.com/EndorsedByOrg/pylib#readme
|
|
7
|
+
Project-URL: API reference, https://api.ballotdata.net/docs
|
|
8
|
+
Project-URL: Source, https://github.com/EndorsedByOrg/pylib
|
|
9
|
+
Project-URL: Changelog, https://github.com/EndorsedByOrg/pylib/blob/main/CHANGELOG.md
|
|
10
|
+
Author-email: BallotData <api@ballotdata.net>
|
|
11
|
+
License-Expression: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: api,ballot,ballotdata,elections,endorsements,sse,voting
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Framework :: AsyncIO
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: httpx<1,>=0.25
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# ballotdata
|
|
30
|
+
|
|
31
|
+
The official Python client for the [BallotData API](https://api.ballotdata.net/docs): U.S. ballots
|
|
32
|
+
(elections, contests and their choices), endorsers and endorsements, officeholders, and the live
|
|
33
|
+
**Endorsement Stream** of changes.
|
|
34
|
+
|
|
35
|
+
- Covers every endpoint of the API, with type hints for every response and parameter.
|
|
36
|
+
- Sync and async (`asyncio`) clients with the same methods.
|
|
37
|
+
- One dependency ([httpx](https://www.python-httpx.org/)). Python 3.10 or newer.
|
|
38
|
+
- Handles the details: retries with `Retry-After`, timeouts, typed errors, paging, and a stream that
|
|
39
|
+
reconnects and resumes where it stopped.
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
pip install ballotdata
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from ballotdata import BallotData
|
|
47
|
+
|
|
48
|
+
client = BallotData() # reads BALLOTDATA_API_KEY
|
|
49
|
+
|
|
50
|
+
contest = client.contests.get(4410, categories="labor")
|
|
51
|
+
for position in contest["endorsements"]:
|
|
52
|
+
print(position["endorser"]["name"], [pick["label"] for pick in position["picks"]])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Contents
|
|
56
|
+
|
|
57
|
+
- [Getting an API key](#getting-an-api-key)
|
|
58
|
+
- [Setting up the client](#setting-up-the-client)
|
|
59
|
+
- [What you can ask for](#what-you-can-ask-for)
|
|
60
|
+
- [Paging](#paging)
|
|
61
|
+
- [Ballots: contests, choices and voting rules](#ballots-contests-choices-and-voting-rules)
|
|
62
|
+
- [Endorsers and endorsements](#endorsers-and-endorsements)
|
|
63
|
+
- [The Endorsement Stream](#the-endorsement-stream)
|
|
64
|
+
- [Polling instead of streaming](#polling-instead-of-streaming)
|
|
65
|
+
- [Subscriptions](#subscriptions)
|
|
66
|
+
- [Postbacks (signed webhooks)](#postbacks-signed-webhooks)
|
|
67
|
+
- [Errors](#errors)
|
|
68
|
+
- [Retries and timeouts](#retries-and-timeouts)
|
|
69
|
+
- [Rate limits](#rate-limits)
|
|
70
|
+
- [Async](#async)
|
|
71
|
+
- [Types](#types)
|
|
72
|
+
- [More documentation](#more-documentation)
|
|
73
|
+
|
|
74
|
+
## Getting an API key
|
|
75
|
+
|
|
76
|
+
API accounts come with a purchase at [ballotdata.net/pricing](https://ballotdata.net/pricing). Once
|
|
77
|
+
it goes through, sign in at [api.ballotdata.net](https://api.ballotdata.net/login) and create a key
|
|
78
|
+
in the [console](https://api.ballotdata.net/console/keys).
|
|
79
|
+
|
|
80
|
+
A key covers what your account bought. Asking about another state answers `403 outside_purchase`.
|
|
81
|
+
Keys have scopes: `data:read` for the data endpoints, `stream:read` for the stream, polling and
|
|
82
|
+
reading subscriptions, and `subscriptions:write` to change subscriptions.
|
|
83
|
+
|
|
84
|
+
Keep keys on servers. Never put one in a browser page or a mobile app.
|
|
85
|
+
|
|
86
|
+
## Setting up the client
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
import os
|
|
90
|
+
from ballotdata import BallotData
|
|
91
|
+
|
|
92
|
+
client = BallotData(
|
|
93
|
+
api_key=os.environ["BALLOTDATA_API_KEY"], # the default, so you can leave it out
|
|
94
|
+
timeout=30, # seconds to wait for each response (default 30)
|
|
95
|
+
max_retries=2, # retries for 429, 503, network errors (default 2)
|
|
96
|
+
)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
| Argument | Default | What it does |
|
|
100
|
+
| --- | --- | --- |
|
|
101
|
+
| `api_key` | `BALLOTDATA_API_KEY` | Your key, `ebk_…`. |
|
|
102
|
+
| `base_url` | `BALLOTDATA_API_URL`, then `https://api.ballotdata.net` | Where requests go. |
|
|
103
|
+
| `timeout` | `30` | Seconds to wait for each response. |
|
|
104
|
+
| `max_retries` | `2` | Retries after 429, 503, network errors and timeouts, and 500/502/504 on GET, PUT and DELETE. `0` turns retries off. |
|
|
105
|
+
| `http_client` | a new `httpx.Client` | Your own `httpx.Client` (proxies, custom TLS, tests). You close it yourself. |
|
|
106
|
+
| `default_headers` | `{}` | Extra headers on every request. |
|
|
107
|
+
| `on_response` | none | Called with a `ResponseInfo` (`method`, `url`, `status`, `request_id`, `rate_limit`) after every response. |
|
|
108
|
+
|
|
109
|
+
Every method also takes `timeout`, `max_retries` and `extra_headers` keyword arguments for that one
|
|
110
|
+
call. The client holds a connection pool: use it as a context manager (`with BallotData() as
|
|
111
|
+
client:`) or call `client.close()` when you're done.
|
|
112
|
+
|
|
113
|
+
## What you can ask for
|
|
114
|
+
|
|
115
|
+
| Method | Endpoint | Returns |
|
|
116
|
+
| --- | --- | --- |
|
|
117
|
+
| `client.states.list()` | `GET /v1/states` | `list[State]` |
|
|
118
|
+
| `client.coverage.list(upcoming=)` | `GET /v1/coverage` | `list[Coverage]` |
|
|
119
|
+
| `client.elections.list(...)` | `GET /v1/elections` | pages of `ElectionSummary` |
|
|
120
|
+
| `client.contests.list(...)` | `GET /v1/contests` | pages of `Contest` |
|
|
121
|
+
| `client.contests.get(id, ...)` | `GET /v1/contests/{id}` | `ContestDetail` (with every endorser's position) |
|
|
122
|
+
| `client.endorser_categories.list(lang=)` | `GET /v1/endorser-categories` | `list[EndorserCategory]` |
|
|
123
|
+
| `client.endorsers.list(...)` | `GET /v1/endorsers` | pages of `Endorser` |
|
|
124
|
+
| `client.endorsers.get(slug, lang=)` | `GET /v1/endorsers/{slug}` | `EndorserDetail` |
|
|
125
|
+
| `client.endorsements.list(...)` | `GET /v1/endorsements` | pages of `Endorsement` (the basics) |
|
|
126
|
+
| `client.endorsements.get(id, lang=)` | `GET /v1/endorsements/{id}` | `EndorsementDetail` (the full record) |
|
|
127
|
+
| `client.officeholders.list(...)` | `GET /v1/officeholders` | pages of `Officeholder` |
|
|
128
|
+
| `client.officeholders.get(person_id, lang=)` | `GET /v1/officeholders/{id}` | `OfficeholderDetail` |
|
|
129
|
+
| `client.stream.connect(...)` | `GET /v1/stream` | a live `EndorsementStream` |
|
|
130
|
+
| `client.events.list(...)` | `GET /v1/events` | `EventsPage` |
|
|
131
|
+
| `client.events.follow(...)` | `GET /v1/events` (long polling) | changes, forever |
|
|
132
|
+
| `client.subscriptions.list()` | `GET /v1/subscriptions` | `list[Subscription]` |
|
|
133
|
+
| `client.subscriptions.create(name=, filter=)` | `POST /v1/subscriptions` | `Subscription` |
|
|
134
|
+
| `client.subscriptions.get(id)` | `GET /v1/subscriptions/{id}` | `Subscription` |
|
|
135
|
+
| `client.subscriptions.update(id, name=, filter=)` | `PUT /v1/subscriptions/{id}` | `Subscription` |
|
|
136
|
+
| `client.subscriptions.delete(id)` | `DELETE /v1/subscriptions/{id}` | `None` |
|
|
137
|
+
| `verify_postback_signature()`, `construct_postback()` | your endpoint | checks and parses postbacks |
|
|
138
|
+
|
|
139
|
+
Every argument of every method is listed in the [reference](docs/reference.md). Arguments are
|
|
140
|
+
keyword-only and named as in the API, except `from`, a Python keyword, which is `from_`. List
|
|
141
|
+
arguments take a list or a comma-separated string: `states=["CA", "NV"]` and `states="CA,NV"` mean
|
|
142
|
+
the same. Ballot text comes in Spanish with `lang="es"` where the state publishes it.
|
|
143
|
+
|
|
144
|
+
Responses are plain dicts with the API's field names, typed with `TypedDict`, so the
|
|
145
|
+
[API reference](https://api.ballotdata.net/docs/reference) reads the same as your code.
|
|
146
|
+
|
|
147
|
+
## Paging
|
|
148
|
+
|
|
149
|
+
List methods return the first page, a `SyncPage`. Its `data` holds the page's items and
|
|
150
|
+
`next_cursor` the cursor for the next page. Iterating the page walks every item on every page,
|
|
151
|
+
fetching pages as you go:
|
|
152
|
+
|
|
153
|
+
```python
|
|
154
|
+
# One page
|
|
155
|
+
page = client.endorsements.list(states="CA", limit=500)
|
|
156
|
+
page.data, page.next_cursor
|
|
157
|
+
|
|
158
|
+
# Every item, across every page
|
|
159
|
+
for endorsement in client.endorsements.list(states="CA", limit=500):
|
|
160
|
+
save(endorsement)
|
|
161
|
+
|
|
162
|
+
# Every item in a list (pass a maximum for big lists)
|
|
163
|
+
everything = client.endorsers.list(categories="labor").to_list(max=1000)
|
|
164
|
+
|
|
165
|
+
# Page by page
|
|
166
|
+
for page in client.contests.list(elections="ca-2026-11-03-general").iter_pages():
|
|
167
|
+
print(len(page.data), page.next_cursor)
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
To page by hand, pass the last page's `next_cursor` as `cursor`, or call `page.next_page()`.
|
|
171
|
+
|
|
172
|
+
## Ballots: contests, choices and voting rules
|
|
173
|
+
|
|
174
|
+
A contest's `vote_method` says how it is voted, and its choices are in ballot order:
|
|
175
|
+
|
|
176
|
+
| `vote_method` | Meaning | Look at |
|
|
177
|
+
| --- | --- | --- |
|
|
178
|
+
| `plurality` | Vote for one | `votes_allowed` (1) |
|
|
179
|
+
| `multi_seat` | Vote for up to N | `votes_allowed` |
|
|
180
|
+
| `ranked_choice` | Rank up to N | `ranks_allowed`, and each pick's `rank` |
|
|
181
|
+
| `approval` | Vote for as many as you like | |
|
|
182
|
+
| `yes_no` | A measure or retention question | each choice's `measure_answer` |
|
|
183
|
+
|
|
184
|
+
```python
|
|
185
|
+
election = client.elections.list(states="CA", types="general", upcoming=True).data[0]
|
|
186
|
+
|
|
187
|
+
for contest in client.contests.list(elections=election["slug"]):
|
|
188
|
+
if contest["vote_method"] == "ranked_choice":
|
|
189
|
+
rule = f"rank up to {contest['ranks_allowed']}"
|
|
190
|
+
else:
|
|
191
|
+
rule = f"vote for {contest['votes_allowed']}"
|
|
192
|
+
print(f"{contest['title']} ({rule}):", ", ".join(c["label"] for c in contest["choices"]))
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`client.contests.get(id)` adds every endorser's position. Each position's `picks` hold one pick for
|
|
196
|
+
vote-for-one, up to N for vote-for-N, the ranking for ranked choice, or Yes/No for a measure:
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
contest = client.contests.get(4410, categories=["labor", "editorial"])
|
|
200
|
+
for position in contest["endorsements"]:
|
|
201
|
+
marks = [f"{p['rank']}. {p['label']}" if p["rank"] else p["label"] for p in position["picks"]]
|
|
202
|
+
print(f"{position['endorser']['name']}: {' '.join(marks)}")
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Endorsers and endorsements
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
# The kinds of endorser, for categories filters
|
|
209
|
+
categories = client.endorser_categories.list()
|
|
210
|
+
|
|
211
|
+
# Find endorsers by category, home state (or "national"), election, or name
|
|
212
|
+
unions = client.endorsers.list(categories="labor", states="CA", q="council").data
|
|
213
|
+
|
|
214
|
+
# One endorser and the elections they endorsed in
|
|
215
|
+
endorser = client.endorsers.get("sf-labor-council")
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`client.endorsements.list()` returns one row per endorser and choice with the basics: the endorser,
|
|
219
|
+
contest and election (ids, slugs and names), the choice and its rank. It is the way to sync
|
|
220
|
+
everything. A ranked-choice endorsement of three candidates is three rows with ranks 1 to 3.
|
|
221
|
+
`client.endorsements.get(id)` has the full record: the statement and source, the endorser's details,
|
|
222
|
+
the contest with its district, election and voting rules, and the choice with its candidate.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
e = client.endorsements.get(90211)
|
|
226
|
+
print(e["endorser"]["category"]["name"], e["contest"]["vote_method"], e["statement"], e["source_url"])
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
`group_by_contest()` turns rows back into positions:
|
|
230
|
+
|
|
231
|
+
```python
|
|
232
|
+
from ballotdata import group_by_contest
|
|
233
|
+
|
|
234
|
+
rows = client.endorsements.list(endorsers="sf-labor-council", elections="ca-2026-11-03-general", limit=500)
|
|
235
|
+
for group in group_by_contest(rows):
|
|
236
|
+
print(group["contest"]["title"], [p["label"] for p in group["positions"][0]["picks"]])
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## The Endorsement Stream
|
|
240
|
+
|
|
241
|
+
`client.stream.connect()` opens `GET /v1/stream` and yields every endorsement change that matches
|
|
242
|
+
your filter (or saved subscription), within seconds of it happening.
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
with client.stream.connect(states=["CA", "NV"], categories="labor", cursor=load_cursor(), on_cursor=save_cursor) as stream:
|
|
246
|
+
for message in stream:
|
|
247
|
+
if message.type == "ready":
|
|
248
|
+
print("connected")
|
|
249
|
+
elif message.type == "endorsement":
|
|
250
|
+
event = message.event # event["type"]: endorsement.created | updated | deleted
|
|
251
|
+
print(event["type"], event["endorser"]["name"], event["choice"]["label"], event["contest"]["title"])
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
The stream keeps its place for you:
|
|
255
|
+
|
|
256
|
+
- Each change moves the **cursor**. Save it in `on_cursor` and pass it back as `cursor` after a
|
|
257
|
+
restart to continue exactly where you stopped. Without a cursor the stream starts at
|
|
258
|
+
`from_="latest"` (or `"earliest"`, or `since=datetime`).
|
|
259
|
+
- It **reconnects** with the cursor after the API's hourly `reconnect`, a dropped connection, a
|
|
260
|
+
connection that goes silent for a minute (`idle_timeout`), or a 429/503 (after `Retry-After`).
|
|
261
|
+
Nothing is missed or repeated.
|
|
262
|
+
- `checkpoint` events move the cursor past changes your filter skipped. You don't see them; the
|
|
263
|
+
cursor just moves.
|
|
264
|
+
- If the cursor is older than the stream keeps, iteration raises `CursorExpiredError`. Catch up with
|
|
265
|
+
`client.endorsements.list()` and start again with `from_="latest"`.
|
|
266
|
+
- `stream.close()` ends the loop quietly, even from another thread. Leaving the `with` block closes
|
|
267
|
+
the connection.
|
|
268
|
+
|
|
269
|
+
`client.stream.endorsements(...)` takes the same arguments and yields only the `EndorsementMessage`s
|
|
270
|
+
(each with `.event` and `.cursor`).
|
|
271
|
+
|
|
272
|
+
The stream needs a key with `stream:read` and a purchase that includes the stream. The
|
|
273
|
+
[stream guide](docs/stream.md) covers syncing first, recovering from an expired cursor, running in
|
|
274
|
+
a background thread, and serverless functions.
|
|
275
|
+
|
|
276
|
+
## Polling instead of streaming
|
|
277
|
+
|
|
278
|
+
Where a connection can't stay open (serverless functions, cron, some proxies), read the same changes
|
|
279
|
+
from `GET /v1/events`:
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
# One page; wait up to 20 s for something new
|
|
283
|
+
page = client.events.list(subscription="CA labor", cursor=cursor, wait=20)
|
|
284
|
+
# page["data"], page["next_cursor"], page["has_more"]
|
|
285
|
+
|
|
286
|
+
# Or forever
|
|
287
|
+
for message in client.events.follow(states="CA", cursor=load_cursor()):
|
|
288
|
+
handle(message.event)
|
|
289
|
+
save_cursor(message.cursor)
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Subscriptions
|
|
293
|
+
|
|
294
|
+
A subscription is a saved filter. Use it by id or name as `subscription=` on the stream and on events.
|
|
295
|
+
|
|
296
|
+
```python
|
|
297
|
+
sub = client.subscriptions.create(name="CA labor", filter={"categories": ["labor"], "states": ["CA"]})
|
|
298
|
+
subs = client.subscriptions.list()
|
|
299
|
+
client.subscriptions.update(sub["id"], name="CA labor", filter={"categories": ["labor"], "states": ["CA", "NV"]})
|
|
300
|
+
client.subscriptions.delete(sub["id"])
|
|
301
|
+
|
|
302
|
+
with client.stream.connect(subscription="CA labor") as stream:
|
|
303
|
+
...
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
A filter's fields combine with AND and the values within a field with OR: `categories`, `endorsers`,
|
|
307
|
+
`endorser_states`, `states`, `elections`, `contest_kinds`, `levels`, and `types`
|
|
308
|
+
(`created`, `updated`, `deleted`). An account can have up to 50.
|
|
309
|
+
|
|
310
|
+
## Postbacks (signed webhooks)
|
|
311
|
+
|
|
312
|
+
With postbacks, BallotData POSTs each batch of changes to your HTTPS endpoint, signed with your
|
|
313
|
+
account's signing secret. Check the signature against the **raw** body:
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
import os
|
|
317
|
+
from flask import Flask, request
|
|
318
|
+
from ballotdata import PostbackSignatureError, construct_postback
|
|
319
|
+
|
|
320
|
+
app = Flask(__name__)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
@app.post("/ballotdata")
|
|
324
|
+
def ballotdata_postback():
|
|
325
|
+
try:
|
|
326
|
+
postback = construct_postback(
|
|
327
|
+
request.get_data(), request.headers.get("BallotData-Signature"), os.environ["BALLOTDATA_SIGNING_SECRET"]
|
|
328
|
+
)
|
|
329
|
+
except PostbackSignatureError:
|
|
330
|
+
return "", 401
|
|
331
|
+
for change in postback["changes"]:
|
|
332
|
+
handle(change) # change["id"] drops repeats
|
|
333
|
+
return "", 204
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
`verify_postback_signature(raw_body, header, secret)` returns True or False instead of raising.
|
|
337
|
+
`sign_postback(raw_body, secret)` makes a header for testing your endpoint. See the
|
|
338
|
+
[postbacks guide](docs/postbacks.md) for Django and FastAPI.
|
|
339
|
+
|
|
340
|
+
## Errors
|
|
341
|
+
|
|
342
|
+
Every error the API answers with is an `APIError` with `status`, `code` (such as
|
|
343
|
+
`outside_purchase`), `message` and `request_id`. Subclasses let you catch by kind:
|
|
344
|
+
|
|
345
|
+
| Class | Status | When |
|
|
346
|
+
| --- | --- | --- |
|
|
347
|
+
| `BadRequestError` | 400 | A parameter isn't valid. |
|
|
348
|
+
| `AuthenticationError` | 401 | No key, or a bad, revoked or expired one. |
|
|
349
|
+
| `PermissionDeniedError` | 403 | Missing scope, IP allowlist, account not active, or data you haven't bought. |
|
|
350
|
+
| `NotFoundError` | 404 | No such contest, endorser, officeholder or subscription. |
|
|
351
|
+
| `ConflictError` | 409 | A subscription with that name exists. |
|
|
352
|
+
| `CursorExpiredError` | 410 | The stream position is too old. |
|
|
353
|
+
| `RateLimitError` | 429 | Over a rate limit, or too many open streams. Has `retry_after`. |
|
|
354
|
+
| `InternalServerError` | 5xx | A problem at BallotData. |
|
|
355
|
+
| `APIConnectionError` | | The API couldn't be reached. |
|
|
356
|
+
| `APITimeoutError` | | No answer within `timeout` (a kind of `APIConnectionError`). |
|
|
357
|
+
|
|
358
|
+
All of them derive from `BallotDataError`.
|
|
359
|
+
|
|
360
|
+
```python
|
|
361
|
+
from ballotdata import APIError, NotFoundError
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
contest = client.contests.get(contest_id)
|
|
365
|
+
except NotFoundError:
|
|
366
|
+
contest = None
|
|
367
|
+
except APIError as err:
|
|
368
|
+
print(err.code, err.message, err.request_id)
|
|
369
|
+
raise
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
Quote the `request_id` when you write to [sales@ballotdata.net](mailto:sales@ballotdata.net). Every
|
|
373
|
+
code is listed in [docs/errors.md](docs/errors.md).
|
|
374
|
+
|
|
375
|
+
## Retries and timeouts
|
|
376
|
+
|
|
377
|
+
The client retries, up to `max_retries` times (default 2):
|
|
378
|
+
|
|
379
|
+
- 429 and 503 on any request, after the `Retry-After` the API sends (when it's a minute or less: a
|
|
380
|
+
daily limit isn't worth waiting out).
|
|
381
|
+
- Network errors and timeouts, and 500, 502 and 504, on GET, PUT and DELETE. A POST that may have
|
|
382
|
+
reached the API isn't repeated.
|
|
383
|
+
|
|
384
|
+
```python
|
|
385
|
+
# Per call
|
|
386
|
+
client.endorsements.list(states="CA", timeout=60, max_retries=5)
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
## Rate limits
|
|
390
|
+
|
|
391
|
+
Your plan sets requests per minute (and per day on some plans). The latest headers are on
|
|
392
|
+
`client.last_response.rate_limit` (`limit`, `remaining`, `reset`, `daily_limit`, `daily_remaining`),
|
|
393
|
+
and `on_response` sees every response:
|
|
394
|
+
|
|
395
|
+
```python
|
|
396
|
+
def watch(info):
|
|
397
|
+
if info.rate_limit.remaining is not None and info.rate_limit.remaining < 5:
|
|
398
|
+
print("Nearly out of requests this minute")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
client = BallotData(on_response=watch)
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
## Async
|
|
405
|
+
|
|
406
|
+
`AsyncBallotData` has the same resources and arguments. Methods that answer once are coroutines.
|
|
407
|
+
List methods can be awaited for the first page or iterated with `async for` for every item:
|
|
408
|
+
|
|
409
|
+
```python
|
|
410
|
+
import asyncio
|
|
411
|
+
from ballotdata import AsyncBallotData
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
async def main():
|
|
415
|
+
async with AsyncBallotData() as client:
|
|
416
|
+
contest = await client.contests.get(4410)
|
|
417
|
+
|
|
418
|
+
page = await client.endorsements.list(states="CA") # the first page
|
|
419
|
+
async for endorsement in client.endorsements.list(states="CA"): # every item
|
|
420
|
+
...
|
|
421
|
+
|
|
422
|
+
async with client.stream.connect(subscription="CA labor", cursor=cursor) as stream:
|
|
423
|
+
async for message in stream:
|
|
424
|
+
if message.type == "endorsement":
|
|
425
|
+
await handle(message.event)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
asyncio.run(main())
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
To stop an async stream from another task, `await stream.aclose()` (or cancel the task reading it).
|
|
432
|
+
|
|
433
|
+
## Types
|
|
434
|
+
|
|
435
|
+
Every response and parameter has a type in `ballotdata.types`: response objects (`Contest`,
|
|
436
|
+
`Endorsement`, `EndorsementEvent`, …), the subscription `Filter`, and enums as `Literal`s
|
|
437
|
+
(`VoteMethod`, `Level`, `ErrorCode`, …). The package ships `py.typed`, so mypy, Pyright and editors
|
|
438
|
+
such as VS Code check your code and complete field names.
|
|
439
|
+
|
|
440
|
+
```python
|
|
441
|
+
from ballotdata.types import Contest, EndorsementEvent
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
## More documentation
|
|
445
|
+
|
|
446
|
+
- [Reference](docs/reference.md): every method, argument and type.
|
|
447
|
+
- [Stream guide](docs/stream.md): syncing, cursors, reconnecting, threads, serverless.
|
|
448
|
+
- [Postbacks guide](docs/postbacks.md): receiving signed changes.
|
|
449
|
+
- [Errors](docs/errors.md): every exception and code.
|
|
450
|
+
- [API docs](https://api.ballotdata.net/docs) and the [API reference](https://api.ballotdata.net/docs/reference).
|
|
451
|
+
- Every class and method has docstrings: try `help(client.stream.connect)`.
|
|
452
|
+
|
|
453
|
+
## License
|
|
454
|
+
|
|
455
|
+
MIT. Copyright BallotData LLC.
|