congress-py 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.
- congress_py/__init__.py +8 -0
- congress_py/cli.py +276 -0
- congress_py/client.py +211 -0
- congress_py/exceptions.py +17 -0
- congress_py/models.py +109 -0
- congress_py-0.1.0.dist-info/METADATA +79 -0
- congress_py-0.1.0.dist-info/RECORD +11 -0
- congress_py-0.1.0.dist-info/WHEEL +5 -0
- congress_py-0.1.0.dist-info/entry_points.txt +2 -0
- congress_py-0.1.0.dist-info/licenses/LICENSE +21 -0
- congress_py-0.1.0.dist-info/top_level.txt +1 -0
congress_py/__init__.py
ADDED
congress_py/cli.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Command-line interface for congress_py."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import asdict, is_dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from congress_py.client import CongressClient
|
|
14
|
+
from congress_py.exceptions import MissingAPIKeyError
|
|
15
|
+
|
|
16
|
+
CONFIG_DIR = Path.home() / ".congress"
|
|
17
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
18
|
+
MISSING_API_KEY_MESSAGE = (
|
|
19
|
+
"No Congress.gov API key found. Run congress configure or set CONGRESS_API_KEY."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
app = typer.Typer(help="CLI for the Congress.gov API.")
|
|
23
|
+
congress_app = typer.Typer(help="Congress session commands.")
|
|
24
|
+
bills_app = typer.Typer(help="Bill commands.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _to_jsonable(value: Any) -> Any:
|
|
28
|
+
if is_dataclass(value):
|
|
29
|
+
return _to_jsonable(asdict(value))
|
|
30
|
+
if hasattr(value, "_asdict"):
|
|
31
|
+
return _to_jsonable(value._asdict())
|
|
32
|
+
if isinstance(value, dict):
|
|
33
|
+
return {key: _to_jsonable(item) for key, item in value.items()}
|
|
34
|
+
if isinstance(value, (list, tuple)):
|
|
35
|
+
return [_to_jsonable(item) for item in value]
|
|
36
|
+
return value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _json_default(value: Any) -> Any:
|
|
40
|
+
converted = _to_jsonable(value)
|
|
41
|
+
if converted is not value:
|
|
42
|
+
return converted
|
|
43
|
+
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _print_json(value: Any) -> None:
|
|
47
|
+
typer.echo(
|
|
48
|
+
json.dumps(_to_jsonable(value), default=_json_default, indent=2, sort_keys=True)
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _parse_simple_auth_toml(content: str) -> Optional[str]:
|
|
53
|
+
in_auth_section = False
|
|
54
|
+
|
|
55
|
+
for raw_line in content.splitlines():
|
|
56
|
+
line = raw_line.strip()
|
|
57
|
+
if not line or line.startswith("#"):
|
|
58
|
+
continue
|
|
59
|
+
if line.startswith("[") and line.endswith("]"):
|
|
60
|
+
in_auth_section = line == "[auth]"
|
|
61
|
+
continue
|
|
62
|
+
if not in_auth_section or "=" not in line:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
key, value = line.split("=", 1)
|
|
66
|
+
if key.strip() != "api_key":
|
|
67
|
+
continue
|
|
68
|
+
value = value.strip()
|
|
69
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in {'"', "'"}:
|
|
70
|
+
value = value[1:-1]
|
|
71
|
+
return value.strip() or None
|
|
72
|
+
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _load_config_api_key(config_file: Optional[Path] = None) -> Optional[str]:
|
|
77
|
+
if config_file is None:
|
|
78
|
+
config_file = CONFIG_FILE
|
|
79
|
+
|
|
80
|
+
if not config_file.exists():
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
content = config_file.read_text(encoding="utf-8")
|
|
84
|
+
try:
|
|
85
|
+
import tomllib
|
|
86
|
+
except ModuleNotFoundError:
|
|
87
|
+
return _parse_simple_auth_toml(content)
|
|
88
|
+
|
|
89
|
+
data = tomllib.loads(content)
|
|
90
|
+
auth = data.get("auth", {})
|
|
91
|
+
api_key = auth.get("api_key")
|
|
92
|
+
if isinstance(api_key, str):
|
|
93
|
+
return api_key.strip() or None
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _resolve_api_key(explicit_api_key: Optional[str]) -> str:
|
|
98
|
+
if explicit_api_key:
|
|
99
|
+
return explicit_api_key
|
|
100
|
+
|
|
101
|
+
env_api_key = os.environ.get("CONGRESS_API_KEY")
|
|
102
|
+
if env_api_key:
|
|
103
|
+
return env_api_key
|
|
104
|
+
|
|
105
|
+
config_api_key = _load_config_api_key()
|
|
106
|
+
if config_api_key:
|
|
107
|
+
return config_api_key
|
|
108
|
+
|
|
109
|
+
raise MissingAPIKeyError(MISSING_API_KEY_MESSAGE)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _get_client(ctx: typer.Context) -> CongressClient:
|
|
113
|
+
explicit_api_key = None
|
|
114
|
+
if ctx.obj:
|
|
115
|
+
explicit_api_key = ctx.obj.get("api_key")
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
return CongressClient(api_key=_resolve_api_key(explicit_api_key))
|
|
119
|
+
except MissingAPIKeyError:
|
|
120
|
+
typer.echo(MISSING_API_KEY_MESSAGE, err=True)
|
|
121
|
+
raise typer.Exit(1)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _toml_string(value: str) -> str:
|
|
125
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@app.callback()
|
|
129
|
+
def main(
|
|
130
|
+
ctx: typer.Context,
|
|
131
|
+
api_key: Optional[str] = typer.Option(
|
|
132
|
+
None,
|
|
133
|
+
"--api-key",
|
|
134
|
+
help=(
|
|
135
|
+
"Congress.gov API key. Defaults to CONGRESS_API_KEY "
|
|
136
|
+
"or ~/.congress/config.toml."
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
) -> None:
|
|
140
|
+
"""Resolve global CLI options for all commands.
|
|
141
|
+
|
|
142
|
+
Args:
|
|
143
|
+
ctx: Typer context used to share resolved option values with commands.
|
|
144
|
+
api_key: Optional Congress.gov API key. If omitted, API commands fall
|
|
145
|
+
back to ``CONGRESS_API_KEY`` and then ``~/.congress/config.toml``.
|
|
146
|
+
|
|
147
|
+
"""
|
|
148
|
+
ctx.obj = {"api_key": api_key}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@app.command()
|
|
152
|
+
def configure(
|
|
153
|
+
api_key: str = typer.Option(
|
|
154
|
+
...,
|
|
155
|
+
"--api-key",
|
|
156
|
+
prompt=True,
|
|
157
|
+
hide_input=True,
|
|
158
|
+
help="Congress.gov API key to save locally.",
|
|
159
|
+
),
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Save a Congress.gov API key for future CLI commands.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
api_key: Congress.gov API key to write to ``~/.congress/config.toml``.
|
|
165
|
+
|
|
166
|
+
"""
|
|
167
|
+
CONFIG_DIR.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
168
|
+
content = f'[auth]\napi_key = "{_toml_string(api_key)}"\n'
|
|
169
|
+
|
|
170
|
+
fd = os.open(CONFIG_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
171
|
+
with os.fdopen(fd, "w", encoding="utf-8") as config:
|
|
172
|
+
config.write(content)
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
CONFIG_FILE.chmod(0o600)
|
|
176
|
+
except OSError:
|
|
177
|
+
pass
|
|
178
|
+
|
|
179
|
+
typer.echo("Configuration saved.")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
@congress_app.command("current")
|
|
183
|
+
def congress_current(ctx: typer.Context) -> None:
|
|
184
|
+
"""Return the current congressional session."""
|
|
185
|
+
client = _get_client(ctx)
|
|
186
|
+
_print_json(client.get_current_session())
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@congress_app.command("list")
|
|
190
|
+
def congress_list(ctx: typer.Context) -> None:
|
|
191
|
+
"""Return available congressional sessions."""
|
|
192
|
+
client = _get_client(ctx)
|
|
193
|
+
_print_json(client.get_congresses())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@bills_app.command("list")
|
|
197
|
+
def bills_list(
|
|
198
|
+
ctx: typer.Context,
|
|
199
|
+
session: Optional[int] = typer.Option(
|
|
200
|
+
None,
|
|
201
|
+
"--session",
|
|
202
|
+
help="Congress session number to pass to the existing SDK method.",
|
|
203
|
+
),
|
|
204
|
+
limit: int = typer.Option(
|
|
205
|
+
20,
|
|
206
|
+
"--limit",
|
|
207
|
+
min=1,
|
|
208
|
+
help="Number of bills to request per API call.",
|
|
209
|
+
),
|
|
210
|
+
offset: int = typer.Option(
|
|
211
|
+
0,
|
|
212
|
+
"--offset",
|
|
213
|
+
min=0,
|
|
214
|
+
help=(
|
|
215
|
+
"Starting offset for single-page mode. "
|
|
216
|
+
"Ignored when --pages is provided."
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
pages: Optional[int] = typer.Option(
|
|
220
|
+
None,
|
|
221
|
+
"--pages",
|
|
222
|
+
min=1,
|
|
223
|
+
help="Number of pages to fetch using the SDK iterator.",
|
|
224
|
+
),
|
|
225
|
+
) -> None:
|
|
226
|
+
"""Return bills, optionally filtered by Congress session.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
ctx: Typer context containing global CLI option values.
|
|
230
|
+
session: Optional Congress number used to filter bill results.
|
|
231
|
+
limit: Number of bills to request per API call.
|
|
232
|
+
offset: Starting offset for single-page results.
|
|
233
|
+
pages: Optional number of pages to fetch with the SDK iterator. When
|
|
234
|
+
provided, ``offset`` is ignored.
|
|
235
|
+
|
|
236
|
+
"""
|
|
237
|
+
client = _get_client(ctx)
|
|
238
|
+
if pages is None:
|
|
239
|
+
bills = client.get_bills(session=session, limit=limit, offset=offset)
|
|
240
|
+
else:
|
|
241
|
+
bills = list(client.iter_bills(session=session, limit=limit, max_pages=pages))
|
|
242
|
+
_print_json(bills)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@bills_app.command("get")
|
|
246
|
+
def bills_get(ctx: typer.Context, congress: int, bill_type: str, number: int) -> None:
|
|
247
|
+
"""Return one bill by congress, bill type, and number."""
|
|
248
|
+
client = _get_client(ctx)
|
|
249
|
+
_print_json(client.get_bill(congress, bill_type, number))
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@bills_app.command("actions")
|
|
253
|
+
def bills_actions(
|
|
254
|
+
ctx: typer.Context, congress: int, bill_type: str, number: int
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Return actions for one bill."""
|
|
257
|
+
client = _get_client(ctx)
|
|
258
|
+
_print_json(client.get_bill_actions(congress, bill_type, number))
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@bills_app.command("summaries")
|
|
262
|
+
def bills_summaries(
|
|
263
|
+
ctx: typer.Context, congress: int, bill_type: str, number: int
|
|
264
|
+
) -> None:
|
|
265
|
+
"""Return summaries for one bill."""
|
|
266
|
+
client = _get_client(ctx)
|
|
267
|
+
_print_json(client.get_bill_summaries(congress, bill_type, number))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
app.add_typer(congress_app, name="congress")
|
|
271
|
+
app.add_typer(bills_app, name="bills")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def run() -> None:
|
|
275
|
+
"""Run the Typer application."""
|
|
276
|
+
app()
|
congress_py/client.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""Client for the Congress.gov API."""
|
|
2
|
+
|
|
3
|
+
from collections import namedtuple
|
|
4
|
+
|
|
5
|
+
import requests
|
|
6
|
+
|
|
7
|
+
from congress_py.models import Bill, BillAction, BillSummary
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CongressClient:
|
|
11
|
+
"""Client for interacting with Congress API endpoints.
|
|
12
|
+
|
|
13
|
+
Args:
|
|
14
|
+
api_key (str): API key provided by api.congress.gov.
|
|
15
|
+
session: Optional ``requests.Session``-compatible object used to make
|
|
16
|
+
HTTP requests. If omitted, a new ``requests.Session`` is created.
|
|
17
|
+
This is mainly useful for tests or for callers that need custom
|
|
18
|
+
session configuration.
|
|
19
|
+
|
|
20
|
+
Attributes:
|
|
21
|
+
api_key: Congress.gov API key used for requests.
|
|
22
|
+
base_url: Base URL for the Congress.gov API.
|
|
23
|
+
congress_url: Base URL for congress/session endpoints.
|
|
24
|
+
bill_url: Base URL for bill endpoints.
|
|
25
|
+
session: HTTP session used to make requests.
|
|
26
|
+
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, api_key, session=None):
|
|
30
|
+
self.api_key = api_key
|
|
31
|
+
self.base_url = "https://api.congress.gov/v3"
|
|
32
|
+
self.congress_url = f"{self.base_url}/congress"
|
|
33
|
+
self.bill_url = f"{self.base_url}/bill"
|
|
34
|
+
self.session = session or requests.Session()
|
|
35
|
+
|
|
36
|
+
def _convert_name_to_session(self, congress_name):
|
|
37
|
+
"""Convert the congress name, return the numeric Congress name string.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
congress_name (str): the numerical congressional session in string
|
|
41
|
+
format (ex. '3rd', '4th')
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
A string, congress_name, with the suffix removed.
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
suffixes = ["st", "nd", "rd", "th"]
|
|
48
|
+
congress_name = congress_name.replace(" Congress", "")
|
|
49
|
+
|
|
50
|
+
for suffix in suffixes:
|
|
51
|
+
if suffix in congress_name:
|
|
52
|
+
congress_name = congress_name.replace(suffix, "")
|
|
53
|
+
return congress_name
|
|
54
|
+
|
|
55
|
+
def _convert_congress_to_tuple(self, congress):
|
|
56
|
+
"""Convert congressional session information into a named tuple.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
congress: Congress data dictionary from the Congress.gov API response.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A named tuple containing the congress name, end year, and chambers.
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
Session = namedtuple("Session", ["name", "endYear", "chambers"])
|
|
66
|
+
chambers = []
|
|
67
|
+
|
|
68
|
+
for session in congress["sessions"]:
|
|
69
|
+
if session["chamber"] not in chambers:
|
|
70
|
+
chambers.append(session["chamber"])
|
|
71
|
+
|
|
72
|
+
return Session(congress["name"], congress["endYear"], chambers)
|
|
73
|
+
|
|
74
|
+
def _get(self, url, params=None):
|
|
75
|
+
"""Return decoded JSON for a Congress API endpoint.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
url: Full endpoint URL to request.
|
|
79
|
+
params: Optional query parameters to include with the request. The API
|
|
80
|
+
key is added automatically.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Decoded JSON response as a dictionary.
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
requests.HTTPError: If Congress.gov returns an unsuccessful HTTP status.
|
|
87
|
+
requests.JSONDecodeError: If the response body is not valid JSON.
|
|
88
|
+
|
|
89
|
+
"""
|
|
90
|
+
request_params = {"api_key": self.api_key}
|
|
91
|
+
if params:
|
|
92
|
+
request_params.update(params)
|
|
93
|
+
|
|
94
|
+
response = self.session.get(url, params=request_params, timeout=10)
|
|
95
|
+
response.raise_for_status()
|
|
96
|
+
return response.json()
|
|
97
|
+
|
|
98
|
+
def get_current_session(self):
|
|
99
|
+
"""Return the current congressional session.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
A named tuple containing the fields ``name``, ``endYear``, and ``chambers``.
|
|
103
|
+
|
|
104
|
+
"""
|
|
105
|
+
data = self._get(f"{self.congress_url}/current")
|
|
106
|
+
congress = data.get("congress")
|
|
107
|
+
if congress is None:
|
|
108
|
+
congress = data["congresses"][0]
|
|
109
|
+
return self._convert_congress_to_tuple(congress)
|
|
110
|
+
|
|
111
|
+
def get_congresses(self):
|
|
112
|
+
"""Return congressional session records.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
list[dict]: The raw ``congresses`` list from the API response.
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
data = self._get(self.congress_url)
|
|
119
|
+
return data["congresses"]
|
|
120
|
+
|
|
121
|
+
def get_bill(self, congress: int, bill_type: str, number: int):
|
|
122
|
+
"""Return a bill for a given congress, bill type, and number.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
congress (int): A Congress number, such as ``118``.
|
|
126
|
+
bill_type: Congress.gov bill type code, such as ``"hr"`` or ``"s"``.
|
|
127
|
+
number: Bill number within that congress and bill type.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Bill: A ``Bill`` model created from the API response's ``bill`` object.
|
|
131
|
+
|
|
132
|
+
"""
|
|
133
|
+
url = f"{self.bill_url}/{congress}/{bill_type}/{number}"
|
|
134
|
+
data = self._get(url)
|
|
135
|
+
return Bill.from_api_dict(data["bill"])
|
|
136
|
+
|
|
137
|
+
def get_bill_actions(self, congress: int, bill_type: str, number: int):
|
|
138
|
+
"""Return actions for a given bill.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
congress: Congress number, such as ``118``.
|
|
142
|
+
bill_type: Congress.gov bill type code, such as ``"hr"`` or ``"s"``.
|
|
143
|
+
number: Bill number within that congress and bill type.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
list[BillAction]: Actions parsed from the API response's ``actions`` list.
|
|
147
|
+
|
|
148
|
+
"""
|
|
149
|
+
url = f"{self.bill_url}/{congress}/{bill_type}/{number}/actions"
|
|
150
|
+
data = self._get(url)
|
|
151
|
+
return [BillAction.from_api_dict(action) for action in data["actions"]]
|
|
152
|
+
|
|
153
|
+
def get_bill_summaries(self, congress: int, bill_type: str, number: int):
|
|
154
|
+
"""Return summaries for a given bill.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
congress: Congress number, such as ``118``.
|
|
158
|
+
bill_type: Congress.gov bill type code, such as ``"hr"`` or ``"s"``.
|
|
159
|
+
number: Bill number within that congress and bill type.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
list[BillSummary]: Summaries parsed from the API response's ``summaries`` list.
|
|
163
|
+
|
|
164
|
+
"""
|
|
165
|
+
url = f"{self.bill_url}/{congress}/{bill_type}/{number}/summaries"
|
|
166
|
+
data = self._get(url)
|
|
167
|
+
return [BillSummary.from_api_dict(summary) for summary in data["summaries"]]
|
|
168
|
+
|
|
169
|
+
def get_bills(self, session=None, limit: int = 20, offset: int = 0):
|
|
170
|
+
"""Return a page of bills, optionally filtered by Congress number.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
session: Optional Congress number used to filter results, such as ``118``.
|
|
174
|
+
limit: Maximum number of bills to request from the API.
|
|
175
|
+
offset: Number of records to skip before returning results.
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
list[Bill]: Bills parsed from the API response's ``bills`` list.
|
|
179
|
+
|
|
180
|
+
"""
|
|
181
|
+
params = {"limit": limit, "offset": offset}
|
|
182
|
+
if session is not None:
|
|
183
|
+
params["session"] = session
|
|
184
|
+
|
|
185
|
+
data = self._get(self.bill_url, params=params)
|
|
186
|
+
return [Bill.from_api_dict(bill) for bill in data["bills"]]
|
|
187
|
+
|
|
188
|
+
def iter_bills(self, session=None, limit: int = 20, max_pages=None):
|
|
189
|
+
"""Yield bills across pages until no bills are returned.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
session: Optional Congress number used to filter results, such as ``118``.
|
|
193
|
+
limit: Number of bills to request per API page.
|
|
194
|
+
max_pages: Optional maximum number of pages to fetch. If omitted, pages
|
|
195
|
+
are fetched until the API returns an empty ``bills`` list.
|
|
196
|
+
|
|
197
|
+
Yields:
|
|
198
|
+
Bill: Bills parsed from each API response's ``bills`` list.
|
|
199
|
+
|
|
200
|
+
"""
|
|
201
|
+
offset = 0
|
|
202
|
+
pages_fetched = 0
|
|
203
|
+
|
|
204
|
+
while max_pages is None or pages_fetched < max_pages:
|
|
205
|
+
bills = self.get_bills(session=session, limit=limit, offset=offset)
|
|
206
|
+
if not bills:
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
yield from bills
|
|
210
|
+
pages_fetched += 1
|
|
211
|
+
offset += limit
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Project-specific exceptions for congress_py."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class CongressAPIError(Exception):
|
|
5
|
+
"""Base exception for congress_py errors."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CongressAuthError(CongressAPIError):
|
|
9
|
+
"""Base exception for authentication-related errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MissingAPIKeyError(CongressAuthError):
|
|
13
|
+
"""Raised when no Congress.gov API key is available."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class InvalidAPIKeyError(CongressAuthError):
|
|
17
|
+
"""Raised when Congress.gov rejects the provided API key."""
|
congress_py/models.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Data models for Congress API responses."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Bill:
|
|
9
|
+
"""Bill data returned by Congress.gov.
|
|
10
|
+
|
|
11
|
+
Attributes:
|
|
12
|
+
congress: Congress number, such as ``118``.
|
|
13
|
+
latest_action_date: Date of the latest recorded action.
|
|
14
|
+
latest_action_text: Description of the latest recorded action.
|
|
15
|
+
number: Bill number within its bill type.
|
|
16
|
+
origin_chamber: Chamber where the bill originated.
|
|
17
|
+
title: Official bill title.
|
|
18
|
+
bill_type: Congress.gov bill type code, such as ``"hr"`` or ``"s"``.
|
|
19
|
+
update_date: Date the bill record was last updated.
|
|
20
|
+
update_including_text: Whether the bill text was included in the latest update.
|
|
21
|
+
url: Optional Congress.gov API URL for the bill record.
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
congress: int
|
|
25
|
+
latest_action_date: str
|
|
26
|
+
latest_action_text: str
|
|
27
|
+
number: str
|
|
28
|
+
origin_chamber: str
|
|
29
|
+
title: str
|
|
30
|
+
bill_type: str
|
|
31
|
+
update_date: str
|
|
32
|
+
update_including_text: bool
|
|
33
|
+
url: Optional[str] = None
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_api_dict(cls, bill_data):
|
|
37
|
+
"""Create a Bill from an API response dictionary."""
|
|
38
|
+
return cls(
|
|
39
|
+
congress=bill_data["congress"],
|
|
40
|
+
latest_action_date=bill_data["latestAction"]["actionDate"],
|
|
41
|
+
latest_action_text=bill_data["latestAction"]["text"],
|
|
42
|
+
number=bill_data["number"],
|
|
43
|
+
origin_chamber=bill_data["originChamber"],
|
|
44
|
+
title=bill_data["title"],
|
|
45
|
+
bill_type=bill_data["type"],
|
|
46
|
+
update_date=bill_data["updateDate"],
|
|
47
|
+
update_including_text=bill_data["updateDateIncludingText"],
|
|
48
|
+
url=bill_data.get("url"),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class BillAction:
|
|
54
|
+
"""Legislative action recorded for a bill.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
action_date: Date the action occurred, if provided by the API.
|
|
58
|
+
text: Description of the action, if provided by the API.
|
|
59
|
+
action_type: Congress.gov action type value, if provided.
|
|
60
|
+
source_system: Source system metadata from Congress.gov, if provided.
|
|
61
|
+
url: Optional Congress.gov API URL for the action record.
|
|
62
|
+
|
|
63
|
+
"""
|
|
64
|
+
action_date: Optional[str]
|
|
65
|
+
text: Optional[str]
|
|
66
|
+
action_type: Optional[str] = None
|
|
67
|
+
source_system: Optional[Dict[str, Any]] = None
|
|
68
|
+
url: Optional[str] = None
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_api_dict(cls, action_data):
|
|
72
|
+
"""Create a BillAction from an API response dictionary."""
|
|
73
|
+
return cls(
|
|
74
|
+
action_date=action_data.get("actionDate"),
|
|
75
|
+
text=action_data.get("text"),
|
|
76
|
+
action_type=action_data.get("type"),
|
|
77
|
+
source_system=action_data.get("sourceSystem"),
|
|
78
|
+
url=action_data.get("url"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class BillSummary:
|
|
84
|
+
"""Summary text available for a bill.
|
|
85
|
+
|
|
86
|
+
Attributes:
|
|
87
|
+
action_date: Date associated with the summary action, if provided.
|
|
88
|
+
text: Summary text, if provided by the API.
|
|
89
|
+
update_date: Date the summary record was last updated, if provided.
|
|
90
|
+
version_code: Congress.gov summary version code, if provided.
|
|
91
|
+
action_desc: Description of the action associated with the summary, if provided.
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
action_date: Optional[str]
|
|
95
|
+
text: Optional[str]
|
|
96
|
+
update_date: Optional[str] = None
|
|
97
|
+
version_code: Optional[str] = None
|
|
98
|
+
action_desc: Optional[str] = None
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_api_dict(cls, summary_data):
|
|
102
|
+
"""Create a BillSummary from an API response dictionary."""
|
|
103
|
+
return cls(
|
|
104
|
+
action_date=summary_data.get("actionDate"),
|
|
105
|
+
text=summary_data.get("text"),
|
|
106
|
+
update_date=summary_data.get("updateDate"),
|
|
107
|
+
version_code=summary_data.get("versionCode"),
|
|
108
|
+
action_desc=summary_data.get("actionDesc"),
|
|
109
|
+
)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: congress-py
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A lightweight Python SDK for interacting with the United States Congress API.
|
|
5
|
+
Author: Antonio Alaniz
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/talaniz/congress.py
|
|
8
|
+
Project-URL: Documentation, https://talaniz.github.io/congress.py/
|
|
9
|
+
Project-URL: Repository, https://github.com/talaniz/congress.py
|
|
10
|
+
Project-URL: Issues, https://github.com/talaniz/congress.py/issues
|
|
11
|
+
Project-URL: Changelog, https://github.com/talaniz/congress.py/blob/main/docs/changelog.md
|
|
12
|
+
Keywords: congress,api,legislative,government,sdk
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Requires-Dist: requests<3,>=2.32.3
|
|
27
|
+
Requires-Dist: typer<1,>=0.12.5
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: codecov<3,>=2.1.13; extra == "dev"
|
|
30
|
+
Requires-Dist: coverage<8,>=7.6.4; extra == "dev"
|
|
31
|
+
Requires-Dist: pytest<9,>=8.3.3; extra == "dev"
|
|
32
|
+
Requires-Dist: pytest-cov<7,>=6.0.0; extra == "dev"
|
|
33
|
+
Requires-Dist: requests-mock<2,>=1.12.1; extra == "dev"
|
|
34
|
+
Provides-Extra: docs
|
|
35
|
+
Requires-Dist: mkdocs<2,>=1.6.1; extra == "docs"
|
|
36
|
+
Requires-Dist: mkdocs-material<10,>=9.5.49; extra == "docs"
|
|
37
|
+
Requires-Dist: mkdocstrings[python]<1,>=0.27.1; extra == "docs"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# congress_py
|
|
41
|
+
|
|
42
|
+

|
|
43
|
+
|
|
44
|
+
`congress_py` is an unofficial Python SDK and CLI for reading data from the
|
|
45
|
+
Congress.gov API.
|
|
46
|
+
|
|
47
|
+
This project is not affiliated with Congress.gov, the Library of Congress,
|
|
48
|
+
Congress, or the U.S. government. It does not provide legal, legislative,
|
|
49
|
+
lobbying, financial, compliance, or policy advice.
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install congress-py
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Local Development
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
git clone https://github.com/talaniz/congress.py.git
|
|
61
|
+
cd congress.py
|
|
62
|
+
python3 -m venv .venv
|
|
63
|
+
.venv/bin/python -m pip install -e .
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
You will need your own Congress.gov API key.
|
|
67
|
+
|
|
68
|
+
## Documentation
|
|
69
|
+
|
|
70
|
+
Full documentation is available at:
|
|
71
|
+
|
|
72
|
+
https://talaniz.github.io/congress.py/
|
|
73
|
+
|
|
74
|
+
The documentation site includes installation steps, API-key setup, SDK examples,
|
|
75
|
+
CLI examples, API reference, contributing guidance, and the changelog.
|
|
76
|
+
|
|
77
|
+
## License
|
|
78
|
+
|
|
79
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
congress_py/__init__.py,sha256=dDUrm_N3yR5IrGu3Jg6tETOFhrvQahcK0rl0olZk2Mw,246
|
|
2
|
+
congress_py/cli.py,sha256=Bvs3eOvTZvjGa-ufNw3omHJ6utdAjsh4ZmWPxgqL164,7800
|
|
3
|
+
congress_py/client.py,sha256=5qP725nRvoIMXrH2PWGDZgbPi-F94tYgxqu573E0SEA,7640
|
|
4
|
+
congress_py/exceptions.py,sha256=_Hpf5pJhyMr_Lw2IWw-AoGAilf9IoK1xPjHpkvdtTpM,461
|
|
5
|
+
congress_py/models.py,sha256=_VqM5eoVv_ZYr_I7kTztu_hYWQP1Kl0XQFPhir5n3uw,3813
|
|
6
|
+
congress_py-0.1.0.dist-info/licenses/LICENSE,sha256=jglVJoTRQMqCiDv_hN-1ppc0L0Pyu8qFt4fk0zCQpjI,1071
|
|
7
|
+
congress_py-0.1.0.dist-info/METADATA,sha256=q2XT782rKxKZEMFX8ZvMLyC3uoVF_BVsvq53DDnKEtw,2669
|
|
8
|
+
congress_py-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
9
|
+
congress_py-0.1.0.dist-info/entry_points.txt,sha256=NrISWYsCMRO9bddsNUT6JuX6IxQlCHjSKhm4ntO8avw,49
|
|
10
|
+
congress_py-0.1.0.dist-info/top_level.txt,sha256=13cg4YqsszHdNEffQ2LZ_HD2byJ9hEAO_sd-pj54aeI,12
|
|
11
|
+
congress_py-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Antonio Alaniz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
congress_py
|