Skip to content

Instantly share code, notes, and snippets.

@andysylvester
Created September 18, 2026 19:17
Show Gist options
  • Select an option

  • Save andysylvester/d6c10bcf559ec8fa33db1cfbc28af883 to your computer and use it in GitHub Desktop.

Select an option

Save andysylvester/d6c10bcf559ec8fa33db1cfbc28af883 to your computer and use it in GitHub Desktop.
Identify Oregon measures that became law, from the OLIS OData API
"""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