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.
@@ -0,0 +1,170 @@
1
+ """The HTTP layer: one attempt at a time, retries, timeouts and error mapping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from collections.abc import Mapping
9
+ from typing import Any, Literal
10
+
11
+ import httpx
12
+
13
+ from ._base import BaseSettings, backoff, encode_query, retryable_status
14
+ from .errors import APIConnectionError, APIError, APITimeoutError, error_from_response, parse_retry_after
15
+
16
+ Method = Literal["GET", "POST", "PUT", "DELETE"]
17
+
18
+
19
+ class RequestOptions:
20
+ """Per-call overrides, taken by every method as keyword arguments."""
21
+
22
+ __slots__ = ("extra_headers", "max_retries", "timeout")
23
+
24
+ def __init__(
25
+ self,
26
+ timeout: float | None = None,
27
+ max_retries: int | None = None,
28
+ extra_headers: Mapping[str, str] | None = None,
29
+ ) -> None:
30
+ self.timeout = timeout
31
+ self.max_retries = max_retries
32
+ self.extra_headers = extra_headers
33
+
34
+
35
+ def _wrap_transport_error(err: httpx.TransportError) -> APIConnectionError:
36
+ if isinstance(err, httpx.TimeoutException):
37
+ return APITimeoutError(f"No response from BallotData in time: {err}")
38
+ return APIConnectionError(f"Could not reach BallotData: {err}")
39
+
40
+
41
+ def _decode(response: httpx.Response) -> Any:
42
+ if response.status_code == 204 or not response.content:
43
+ return None
44
+ return response.json()
45
+
46
+
47
+ class _Common(BaseSettings):
48
+ def build(
49
+ self,
50
+ method: Method,
51
+ path: str,
52
+ query: Mapping[str, Any] | None,
53
+ body: Any,
54
+ options: RequestOptions,
55
+ timeout: float,
56
+ ) -> dict[str, Any]:
57
+ extra: dict[str, str] = {}
58
+ content = None
59
+ if body is not None:
60
+ extra["Content-Type"] = "application/json"
61
+ content = json.dumps(body).encode()
62
+ if options.extra_headers:
63
+ extra.update(options.extra_headers)
64
+ return {
65
+ "method": method,
66
+ "url": self.url(path),
67
+ "params": encode_query(query),
68
+ "headers": self.headers(extra),
69
+ "content": content,
70
+ "timeout": timeout,
71
+ }
72
+
73
+ def outcome(self, method: Method, response: httpx.Response) -> tuple[bool, float | None, APIError]:
74
+ """For an error response: (retryable, retry_after, error)."""
75
+ error = error_from_response(response)
76
+ retry_after = parse_retry_after(response.headers.get("retry-after"))
77
+ return retryable_status(response.status_code, method, retry_after), retry_after, error
78
+
79
+
80
+ class SyncTransport(_Common):
81
+ """Sends requests with an ``httpx.Client``. Shared by every resource of one client."""
82
+
83
+ def __init__(self, *, http_client: httpx.Client | None, **settings: Any) -> None:
84
+ super().__init__(**settings)
85
+ self._own_client = http_client is None
86
+ self.client = http_client or httpx.Client(follow_redirects=True)
87
+
88
+ def close(self) -> None:
89
+ if self._own_client:
90
+ self.client.close()
91
+
92
+ def request(
93
+ self,
94
+ method: Method,
95
+ path: str,
96
+ *,
97
+ query: Mapping[str, Any] | None = None,
98
+ body: Any = None,
99
+ options: RequestOptions | None = None,
100
+ ) -> Any:
101
+ """Make a request, retrying where it's safe, and return the parsed JSON (None for 204)."""
102
+ options = options or RequestOptions()
103
+ max_retries = self.max_retries if options.max_retries is None else options.max_retries
104
+ timeout = self.timeout if options.timeout is None else options.timeout
105
+ args = self.build(method, path, query, body, options, timeout)
106
+ attempt = 0
107
+ while True:
108
+ try:
109
+ response = self.client.request(**args)
110
+ except httpx.TransportError as err:
111
+ # A request that never got a full answer may or may not have been processed: only retry idempotent ones.
112
+ if method == "POST" or attempt >= max_retries:
113
+ raise _wrap_transport_error(err) from err
114
+ time.sleep(backoff(attempt))
115
+ attempt += 1
116
+ continue
117
+ self.observe(response)
118
+ if response.is_success:
119
+ return _decode(response)
120
+ retryable, retry_after, error = self.outcome(method, response)
121
+ if not retryable or attempt >= max_retries:
122
+ raise error
123
+ time.sleep(retry_after if retry_after is not None else backoff(attempt))
124
+ attempt += 1
125
+
126
+
127
+ class AsyncTransport(_Common):
128
+ """Sends requests with an ``httpx.AsyncClient``. Shared by every resource of one client."""
129
+
130
+ def __init__(self, *, http_client: httpx.AsyncClient | None, **settings: Any) -> None:
131
+ super().__init__(**settings)
132
+ self._own_client = http_client is None
133
+ self.client = http_client or httpx.AsyncClient(follow_redirects=True)
134
+
135
+ async def close(self) -> None:
136
+ if self._own_client:
137
+ await self.client.aclose()
138
+
139
+ async def request(
140
+ self,
141
+ method: Method,
142
+ path: str,
143
+ *,
144
+ query: Mapping[str, Any] | None = None,
145
+ body: Any = None,
146
+ options: RequestOptions | None = None,
147
+ ) -> Any:
148
+ """Make a request, retrying where it's safe, and return the parsed JSON (None for 204)."""
149
+ options = options or RequestOptions()
150
+ max_retries = self.max_retries if options.max_retries is None else options.max_retries
151
+ timeout = self.timeout if options.timeout is None else options.timeout
152
+ args = self.build(method, path, query, body, options, timeout)
153
+ attempt = 0
154
+ while True:
155
+ try:
156
+ response = await self.client.request(**args)
157
+ except httpx.TransportError as err:
158
+ if method == "POST" or attempt >= max_retries:
159
+ raise _wrap_transport_error(err) from err
160
+ await asyncio.sleep(backoff(attempt))
161
+ attempt += 1
162
+ continue
163
+ self.observe(response)
164
+ if response.is_success:
165
+ return _decode(response)
166
+ retryable, retry_after, error = self.outcome(method, response)
167
+ if not retryable or attempt >= max_retries:
168
+ raise error
169
+ await asyncio.sleep(retry_after if retry_after is not None else backoff(attempt))
170
+ attempt += 1
ballotdata/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
ballotdata/errors.py ADDED
@@ -0,0 +1,207 @@
1
+ """Exceptions raised by the client.
2
+
3
+ Every error the API answers with becomes an :class:`APIError` (or one of its
4
+ subclasses, chosen by HTTP status), carrying the API's ``code``, its message
5
+ and the request id. Problems reaching the API at all become
6
+ :class:`APIConnectionError` or :class:`APITimeoutError`::
7
+
8
+ from ballotdata import APIError, NotFoundError
9
+
10
+ try:
11
+ client.contests.get(4410)
12
+ except NotFoundError:
13
+ ...
14
+ except APIError as err:
15
+ if err.code == "outside_purchase":
16
+ ...
17
+ raise
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import email.utils
23
+ import time
24
+ from collections.abc import Mapping
25
+
26
+ import httpx
27
+
28
+ __all__ = [
29
+ "BallotDataError",
30
+ "APIError",
31
+ "BadRequestError",
32
+ "AuthenticationError",
33
+ "PermissionDeniedError",
34
+ "NotFoundError",
35
+ "ConflictError",
36
+ "CursorExpiredError",
37
+ "PayloadTooLargeError",
38
+ "RateLimitError",
39
+ "InternalServerError",
40
+ "APIConnectionError",
41
+ "APITimeoutError",
42
+ ]
43
+
44
+
45
+ class BallotDataError(Exception):
46
+ """Base class for everything this library raises on purpose."""
47
+
48
+
49
+ class APIError(BallotDataError):
50
+ """The API answered with an error: ``{"error": {"code", "message", "request_id"}}``."""
51
+
52
+ status: int
53
+ """HTTP status."""
54
+ code: str
55
+ """Machine-readable code, e.g. ``"outside_purchase"``. See :data:`ballotdata.types.ErrorCode`."""
56
+ message: str
57
+ """What went wrong, in English."""
58
+ request_id: str | None
59
+ """Request id from the error body or the X-Request-Id header. Quote it when you write to BallotData."""
60
+ headers: httpx.Headers
61
+ """The response headers."""
62
+
63
+ def __init__(
64
+ self,
65
+ status: int,
66
+ code: str,
67
+ message: str,
68
+ request_id: str | None = None,
69
+ headers: Mapping[str, str] | httpx.Headers | None = None,
70
+ ) -> None:
71
+ super().__init__(message)
72
+ self.status = status
73
+ self.code = code
74
+ self.message = message
75
+ self.request_id = request_id
76
+ self.headers = httpx.Headers(headers or {})
77
+
78
+ def __str__(self) -> str:
79
+ suffix = f" (request {self.request_id})" if self.request_id else ""
80
+ return f"{self.status} {self.code}: {self.message}{suffix}"
81
+
82
+ def __repr__(self) -> str:
83
+ return (
84
+ f"{type(self).__name__}(status={self.status!r}, code={self.code!r}, message={self.message!r}, request_id={self.request_id!r})"
85
+ )
86
+
87
+
88
+ class BadRequestError(APIError):
89
+ """400: a parameter is missing or not valid (``invalid_parameter``, ``invalid_cursor``, ``invalid_filter``, ``invalid_json``, …)."""
90
+
91
+
92
+ class AuthenticationError(APIError):
93
+ """401: no API key, or one that isn't valid, was revoked or expired."""
94
+
95
+
96
+ class PermissionDeniedError(APIError):
97
+ """403: the key can't be used for this.
98
+
99
+ A missing scope (``insufficient_scope``), an IP allowlist (``ip_not_allowed``),
100
+ an account that isn't active, or data the account hasn't bought (``no_access``,
101
+ ``outside_purchase``).
102
+ """
103
+
104
+
105
+ class NotFoundError(APIError):
106
+ """404: nothing has that id or slug (``not_found``, ``subscription_not_found``)."""
107
+
108
+
109
+ class ConflictError(APIError):
110
+ """409: a subscription with that name already exists."""
111
+
112
+
113
+ class CursorExpiredError(APIError):
114
+ """410 ``cursor_expired``: the position is older than the stream keeps.
115
+
116
+ Catch up with ``client.endorsements.list()``, then start again with ``from_="latest"``.
117
+ """
118
+
119
+
120
+ class PayloadTooLargeError(APIError):
121
+ """413: the request body is too large."""
122
+
123
+
124
+ class RateLimitError(APIError):
125
+ """429: over a rate limit (``rate_limit_exceeded``, ``daily_limit_exceeded``) or holding too many streams (``too_many_streams``)."""
126
+
127
+ @property
128
+ def retry_after(self) -> float | None:
129
+ """Seconds the API asked you to wait (the Retry-After header), or None."""
130
+ return parse_retry_after(self.headers.get("retry-after"))
131
+
132
+
133
+ class InternalServerError(APIError):
134
+ """500 or above: something went wrong at BallotData. 503 ``warming_up`` and ``server_busy`` are retried for you first."""
135
+
136
+
137
+ class APIConnectionError(BallotDataError):
138
+ """The API couldn't be reached: DNS, TLS, a refused or dropped connection."""
139
+
140
+
141
+ class APITimeoutError(APIConnectionError):
142
+ """The API didn't answer within the ``timeout``."""
143
+
144
+
145
+ def error_for(
146
+ status: int,
147
+ code: str,
148
+ message: str,
149
+ request_id: str | None = None,
150
+ headers: Mapping[str, str] | httpx.Headers | None = None,
151
+ ) -> APIError:
152
+ """Build the right :class:`APIError` subclass for a status."""
153
+ cls: type[APIError]
154
+ if status == 400:
155
+ cls = BadRequestError
156
+ elif status == 401:
157
+ cls = AuthenticationError
158
+ elif status == 403:
159
+ cls = PermissionDeniedError
160
+ elif status == 404:
161
+ cls = NotFoundError
162
+ elif status == 409:
163
+ cls = ConflictError
164
+ elif status == 410 or code == "cursor_expired":
165
+ cls = CursorExpiredError
166
+ elif status == 413:
167
+ cls = PayloadTooLargeError
168
+ elif status == 429:
169
+ cls = RateLimitError
170
+ elif status >= 500:
171
+ cls = InternalServerError
172
+ else:
173
+ cls = APIError
174
+ return cls(status, code, message, request_id, headers)
175
+
176
+
177
+ def error_from_response(response: httpx.Response) -> APIError:
178
+ """Turn an error response (with its body read) into an :class:`APIError`."""
179
+ header_id = response.headers.get("x-request-id")
180
+ code = "http_error"
181
+ message = response.reason_phrase or f"HTTP {response.status_code}"
182
+ request_id = header_id
183
+ try:
184
+ body = response.json()
185
+ except ValueError:
186
+ body = None # Not JSON (a proxy's error page, say): keep the status text.
187
+ error = body.get("error") if isinstance(body, dict) else None
188
+ if isinstance(error, dict) and error.get("code"):
189
+ code = str(error["code"])
190
+ message = str(error.get("message") or message)
191
+ request_id = error.get("request_id") or header_id
192
+ return error_for(response.status_code, code, message, request_id, response.headers)
193
+
194
+
195
+ def parse_retry_after(value: str | None) -> float | None:
196
+ """Seconds from a Retry-After header (seconds or an HTTP date), or None."""
197
+ if not value:
198
+ return None
199
+ try:
200
+ return max(0.0, float(value))
201
+ except ValueError:
202
+ pass
203
+ try:
204
+ when = email.utils.parsedate_to_datetime(value)
205
+ except (TypeError, ValueError):
206
+ return None
207
+ return max(0.0, when.timestamp() - time.time())
ballotdata/helpers.py ADDED
@@ -0,0 +1,88 @@
1
+ """Small helpers for working with ballot data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterable, Sequence
7
+ from typing import Any, TypedDict, TypeVar
8
+
9
+ from .types import (
10
+ DateString,
11
+ Endorsement,
12
+ EndorsementContestRef,
13
+ EndorsementElectionRef,
14
+ EndorsementEndorserRef,
15
+ MeasureAnswer,
16
+ )
17
+
18
+ __all__ = ["ContestEndorsements", "EndorsementPick", "EndorsementPosition", "group_by_contest", "sort_picks"]
19
+
20
+ P = TypeVar("P", bound=Any)
21
+
22
+
23
+ class EndorsementPick(TypedDict):
24
+ """One choice an endorser picked, from :func:`group_by_contest`."""
25
+
26
+ endorsement_id: int
27
+ """The endorsement row: ``client.endorsements.get(id)`` has its statement and source."""
28
+ choice_id: int
29
+ label: str
30
+ measure_answer: MeasureAnswer | None
31
+ rank: int | None
32
+ """Ranked choice: 1 for first choice, 2 for second, …; None otherwise."""
33
+ published_on: DateString | None
34
+
35
+
36
+ class EndorsementPosition(TypedDict):
37
+ """One endorser's picks in a contest, from :func:`group_by_contest`."""
38
+
39
+ endorser: EndorsementEndorserRef
40
+ picks: list[EndorsementPick]
41
+
42
+
43
+ class ContestEndorsements(TypedDict):
44
+ """A contest and every endorser's position in it, from :func:`group_by_contest`."""
45
+
46
+ contest: EndorsementContestRef
47
+ election: EndorsementElectionRef
48
+ positions: list[EndorsementPosition]
49
+
50
+
51
+ def group_by_contest(endorsements: Iterable[Endorsement]) -> list[ContestEndorsements]:
52
+ """Group endorsement rows (from ``client.endorsements.list()``) by contest and
53
+ endorser: one position per endorser, with its picks ranked first-to-last for
54
+ ranked choice, like the positions ``client.contests.get()`` returns.
55
+
56
+ Example::
57
+
58
+ rows = client.endorsements.list(endorsers="sf-labor-council", elections=slug, limit=500).to_list()
59
+ for group in group_by_contest(rows):
60
+ print(group["contest"]["title"], [p["label"] for p in group["positions"][0]["picks"]])
61
+ """
62
+ contests: dict[int, tuple[Endorsement, dict[int, EndorsementPosition]]] = {}
63
+ for e in endorsements:
64
+ _first, by_endorser = contests.setdefault(e["contest"]["id"], (e, {}))
65
+ position = by_endorser.setdefault(e["endorser"]["id"], {"endorser": e["endorser"], "picks": []})
66
+ position["picks"].append(
67
+ {
68
+ "endorsement_id": e["id"],
69
+ "choice_id": e["choice"]["id"],
70
+ "label": e["choice"]["label"],
71
+ "measure_answer": e["choice"]["measure_answer"],
72
+ "rank": e["rank"],
73
+ "published_on": e["published_on"],
74
+ }
75
+ )
76
+ return [
77
+ {
78
+ "contest": first["contest"],
79
+ "election": first["election"],
80
+ "positions": [{"endorser": p["endorser"], "picks": sort_picks(p["picks"])} for p in by_endorser.values()],
81
+ }
82
+ for first, by_endorser in contests.values()
83
+ ]
84
+
85
+
86
+ def sort_picks(picks: Sequence[P]) -> list[P]:
87
+ """Picks in ballot-marking order: by ``rank`` for ranked choice, otherwise as given."""
88
+ return sorted(picks, key=lambda p: math.inf if p.get("rank") is None else p["rank"])
@@ -0,0 +1,152 @@
1
+ """Paged lists.
2
+
3
+ List endpoints answer a page at a time: ``{"data": [...], "next_cursor": "..."}``.
4
+
5
+ With the sync client, a list method returns the first :class:`SyncPage`.
6
+ Iterating it walks every item on every page, fetching pages as needed::
7
+
8
+ page = client.endorsements.list(states="CA") # the first page
9
+ page.data, page.next_cursor
10
+ for endorsement in client.endorsements.list(states="CA"): # every item
11
+ ...
12
+
13
+ With the async client, a list method returns an :class:`AsyncPaginator`:
14
+ await it for the first page, or iterate it with ``async for`` for every item::
15
+
16
+ page = await client.endorsements.list(states="CA")
17
+ async for endorsement in client.endorsements.list(states="CA"):
18
+ ...
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import AsyncIterator, Awaitable, Callable, Generator, Iterator
24
+ from typing import Any, Generic, TypeVar
25
+
26
+ T = TypeVar("T")
27
+
28
+ __all__ = ["SyncPage", "AsyncPage", "AsyncPaginator"]
29
+
30
+
31
+ class _Page(Generic[T]):
32
+ data: list[T]
33
+ """This page's items."""
34
+ next_cursor: str | None
35
+ """Pass as ``cursor`` for the next page; None on the last page."""
36
+
37
+ def __init__(self, body: Any) -> None:
38
+ body = body or {}
39
+ self.data = list(body.get("data") or [])
40
+ self.next_cursor = body.get("next_cursor") or None
41
+
42
+ def has_next_page(self) -> bool:
43
+ """Whether there is another page after this one."""
44
+ return self.next_cursor is not None
45
+
46
+ def __repr__(self) -> str:
47
+ return f"{type(self).__name__}(data=<{len(self.data)} items>, next_cursor={self.next_cursor!r})"
48
+
49
+
50
+ class SyncPage(_Page[T]):
51
+ """One page of a list. Iterate it for every item across every page."""
52
+
53
+ def __init__(self, body: Any, fetch: Callable[[str], Any]) -> None:
54
+ super().__init__(body)
55
+ self._fetch = fetch
56
+
57
+ def next_page(self) -> SyncPage[T] | None:
58
+ """The next page, or None after the last."""
59
+ if self.next_cursor is None:
60
+ return None
61
+ return SyncPage(self._fetch(self.next_cursor), self._fetch)
62
+
63
+ def iter_pages(self) -> Iterator[SyncPage[T]]:
64
+ """This page and every page after it."""
65
+ page: SyncPage[T] | None = self
66
+ while page is not None:
67
+ yield page
68
+ page = page.next_page()
69
+
70
+ def __iter__(self) -> Iterator[T]:
71
+ """Every item on this page and every page after it."""
72
+ for page in self.iter_pages():
73
+ yield from page.data
74
+
75
+ def to_list(self, max: int | None = None) -> list[T]:
76
+ """Every item collected into a list. Mind large lists: pass ``max`` to stop after that many."""
77
+ out: list[T] = []
78
+ if max is not None and max <= 0:
79
+ return out
80
+ for item in self:
81
+ out.append(item)
82
+ if max is not None and len(out) >= max:
83
+ break
84
+ return out
85
+
86
+
87
+ class AsyncPage(_Page[T]):
88
+ """One page of a list. Iterate it with ``async for`` for every item across every page."""
89
+
90
+ def __init__(self, body: Any, fetch: Callable[[str], Awaitable[Any]]) -> None:
91
+ super().__init__(body)
92
+ self._fetch = fetch
93
+
94
+ async def next_page(self) -> AsyncPage[T] | None:
95
+ """The next page, or None after the last."""
96
+ if self.next_cursor is None:
97
+ return None
98
+ return AsyncPage(await self._fetch(self.next_cursor), self._fetch)
99
+
100
+ async def iter_pages(self) -> AsyncIterator[AsyncPage[T]]:
101
+ """This page and every page after it."""
102
+ page: AsyncPage[T] | None = self
103
+ while page is not None:
104
+ yield page
105
+ page = await page.next_page()
106
+
107
+ async def __aiter__(self) -> AsyncIterator[T]:
108
+ """Every item on this page and every page after it."""
109
+ async for page in self.iter_pages():
110
+ for item in page.data:
111
+ yield item
112
+
113
+
114
+ class AsyncPaginator(Generic[T]):
115
+ """What an async list method returns. Await it for the first page, or
116
+ iterate it with ``async for`` for every item on every page."""
117
+
118
+ def __init__(self, fetch: Callable[[str | None], Awaitable[Any]], start_cursor: str | None = None) -> None:
119
+ self._fetch = fetch
120
+ self._start = start_cursor
121
+ self._first: AsyncPage[T] | None = None
122
+
123
+ async def _first_page(self) -> AsyncPage[T]:
124
+ if self._first is None:
125
+ self._first = AsyncPage(await self._fetch(self._start), self._fetch)
126
+ return self._first
127
+
128
+ def __await__(self) -> Generator[Any, None, AsyncPage[T]]:
129
+ return self._first_page().__await__()
130
+
131
+ async def iter_pages(self) -> AsyncIterator[AsyncPage[T]]:
132
+ """Every page in turn, starting with the first."""
133
+ first = await self._first_page()
134
+ async for page in first.iter_pages():
135
+ yield page
136
+
137
+ async def __aiter__(self) -> AsyncIterator[T]:
138
+ """Every item on every page."""
139
+ async for page in self.iter_pages():
140
+ for item in page.data:
141
+ yield item
142
+
143
+ async def to_list(self, max: int | None = None) -> list[T]:
144
+ """Every item collected into a list. Mind large lists: pass ``max`` to stop after that many."""
145
+ out: list[T] = []
146
+ if max is not None and max <= 0:
147
+ return out
148
+ async for item in self:
149
+ out.append(item)
150
+ if max is not None and len(out) >= max:
151
+ break
152
+ return out