Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save andysylvester/d0b940c14cddd4b8716d5f82a7b8c567 to your computer and use it in GitHub Desktop.
Cross-reference enacted Oregon laws against one legislator's votes
"""Cross-reference enacted Oregon laws against one legislator's votes.
Joins the enacted-measure list from getEnactedMeasures with the floor and
committee votes from getVotingRecord, so you can see how your representative
voted on each measure that actually became law.
A measure can draw several floor votes (third reading, concurrence,
repassage). All of them are listed; the chronologically last one is treated
as the decisive vote and is what the summary tallies.
Usage:
python getVotesOnLaws.py --district 22
python getVotesOnLaws.py --name Munoz --sessions 2025R1
python getVotesOnLaws.py --district 22 --disagreed-only
"""
import argparse
import sys
from collections import Counter, defaultdict
import requests
import getEnactedMeasures as enacted_mod
import getVotingRecord as votes_mod
DEFAULT_SESSIONS = ["2025R1", "2026R1"]
NO_VOTE = "No vote recorded"
def key_of(row):
return (row.get("MeasurePrefix"), row.get("MeasureNumber"))
def group_votes(votes, date_field):
"""Map measure key -> that legislator's votes, oldest first."""
grouped = defaultdict(list)
for vote in votes:
grouped[key_of(vote)].append(vote)
for items in grouped.values():
items.sort(key=lambda v: v.get(date_field) or "")
return grouped
def decisive_vote(floor_votes):
"""The legislator's last floor vote on a measure decides their position."""
return floor_votes[-1] if floor_votes else None
def build_rows(session_key, codes):
enacted, _vetoed, _skipped = enacted_mod.process(session_key)
floor = votes_mod.fetch_floor_votes(session_key, codes)
committee = votes_mod.fetch_committee_votes(session_key, codes)
floor_by_measure = group_votes(floor, "ActionDate")
committee_by_measure = group_votes(committee, "MeetingDate")
rows = []
for entry in enacted:
measure = entry["measure"]
key = key_of(measure)
floor_votes = floor_by_measure.get(key, [])
final = decisive_vote(floor_votes)
rows.append({
"measure": measure,
"category": entry["category"],
"floor_votes": floor_votes,
"committee_votes": committee_by_measure.get(key, []),
"position": (final.get("Vote") if final else NO_VOTE),
})
return rows
def clean(value):
return votes_mod.clean(value)
def day(value):
return votes_mod.day(value)
def label(measure):
return enacted_mod.label(measure)
def write_report(path, session_key, members, rows, disagreed_only):
listed = [r for r in rows if r["position"] == votes_mod.NAY] if disagreed_only \
else rows
with open(path, "w", encoding="utf-8") as out:
out.write(f"Votes on Enacted Laws - Session {session_key}\n")
out.write("=" * 78 + "\n\n")
for member in members:
out.write(f"{clean(member.get('Title'))} {clean(member.get('FirstName'))} "
f"{clean(member.get('LastName'))}\n")
out.write(f" LegislatorCode: {clean(member.get('LegislatorCode'))}\n")
out.write(f" District: {clean(member.get('DistrictNumber'))}\n")
out.write(f" Party: {clean(member.get('Party'))}\n\n")
out.write(f"Measures enacted into law: {len(rows)}\n")
out.write("Position on final floor vote:\n")
for value, count in Counter(r["position"] for r in rows).most_common():
out.write(f" {clean(value)}: {count}\n")
multi = sum(1 for r in rows if len(r["floor_votes"]) > 1)
out.write(f"Laws drawing more than one floor vote: {multi}\n")
heard = sum(1 for r in rows if r["committee_votes"])
out.write(f"Laws also voted on in committee: {heard}\n")
if disagreed_only:
out.write(f"\nListing only laws voted Nay ({len(listed)}). "
f"Tallies above cover all enacted laws.\n")
out.write("\n" + "=" * 78 + "\n\n")
heading = "LAWS VOTED NAY" if disagreed_only else "ENACTED LAWS AND VOTES"
out.write(heading + "\n")
out.write("-" * 78 + "\n\n")
for row in listed:
measure = row["measure"]
chapter = enacted_mod.real_chapter(measure) or "-"
out.write(f"{label(measure)} (Chapter {chapter}) "
f"[{clean(row['position'])}]\n")
out.write(f" HowEnacted: {row['category']}\n")
out.write(f" CatchLine: {clean(measure.get('CatchLine'))}\n")
for vote in row["floor_votes"]:
out.write(f" Floor: {day(vote.get('ActionDate'))} "
f"{clean(vote.get('Vote')):<22} "
f"{clean(vote.get('ActionText'))}\n")
if not row["floor_votes"]:
out.write(" Floor: no recorded floor vote by this member\n")
for vote in row["committee_votes"]:
out.write(f" Committee: {day(vote.get('MeetingDate'))} "
f"{clean(vote.get('Meaning')):<22} "
f"{clean(vote.get('CommitteeCode'))}\n")
out.write("\n")
def parse_args(argv):
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--district", help="district number, e.g. 22")
parser.add_argument("--name", help="legislator first or last name")
parser.add_argument("--legislator", action="append", default=[], metavar="CODE",
help="extra LegislatorCode to include; repeatable")
parser.add_argument("--chamber", choices=["H", "S"], default="H",
help="H for House (default), S for Senate")
parser.add_argument("--sessions", nargs="+", default=DEFAULT_SESSIONS,
metavar="KEY", help="session keys (default: 2025R1 2026R1)")
parser.add_argument("--disagreed-only", action="store_true",
help="list only laws the member voted Nay on")
args = parser.parse_args(argv)
if not (args.district or args.name or args.legislator):
parser.error("give --district, --name, or --legislator")
return args
def run(args):
for session_key in args.sessions:
roster = votes_mod.fetch_roster(session_key)
members = votes_mod.resolve_legislators(roster, args.district, args.name,
args.chamber)
by_code = {m["LegislatorCode"]: m for m in members}
for code in args.legislator:
if code in by_code:
continue
match = next((m for m in roster if m["LegislatorCode"] == code), None)
by_code[code] = match or {"LegislatorCode": code, "Title": "",
"FirstName": "", "LastName": "",
"DistrictNumber": "", "Party": ""}
if not by_code:
print(f"{session_key}: no legislator matched; "
f"run getVotingRecord.py --list to see the roster")
continue
codes = sorted(by_code)
rows = build_rows(session_key, codes)
if args.district:
who = f"{'HD' if args.chamber == 'H' else 'SD'}{args.district}"
else:
who = (clean(by_code[codes[0]].get("LastName")) or codes[0]).replace(" ", "_")
suffix = "_nay" if args.disagreed_only else ""
path = f"votes_on_laws_{who}_{session_key}{suffix}.txt"
write_report(path, session_key, [by_code[c] for c in codes], rows,
args.disagreed_only)
tally = Counter(r["position"] for r in rows)
summary = ", ".join(f"{count} {clean(value).lower()}"
for value, count in tally.most_common())
print(f"{session_key}: {len(rows)} enacted laws - {summary} -> {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