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.
@@ -0,0 +1,4 @@
1
+ from va_lis_client.client import LISClient
2
+ from va_lis_client.exceptions import LISClientError
3
+
4
+ __all__ = ["LISClient", "LISClientError"]
@@ -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", [])]
@@ -0,0 +1,2 @@
1
+ class LISClientError(Exception):
2
+ """Raised when the LIS API returns a non-success response."""
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())