Created
September 18, 2026 19:17
-
-
Save andysylvester/d6c10bcf559ec8fa33db1cfbc28af883 to your computer and use it in GitHub Desktop.
Identify Oregon measures that became law, from the OLIS OData API
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Identify Oregon measures that became law, from the OLIS OData API. | |
| A measure is treated as enacted when it carries a real Oregon Laws chapter | |
| number and was not stopped by a sustained veto. Each enacted measure is | |
| classified by how it became law: | |
| Signed by Governor "Governor signed." appears in its history | |
| Law without signature chaptered, but never signed and never vetoed | |
| Veto overridden repassed under Art. V sec. 15b, veto not sustained | |
| Vetoed measures are listed separately so the excluded set is auditable. | |
| Usage: | |
| python getEnactedMeasures.py | |
| python getEnactedMeasures.py --sessions 2023R1 | |
| """ | |
| import argparse | |
| import sys | |
| from collections import Counter, defaultdict | |
| import requests | |
| BASE_URL = "https://api.oregonlegislature.gov/odata/odataservice.svc/" | |
| DEFAULT_SESSIONS = ["2025R1", "2026R1"] | |
| PAGE_SIZE = 1000 | |
| SESSION_FILES = { | |
| "2025R1": "laws_2025.txt", | |
| "2026R1": "laws_2026.txt", | |
| } | |
| # Only bills become Oregon Laws. Resolutions and memorials never go to the | |
| # Governor, and joint resolutions go to the voters instead. | |
| BILL_PREFIXES = ("HB", "SB") | |
| # Resolutions are sometimes stamped with chapter 9999 as a placeholder; it is | |
| # not a real Oregon Laws chapter. | |
| SENTINEL_CHAPTER = 9000 | |
| SIGNED = "Governor signed." | |
| VETOED = "Governor vetoed." | |
| # Substrings, because the sustain/repass wording varies between chambers. | |
| SUSTAINED = "veto sustained" | |
| REPASSED = "notwithstanding" | |
| BY_SIGNATURE = "Signed by Governor" | |
| WITHOUT_SIGNATURE = "Became law without Governor's signature" | |
| OVERRIDDEN = "Became law over Governor's veto" | |
| def odata_get(entity, params): | |
| """GET an entity set, paging through the full result.""" | |
| headers = {"Accept": "application/json"} | |
| rows = [] | |
| skip = 0 | |
| while True: | |
| page_params = dict(params, **{"$top": PAGE_SIZE, "$skip": skip, | |
| "$format": "json"}) | |
| response = requests.get( | |
| f"{BASE_URL}{entity}", headers=headers, params=page_params, timeout=180 | |
| ) | |
| response.raise_for_status() | |
| page = response.json().get("value", []) | |
| rows.extend(page) | |
| if len(page) < PAGE_SIZE: | |
| return rows | |
| skip += PAGE_SIZE | |
| def quote(value): | |
| """Escape a string for use as an OData literal.""" | |
| return "'" + str(value).replace("'", "''") + "'" | |
| def key_of(row): | |
| return (row.get("MeasurePrefix"), row.get("MeasureNumber")) | |
| def fetch_measures(session_key): | |
| return odata_get("Measures", { | |
| "$filter": f"SessionKey eq {quote(session_key)}", | |
| "$select": ("SessionKey,MeasurePrefix,MeasureNumber,CatchLine," | |
| "ChapterNumber,EffectiveDate,CurrentLocation,Vetoed"), | |
| "$orderby": "MeasurePrefix,MeasureNumber", | |
| }) | |
| def fetch_history(session_key): | |
| """Map (prefix, number) -> history actions, oldest first.""" | |
| rows = odata_get("MeasureHistoryActions", { | |
| "$filter": f"SessionKey eq {quote(session_key)}", | |
| "$select": "MeasurePrefix,MeasureNumber,ActionText,ActionDate", | |
| }) | |
| history = defaultdict(list) | |
| for row in rows: | |
| history[key_of(row)].append(row) | |
| for actions in history.values(): | |
| actions.sort(key=lambda a: a.get("ActionDate") or "") | |
| return history | |
| def real_chapter(measure): | |
| """Return the Oregon Laws chapter number, or None if there isn't a real one.""" | |
| raw = str(measure.get("ChapterNumber") or "").strip() | |
| if not raw.isdigit(): | |
| return None | |
| if int(raw) >= SENTINEL_CHAPTER: | |
| return None | |
| return raw | |
| def classify(measure, actions): | |
| """Decide whether a measure became law, and how. | |
| Returns (category, detail_date) or (None, reason_not_enacted). | |
| """ | |
| texts = [(a.get("ActionText") or "") for a in actions] | |
| lowered = [t.lower() for t in texts] | |
| signed_on = next((a.get("ActionDate") for a in actions | |
| if (a.get("ActionText") or "") == SIGNED), None) | |
| vetoed_on = next((a.get("ActionDate") for a in actions | |
| if (a.get("ActionText") or "") == VETOED), None) | |
| sustained = any(SUSTAINED in t for t in lowered) | |
| repassed = any(REPASSED in t and "veto" in t for t in lowered) | |
| if signed_on: | |
| return BY_SIGNATURE, signed_on | |
| # A veto that was sustained stops the measure, even if one chamber voted | |
| # to repass it. | |
| if vetoed_on and sustained: | |
| return None, "vetoed, veto sustained" | |
| if vetoed_on and repassed: | |
| return OVERRIDDEN, vetoed_on | |
| if vetoed_on: | |
| return None, "vetoed" | |
| if real_chapter(measure): | |
| return WITHOUT_SIGNATURE, None | |
| return None, "no chapter number and no Governor action" | |
| def clean(value): | |
| if value is None: | |
| return "" | |
| return " ".join(str(value).split()) | |
| def day(timestamp): | |
| return clean(timestamp)[:10] | |
| def label(measure): | |
| return (f"{clean(measure.get('MeasurePrefix'))} " | |
| f"{clean(measure.get('MeasureNumber'))}").strip() | |
| def chapter_sort_key(entry): | |
| chapter = real_chapter(entry["measure"]) | |
| return int(chapter) if chapter else 0 | |
| def write_report(path, session_key, enacted, vetoed, skipped_chaptered): | |
| with open(path, "w", encoding="utf-8") as out: | |
| out.write(f"Measures Enacted into Law - Session {session_key}\n") | |
| out.write("=" * 78 + "\n\n") | |
| out.write(f"Total enacted: {len(enacted)}\n") | |
| for category, count in Counter(e["category"] for e in enacted).most_common(): | |
| out.write(f" {category}: {count}\n") | |
| out.write(f"Vetoed, did not become law: {len(vetoed)}\n\n") | |
| if skipped_chaptered: | |
| out.write("NOTE: these measures carry a placeholder chapter number " | |
| "but are not Oregon Laws chapters (resolutions are not " | |
| "presented to the Governor): " | |
| + ", ".join(skipped_chaptered) + "\n\n") | |
| out.write("=" * 78 + "\n\nENACTED MEASURES\n") | |
| out.write("-" * 78 + "\n\n") | |
| for entry in enacted: | |
| measure = entry["measure"] | |
| out.write(f"{label(measure)} (Chapter {real_chapter(measure) or '-'})\n") | |
| out.write(f" SessionKey: {clean(measure.get('SessionKey'))}\n") | |
| out.write(f" MeasurePrefix: {clean(measure.get('MeasurePrefix'))}\n") | |
| out.write(f" MeasureNumber: {clean(measure.get('MeasureNumber'))}\n") | |
| out.write(f" ChapterNumber: {real_chapter(measure) or ''}\n") | |
| out.write(f" HowEnacted: {entry['category']}\n") | |
| out.write(f" GovernorAction: {day(entry['action_date'])}\n") | |
| out.write(f" EffectiveDate: {day(measure.get('EffectiveDate'))}\n") | |
| out.write(f" CatchLine: {clean(measure.get('CatchLine'))}\n\n") | |
| out.write("\n" + "=" * 78 + "\n\nVETOED - DID NOT BECOME LAW\n") | |
| out.write("-" * 78 + "\n\n") | |
| for entry in vetoed: | |
| measure = entry["measure"] | |
| out.write(f"{label(measure)}\n") | |
| out.write(f" Outcome: {entry['reason']}\n") | |
| out.write(f" CurrentLocation:{clean(measure.get('CurrentLocation'))}\n") | |
| out.write(f" CatchLine: {clean(measure.get('CatchLine'))}\n\n") | |
| def process(session_key): | |
| measures = fetch_measures(session_key) | |
| history = fetch_history(session_key) | |
| enacted = [] | |
| vetoed = [] | |
| skipped_chaptered = [] | |
| for measure in measures: | |
| prefix = measure.get("MeasurePrefix") | |
| if prefix not in BILL_PREFIXES: | |
| # Flag any non-bill carrying a chapter value, so the placeholder | |
| # convention stays visible rather than silently dropped. | |
| if str(measure.get("ChapterNumber") or "").strip(): | |
| skipped_chaptered.append(label(measure)) | |
| continue | |
| actions = history.get(key_of(measure), []) | |
| category, detail = classify(measure, actions) | |
| if category: | |
| enacted.append({"measure": measure, "category": category, | |
| "action_date": detail}) | |
| elif detail and detail.startswith("vetoed"): | |
| vetoed.append({"measure": measure, "reason": detail}) | |
| enacted.sort(key=chapter_sort_key) | |
| return enacted, vetoed, skipped_chaptered | |
| def parse_args(argv): | |
| parser = argparse.ArgumentParser( | |
| description=__doc__, | |
| formatter_class=argparse.RawDescriptionHelpFormatter) | |
| parser.add_argument("--sessions", nargs="+", default=DEFAULT_SESSIONS, | |
| metavar="KEY", help="session keys (default: 2025R1 2026R1)") | |
| return parser.parse_args(argv) | |
| def run(args): | |
| for session_key in args.sessions: | |
| enacted, vetoed, skipped = process(session_key) | |
| path = SESSION_FILES.get(session_key, f"laws_{session_key}.txt") | |
| write_report(path, session_key, enacted, vetoed, skipped) | |
| counts = Counter(e["category"] for e in enacted) | |
| summary = ", ".join(f"{count} {category.lower()}" | |
| for category, count in counts.most_common()) | |
| print(f"{session_key}: {len(enacted)} enacted ({summary}); " | |
| f"{len(vetoed)} vetoed -> {path}") | |
| def main(argv=None): | |
| args = parse_args(argv if argv is not None else sys.argv[1:]) | |
| try: | |
| run(args) | |
| except requests.exceptions.RequestException as e: | |
| print(f"Error accessing OLIS API: {e}") | |
| return 1 | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment