Created
September 18, 2026 19:19
-
-
Save andysylvester/d0b940c14cddd4b8716d5f82a7b8c567 to your computer and use it in GitHub Desktop.
Cross-reference enacted Oregon laws against one legislator's votes
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
| """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