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.
Files changed (42) hide show
  1. ballotdata-1.0.0/.gitignore +13 -0
  2. ballotdata-1.0.0/CHANGELOG.md +17 -0
  3. ballotdata-1.0.0/LICENSE +21 -0
  4. ballotdata-1.0.0/PKG-INFO +455 -0
  5. ballotdata-1.0.0/README.md +427 -0
  6. ballotdata-1.0.0/docs/errors.md +71 -0
  7. ballotdata-1.0.0/docs/postbacks.md +144 -0
  8. ballotdata-1.0.0/docs/reference.md +403 -0
  9. ballotdata-1.0.0/docs/stream.md +182 -0
  10. ballotdata-1.0.0/pyproject.toml +64 -0
  11. ballotdata-1.0.0/src/ballotdata/__init__.py +90 -0
  12. ballotdata-1.0.0/src/ballotdata/_base.py +187 -0
  13. ballotdata-1.0.0/src/ballotdata/_client.py +315 -0
  14. ballotdata-1.0.0/src/ballotdata/_sse.py +95 -0
  15. ballotdata-1.0.0/src/ballotdata/_transport.py +170 -0
  16. ballotdata-1.0.0/src/ballotdata/_version.py +1 -0
  17. ballotdata-1.0.0/src/ballotdata/errors.py +207 -0
  18. ballotdata-1.0.0/src/ballotdata/helpers.py +88 -0
  19. ballotdata-1.0.0/src/ballotdata/pagination.py +152 -0
  20. ballotdata-1.0.0/src/ballotdata/postbacks.py +141 -0
  21. ballotdata-1.0.0/src/ballotdata/py.typed +0 -0
  22. ballotdata-1.0.0/src/ballotdata/resources/__init__.py +1 -0
  23. ballotdata-1.0.0/src/ballotdata/resources/_resource.py +45 -0
  24. ballotdata-1.0.0/src/ballotdata/resources/contests.py +153 -0
  25. ballotdata-1.0.0/src/ballotdata/resources/elections.py +94 -0
  26. ballotdata-1.0.0/src/ballotdata/resources/endorsements.py +158 -0
  27. ballotdata-1.0.0/src/ballotdata/resources/endorsers.py +109 -0
  28. ballotdata-1.0.0/src/ballotdata/resources/officeholders.py +127 -0
  29. ballotdata-1.0.0/src/ballotdata/resources/reference.py +136 -0
  30. ballotdata-1.0.0/src/ballotdata/resources/subscriptions.py +194 -0
  31. ballotdata-1.0.0/src/ballotdata/stream.py +853 -0
  32. ballotdata-1.0.0/src/ballotdata/types.py +750 -0
  33. ballotdata-1.0.0/tests/__init__.py +0 -0
  34. ballotdata-1.0.0/tests/conftest.py +28 -0
  35. ballotdata-1.0.0/tests/fake_api.py +409 -0
  36. ballotdata-1.0.0/tests/openapi.json +1 -0
  37. ballotdata-1.0.0/tests/test_async.py +139 -0
  38. ballotdata-1.0.0/tests/test_client.py +249 -0
  39. ballotdata-1.0.0/tests/test_coverage.py +94 -0
  40. ballotdata-1.0.0/tests/test_postbacks.py +55 -0
  41. ballotdata-1.0.0/tests/test_stream.py +166 -0
  42. ballotdata-1.0.0/tests/typing_usage.py +74 -0
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .mypy_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ site/
@@ -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`.
@@ -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.