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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ocpf = ocpf_cli.cli:app
@@ -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
@@ -0,0 +1,3 @@
1
+ """ocpf-cli: an opinionated command-line interface to the OCPF API."""
2
+
3
+ __version__ = "0.1.0"
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
+ )
@@ -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)