va-lis-client 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.
- va_lis_client/__init__.py +4 -0
- va_lis_client/client.py +598 -0
- va_lis_client/exceptions.py +2 -0
- va_lis_client/http.py +34 -0
- va_lis_client/models/__init__.py +121 -0
- va_lis_client/models/calendar.py +256 -0
- va_lis_client/models/committee.py +111 -0
- va_lis_client/models/common.py +53 -0
- va_lis_client/models/docket.py +167 -0
- va_lis_client/models/event.py +171 -0
- va_lis_client/models/legislation.py +201 -0
- va_lis_client/models/schedule.py +88 -0
- va_lis_client/models/session.py +51 -0
- va_lis_client/models/text.py +168 -0
- va_lis_client/rate_limiter.py +67 -0
- va_lis_client-0.1.0.dist-info/METADATA +538 -0
- va_lis_client-0.1.0.dist-info/RECORD +20 -0
- va_lis_client-0.1.0.dist-info/WHEEL +5 -0
- va_lis_client-0.1.0.dist-info/licenses/LICENSE +21 -0
- va_lis_client-0.1.0.dist-info/top_level.txt +1 -0
va_lis_client/client.py
ADDED
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LIS API client.
|
|
3
|
+
|
|
4
|
+
Docs: https://lis.virginia.gov/developers
|
|
5
|
+
Auth: ``WebAPIKey`` header on every request.
|
|
6
|
+
Data: Session reference list reaches back to 1994. Bill data observed
|
|
7
|
+
working for the 2024–2027 sessions (as of Aug 2026); earlier
|
|
8
|
+
guidance said 2025–2026 only. Older sessions via legacy CSV.
|
|
9
|
+
|
|
10
|
+
The client wraps a requests session with a system-cert SSL adapter (required
|
|
11
|
+
on Windows for government CA roots) and returns parsed Pydantic models.
|
|
12
|
+
|
|
13
|
+
Identifier quick-reference
|
|
14
|
+
--------------------------
|
|
15
|
+
Most query parameters accept **either** a surrogate PK **or** a
|
|
16
|
+
human-readable code. Common patterns:
|
|
17
|
+
|
|
18
|
+
====================== ==================== ========================
|
|
19
|
+
Parameter Example Notes
|
|
20
|
+
====================== ==================== ========================
|
|
21
|
+
``sessionCode`` ``20261`` Year + sequence. Str or int.
|
|
22
|
+
``sessionID`` ``59`` Surrogate PK.
|
|
23
|
+
``legislationNumber`` ``"HB1"`` **Unpadded** — not ``HB0001``.
|
|
24
|
+
``legislationID`` ``98525`` Surrogate PK for the *logical*
|
|
25
|
+
bill — reused across carry-over
|
|
26
|
+
(see gotchas below).
|
|
27
|
+
``legislationTextID`` ``257719`` PK for a specific text version.
|
|
28
|
+
====================== ==================== ========================
|
|
29
|
+
|
|
30
|
+
Gotchas discovered by hitting the live API:
|
|
31
|
+
|
|
32
|
+
- Bill numbers must be **unpadded**: ``HB1`` works, ``HB0001`` returns 204.
|
|
33
|
+
- Text/summary endpoints require ``sessionCode`` or ``sessionID``. Passing
|
|
34
|
+
``legislationID`` alone returns 400 on the text endpoint.
|
|
35
|
+
- Response envelope keys differ from the OpenAPI spec. E.g. the docs say
|
|
36
|
+
``ListItems`` everywhere, but the real keys are ``Sessions``,
|
|
37
|
+
``Legislations``, ``LegislationTextList``, ``TextsList``,
|
|
38
|
+
``LegislationSummaries``, ``References``, ``LegislationVersionList``,
|
|
39
|
+
``PartnerList``.
|
|
40
|
+
- **Carry-over reuses** ``LegislationID`` (confirmed live 2026-08-12): a
|
|
41
|
+
bill continued from an even-year session keeps its ID in the following
|
|
42
|
+
odd-year session, so the same ID appears in *both* sessions' bill lists
|
|
43
|
+
(e.g. HB9, ID ``98631``, in ``20261`` and ``20271``). Carry-over is
|
|
44
|
+
even→odd only, never twice, and never across a two-year GA term — so an
|
|
45
|
+
ID is unique within a term but is NOT one-session-one-record.
|
|
46
|
+
Session-scoped identity is ``(SessionCode, LegislationNumber)`` or
|
|
47
|
+
``(SessionCode, LegislationID)``.
|
|
48
|
+
- ``get_bill`` returns top-level ``SessionID``/``SessionCode`` as ``None``.
|
|
49
|
+
Session membership — including carry-over lineage — lives in the
|
|
50
|
+
``Sessions`` cross-reference list (two entries for a carried-over bill).
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import logging
|
|
56
|
+
import os
|
|
57
|
+
import time
|
|
58
|
+
|
|
59
|
+
import requests as _requests
|
|
60
|
+
|
|
61
|
+
from va_lis_client.exceptions import LISClientError
|
|
62
|
+
from va_lis_client.http import requests_session
|
|
63
|
+
from va_lis_client.models import (
|
|
64
|
+
ActorType,
|
|
65
|
+
Heartbeat,
|
|
66
|
+
Legislation,
|
|
67
|
+
LegislationEvent,
|
|
68
|
+
LegislationEventType,
|
|
69
|
+
LegislationStatus,
|
|
70
|
+
LegislationSummary,
|
|
71
|
+
LegislationSummaryItem,
|
|
72
|
+
LegislationTextDetail,
|
|
73
|
+
LegislationTextItem,
|
|
74
|
+
LegislationVersion,
|
|
75
|
+
Partner,
|
|
76
|
+
Session,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
logger = logging.getLogger(__name__)
|
|
80
|
+
|
|
81
|
+
BASE_URL = "https://lis.virginia.gov"
|
|
82
|
+
|
|
83
|
+
# Retry configuration
|
|
84
|
+
_MAX_RETRIES = 8
|
|
85
|
+
_INITIAL_DELAY = 2 # seconds
|
|
86
|
+
_BACKOFF_FACTOR = 2
|
|
87
|
+
_MAX_DELAY = 300 # 5 minutes
|
|
88
|
+
_RETRYABLE_STATUS_CODES = {429, 500, 502, 503, 504}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class LISClient:
|
|
92
|
+
"""Low-level HTTP client for the LIS REST API.
|
|
93
|
+
|
|
94
|
+
Usage::
|
|
95
|
+
|
|
96
|
+
from va_lis_client import LISClient
|
|
97
|
+
|
|
98
|
+
# Pass the API key explicitly, or set the LIS_API_KEY env var
|
|
99
|
+
client = LISClient(api_key="your-key-here")
|
|
100
|
+
# or:
|
|
101
|
+
# export LIS_API_KEY=your-key-here
|
|
102
|
+
client = LISClient()
|
|
103
|
+
|
|
104
|
+
# Check key validity (heartbeats don't validate!)
|
|
105
|
+
partner = client.check_api_key()
|
|
106
|
+
|
|
107
|
+
# Get 2026 sessions
|
|
108
|
+
sessions = client.get_sessions(year=2026)
|
|
109
|
+
|
|
110
|
+
# List all bills in 2026 Regular Session
|
|
111
|
+
bills = client.get_session_bills(session_code=20261)
|
|
112
|
+
|
|
113
|
+
# Full detail + patron list for HB1
|
|
114
|
+
bill = client.get_bill(legislation_id=98525)
|
|
115
|
+
|
|
116
|
+
# Bill text (HTML) for all versions of HB1
|
|
117
|
+
texts = client.get_bill_text_detail(
|
|
118
|
+
legislation_id=98525, session_code=20261,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Bill summaries (introduced, passed) for HB1
|
|
122
|
+
summaries = client.get_bill_summaries(
|
|
123
|
+
legislation_number="HB1", session_code=20261,
|
|
124
|
+
)
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
def __init__(self, api_key: str | None = None, max_retries: int = _MAX_RETRIES):
|
|
128
|
+
self.session = requests_session
|
|
129
|
+
self.api_key = api_key if api_key is not None else os.environ.get("LIS_API_KEY")
|
|
130
|
+
self._max_retries = max_retries
|
|
131
|
+
if not self.api_key:
|
|
132
|
+
raise LISClientError(
|
|
133
|
+
"LIS API key is not configured. "
|
|
134
|
+
"Pass it as api_key= or set the LIS_API_KEY environment variable."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Internal helpers
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
def _headers(self) -> dict[str, str]:
|
|
142
|
+
return {"WebAPIKey": self.api_key}
|
|
143
|
+
|
|
144
|
+
def _request(self, method: str, path: str, **kwargs):
|
|
145
|
+
"""Send an HTTP request with exponential backoff on transient failures.
|
|
146
|
+
|
|
147
|
+
Retries on connection errors, timeouts, and HTTP 429/5xx responses.
|
|
148
|
+
Respects ``Retry-After`` header when present. Logs all response
|
|
149
|
+
headers on 429 to help discover LIS rate-limit SLAs.
|
|
150
|
+
"""
|
|
151
|
+
url = f"{BASE_URL}{path}"
|
|
152
|
+
kwargs.setdefault("headers", self._headers())
|
|
153
|
+
delay = _INITIAL_DELAY
|
|
154
|
+
|
|
155
|
+
for attempt in range(self._max_retries + 1):
|
|
156
|
+
try:
|
|
157
|
+
resp = self.session.request(method, url, **kwargs)
|
|
158
|
+
except (_requests.ConnectionError, _requests.Timeout) as exc:
|
|
159
|
+
if attempt == self._max_retries:
|
|
160
|
+
raise
|
|
161
|
+
logger.warning(
|
|
162
|
+
"LIS request %s %s failed (attempt %d/%d): %s — retrying in %ds",
|
|
163
|
+
method,
|
|
164
|
+
path,
|
|
165
|
+
attempt + 1,
|
|
166
|
+
self._max_retries,
|
|
167
|
+
exc,
|
|
168
|
+
delay,
|
|
169
|
+
)
|
|
170
|
+
time.sleep(delay)
|
|
171
|
+
delay = min(delay * _BACKOFF_FACTOR, _MAX_DELAY)
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
if resp.status_code == 204:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
if resp.status_code not in _RETRYABLE_STATUS_CODES:
|
|
178
|
+
resp.raise_for_status()
|
|
179
|
+
return resp.json()
|
|
180
|
+
|
|
181
|
+
# Retryable HTTP error
|
|
182
|
+
if attempt == self._max_retries:
|
|
183
|
+
resp.raise_for_status()
|
|
184
|
+
|
|
185
|
+
# Log rate-limit details on 429 so we can discover SLAs
|
|
186
|
+
if resp.status_code == 429:
|
|
187
|
+
logger.warning(
|
|
188
|
+
"LIS 429 rate-limited on %s %s — response headers: %s",
|
|
189
|
+
method,
|
|
190
|
+
path,
|
|
191
|
+
dict(resp.headers),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
# Respect Retry-After header if present
|
|
195
|
+
retry_after = resp.headers.get("Retry-After")
|
|
196
|
+
if retry_after:
|
|
197
|
+
try:
|
|
198
|
+
wait = int(retry_after)
|
|
199
|
+
except ValueError:
|
|
200
|
+
wait = delay
|
|
201
|
+
wait = min(wait, _MAX_DELAY)
|
|
202
|
+
else:
|
|
203
|
+
wait = delay
|
|
204
|
+
|
|
205
|
+
logger.warning(
|
|
206
|
+
"LIS %d on %s %s (attempt %d/%d) — retrying in %ds",
|
|
207
|
+
resp.status_code,
|
|
208
|
+
method,
|
|
209
|
+
path,
|
|
210
|
+
attempt + 1,
|
|
211
|
+
self._max_retries,
|
|
212
|
+
wait,
|
|
213
|
+
)
|
|
214
|
+
time.sleep(wait)
|
|
215
|
+
delay = min(delay * _BACKOFF_FACTOR, _MAX_DELAY)
|
|
216
|
+
|
|
217
|
+
# Should not reach here, but just in case
|
|
218
|
+
raise LISClientError(
|
|
219
|
+
f"LIS request {method} {path} failed after {self._max_retries} retries"
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
def _get(self, path: str, params: dict | None = None):
|
|
223
|
+
return self._request("GET", path, params=params)
|
|
224
|
+
|
|
225
|
+
def _post(self, path: str, json: dict | None = None):
|
|
226
|
+
return self._request("POST", path, json=json)
|
|
227
|
+
|
|
228
|
+
# ------------------------------------------------------------------
|
|
229
|
+
# Health / auth verification
|
|
230
|
+
# ------------------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
def check_api_key(self) -> Partner | None:
|
|
233
|
+
"""Validate the API key via PartnerAuthentication.
|
|
234
|
+
|
|
235
|
+
Returns the :class:`Partner` on success, or ``None`` if the key is
|
|
236
|
+
not registered (HTTP 204).
|
|
237
|
+
|
|
238
|
+
**Important:** Heartbeat endpoints accept *any* key — even garbage.
|
|
239
|
+
This is the only endpoint that actually validates the key.
|
|
240
|
+
"""
|
|
241
|
+
url = f"{BASE_URL}/PartnerAuthentication/api/checkpartnerkeyasync/{self.api_key}"
|
|
242
|
+
resp = self.session.get(url)
|
|
243
|
+
resp.raise_for_status()
|
|
244
|
+
if resp.status_code == 204:
|
|
245
|
+
return None
|
|
246
|
+
data = resp.json()
|
|
247
|
+
partners = data.get("PartnerList", [])
|
|
248
|
+
if not partners:
|
|
249
|
+
return None
|
|
250
|
+
return Partner.model_validate(partners[0])
|
|
251
|
+
|
|
252
|
+
def heartbeat(self, service: str = "Legislation") -> Heartbeat:
|
|
253
|
+
"""Hit a service heartbeat endpoint.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
service: Service name, e.g. ``"Legislation"``, ``"Session"``,
|
|
257
|
+
``"Authentication"``. The Authentication service uses a
|
|
258
|
+
different path (``/heartbeat/heartbeatsync``).
|
|
259
|
+
|
|
260
|
+
Note: heartbeats do NOT validate the API key.
|
|
261
|
+
"""
|
|
262
|
+
if service == "Authentication":
|
|
263
|
+
path = f"/{service}/api/heartbeat/heartbeatsync"
|
|
264
|
+
else:
|
|
265
|
+
path = f"/{service}/api/heartbeatasync"
|
|
266
|
+
data = self._get(path)
|
|
267
|
+
return Heartbeat.model_validate(data)
|
|
268
|
+
|
|
269
|
+
# ------------------------------------------------------------------
|
|
270
|
+
# Session
|
|
271
|
+
# ------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
def get_sessions(self, year: int | str = "") -> list[Session]:
|
|
274
|
+
"""Return legislative sessions, optionally filtered by year.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
year: Calendar year (e.g. ``2026``). Omit to get all available.
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
List of :class:`Session` models. For 2026 this returns
|
|
281
|
+
two entries: Regular Session (``20261``) and Special Session I
|
|
282
|
+
(``20262``).
|
|
283
|
+
|
|
284
|
+
Envelope key: ``Sessions``.
|
|
285
|
+
"""
|
|
286
|
+
params = {"year": str(year)} if year else None
|
|
287
|
+
data = self._get("/Session/api/getsessionlistasync", params=params)
|
|
288
|
+
if data is None:
|
|
289
|
+
return []
|
|
290
|
+
return [Session.model_validate(s) for s in data.get("Sessions", [])]
|
|
291
|
+
|
|
292
|
+
def get_default_session(self) -> Session | None:
|
|
293
|
+
"""Return the current/default legislative session.
|
|
294
|
+
|
|
295
|
+
LIS marks exactly one session as ``IsDefault=True`` at any time.
|
|
296
|
+
|
|
297
|
+
The default tracks the General Assembly's *working* session, not
|
|
298
|
+
the most recently convened one. Once a session wraps up (sine
|
|
299
|
+
die, then the April reconvened session), the default advances to
|
|
300
|
+
the upcoming session during the interim — observed pointing at
|
|
301
|
+
``20271`` by August 2026, months before that session convenes.
|
|
302
|
+
That is where continued bills, interim committee action, and
|
|
303
|
+
(from mid-November) new prefiles accumulate. For retrospective
|
|
304
|
+
work on a just-ended session (enactments, final statuses), pass
|
|
305
|
+
its explicit ``session_code`` instead of using the default.
|
|
306
|
+
|
|
307
|
+
Envelope key: ``Sessions`` (single-element list).
|
|
308
|
+
"""
|
|
309
|
+
data = self._get("/Session/api/getdefaultsessionasync")
|
|
310
|
+
if data is None:
|
|
311
|
+
return None
|
|
312
|
+
sessions = data.get("Sessions", [])
|
|
313
|
+
return Session.model_validate(sessions[0]) if sessions else None
|
|
314
|
+
|
|
315
|
+
# ------------------------------------------------------------------
|
|
316
|
+
# Legislation
|
|
317
|
+
# ------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
def get_session_bills(
|
|
320
|
+
self,
|
|
321
|
+
session_code: int | None = None,
|
|
322
|
+
session_id: int | None = None,
|
|
323
|
+
) -> list[LegislationSummaryItem]:
|
|
324
|
+
"""Lightweight bill list for a session.
|
|
325
|
+
|
|
326
|
+
Provide **one of**:
|
|
327
|
+
|
|
328
|
+
- ``session_code``: e.g. ``20261`` (2026 Regular Session)
|
|
329
|
+
- ``session_id``: e.g. ``59``
|
|
330
|
+
|
|
331
|
+
Returns every bill in the session with basic metadata and the
|
|
332
|
+
chief patron. For full detail (all patrons, dates, status ID),
|
|
333
|
+
call :meth:`get_bill` with the ``LegislationID``.
|
|
334
|
+
|
|
335
|
+
**Carry-over:** an odd-year session's list starts as pure
|
|
336
|
+
carry-over — every bill continued from the preceding even-year
|
|
337
|
+
session appears with its ``LegislationID`` unchanged (as of Aug
|
|
338
|
+
2026, ``20271`` returned 443 bills, all sharing IDs with
|
|
339
|
+
``20261``'s 3,645). See the module docstring for the identity
|
|
340
|
+
rules.
|
|
341
|
+
|
|
342
|
+
Envelope key: ``Legislations``.
|
|
343
|
+
"""
|
|
344
|
+
params = {}
|
|
345
|
+
if session_code is not None:
|
|
346
|
+
params["sessionCode"] = session_code
|
|
347
|
+
if session_id is not None:
|
|
348
|
+
params["sessionID"] = session_id
|
|
349
|
+
data = self._get("/Legislation/api/getlegislationsessionlistasync", params=params)
|
|
350
|
+
if data is None:
|
|
351
|
+
return []
|
|
352
|
+
return [
|
|
353
|
+
LegislationSummaryItem.model_validate(item) for item in data.get("Legislations", [])
|
|
354
|
+
]
|
|
355
|
+
|
|
356
|
+
def get_bill(self, legislation_id: int) -> Legislation | None:
|
|
357
|
+
"""Full bill detail by numeric LIS ID.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
legislation_id: Surrogate PK, e.g. ``98525`` for HB1 in 20261.
|
|
361
|
+
Get this from :meth:`get_session_bills` or from an existing
|
|
362
|
+
:class:`LegislationSummaryItem`.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
:class:`Legislation` with all patrons (65 for HB1!), session
|
|
366
|
+
cross-refs, and status info. ``None`` if not found.
|
|
367
|
+
|
|
368
|
+
Top-level ``SessionID``/``SessionCode`` are ``None`` in observed
|
|
369
|
+
responses. Session membership comes from the ``Sessions`` list,
|
|
370
|
+
which holds one entry per session the bill appears in — two for a
|
|
371
|
+
bill carried over from an even-year session (e.g. ``98631`` lists
|
|
372
|
+
``20261`` and ``20271``).
|
|
373
|
+
|
|
374
|
+
Envelope key: ``Legislations``.
|
|
375
|
+
"""
|
|
376
|
+
data = self._get(f"/Legislation/api/getlegislationbyidasync/{legislation_id}")
|
|
377
|
+
if data is None:
|
|
378
|
+
return None
|
|
379
|
+
items = data.get("Legislations", [])
|
|
380
|
+
return Legislation.model_validate(items[0]) if items else None
|
|
381
|
+
|
|
382
|
+
def get_legislation_statuses(self) -> list[LegislationStatus]:
|
|
383
|
+
"""Reference list of all 52 legislation statuses.
|
|
384
|
+
|
|
385
|
+
No parameters needed. Returns statuses like Introduced (1),
|
|
386
|
+
In Committee (2), Passed Both (6), Approved (8), Failed (9), etc.
|
|
387
|
+
|
|
388
|
+
Envelope key: ``References``.
|
|
389
|
+
"""
|
|
390
|
+
data = self._get("/Legislation/api/getlegislationstatuslistasync")
|
|
391
|
+
if data is None:
|
|
392
|
+
return []
|
|
393
|
+
return [LegislationStatus.model_validate(r) for r in data.get("References", [])]
|
|
394
|
+
|
|
395
|
+
# ------------------------------------------------------------------
|
|
396
|
+
# Legislation text
|
|
397
|
+
# ------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
def get_legislation_versions(self) -> list[LegislationVersion]:
|
|
400
|
+
"""Reference list of the 13 legislation version types.
|
|
401
|
+
|
|
402
|
+
No parameters needed. Returns version types like Introduced (1,
|
|
403
|
+
suffix=None), Engrossed (2, suffix="E"), Enrolled (3, suffix="ER"),
|
|
404
|
+
Substitute (5, suffix="S"), etc.
|
|
405
|
+
|
|
406
|
+
The ``Suffix`` is appended to the bill number to form a
|
|
407
|
+
``DocumentCode``: e.g. ``HB1`` + ``"ER"`` → ``"HB1ER"``.
|
|
408
|
+
|
|
409
|
+
Envelope key: ``LegislationVersionList``.
|
|
410
|
+
"""
|
|
411
|
+
data = self._get("/LegislationText/api/getlegislationversionlistasync")
|
|
412
|
+
if data is None:
|
|
413
|
+
return []
|
|
414
|
+
return [
|
|
415
|
+
LegislationVersion.model_validate(v) for v in data.get("LegislationVersionList", [])
|
|
416
|
+
]
|
|
417
|
+
|
|
418
|
+
def get_bill_texts(
|
|
419
|
+
self,
|
|
420
|
+
*,
|
|
421
|
+
legislation_number: str | None = None,
|
|
422
|
+
legislation_id: int | None = None,
|
|
423
|
+
session_code: int | None = None,
|
|
424
|
+
session_id: int | None = None,
|
|
425
|
+
) -> list[LegislationTextItem]:
|
|
426
|
+
"""List text versions for a bill (introduced, enrolled, etc.).
|
|
427
|
+
|
|
428
|
+
Provide a session identifier (``session_code`` or ``session_id``)
|
|
429
|
+
**plus** a bill identifier (``legislation_number`` or ``legislation_id``).
|
|
430
|
+
|
|
431
|
+
Args:
|
|
432
|
+
legislation_number: Unpadded bill number, e.g. ``"HB1"``.
|
|
433
|
+
**Not** ``"HB0001"`` — padded numbers return 204.
|
|
434
|
+
legislation_id: Surrogate PK, e.g. ``98525``.
|
|
435
|
+
session_code: e.g. ``20261``.
|
|
436
|
+
session_id: e.g. ``59``.
|
|
437
|
+
|
|
438
|
+
Returns:
|
|
439
|
+
One :class:`LegislationTextItem` per version. HB1 in 20261
|
|
440
|
+
returns two: Introduced (``DocumentCode="HB1"``) and
|
|
441
|
+
Enrolled (``DocumentCode="HB1ER"``).
|
|
442
|
+
|
|
443
|
+
Envelope key: ``LegislationTextList``.
|
|
444
|
+
"""
|
|
445
|
+
params: dict = {}
|
|
446
|
+
if legislation_number is not None:
|
|
447
|
+
params["legislationNumber"] = legislation_number
|
|
448
|
+
if legislation_id is not None:
|
|
449
|
+
params["legislationID"] = legislation_id
|
|
450
|
+
if session_code is not None:
|
|
451
|
+
params["sessionCode"] = session_code
|
|
452
|
+
if session_id is not None:
|
|
453
|
+
params["sessionID"] = session_id
|
|
454
|
+
data = self._get("/LegislationText/api/getlegislationtextlistasync", params=params)
|
|
455
|
+
if data is None:
|
|
456
|
+
return []
|
|
457
|
+
return [LegislationTextItem.model_validate(t) for t in data.get("LegislationTextList", [])]
|
|
458
|
+
|
|
459
|
+
def get_bill_text_detail(
|
|
460
|
+
self,
|
|
461
|
+
*,
|
|
462
|
+
legislation_id: int,
|
|
463
|
+
session_code: int,
|
|
464
|
+
) -> list[LegislationTextDetail]:
|
|
465
|
+
"""Full text detail including the bill body HTML.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
legislation_id: Surrogate PK, e.g. ``98525``.
|
|
469
|
+
session_code: e.g. ``20261``.
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
One :class:`LegislationTextDetail` per version. The
|
|
473
|
+
``DraftText`` field contains the full bill text as HTML with
|
|
474
|
+
additions (``<em class="new">``) and deletions (``<s>``).
|
|
475
|
+
|
|
476
|
+
Envelope key: ``TextsList``.
|
|
477
|
+
"""
|
|
478
|
+
data = self._get(
|
|
479
|
+
"/LegislationText/api/getlegislationtextbyidasync",
|
|
480
|
+
params={
|
|
481
|
+
"legislationID": legislation_id,
|
|
482
|
+
"sessionCode": session_code,
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
if data is None:
|
|
486
|
+
return []
|
|
487
|
+
return [LegislationTextDetail.model_validate(t) for t in data.get("TextsList", [])]
|
|
488
|
+
|
|
489
|
+
# ------------------------------------------------------------------
|
|
490
|
+
# Legislation summary
|
|
491
|
+
# ------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
def get_bill_summaries(
|
|
494
|
+
self,
|
|
495
|
+
*,
|
|
496
|
+
legislation_number: str | None = None,
|
|
497
|
+
legislation_id: int | None = None,
|
|
498
|
+
session_code: int | None = None,
|
|
499
|
+
session_id: int | None = None,
|
|
500
|
+
) -> list[LegislationSummary]:
|
|
501
|
+
"""Bill summaries (introduced, passed, etc.).
|
|
502
|
+
|
|
503
|
+
Provide a session identifier **plus** a bill identifier.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
legislation_number: Unpadded, e.g. ``"HB1"``.
|
|
507
|
+
legislation_id: Surrogate PK, e.g. ``98525``.
|
|
508
|
+
session_code: e.g. ``20261``.
|
|
509
|
+
session_id: e.g. ``59``.
|
|
510
|
+
|
|
511
|
+
Returns:
|
|
512
|
+
One :class:`LegislationSummary` per summary version. HB1 in
|
|
513
|
+
20261 returns two: ``"SUMMARY AS INTRODUCED"`` (``IsActive=False``)
|
|
514
|
+
and ``"SUMMARY AS PASSED"`` (``IsActive=True``). The active
|
|
515
|
+
summary is the current/latest one.
|
|
516
|
+
|
|
517
|
+
``Summary`` is HTML (``<p class="sumtext">...</p>``).
|
|
518
|
+
|
|
519
|
+
Envelope key: ``LegislationSummaries``.
|
|
520
|
+
"""
|
|
521
|
+
params: dict = {}
|
|
522
|
+
if legislation_number is not None:
|
|
523
|
+
params["legislationNumber"] = legislation_number
|
|
524
|
+
if legislation_id is not None:
|
|
525
|
+
params["legislationID"] = legislation_id
|
|
526
|
+
if session_code is not None:
|
|
527
|
+
params["sessionCode"] = session_code
|
|
528
|
+
if session_id is not None:
|
|
529
|
+
params["sessionID"] = session_id
|
|
530
|
+
data = self._get(
|
|
531
|
+
"/LegislationSummary/api/getlegislationsummarylistasync",
|
|
532
|
+
params=params,
|
|
533
|
+
)
|
|
534
|
+
if data is None:
|
|
535
|
+
return []
|
|
536
|
+
return [LegislationSummary.model_validate(s) for s in data.get("LegislationSummaries", [])]
|
|
537
|
+
|
|
538
|
+
# ------------------------------------------------------------------
|
|
539
|
+
# Legislation events (bill action history)
|
|
540
|
+
# ------------------------------------------------------------------
|
|
541
|
+
|
|
542
|
+
def get_bill_events(self, legislation_id: int) -> list[LegislationEvent]:
|
|
543
|
+
"""Chronological action history for a bill.
|
|
544
|
+
|
|
545
|
+
Returns every event from prefiling through governor action.
|
|
546
|
+
This is the **primary source for passage dates** — the
|
|
547
|
+
``HousePassageDate`` / ``SenatePassageDate`` fields on
|
|
548
|
+
``Legislation`` are always null.
|
|
549
|
+
|
|
550
|
+
Look for events where ``Status`` is ``"Passed House"`` or
|
|
551
|
+
``"Passed Senate"`` and read ``EventDate`` for the passage date.
|
|
552
|
+
``VoteTally`` contains the vote count (e.g. ``"(64-Y 34-N 0-A)"``).
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
legislation_id: Surrogate PK, e.g. ``98525`` for HB1.
|
|
556
|
+
|
|
557
|
+
Returns:
|
|
558
|
+
List of :class:`LegislationEvent` in chronological order.
|
|
559
|
+
|
|
560
|
+
Envelope key: ``LegislationEvents``.
|
|
561
|
+
"""
|
|
562
|
+
data = self._get(
|
|
563
|
+
"/LegislationEvent/api/getlegislationeventbylegislationidasync",
|
|
564
|
+
params={"legislationID": legislation_id},
|
|
565
|
+
)
|
|
566
|
+
if data is None:
|
|
567
|
+
return []
|
|
568
|
+
return [LegislationEvent.model_validate(e) for e in data.get("LegislationEvents", [])]
|
|
569
|
+
|
|
570
|
+
def get_event_types(self) -> list[LegislationEventType]:
|
|
571
|
+
"""Reference list of ~3,900 legislation event types.
|
|
572
|
+
|
|
573
|
+
Key fields for filtering:
|
|
574
|
+
|
|
575
|
+
- ``IsPassage``: True for passage/defeat events (H5000/S5000 family)
|
|
576
|
+
- ``EventCode``: Prefix = actor (H=House, S=Senate, G=Governor)
|
|
577
|
+
- ``LegislationDescription``: Human-readable action name
|
|
578
|
+
|
|
579
|
+
Envelope key: ``EventTypes``.
|
|
580
|
+
"""
|
|
581
|
+
data = self._get(
|
|
582
|
+
"/LegislationEvent/api/getlegislationeventtypereferencesasync",
|
|
583
|
+
)
|
|
584
|
+
if data is None:
|
|
585
|
+
return []
|
|
586
|
+
return [LegislationEventType.model_validate(t) for t in data.get("EventTypes", [])]
|
|
587
|
+
|
|
588
|
+
def get_actor_types(self) -> list[ActorType]:
|
|
589
|
+
"""Reference list of 5 actor types (House, Senate, Committee, etc.).
|
|
590
|
+
|
|
591
|
+
Envelope key: ``ActorTypes``.
|
|
592
|
+
"""
|
|
593
|
+
data = self._get(
|
|
594
|
+
"/LegislationEvent/api/getactortypereferencesasync",
|
|
595
|
+
)
|
|
596
|
+
if data is None:
|
|
597
|
+
return []
|
|
598
|
+
return [ActorType.model_validate(a) for a in data.get("ActorTypes", [])]
|
va_lis_client/http.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Requests session with a system-certificate SSL adapter.
|
|
3
|
+
|
|
4
|
+
Python on Windows does not automatically load certificates from the system
|
|
5
|
+
certificate store the way Linux OpenSSL builds typically do. This adapter
|
|
6
|
+
mounts a custom PoolManager that passes the default SSL context — populated
|
|
7
|
+
with ``ssl.create_default_context()`` + ``load_default_certs()`` — so that
|
|
8
|
+
connections to hosts with enterprise or government CA roots succeed without
|
|
9
|
+
manually bundling certificates.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import ssl
|
|
13
|
+
|
|
14
|
+
import requests
|
|
15
|
+
from requests.adapters import HTTPAdapter
|
|
16
|
+
from urllib3 import PoolManager
|
|
17
|
+
|
|
18
|
+
ssl_context = ssl.create_default_context()
|
|
19
|
+
ssl_context.load_default_certs()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SystemCertSSLAdapter(HTTPAdapter):
|
|
23
|
+
def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
|
|
24
|
+
self.poolmanager = PoolManager(
|
|
25
|
+
num_pools=connections,
|
|
26
|
+
maxsize=maxsize,
|
|
27
|
+
block=block,
|
|
28
|
+
ssl_context=ssl_context,
|
|
29
|
+
**pool_kwargs,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
requests_session = requests.Session()
|
|
34
|
+
requests_session.mount("https://", SystemCertSSLAdapter())
|