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/_transport.py
ADDED
|
@@ -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"])
|
ballotdata/pagination.py
ADDED
|
@@ -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
|