ocpf 0.1.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.
- ocpf-0.1.0.dist-info/METADATA +190 -0
- ocpf-0.1.0.dist-info/RECORD +14 -0
- ocpf-0.1.0.dist-info/WHEEL +4 -0
- ocpf-0.1.0.dist-info/entry_points.txt +2 -0
- ocpf-0.1.0.dist-info/licenses/LICENSE +21 -0
- ocpf_cli/__init__.py +3 -0
- ocpf_cli/api.py +80 -0
- ocpf_cli/cli.py +35 -0
- ocpf_cli/commands/__init__.py +1 -0
- ocpf_cli/commands/filer.py +217 -0
- ocpf_cli/commands/race.py +244 -0
- ocpf_cli/districts.py +116 -0
- ocpf_cli/legislative.py +104 -0
- ocpf_cli/render.py +89 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ocpf
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An opinionated command-line interface to the OCPF (Massachusetts campaign finance) API
|
|
5
|
+
Project-URL: Homepage, https://github.com/bwbensonjr/ocpf-cli
|
|
6
|
+
Project-URL: Repository, https://github.com/bwbensonjr/ocpf-cli
|
|
7
|
+
Project-URL: Issues, https://github.com/bwbensonjr/ocpf-cli/issues
|
|
8
|
+
Author-email: Brent Benson <bwbensonjr@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: campaign-finance,cli,elections,massachusetts,ocpf,politics
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Utilities
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Requires-Dist: httpx>=0.27
|
|
23
|
+
Requires-Dist: typer>=0.12
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# OCPF Command Line Interface
|
|
30
|
+
|
|
31
|
+
`ocpf` is an opinionated command-line interface to the Massachusetts
|
|
32
|
+
[Office of Campaign and Political Finance](https://www.ocpf.us/) (OCPF) API
|
|
33
|
+
(`https://api.ocpf.us/`). It turns a recurring, multi-step lookup — "who is
|
|
34
|
+
running in this district and how much have they raised and spent?" — into a
|
|
35
|
+
single command.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
Run it with no install at all using [`uvx`](https://docs.astral.sh/uv/):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uvx ocpf race 37th
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Or install the `ocpf` command onto your PATH:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pipx install ocpf # isolated, recommended
|
|
49
|
+
pip install ocpf # into the current environment
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Then:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ocpf --help
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
ocpf race <district> [--year <year>] [--json]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`ocpf race` produces a year-to-date (YTD) financial summary of the legislative
|
|
65
|
+
(House or Senate) candidates in a district.
|
|
66
|
+
|
|
67
|
+
- `<district>` may be a **name** matched case-insensitively against OCPF's
|
|
68
|
+
district descriptions (`&` and `and` are treated alike) or a **raw numeric
|
|
69
|
+
district code**. Ambiguous names are never guessed — the tool prints the
|
|
70
|
+
matching districts with their codes and exits so you can pick one.
|
|
71
|
+
- `--year` defaults to the current calendar year.
|
|
72
|
+
- `--json` emits the merged, filtered candidate records (including the
|
|
73
|
+
underlying `*Numeric` values) as JSON to stdout. Human status/progress goes
|
|
74
|
+
to stderr, so JSON output stays pipeable.
|
|
75
|
+
|
|
76
|
+
### Example
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
$ ocpf race "Suffolk and Middlesex" --year 2026
|
|
80
|
+
District: Senate, Suffolk and Middlesex (code 166)
|
|
81
|
+
Election: primary 9/1/2026, general 11/3/2026
|
|
82
|
+
As of: 6/30/2026 (year-to-date, cumulative)
|
|
83
|
+
|
|
84
|
+
Candidate Party Inc Raised YTD Spent YTD Cash on Hand
|
|
85
|
+
------------------------ ----- --- ----------- ----------- ------------
|
|
86
|
+
Brownsberger, William N. - * $265,435.76 $135,490.74 $326,673.49
|
|
87
|
+
Lander, Daniel - $117,740.53 $32,674.59 $136,386.28
|
|
88
|
+
Wood, Brandon - $80.00 $3.00 $77.00
|
|
89
|
+
|
|
90
|
+
* incumbent (holds this seat)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Election dates and the as-of date are **timeline context**. The money is the
|
|
94
|
+
single cumulative YTD figure the API provides for each candidate; it is never
|
|
95
|
+
split into per-primary and per-general amounts.
|
|
96
|
+
|
|
97
|
+
### `ocpf filer` — one candidate's filing summary
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
ocpf filer <filer> [--year <year>] [--json]
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`ocpf filer` drills into a single filer: their committee profile, cumulative
|
|
104
|
+
YTD finances, and most recent reports.
|
|
105
|
+
|
|
106
|
+
- `<filer>` may be a **raw numeric cpfId** (works for any filer type) or a
|
|
107
|
+
**candidate name** matched case-insensitively against the legislative field
|
|
108
|
+
for the year. cpfIds are shown by `ocpf race`. As with district names,
|
|
109
|
+
ambiguous names are never guessed — the tool prints the matching filers with
|
|
110
|
+
their cpfIds and exits. Name lookup covers legislative filers; for other
|
|
111
|
+
filer types, pass a cpfId directly.
|
|
112
|
+
- `--year` defaults to the current calendar year (used for name resolution and
|
|
113
|
+
YTD context).
|
|
114
|
+
- `--json` emits the `filer`, `ytdReport`, and `logReports` records (including
|
|
115
|
+
numeric values) to stdout.
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
$ ocpf filer "Brownsberger" --year 2026
|
|
119
|
+
Filer: Brownsberger, William N. (cpfId 14454)
|
|
120
|
+
Committee: Brownsberger Committee
|
|
121
|
+
Party: Democratic Type: Legislative Candidates
|
|
122
|
+
Office: Senate, Suffolk and Middlesex
|
|
123
|
+
Status: active
|
|
124
|
+
Organized: 12/13/2005
|
|
125
|
+
Treasurer: David Merfeld
|
|
126
|
+
|
|
127
|
+
Year-to-date (as of 6/30/2026):
|
|
128
|
+
Raised YTD: $265,435.76
|
|
129
|
+
Spent YTD: $135,490.74
|
|
130
|
+
Cash on Hand: $326,673.49
|
|
131
|
+
|
|
132
|
+
Recent reports:
|
|
133
|
+
Type Period Filed Receipts Expenditures
|
|
134
|
+
-------------- ------- -------------- --------- ------------
|
|
135
|
+
Deposit Report 7/20/26 Mon, 7/20/2026 $500.00 $0.00
|
|
136
|
+
...
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Scope
|
|
140
|
+
|
|
141
|
+
v1 covers **legislative** races (House and Senate). Other office types
|
|
142
|
+
(statewide, county, mayoral, ballot question), drill-down into individual
|
|
143
|
+
reports/donors/expenditures, and free-text candidate-name search are out of
|
|
144
|
+
scope. See `openspec/` for the design and specifications.
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
The project is managed with [`uv`](https://docs.astral.sh/uv/). From a clone of
|
|
149
|
+
this repository:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
uv sync --extra dev # runtime deps + pytest/respx
|
|
153
|
+
uv run pytest # run the test suite
|
|
154
|
+
uv run ocpf race 37th # run the CLI from source
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Releasing
|
|
158
|
+
|
|
159
|
+
Releases are published to PyPI automatically by GitHub Actions when a version tag
|
|
160
|
+
is pushed. Versioning is tag-driven (via `hatch-vcs`), so the tag is the single
|
|
161
|
+
source of truth for the package version:
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
git tag v0.1.0
|
|
165
|
+
git push --tags
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The release workflow runs the test suite, builds the sdist and wheel, and
|
|
169
|
+
publishes to PyPI using [Trusted Publishing](https://docs.pypi.org/trusted-publishers/)
|
|
170
|
+
(OIDC) — no API token is stored in the repository.
|
|
171
|
+
|
|
172
|
+
**First release only:** before pushing the first tag, register a *pending*
|
|
173
|
+
Trusted Publisher on PyPI (Your projects → Publishing) so the initial upload can
|
|
174
|
+
create the project:
|
|
175
|
+
|
|
176
|
+
- PyPI project name: `ocpf`
|
|
177
|
+
- Owner: `bwbensonjr` · Repository: `ocpf-cli`
|
|
178
|
+
- Workflow: `release.yml` · Environment: `pypi`
|
|
179
|
+
|
|
180
|
+
## Design Guidelines
|
|
181
|
+
|
|
182
|
+
- Use OpenSpec to draft designs and create change proposals.
|
|
183
|
+
- Use [clig.dev](https://clig.dev/) for command line interface guidelines.
|
|
184
|
+
- Use the `github.com/bwbensonjr/ocpf-analysis` repository (available
|
|
185
|
+
locally) for information about the OCPF APIs, especially the `/api`
|
|
186
|
+
directory which contains `ENDPOINT-STATUS.md` and other script and
|
|
187
|
+
OCPF API test code.
|
|
188
|
+
- Use the `github.com/bwbensonjr/ma-election-db` repository (available
|
|
189
|
+
locally) for information on candidates and districts we might want
|
|
190
|
+
to look up.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
ocpf_cli/__init__.py,sha256=v9NPEHoM3NguJ5wWtiw60cF0ZL6Iar0q5gz5EKHipl8,94
|
|
2
|
+
ocpf_cli/api.py,sha256=4m8koY0hM3bpdH5KMQxKexPYXMWjhLVWXyecw8QNlYc,2298
|
|
3
|
+
ocpf_cli/cli.py,sha256=Llw7bzbl7dXh3Urqe0y6gMHcru_TV_azX2nUEnwORw0,911
|
|
4
|
+
ocpf_cli/districts.py,sha256=BBdvCECvdmBd6V0FaARZJdVcnVU6i-EEDuyzjHENomY,3738
|
|
5
|
+
ocpf_cli/legislative.py,sha256=vazG2OTHoqlanG_hIx4MS509adJGbQH1l9emmdtaWwU,4111
|
|
6
|
+
ocpf_cli/render.py,sha256=J_kvGf6Zqoyn7X3vRYvx2Baon_3W61-4uLRLCLIFzFE,2727
|
|
7
|
+
ocpf_cli/commands/__init__.py,sha256=_AADeyIQCA5GT0tAHVldze53scgIXA-EvUxISO5bxEA,36
|
|
8
|
+
ocpf_cli/commands/filer.py,sha256=_wmU8YopX1UI2a9DR7AMYGzHzlOsjaak4taRrC0J3K0,7300
|
|
9
|
+
ocpf_cli/commands/race.py,sha256=nKGqd4K9cE009HeAD8zo8VVb0XNrknvVf4sWRTr6ZhU,8207
|
|
10
|
+
ocpf-0.1.0.dist-info/METADATA,sha256=tfKWbo8KL24vWvDlX9dFzL53-HrADX59kzpZgchjDFU,6755
|
|
11
|
+
ocpf-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
ocpf-0.1.0.dist-info/entry_points.txt,sha256=WYlJK_0txq2VvbmbkLXuE3ZlonlGy9pyuJjHalcxZTE,42
|
|
13
|
+
ocpf-0.1.0.dist-info/licenses/LICENSE,sha256=ebfY9LaFkb36Mhe17raVRibZx12WMYkVk3EhohZ0iP0,1069
|
|
14
|
+
ocpf-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Brent Benson
|
|
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.
|
ocpf_cli/__init__.py
ADDED
ocpf_cli/api.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Shared HTTP client for the OCPF REST API.
|
|
2
|
+
|
|
3
|
+
All network access to `https://api.ocpf.us/` funnels through `get_json`, so
|
|
4
|
+
commands stay testable (mock one function) and error handling lives in one
|
|
5
|
+
place. Failures surface as `OcpfApiError` rather than raw library exceptions.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
BASE_URL = "https://api.ocpf.us/"
|
|
15
|
+
|
|
16
|
+
# Default per-request timeout in seconds. The OCPF API is generally fast, but
|
|
17
|
+
# some report-page endpoints return hundreds of rows.
|
|
18
|
+
DEFAULT_TIMEOUT = 30.0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class OcpfApiError(Exception):
|
|
22
|
+
"""A failure talking to the OCPF API.
|
|
23
|
+
|
|
24
|
+
Carries the requested `path` and, for HTTP errors, the `status_code`.
|
|
25
|
+
Network and timeout failures leave `status_code` as None.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
message: str,
|
|
31
|
+
*,
|
|
32
|
+
path: str,
|
|
33
|
+
status_code: int | None = None,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.path = path
|
|
37
|
+
self.status_code = status_code
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_json(
|
|
41
|
+
path: str,
|
|
42
|
+
params: dict[str, Any] | None = None,
|
|
43
|
+
*,
|
|
44
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
45
|
+
) -> Any:
|
|
46
|
+
"""GET `path` from the OCPF API and return the parsed JSON body.
|
|
47
|
+
|
|
48
|
+
`path` is joined onto `BASE_URL`; a leading slash is tolerated. Raises
|
|
49
|
+
`OcpfApiError` on any HTTP 4xx/5xx status, network failure, timeout, or
|
|
50
|
+
unparseable body.
|
|
51
|
+
"""
|
|
52
|
+
url = BASE_URL + path.lstrip("/")
|
|
53
|
+
try:
|
|
54
|
+
response = httpx.get(url, params=params, timeout=timeout)
|
|
55
|
+
except httpx.TimeoutException as exc:
|
|
56
|
+
raise OcpfApiError(
|
|
57
|
+
f"Request to {path} timed out after {timeout:g}s",
|
|
58
|
+
path=path,
|
|
59
|
+
) from exc
|
|
60
|
+
except httpx.HTTPError as exc:
|
|
61
|
+
raise OcpfApiError(
|
|
62
|
+
f"Network error requesting {path}: {exc}",
|
|
63
|
+
path=path,
|
|
64
|
+
) from exc
|
|
65
|
+
|
|
66
|
+
if response.status_code >= 400:
|
|
67
|
+
raise OcpfApiError(
|
|
68
|
+
f"OCPF API returned HTTP {response.status_code} for {path}",
|
|
69
|
+
path=path,
|
|
70
|
+
status_code=response.status_code,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return response.json()
|
|
75
|
+
except ValueError as exc:
|
|
76
|
+
raise OcpfApiError(
|
|
77
|
+
f"OCPF API returned a non-JSON response for {path}",
|
|
78
|
+
path=path,
|
|
79
|
+
status_code=response.status_code,
|
|
80
|
+
) from exc
|
ocpf_cli/cli.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Typer application: the `ocpf` command and its subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from .commands.filer import filer
|
|
8
|
+
from .commands.race import race
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="ocpf",
|
|
12
|
+
help="Query the Massachusetts OCPF campaign-finance API from the command line.",
|
|
13
|
+
add_completion=False,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.callback(invoke_without_command=True)
|
|
18
|
+
def main(ctx: typer.Context) -> None:
|
|
19
|
+
"""Query the OCPF campaign-finance API from the command line.
|
|
20
|
+
|
|
21
|
+
With no subcommand, print top-level help and exit successfully. The
|
|
22
|
+
callback also keeps the app in multi-command (group) mode so that `race`
|
|
23
|
+
stays a named subcommand rather than collapsing into the root command.
|
|
24
|
+
"""
|
|
25
|
+
if ctx.invoked_subcommand is None:
|
|
26
|
+
print(ctx.get_help())
|
|
27
|
+
raise typer.Exit(code=0)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app.command("race")(race)
|
|
31
|
+
app.command("filer")(filer)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
app()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Subcommands for the ocpf CLI."""
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""`ocpf filer` — profile, YTD finances, and recent reports for one filer.
|
|
2
|
+
|
|
3
|
+
Pipeline: resolve the argument to a `cpfId` (a bare integer is used directly; a
|
|
4
|
+
name is matched against the legislative field for the year) -> fetch
|
|
5
|
+
`filer/payload/{cpfId}` in one call -> render the profile, YTD line, and recent
|
|
6
|
+
filing log (or JSON).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import date
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from .. import api, render
|
|
18
|
+
from ..legislative import fetch_merged_field
|
|
19
|
+
|
|
20
|
+
FILER_PAYLOAD_PATH = "filer/payload/{cpf_id}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class FilerMatch:
|
|
25
|
+
"""A candidate name match from the legislative field."""
|
|
26
|
+
|
|
27
|
+
cpf_id: int
|
|
28
|
+
name: str
|
|
29
|
+
office: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FilerResolutionError(Exception):
|
|
33
|
+
"""Name resolution failed. `matches` is populated for the ambiguous case."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, *, matches: list[FilerMatch] | None = None) -> None:
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.matches = matches or []
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _normalize(text: str) -> str:
|
|
41
|
+
"""Lowercase and collapse whitespace for forgiving name matching."""
|
|
42
|
+
return " ".join(text.lower().split())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_filer(query: str, year: int) -> int:
|
|
46
|
+
"""Resolve `query` to a single `cpfId`.
|
|
47
|
+
|
|
48
|
+
A bare integer is used directly (works for any filer type). Otherwise the
|
|
49
|
+
value is matched case-insensitively against `filerName` in the legislative
|
|
50
|
+
field for `year`; ambiguity is never guessed away.
|
|
51
|
+
"""
|
|
52
|
+
stripped = query.strip()
|
|
53
|
+
if stripped.lstrip("-").isdigit():
|
|
54
|
+
return int(stripped)
|
|
55
|
+
|
|
56
|
+
target = _normalize(query)
|
|
57
|
+
field = fetch_merged_field(year)
|
|
58
|
+
matches = [
|
|
59
|
+
FilerMatch(
|
|
60
|
+
cpf_id=row.get("cpfId"),
|
|
61
|
+
name=row.get("filerName", ""),
|
|
62
|
+
office=row.get("officeSought", ""),
|
|
63
|
+
)
|
|
64
|
+
for row in field
|
|
65
|
+
if target in _normalize(row.get("filerName", ""))
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
if len(matches) == 1:
|
|
69
|
+
return matches[0].cpf_id
|
|
70
|
+
if len(matches) == 0:
|
|
71
|
+
raise FilerResolutionError(
|
|
72
|
+
f'"{query}" matches no legislative filer for {year}; '
|
|
73
|
+
f"pass a numeric cpfId directly to look up any filer"
|
|
74
|
+
)
|
|
75
|
+
raise FilerResolutionError(
|
|
76
|
+
f'"{query}" matches more than one legislative filer',
|
|
77
|
+
matches=matches,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def fetch_filer_payload(cpf_id: int) -> dict:
|
|
82
|
+
"""Fetch `filer/payload/{cpfId}`; raise on an unknown/invalid cpfId.
|
|
83
|
+
|
|
84
|
+
OCPF returns HTTP 400 for an out-of-range cpfId; we translate that into a
|
|
85
|
+
clear "no filer found" error rather than a bare HTTP message.
|
|
86
|
+
"""
|
|
87
|
+
try:
|
|
88
|
+
payload = api.get_json(FILER_PAYLOAD_PATH.format(cpf_id=cpf_id))
|
|
89
|
+
except api.OcpfApiError as exc:
|
|
90
|
+
if exc.status_code == 400:
|
|
91
|
+
raise FilerResolutionError(f"No filer found for cpfId {cpf_id}") from exc
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
if not isinstance(payload, dict) or not payload.get("filer"):
|
|
95
|
+
raise FilerResolutionError(f"No filer found for cpfId {cpf_id}")
|
|
96
|
+
return payload
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _office_str(value: Any) -> str:
|
|
100
|
+
"""Render an office field that may be a string or a nested object."""
|
|
101
|
+
if isinstance(value, dict):
|
|
102
|
+
return value.get("officeDistrict") or value.get("display") or ""
|
|
103
|
+
return value or ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _render_summary(payload: dict) -> None:
|
|
107
|
+
"""Print the human-readable profile, YTD line, and recent-reports table."""
|
|
108
|
+
filer = payload.get("filer", {})
|
|
109
|
+
|
|
110
|
+
name = filer.get("fullNameReverse") or filer.get("fullName") or ""
|
|
111
|
+
print(f"Filer: {name} (cpfId {filer.get('cpfId')})")
|
|
112
|
+
if filer.get("committeeName"):
|
|
113
|
+
print(f"Committee: {filer.get('committeeName')}")
|
|
114
|
+
party = filer.get("partyAffiliation") or "-"
|
|
115
|
+
acct = filer.get("accountType", {}).get("description", "")
|
|
116
|
+
print(f"Party: {party}" + (f" Type: {acct}" if acct else ""))
|
|
117
|
+
|
|
118
|
+
office = _office_str(filer.get("officeSoughtDescription") or filer.get("officeSought"))
|
|
119
|
+
if office:
|
|
120
|
+
print(f"Office: {office}")
|
|
121
|
+
held = _office_str(filer.get("officeHeld"))
|
|
122
|
+
if held and held != office:
|
|
123
|
+
print(f"Holds: {held}")
|
|
124
|
+
|
|
125
|
+
if filer.get("isActive") and not filer.get("closedDate"):
|
|
126
|
+
status = "active"
|
|
127
|
+
else:
|
|
128
|
+
status = "closed"
|
|
129
|
+
if filer.get("closedDate"):
|
|
130
|
+
status += f" ({filer.get('closedDate')})"
|
|
131
|
+
print(f"Status: {status}")
|
|
132
|
+
if filer.get("organizationDate"):
|
|
133
|
+
print(f"Organized: {filer.get('organizationDate')}")
|
|
134
|
+
|
|
135
|
+
treasurer = filer.get("treasurer") or {}
|
|
136
|
+
if isinstance(treasurer, dict) and treasurer.get("fullName"):
|
|
137
|
+
print(f"Treasurer: {treasurer.get('fullName')}")
|
|
138
|
+
|
|
139
|
+
# Year-to-date finances (single cumulative figure, never split by election).
|
|
140
|
+
ytd = payload.get("ytdReport") or {}
|
|
141
|
+
print()
|
|
142
|
+
if ytd:
|
|
143
|
+
as_of = ytd.get("bankReportEndDate")
|
|
144
|
+
suffix = f" (as of {as_of})" if as_of else ""
|
|
145
|
+
print(f"Year-to-date{suffix}:")
|
|
146
|
+
print(f" Raised YTD: {render.format_currency(ytd.get('receiptsYtdNumeric'))}")
|
|
147
|
+
print(f" Spent YTD: {render.format_currency(ytd.get('expendituresYtdNumeric'))}")
|
|
148
|
+
print(f" Cash on Hand: {render.format_currency(ytd.get('currentCashOnHandNumeric'))}")
|
|
149
|
+
else:
|
|
150
|
+
print("Year-to-date: no current summary on file")
|
|
151
|
+
|
|
152
|
+
# Recent filing log.
|
|
153
|
+
logs = payload.get("logReports") or []
|
|
154
|
+
print()
|
|
155
|
+
if not logs:
|
|
156
|
+
print("Recent reports: none found")
|
|
157
|
+
return
|
|
158
|
+
print("Recent reports:")
|
|
159
|
+
rows = [
|
|
160
|
+
[
|
|
161
|
+
log.get("reportTypeDescription", ""),
|
|
162
|
+
log.get("reportingPeriod", ""),
|
|
163
|
+
log.get("dateFiledHeader", "") or log.get("dateFiled", ""),
|
|
164
|
+
log.get("receiptTotal", ""),
|
|
165
|
+
log.get("expenditureTotal", ""),
|
|
166
|
+
]
|
|
167
|
+
for log in logs
|
|
168
|
+
]
|
|
169
|
+
headers = ["Type", "Period", "Filed", "Receipts", "Expenditures"]
|
|
170
|
+
print(render.render_table(rows, headers, right_align=[3, 4]))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def filer(
|
|
174
|
+
filer: str = typer.Argument(
|
|
175
|
+
..., help="Numeric cpfId, or a candidate name (legislative filers)"
|
|
176
|
+
),
|
|
177
|
+
year: int = typer.Option(
|
|
178
|
+
None, "--year", help="Year for name resolution / YTD context (default: current)"
|
|
179
|
+
),
|
|
180
|
+
json_output: bool = typer.Option(
|
|
181
|
+
False, "--json", help="Emit the filer profile, YTD, and reports as JSON"
|
|
182
|
+
),
|
|
183
|
+
) -> None:
|
|
184
|
+
"""Profile, year-to-date finances, and recent reports for a single filer."""
|
|
185
|
+
if year is None:
|
|
186
|
+
year = date.today().year
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
cpf_id = resolve_filer(filer, year)
|
|
190
|
+
except FilerResolutionError as exc:
|
|
191
|
+
render.error(str(exc))
|
|
192
|
+
for m in exc.matches:
|
|
193
|
+
render.status(f" {m.cpf_id} {m.name} ({m.office})")
|
|
194
|
+
raise typer.Exit(code=1)
|
|
195
|
+
except api.OcpfApiError as exc:
|
|
196
|
+
render.error(str(exc))
|
|
197
|
+
raise typer.Exit(code=1)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
payload = fetch_filer_payload(cpf_id)
|
|
201
|
+
except FilerResolutionError as exc:
|
|
202
|
+
render.error(str(exc))
|
|
203
|
+
raise typer.Exit(code=1)
|
|
204
|
+
except api.OcpfApiError as exc:
|
|
205
|
+
render.error(str(exc))
|
|
206
|
+
raise typer.Exit(code=1)
|
|
207
|
+
|
|
208
|
+
if json_output:
|
|
209
|
+
render.emit_json(
|
|
210
|
+
{
|
|
211
|
+
"filer": payload.get("filer"),
|
|
212
|
+
"ytdReport": payload.get("ytdReport"),
|
|
213
|
+
"logReports": payload.get("logReports"),
|
|
214
|
+
}
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
_render_summary(payload)
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""`ocpf race` — YTD financial summary for a legislative district's candidates.
|
|
2
|
+
|
|
3
|
+
Pipeline: resolve the district -> fetch and merge the depository + non-depository
|
|
4
|
+
legislative feeds -> filter to the district -> add timeline context (election
|
|
5
|
+
dates, as-of date) -> render a table (or JSON).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from .. import api, render
|
|
16
|
+
from ..districts import DistrictResolutionError, District, resolve_district
|
|
17
|
+
from ..legislative import (
|
|
18
|
+
DEPOSITORY_PATH,
|
|
19
|
+
NON_DEPOSITORY_PATH,
|
|
20
|
+
fetch_finsummaries,
|
|
21
|
+
fetch_merged_field,
|
|
22
|
+
has_money,
|
|
23
|
+
normalize_finsummaries,
|
|
24
|
+
reports_of,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Backwards-compatible alias: the merge helpers now live in `legislative`.
|
|
28
|
+
_reports_of = reports_of
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def filter_by_district(rows: list[dict], code: int) -> list[dict]:
|
|
32
|
+
"""Keep rows sought-for or held-in the given district code."""
|
|
33
|
+
return [
|
|
34
|
+
row
|
|
35
|
+
for row in rows
|
|
36
|
+
if row.get("districtCodeSought") == code
|
|
37
|
+
or row.get("districtCodeHeld") == code
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_report_date(value: str | None) -> tuple[int, int, int] | None:
|
|
42
|
+
"""Parse an `M/D/YYYY` OCPF date into a sortable `(y, m, d)` tuple."""
|
|
43
|
+
if not value:
|
|
44
|
+
return None
|
|
45
|
+
parts = value.split("/")
|
|
46
|
+
if len(parts) != 3:
|
|
47
|
+
return None
|
|
48
|
+
try:
|
|
49
|
+
month, day, year = (int(p) for p in parts)
|
|
50
|
+
except ValueError:
|
|
51
|
+
return None
|
|
52
|
+
return (year, month, day)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class Timeline:
|
|
57
|
+
"""Timeline context for the summary header."""
|
|
58
|
+
|
|
59
|
+
primary_election_date: str | None
|
|
60
|
+
general_election_date: str | None
|
|
61
|
+
as_of_date: str | None
|
|
62
|
+
lagging_filers: list[str]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_timeline(year: int, rows: list[dict]) -> Timeline:
|
|
66
|
+
"""Fetch election dates and derive the as-of date from the matched rows.
|
|
67
|
+
|
|
68
|
+
The as-of date is the latest `bankReportEndDate` present; any row whose
|
|
69
|
+
date lags behind that is flagged so comparisons aren't silently uneven.
|
|
70
|
+
"""
|
|
71
|
+
schedule = api.get_json(f"filingSchedules/{year}")
|
|
72
|
+
primary = schedule.get("primaryElectionDate") if isinstance(schedule, dict) else None
|
|
73
|
+
general = schedule.get("generalElectionDate") if isinstance(schedule, dict) else None
|
|
74
|
+
|
|
75
|
+
dated = [
|
|
76
|
+
(row, _parse_report_date(row.get("bankReportEndDate")))
|
|
77
|
+
for row in rows
|
|
78
|
+
]
|
|
79
|
+
present = [(row, d) for row, d in dated if d is not None]
|
|
80
|
+
|
|
81
|
+
as_of_date: str | None = None
|
|
82
|
+
lagging: list[str] = []
|
|
83
|
+
if present:
|
|
84
|
+
latest = max(d for _, d in present)
|
|
85
|
+
as_of_row = next(row for row, d in present if d == latest)
|
|
86
|
+
as_of_date = as_of_row.get("bankReportEndDate")
|
|
87
|
+
lagging = [
|
|
88
|
+
row.get("filerName", "")
|
|
89
|
+
for row, d in present
|
|
90
|
+
if d != latest
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
return Timeline(
|
|
94
|
+
primary_election_date=primary,
|
|
95
|
+
general_election_date=general,
|
|
96
|
+
as_of_date=as_of_date,
|
|
97
|
+
lagging_filers=lagging,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _is_incumbent(row: dict, code: int) -> bool:
|
|
102
|
+
# Historical (finsummaries) rows carry an explicit flag and a districtCode of
|
|
103
|
+
# 0, so they can't be matched on districtCodeHeld; current-cycle rows use the
|
|
104
|
+
# held-code comparison.
|
|
105
|
+
if "isIncumbent" in row:
|
|
106
|
+
return bool(row["isIncumbent"])
|
|
107
|
+
return row.get("districtCodeHeld") == code
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _sort_key(row: dict) -> Any:
|
|
111
|
+
# Order by receipts descending (biggest fundraiser first), then name.
|
|
112
|
+
return (-(row.get("receiptsYtdNumeric") or 0), row.get("filerName", ""))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _render_table(
|
|
116
|
+
district: District,
|
|
117
|
+
rows: list[dict],
|
|
118
|
+
timeline: Timeline,
|
|
119
|
+
historical: bool = False,
|
|
120
|
+
) -> None:
|
|
121
|
+
"""Print the human-readable summary header and table to stdout.
|
|
122
|
+
|
|
123
|
+
Current-cycle data renders as a YTD snapshot with an as-of date. Historical
|
|
124
|
+
(finsummaries) data renders as final full-cycle totals: no as-of line,
|
|
125
|
+
final-total column labels, and a winner marker.
|
|
126
|
+
"""
|
|
127
|
+
print(f"District: {district.label} (code {district.code})")
|
|
128
|
+
dates = []
|
|
129
|
+
if timeline.primary_election_date:
|
|
130
|
+
dates.append(f"primary {timeline.primary_election_date}")
|
|
131
|
+
if timeline.general_election_date:
|
|
132
|
+
dates.append(f"general {timeline.general_election_date}")
|
|
133
|
+
if dates:
|
|
134
|
+
print(f"Election: {', '.join(dates)}")
|
|
135
|
+
if timeline.as_of_date:
|
|
136
|
+
print(f"As of: {timeline.as_of_date} (year-to-date, cumulative)")
|
|
137
|
+
print()
|
|
138
|
+
|
|
139
|
+
ordered = sorted(rows, key=_sort_key)
|
|
140
|
+
table_rows = []
|
|
141
|
+
for row in ordered:
|
|
142
|
+
incumbent = "*" if _is_incumbent(row, district.code) else ""
|
|
143
|
+
money = [
|
|
144
|
+
render.format_currency(row.get("receiptsYtdNumeric")),
|
|
145
|
+
render.format_currency(row.get("expendituresYtdNumeric")),
|
|
146
|
+
render.format_currency(row.get("currentCashOnHandNumeric")),
|
|
147
|
+
]
|
|
148
|
+
base = [
|
|
149
|
+
row.get("filerName", ""),
|
|
150
|
+
row.get("partyAffiliation", "") or "-",
|
|
151
|
+
incumbent,
|
|
152
|
+
]
|
|
153
|
+
if historical:
|
|
154
|
+
won = "W" if row.get("isWinner") else ""
|
|
155
|
+
table_rows.append(base + [won] + money)
|
|
156
|
+
else:
|
|
157
|
+
table_rows.append(base + money)
|
|
158
|
+
|
|
159
|
+
if historical:
|
|
160
|
+
headers = ["Candidate", "Party", "Inc", "Won", "Raised", "Spent", "End Bal"]
|
|
161
|
+
right_align = [4, 5, 6]
|
|
162
|
+
else:
|
|
163
|
+
headers = [
|
|
164
|
+
"Candidate",
|
|
165
|
+
"Party",
|
|
166
|
+
"Inc",
|
|
167
|
+
"Raised YTD",
|
|
168
|
+
"Spent YTD",
|
|
169
|
+
"Cash on Hand",
|
|
170
|
+
]
|
|
171
|
+
right_align = [3, 4, 5]
|
|
172
|
+
print(render.render_table(table_rows, headers, right_align=right_align))
|
|
173
|
+
print()
|
|
174
|
+
print("* incumbent (holds this seat)")
|
|
175
|
+
# Only explain the winner marker when at least one candidate carries it;
|
|
176
|
+
# OCPF's isWinner is unset for some historical races (e.g. unopposed).
|
|
177
|
+
if historical and any(row.get("isWinner") for row in rows):
|
|
178
|
+
print("W won the general election")
|
|
179
|
+
if timeline.lagging_filers:
|
|
180
|
+
names = ", ".join(timeline.lagging_filers)
|
|
181
|
+
render.status(
|
|
182
|
+
f"note: figures for {names} are as of an earlier report than "
|
|
183
|
+
f"{timeline.as_of_date}"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def race(
|
|
188
|
+
district: str = typer.Argument(
|
|
189
|
+
..., help="District name (e.g. 'Suffolk and Middlesex') or numeric code"
|
|
190
|
+
),
|
|
191
|
+
year: int = typer.Option(
|
|
192
|
+
None, "--year", help="Election/filing year (defaults to the current year)"
|
|
193
|
+
),
|
|
194
|
+
json_output: bool = typer.Option(
|
|
195
|
+
False, "--json", help="Emit merged candidate records as JSON"
|
|
196
|
+
),
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Year-to-date financial summary of the candidates in a legislative district."""
|
|
199
|
+
if year is None:
|
|
200
|
+
# Avoid importing datetime at module load; current year is a runtime fact.
|
|
201
|
+
from datetime import date
|
|
202
|
+
|
|
203
|
+
year = date.today().year
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
resolved = resolve_district(district)
|
|
207
|
+
except DistrictResolutionError as exc:
|
|
208
|
+
render.error(str(exc))
|
|
209
|
+
for cand in exc.candidates:
|
|
210
|
+
render.status(f" {cand.code} {cand.label}")
|
|
211
|
+
raise typer.Exit(code=1)
|
|
212
|
+
except api.OcpfApiError as exc:
|
|
213
|
+
render.error(str(exc))
|
|
214
|
+
raise typer.Exit(code=1)
|
|
215
|
+
|
|
216
|
+
try:
|
|
217
|
+
merged = fetch_merged_field(year)
|
|
218
|
+
matched = filter_by_district(merged, resolved.code)
|
|
219
|
+
historical = False
|
|
220
|
+
# The current-cycle feeds only carry money from ~2020 on. For earlier
|
|
221
|
+
# cycles they resolve names but leave the money blank, so fall back to
|
|
222
|
+
# the district-scoped historical summaries (final full-cycle totals).
|
|
223
|
+
if not has_money(matched):
|
|
224
|
+
hist_rows = normalize_finsummaries(
|
|
225
|
+
fetch_finsummaries(year, resolved.code)
|
|
226
|
+
)
|
|
227
|
+
if hist_rows:
|
|
228
|
+
matched = hist_rows
|
|
229
|
+
historical = True
|
|
230
|
+
if not matched:
|
|
231
|
+
render.error(
|
|
232
|
+
f"No candidates found for {resolved.label} (code "
|
|
233
|
+
f"{resolved.code}) in {year}"
|
|
234
|
+
)
|
|
235
|
+
raise typer.Exit(code=1)
|
|
236
|
+
timeline = build_timeline(year, matched)
|
|
237
|
+
except api.OcpfApiError as exc:
|
|
238
|
+
render.error(str(exc))
|
|
239
|
+
raise typer.Exit(code=1)
|
|
240
|
+
|
|
241
|
+
if json_output:
|
|
242
|
+
render.emit_json(sorted(matched, key=_sort_key))
|
|
243
|
+
else:
|
|
244
|
+
_render_table(resolved, matched, timeline, historical=historical)
|
ocpf_cli/districts.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""District resolution: turn a `<district>` argument into one OCPF code.
|
|
2
|
+
|
|
3
|
+
Resolution is district-first (the API's free-text filer search is dead). A raw
|
|
4
|
+
numeric code is validated against the legislative set; otherwise the name is
|
|
5
|
+
matched case-insensitively against district descriptions, restricted to
|
|
6
|
+
legislative offices (House and Senate). Ambiguity is never guessed away.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import api
|
|
15
|
+
|
|
16
|
+
# Office values in the `districts` reference that count as legislative.
|
|
17
|
+
LEGISLATIVE_OFFICES = ("House", "Senate")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class District:
|
|
22
|
+
"""A single legislative district from the OCPF `districts` reference."""
|
|
23
|
+
|
|
24
|
+
code: int
|
|
25
|
+
office: str
|
|
26
|
+
description: str
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def label(self) -> str:
|
|
30
|
+
"""Human label, e.g. `Senate, Suffolk and Middlesex`."""
|
|
31
|
+
return f"{self.office}, {self.description}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class DistrictResolutionError(Exception):
|
|
35
|
+
"""Resolution failed. `candidates` is populated for the ambiguous case."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self, message: str, *, candidates: list[District] | None = None
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.candidates = candidates or []
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _normalize(text: str) -> str:
|
|
45
|
+
"""Lowercase, collapse whitespace, and treat `&` and `and` alike.
|
|
46
|
+
|
|
47
|
+
So `"Suffolk and Middlesex"` and `"Suffolk & Middlesex"` normalize to the
|
|
48
|
+
same string, while `"Middlesex & Suffolk"` stays distinct (order matters).
|
|
49
|
+
"""
|
|
50
|
+
lowered = text.lower().replace("&", " and ")
|
|
51
|
+
return " ".join(lowered.split())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def fetch_legislative_districts() -> list[District]:
|
|
55
|
+
"""Fetch the `districts` reference and keep only House/Senate offices."""
|
|
56
|
+
raw: Any = api.get_json("districts")
|
|
57
|
+
districts = []
|
|
58
|
+
for row in raw:
|
|
59
|
+
office = row.get("office", "")
|
|
60
|
+
if office in LEGISLATIVE_OFFICES:
|
|
61
|
+
districts.append(
|
|
62
|
+
District(
|
|
63
|
+
code=int(row["code"]),
|
|
64
|
+
office=office,
|
|
65
|
+
description=row.get("description", ""),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
return districts
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def resolve_district(
|
|
72
|
+
query: str, districts: list[District] | None = None
|
|
73
|
+
) -> District:
|
|
74
|
+
"""Resolve `query` to exactly one legislative `District`.
|
|
75
|
+
|
|
76
|
+
Raises `DistrictResolutionError` on no match or ambiguous match (with the
|
|
77
|
+
candidate list attached), so the caller can print options without guessing.
|
|
78
|
+
"""
|
|
79
|
+
if districts is None:
|
|
80
|
+
districts = fetch_legislative_districts()
|
|
81
|
+
|
|
82
|
+
by_code = {d.code: d for d in districts}
|
|
83
|
+
|
|
84
|
+
# A bare integer is treated as a raw district code.
|
|
85
|
+
stripped = query.strip()
|
|
86
|
+
if stripped.lstrip("-").isdigit():
|
|
87
|
+
code = int(stripped)
|
|
88
|
+
if code in by_code:
|
|
89
|
+
return by_code[code]
|
|
90
|
+
raise DistrictResolutionError(
|
|
91
|
+
f"{code} is not a legislative (House/Senate) district code"
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
target = _normalize(query)
|
|
95
|
+
|
|
96
|
+
# Prefer an exact normalized-name match; fall back to substring matches.
|
|
97
|
+
exact = [d for d in districts if _normalize(d.description) == target]
|
|
98
|
+
if len(exact) == 1:
|
|
99
|
+
return exact[0]
|
|
100
|
+
if len(exact) > 1:
|
|
101
|
+
raise DistrictResolutionError(
|
|
102
|
+
f'"{query}" matches more than one legislative district',
|
|
103
|
+
candidates=exact,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
matches = [d for d in districts if target in _normalize(d.description)]
|
|
107
|
+
if len(matches) == 1:
|
|
108
|
+
return matches[0]
|
|
109
|
+
if len(matches) == 0:
|
|
110
|
+
raise DistrictResolutionError(
|
|
111
|
+
f'"{query}" matches no legislative (House/Senate) district'
|
|
112
|
+
)
|
|
113
|
+
raise DistrictResolutionError(
|
|
114
|
+
f'"{query}" matches more than one legislative district',
|
|
115
|
+
candidates=matches,
|
|
116
|
+
)
|
ocpf_cli/legislative.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Shared access to the legislative YTD field.
|
|
2
|
+
|
|
3
|
+
Both `ocpf race` and `ocpf filer` need the merged legislative field: `race`
|
|
4
|
+
filters it by district, `filer` matches names against it. The fetch/merge logic
|
|
5
|
+
lives here so there is one source of truth for the two feeds.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import api, render
|
|
13
|
+
|
|
14
|
+
# The two legislative feeds. The depository feed is the fuller record (bank
|
|
15
|
+
# reports) and wins on conflict; the non-depository feed catches smaller filers.
|
|
16
|
+
DEPOSITORY_PATH = "reports/legislative/depository/ytd/{year}"
|
|
17
|
+
NON_DEPOSITORY_PATH = "reports/legislative/race/nd/{year}"
|
|
18
|
+
|
|
19
|
+
# Historical fallback. The current-cycle feeds above are only populated from
|
|
20
|
+
# ~2020 on; earlier cycles live in this district-scoped endpoint, which returns
|
|
21
|
+
# a bare list of final full-cycle totals (not a YTD snapshot).
|
|
22
|
+
FINSUMMARIES_PATH = "onballot/finsummaries/{year}/{code}"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def reports_of(payload: Any) -> list[dict]:
|
|
26
|
+
"""Normalize a feed payload to a list of report rows.
|
|
27
|
+
|
|
28
|
+
The depository feed returns `{reports: [...], summary: {...}}`; the
|
|
29
|
+
non-depository feed returns a bare list. Either way we want the rows.
|
|
30
|
+
"""
|
|
31
|
+
if isinstance(payload, dict):
|
|
32
|
+
return list(payload.get("reports", []))
|
|
33
|
+
if isinstance(payload, list):
|
|
34
|
+
return list(payload)
|
|
35
|
+
return []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def fetch_merged_field(year: int) -> list[dict]:
|
|
39
|
+
"""Fetch both legislative feeds and merge by `cpfId` (depository wins)."""
|
|
40
|
+
depository = reports_of(api.get_json(DEPOSITORY_PATH.format(year=year)))
|
|
41
|
+
non_depository = reports_of(api.get_json(NON_DEPOSITORY_PATH.format(year=year)))
|
|
42
|
+
|
|
43
|
+
merged: dict[Any, dict] = {}
|
|
44
|
+
# Seed with non-depository first so depository rows overwrite on conflict.
|
|
45
|
+
for row in non_depository:
|
|
46
|
+
merged[row.get("cpfId")] = row
|
|
47
|
+
for row in depository:
|
|
48
|
+
merged[row.get("cpfId")] = row
|
|
49
|
+
return list(merged.values())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# The numeric money fields shared by the current-cycle feed rows and the
|
|
53
|
+
# normalized historical rows. Used to decide whether a field actually has money.
|
|
54
|
+
MONEY_FIELDS = (
|
|
55
|
+
"receiptsYtdNumeric",
|
|
56
|
+
"expendituresYtdNumeric",
|
|
57
|
+
"currentCashOnHandNumeric",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def has_money(rows: list[dict]) -> bool:
|
|
62
|
+
"""True if any row carries a nonzero value in any money field.
|
|
63
|
+
|
|
64
|
+
Drives the historical fallback: the current-cycle feeds resolve names for
|
|
65
|
+
older cycles but leave the money blank, so "did we actually get money?" is
|
|
66
|
+
the signal to fall back to `finsummaries`.
|
|
67
|
+
"""
|
|
68
|
+
return any(row.get(field) for row in rows for field in MONEY_FIELDS)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def fetch_finsummaries(year: int, code: int) -> list[dict]:
|
|
72
|
+
"""Fetch the district-scoped historical financial summaries (a bare list)."""
|
|
73
|
+
payload = api.get_json(FINSUMMARIES_PATH.format(year=year, code=code))
|
|
74
|
+
return list(payload) if isinstance(payload, list) else []
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def normalize_finsummaries(rows: list[dict]) -> list[dict]:
|
|
78
|
+
"""Map `finsummaries` rows into the shared candidate-row shape.
|
|
79
|
+
|
|
80
|
+
The historical feed reports money as currency strings (`receipts`,
|
|
81
|
+
`expenditures`, `endBalance`) and carries `isIncumbent`/`isWinner` flags.
|
|
82
|
+
We parse the strings into the same numeric fields the current-cycle path
|
|
83
|
+
uses so rendering, sorting, and `--json` work unchanged. `districtCode` is
|
|
84
|
+
`0` on every row, so incumbency comes from the flag, not the code.
|
|
85
|
+
"""
|
|
86
|
+
normalized = []
|
|
87
|
+
for row in rows:
|
|
88
|
+
normalized.append(
|
|
89
|
+
{
|
|
90
|
+
"cpfId": row.get("cpfId"),
|
|
91
|
+
"filerName": row.get("filerName", ""),
|
|
92
|
+
"partyAffiliation": row.get("partyAffiliation", ""),
|
|
93
|
+
"receiptsYtdNumeric": render.parse_currency(row.get("receipts")),
|
|
94
|
+
"expendituresYtdNumeric": render.parse_currency(
|
|
95
|
+
row.get("expenditures")
|
|
96
|
+
),
|
|
97
|
+
"currentCashOnHandNumeric": render.parse_currency(
|
|
98
|
+
row.get("endBalance")
|
|
99
|
+
),
|
|
100
|
+
"isIncumbent": bool(row.get("isIncumbent")),
|
|
101
|
+
"isWinner": bool(row.get("isWinner")),
|
|
102
|
+
}
|
|
103
|
+
)
|
|
104
|
+
return normalized
|
ocpf_cli/render.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Output rendering helpers: aligned human tables and JSON emission.
|
|
2
|
+
|
|
3
|
+
Conventions (per clig.dev):
|
|
4
|
+
- Data goes to stdout. Human status/progress and errors go to stderr, so a
|
|
5
|
+
`--json` stdout stays a clean, pipeable document.
|
|
6
|
+
- `emit_json` prints only JSON to stdout.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from typing import Any, Sequence
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def status(message: str) -> None:
|
|
17
|
+
"""Print a human status/progress line to stderr (never stdout)."""
|
|
18
|
+
print(message, file=sys.stderr)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def error(message: str) -> None:
|
|
22
|
+
"""Print an error line to stderr."""
|
|
23
|
+
print(f"error: {message}", file=sys.stderr)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def emit_json(value: Any) -> None:
|
|
27
|
+
"""Print `value` as a JSON document to stdout and nothing else."""
|
|
28
|
+
json.dump(value, sys.stdout, indent=2, default=str)
|
|
29
|
+
sys.stdout.write("\n")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def format_currency(amount: float | int | None) -> str:
|
|
33
|
+
"""Format a numeric dollar amount as `$1,234.56`. None -> empty string."""
|
|
34
|
+
if amount is None:
|
|
35
|
+
return ""
|
|
36
|
+
return f"${amount:,.2f}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_currency(value: str | float | int | None) -> float:
|
|
40
|
+
"""Parse a currency string like `$63,727.50` into a float.
|
|
41
|
+
|
|
42
|
+
Inverse of `format_currency` for the currency-string fields the historical
|
|
43
|
+
`finsummaries` feed returns. Blank or unparseable input yields `0.0`.
|
|
44
|
+
"""
|
|
45
|
+
if value is None:
|
|
46
|
+
return 0.0
|
|
47
|
+
if isinstance(value, (int, float)):
|
|
48
|
+
return float(value)
|
|
49
|
+
cleaned = value.strip().lstrip("$").replace(",", "")
|
|
50
|
+
if not cleaned:
|
|
51
|
+
return 0.0
|
|
52
|
+
try:
|
|
53
|
+
return float(cleaned)
|
|
54
|
+
except ValueError:
|
|
55
|
+
return 0.0
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render_table(
|
|
59
|
+
rows: Sequence[Sequence[Any]],
|
|
60
|
+
headers: Sequence[str],
|
|
61
|
+
*,
|
|
62
|
+
right_align: Sequence[int] | None = None,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Render `rows` as an aligned, labeled table.
|
|
65
|
+
|
|
66
|
+
`right_align` is a set of column indexes to right-justify (e.g. currency).
|
|
67
|
+
Returns the table as a string; the caller prints it to stdout.
|
|
68
|
+
"""
|
|
69
|
+
right = set(right_align or ())
|
|
70
|
+
str_rows = [["" if cell is None else str(cell) for cell in row] for row in rows]
|
|
71
|
+
widths = [len(h) for h in headers]
|
|
72
|
+
for row in str_rows:
|
|
73
|
+
for i, cell in enumerate(row):
|
|
74
|
+
widths[i] = max(widths[i], len(cell))
|
|
75
|
+
|
|
76
|
+
def fmt_row(cells: Sequence[str]) -> str:
|
|
77
|
+
out = []
|
|
78
|
+
for i, cell in enumerate(cells):
|
|
79
|
+
if i in right:
|
|
80
|
+
out.append(cell.rjust(widths[i]))
|
|
81
|
+
else:
|
|
82
|
+
out.append(cell.ljust(widths[i]))
|
|
83
|
+
return " ".join(out).rstrip()
|
|
84
|
+
|
|
85
|
+
lines = [fmt_row(list(headers))]
|
|
86
|
+
lines.append(" ".join("-" * widths[i] for i in range(len(headers))).rstrip())
|
|
87
|
+
for row in str_rows:
|
|
88
|
+
lines.append(fmt_row(row))
|
|
89
|
+
return "\n".join(lines)
|