"""bill_id codec + session/chamber/number helpers, per spec §4.""" from __future__ import annotations import re from typing import Optional, Tuple # Universal bill_id pattern across all daily-file links (spec §4). # 20252026 0 AB 28 -> 202520260AB28 BILL_ID_RE = re.compile( r"^(?P\d{8})0?(?PAB|SB|ACR|SCR|HR|SJR|ABR)" r"(?P\d+)$" ) # Chamber prefix -> origin house (spec §8 #7: origin, not publishing house). ASSEMBLY_PREFIXES = {"AB", "ACR", "HR", "ABR"} SENATE_PREFIXES = {"SB", "SCR", "SJR"} _SESSION_RE = re.compile(r"^(?P\d{4})(?P\d{4})$") _BILL_DISPLAY_RE = re.compile(r"^(?PAB|SB|ACR|SCR|HR|SJR|ABR)\s*(?P\d+)$") _SUPPLEMENT_RE = re.compile(r"Supplemental\s+(\d+)", re.IGNORECASE) def origin_house(chamber: str) -> str: if chamber in ASSEMBLY_PREFIXES: return "assembly" if chamber in SENATE_PREFIXES: return "senate" return "unknown" def normalise_session(raw: str) -> str: """'20252026' -> '2025-2026'.""" m = _SESSION_RE.match(raw or "") if m: return f"{m['a']}-{m['b']}" return raw def parse_bill_id(bill_id: str) -> Optional[dict]: """Decode a canonical bill_id. Returns dict or None if it does not match. Keys: session (YYYY-YYYY), session_raw, chamber (prefix upper), number (int, unpadded), display (e.g. 'AB 28'), house (origin), raw. """ if not bill_id: return None m = BILL_ID_RE.match(bill_id.strip()) if not m: return None session_raw = m["session"] chamber = m["chamber"].upper() number = int(m["number"]) return { "session": normalise_session(session_raw), "session_raw": session_raw, "chamber": chamber, "number": number, "display": f"{chamber} {number}", "house": origin_house(chamber), "raw": bill_id, } def parse_display(display: str) -> Optional[Tuple[str, int]]: """'AB 00028' or 'AB 28' -> ('AB', 28).""" m = _BILL_DISPLAY_RE.match((display or "").strip()) if not m: return None return m["prefix"].upper(), int(m["num"]) def parse_supplement(text: str) -> Optional[int]: m = _SUPPLEMENT_RE.search(text or "") return int(m.group(1)) if m else None