From f38ced99843e7d1e8d1c32951c16b39cd8087d74 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 20 Aug 2022 18:46:03 -0700 Subject: [PATCH 01/48] Script to convert NIST EDF data into BallotLab data. --- src/electos/ballotmaker/scripts/README.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/electos/ballotmaker/scripts/README.md diff --git a/src/electos/ballotmaker/scripts/README.md b/src/electos/ballotmaker/scripts/README.md new file mode 100644 index 0000000..9dcb046 --- /dev/null +++ b/src/electos/ballotmaker/scripts/README.md @@ -0,0 +1 @@ +Extract data from NIST EDF models for use by BallotLab. From 88aa5bc419a0a5ae29f162eea0bb11ae92123b08 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 20 Aug 2022 20:40:24 -0700 Subject: [PATCH 02/48] First pass at candidate contests. --- .../ballotmaker/scripts/ballot-lab-data.py | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 src/electos/ballotmaker/scripts/ballot-lab-data.py diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py new file mode 100644 index 0000000..49f80e0 --- /dev/null +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -0,0 +1,118 @@ +from typing import List, Union + +from electos.datamodels.nist.models.edf import * +from electos.datamodels.nist.indexes import ElementIndex + +OrderedContent = Union[OrderedContest, OrderedHeader] + + +# --- Utilities + + +def text_content(item): + """Return joined lines in internationalized text.""" + assert isinstance(item, InternationalizedText) + text = "\n".join(_.content for _ in item.text) + return text + + +def walk_ordered_content(content: List[OrderedContent]): + for item in content: + if isinstance(item, OrderedContest): + yield item + elif isinstance(item, OrderedHeader): + yield item + yield from walk_ordered_content(item.ordered_content) + else: + raise TypeError(f"Unexpected type: {type(item).__name__}") + + +# --- Ballot Properties + + +def all_ballot_styles(election_report: ElectionReport, index): + for ballot_style in index.by_type("BallotStyle"): + yield ballot_style + + +def ballot_style_name(ballot_style: BallotStyle): + assert len (ballot_style.external_identifier) == 1, \ + "Not ready to handle multiple BallotStyle external IDs" + name = ballot_style.external_identifier[0].value + return name + + +def ballot_style_gp_units(ballot_style: BallotStyle, index): + for id_ in ballot_style.gp_unit_ids: + gp_unit = index.by_id(id_) + yield gp_unit + + +def ballot_style_contests(ballot_style: BallotStyle, index): + for item in walk_ordered_content(ballot_style.ordered_content): + contest = index.by_id(item.contest_id) + yield contest + + +def ballot_style_candidate_contests(ballot_style: BallotStyle, index): + for contest in ballot_style_contests(ballot_style, index): + if not isinstance(contest, CandidateContest): + continue + candidates = [] + for selection in contest.contest_selection: + assert isinstance(selection, CandidateSelection), \ + "Unexpected non-candidate selection: {type(selection).__name__}" + # Ignore write-ins for tier 1 + if selection.is_write_in: + continue + for id_ in selection.candidate_ids: + candidate = index.by_id(id_) + candidates.append(candidate) + yield contest, candidates + + +def candidate_name(candidate: Candidate): + name = text_content(candidate.ballot_name) + return name + + +# --- Main + +import json +import sys +from pathlib import Path + + +def report(root, index): + ballot_styles = list(all_ballot_styles(root, index)) + # Only look at the first index + ballot_style = ballot_styles[0] + name = ballot_style_name(ballot_style) + print("name:", name) + gp_units = ballot_style_gp_units(ballot_style, index) + print("gp units:") + for item in gp_units: + print(f"- {text_content(item.name)}") + print("contests:") + contests = ballot_style_candidate_contests(ballot_style, index) + for contest, candidates in contests: + print(f"- name: {contest.name}") + print(f" type: {contest.vote_variation.value}") + print(f" votes: {contest.votes_allowed}") + print(f" candidates:") + for candidate in candidates: + print(f" - {candidate_name(candidate)}") + + +def main(): + file = Path(sys.argv[1]) + with file.open() as input: + text = input.read() + data = json.loads(text) + edf = ElectionReport(**data) + index = ElementIndex(edf, "ElectionResults") + report(edf, index) + + +if __name__ == '__main__': + main() From 41e188cb75879bb996f1c06c29898029fd94f981 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 12:01:13 -0700 Subject: [PATCH 03/48] Ordered content includes headers; use argparse. --- .../ballotmaker/scripts/ballot-lab-data.py | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 49f80e0..e725ffe 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -16,13 +16,23 @@ def text_content(item): return text -def walk_ordered_content(content: List[OrderedContent]): +def walk_ordered_contests(content: List[OrderedContent]): + """Walk ordered content yielding contests.""" for item in content: if isinstance(item, OrderedContest): yield item elif isinstance(item, OrderedHeader): + yield from walk_ordered_contests(item.ordered_content) + else: + raise TypeError(f"Unexpected type: {type(item).__name__}") + + +def walk_ordered_headers(content: List[OrderedContent]): + """Walk ordered content yielding headers.""" + for item in content: + if isinstance(item, OrderedHeader): yield item - yield from walk_ordered_content(item.ordered_content) + yield from walk_ordered_headers(item.ordered_content) else: raise TypeError(f"Unexpected type: {type(item).__name__}") @@ -36,9 +46,12 @@ def all_ballot_styles(election_report: ElectionReport, index): def ballot_style_name(ballot_style: BallotStyle): - assert len (ballot_style.external_identifier) == 1, \ - "Not ready to handle multiple BallotStyle external IDs" - name = ballot_style.external_identifier[0].value + if ballot_style.external_identifier: + assert len(ballot_style.external_identifier) == 1, \ + "Not ready to handle multiple BallotStyle external IDs" + name = ballot_style.external_identifier[0].value + else: + name = "" return name @@ -49,7 +62,7 @@ def ballot_style_gp_units(ballot_style: BallotStyle, index): def ballot_style_contests(ballot_style: BallotStyle, index): - for item in walk_ordered_content(ballot_style.ordered_content): + for item in walk_ordered_contests(ballot_style.ordered_content): contest = index.by_id(item.contest_id) yield contest @@ -78,8 +91,8 @@ def candidate_name(candidate: Candidate): # --- Main +import argparse import json -import sys from pathlib import Path @@ -105,7 +118,17 @@ def report(root, index): def main(): - file = Path(sys.argv[1]) + parser = argparse.ArgumentParser() + parser.add_argument( + "file", type = Path, + help = "Test case data (JSON)" + ) + parser.add_argument( + "nth", nargs = "?", type = int, default = 1, + help = "Index of the ballot style, starting from 1 (default: 1)" + ) + opts = parser.parse_args() + file = opts.file with file.open() as input: text = input.read() data = json.loads(text) From 25ff3cf685c33f8a1f8edf09a1dd710b70f29355 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 13:30:33 -0700 Subject: [PATCH 04/48] Flags for keeping write-ins, n-of-m contests. --- .../ballotmaker/scripts/ballot-lab-data.py | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index e725ffe..6cecf6f 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -67,16 +67,21 @@ def ballot_style_contests(ballot_style: BallotStyle, index): yield contest -def ballot_style_candidate_contests(ballot_style: BallotStyle, index): +def ballot_style_candidate_contests( + ballot_style: BallotStyle, index, keep_write_ins, keep_n_of_m, **opts +): for contest in ballot_style_contests(ballot_style, index): if not isinstance(contest, CandidateContest): continue + # Ignore N-of-M by default + if not keep_n_of_m and contest.vote_variation == VoteVariation.N_OF_M: + continue candidates = [] for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ "Unexpected non-candidate selection: {type(selection).__name__}" - # Ignore write-ins for tier 1 - if selection.is_write_in: + # Ignore write-ins by default + if not keep_write_ins and selection.is_write_in: continue for id_ in selection.candidate_ids: candidate = index.by_id(id_) @@ -96,10 +101,14 @@ def candidate_name(candidate: Candidate): from pathlib import Path -def report(root, index): +def report(root, index, nth, **opts): + """Generate data needed by BallotLab""" ballot_styles = list(all_ballot_styles(root, index)) - # Only look at the first index - ballot_style = ballot_styles[0] + if not (1 <= nth <= len(ballot_styles)): + print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") + return + nth -= 1 + ballot_style = ballot_styles[nth] name = ballot_style_name(ballot_style) print("name:", name) gp_units = ballot_style_gp_units(ballot_style, index) @@ -107,11 +116,11 @@ def report(root, index): for item in gp_units: print(f"- {text_content(item.name)}") print("contests:") - contests = ballot_style_candidate_contests(ballot_style, index) + contests = ballot_style_candidate_contests(ballot_style, index, **opts) for contest, candidates in contests: print(f"- name: {contest.name}") - print(f" type: {contest.vote_variation.value}") - print(f" votes: {contest.votes_allowed}") + print(f" vote type: {contest.vote_variation.value}") + print(f" votes allowed: {contest.votes_allowed}") print(f" candidates:") for candidate in candidates: print(f" - {candidate_name(candidate)}") @@ -127,14 +136,24 @@ def main(): "nth", nargs = "?", type = int, default = 1, help = "Index of the ballot style, starting from 1 (default: 1)" ) + parser.add_argument( + "--keep-write-ins", action = "store_true", + help = "Process write in candidates", + ) + parser.add_argument( + "--keep-n-of-m", action = "store_true", + help = "Process N-of-M contests", + ) opts = parser.parse_args() file = opts.file + opts = vars(opts) + with file.open() as input: text = input.read() data = json.loads(text) edf = ElectionReport(**data) index = ElementIndex(edf, "ElectionResults") - report(edf, index) + report(edf, index, **opts) if __name__ == '__main__': From 584bb410b3c32a5338001112940f312c5c2f6641 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 15:48:56 -0700 Subject: [PATCH 05/48] Drop 'keep' flags, add '--debug' for exceptions, more doc comments. --- .../ballotmaker/scripts/ballot-lab-data.py | 37 ++++++++++--------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 6cecf6f..5e1d3ac 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -10,7 +10,7 @@ def text_content(item): - """Return joined lines in internationalized text.""" + """Return joined lines from internationalized text.""" assert isinstance(item, InternationalizedText) text = "\n".join(_.content for _ in item.text) return text @@ -41,6 +41,7 @@ def walk_ordered_headers(content: List[OrderedContent]): def all_ballot_styles(election_report: ElectionReport, index): + """Yield all ballot styles.""" for ballot_style in index.by_type("BallotStyle"): yield ballot_style @@ -62,26 +63,25 @@ def ballot_style_gp_units(ballot_style: BallotStyle, index): def ballot_style_contests(ballot_style: BallotStyle, index): + """Yield the contests of a ballot style.""" for item in walk_ordered_contests(ballot_style.ordered_content): contest = index.by_id(item.contest_id) yield contest -def ballot_style_candidate_contests( - ballot_style: BallotStyle, index, keep_write_ins, keep_n_of_m, **opts -): +def ballot_style_candidate_contests(ballot_style: BallotStyle, index, **opts): for contest in ballot_style_contests(ballot_style, index): if not isinstance(contest, CandidateContest): continue # Ignore N-of-M by default - if not keep_n_of_m and contest.vote_variation == VoteVariation.N_OF_M: + if contest.vote_variation == VoteVariation.N_OF_M: continue candidates = [] for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ "Unexpected non-candidate selection: {type(selection).__name__}" # Ignore write-ins by default - if not keep_write_ins and selection.is_write_in: + if selection.is_write_in: continue for id_ in selection.candidate_ids: candidate = index.by_id(id_) @@ -137,23 +137,24 @@ def main(): help = "Index of the ballot style, starting from 1 (default: 1)" ) parser.add_argument( - "--keep-write-ins", action = "store_true", - help = "Process write in candidates", - ) - parser.add_argument( - "--keep-n-of-m", action = "store_true", - help = "Process N-of-M contests", + "--debug", action = "store_true", + help = "Enable debugging output and stack traces" ) opts = parser.parse_args() file = opts.file opts = vars(opts) - with file.open() as input: - text = input.read() - data = json.loads(text) - edf = ElectionReport(**data) - index = ElementIndex(edf, "ElectionResults") - report(edf, index, **opts) + try: + with file.open() as input: + text = input.read() + data = json.loads(text) + edf = ElectionReport(**data) + index = ElementIndex(edf, "ElectionResults") + report(edf, index, **opts) + except Exception as ex: + if opts["debug"]: + raise ex + print("error:", ex) if __name__ == '__main__': From 43eb184d1de02e1007fe1157bd8bc9deb5fe3d49 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 16:03:35 -0700 Subject: [PATCH 06/48] Significant refactoring of candidate contests. BallotStyles don't drive the contests. --- .../ballotmaker/scripts/ballot-lab-data.py | 109 ++++++++++++------ 1 file changed, 71 insertions(+), 38 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 5e1d3ac..3c5eb3a 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -3,6 +3,13 @@ from electos.datamodels.nist.models.edf import * from electos.datamodels.nist.indexes import ElementIndex + +# --- Base Types +# +# Schema expresses these as union types not subclasses + +Contest = Union[BallotMeasureContest, CandidateContest] + OrderedContent = Union[OrderedContest, OrderedHeader] @@ -46,7 +53,8 @@ def all_ballot_styles(election_report: ElectionReport, index): yield ballot_style -def ballot_style_name(ballot_style: BallotStyle): +def ballot_style_id(ballot_style: BallotStyle): + """Get the text of a ballot style's external identifier if any.""" if ballot_style.external_identifier: assert len(ballot_style.external_identifier) == 1, \ "Not ready to handle multiple BallotStyle external IDs" @@ -69,31 +77,64 @@ def ballot_style_contests(ballot_style: BallotStyle, index): yield contest -def ballot_style_candidate_contests(ballot_style: BallotStyle, index, **opts): - for contest in ballot_style_contests(ballot_style, index): - if not isinstance(contest, CandidateContest): - continue - # Ignore N-of-M by default - if contest.vote_variation == VoteVariation.N_OF_M: - continue - candidates = [] - for selection in contest.contest_selection: - assert isinstance(selection, CandidateSelection), \ - "Unexpected non-candidate selection: {type(selection).__name__}" - # Ignore write-ins by default - if selection.is_write_in: - continue - for id_ in selection.candidate_ids: - candidate = index.by_id(id_) - candidates.append(candidate) - yield contest, candidates - - def candidate_name(candidate: Candidate): + """Get the name of a candidate as it appears on a ballot.""" name = text_content(candidate.ballot_name) return name +def contest_election_district(contest: Contest, index): + """Get the district name of a contest.""" + district = index.by_id(contest.election_district_id) + district = text_content(district.name) + return district + + +# Gather & Extract +# +# Results are data needed for ballot generation. + +def extract_candidate_contest(contest: CandidateContest, index): + """Extract candidate contest information needed for ballots.""" + district = contest_election_district(contest, index) + candidates = [] + write_ins = 0 + for selection in contest.contest_selection: + assert isinstance(selection, CandidateSelection), \ + f"Unexpected non-candidate selection: {type(selection).__name__}" + # Write-ins have no candidate IDs + if selection.candidate_ids: + for id_ in selection.candidate_ids: + candidate = index.by_id(id_) + candidates.append(candidate) + if selection.is_write_in: + write_ins += 1 + result = { + "title": contest.name, + "type": "candidate", + "vote_type": contest.vote_variation.value, + "district": district, + "candidates": [candidate_name(_) for _ in candidates], + "write_ins": write_ins, + } + return result + + +def gather_contests(ballot_style: BallotStyle, index): + """Extract all contest information needed for ballots.""" + contests = { + kind: [] for kind in ("candidate", "ballot_measure") + } + for contest in ballot_style_contests(ballot_style, index): + if isinstance(contest, CandidateContest): + entry = extract_candidate_contest(contest, index) + contests["candidate"].append(entry) + else: + # Ignore other contest types + print(f"Skipping contest of type {contest.model__type}") + return contests + + # --- Main import argparse @@ -107,23 +148,15 @@ def report(root, index, nth, **opts): if not (1 <= nth <= len(ballot_styles)): print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") return - nth -= 1 - ballot_style = ballot_styles[nth] - name = ballot_style_name(ballot_style) - print("name:", name) - gp_units = ballot_style_gp_units(ballot_style, index) - print("gp units:") - for item in gp_units: - print(f"- {text_content(item.name)}") - print("contests:") - contests = ballot_style_candidate_contests(ballot_style, index, **opts) - for contest, candidates in contests: - print(f"- name: {contest.name}") - print(f" vote type: {contest.vote_variation.value}") - print(f" votes allowed: {contest.votes_allowed}") - print(f" candidates:") - for candidate in candidates: - print(f" - {candidate_name(candidate)}") + ballot_style = ballot_styles[nth - 1] + data = {} + id_ = ballot_style_id(ballot_style) + data["ballot_style"] = id_ + contests = gather_contests(ballot_style, index) + if not contests: + print(f"No contests found for ballot style: {id_}\n") + data["contests"] = contests + print(json.dumps(data, indent = 4)) def main(): From 24b921f6920d7686ca7bf69c3a696ee439ac782d Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 16:18:58 -0700 Subject: [PATCH 07/48] Add offices and parties to candidate contests. --- .../ballotmaker/scripts/ballot-lab-data.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 3c5eb3a..f5d861a 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -83,6 +83,28 @@ def candidate_name(candidate: Candidate): return name +def candidate_contest_offices(contest: CandidateContest, index): + """Get any offices associated with a candidate contest.""" + offices = [] + if contest.office_ids: + for id_ in contest.office_ids: + office = index.by_id(id_) + name = text_content(office.name) + offices.append(name) + return offices + + +def candidate_contest_parties(contest: CandidateContest, index): + """Get any parties associated with a candidate contest.""" + parties = [] + if contest.primary_party_ids: + for id_ in contest.primary_party_ids: + party = index.by_id(id_) + name = text_content(party.name) + parties.append(name) + return parties + + def contest_election_district(contest: Contest, index): """Get the district name of a contest.""" district = index.by_id(contest.election_district_id) @@ -98,6 +120,8 @@ def extract_candidate_contest(contest: CandidateContest, index): """Extract candidate contest information needed for ballots.""" district = contest_election_district(contest, index) candidates = [] + offices = candidate_contest_offices(contest, index) + parties = candidate_contest_parties(contest, index) write_ins = 0 for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ @@ -115,6 +139,8 @@ def extract_candidate_contest(contest: CandidateContest, index): "vote_type": contest.vote_variation.value, "district": district, "candidates": [candidate_name(_) for _ in candidates], + "offices": offices, + "parties": parties, "write_ins": write_ins, } return result From 9f0ab5520aa775b299219ad3ee12cc5289ae01ab Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 16:45:00 -0700 Subject: [PATCH 08/48] Add ballot measure contests. --- .../ballotmaker/scripts/ballot-lab-data.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index f5d861a..c787ef5 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -146,6 +146,24 @@ def extract_candidate_contest(contest: CandidateContest, index): return result +def extract_ballot_measure_contest(contest: BallotMeasureContest, index): + """Extract ballot measure contest information needed for ballots.""" + choices = [] + for selection in contest.contest_selection: + assert isinstance(selection, BallotMeasureSelection), \ + f"Unexpected non-ballot measure selection: {type(selection).__name__}" + choice = text_content(selection.selection) + choices.append(choice) + district = contest_election_district(contest, index) + result = { + "title": contest.name, + "type": "ballot measure", + "district": district, + "choices": choices, + } + return result + + def gather_contests(ballot_style: BallotStyle, index): """Extract all contest information needed for ballots.""" contests = { @@ -155,6 +173,9 @@ def gather_contests(ballot_style: BallotStyle, index): if isinstance(contest, CandidateContest): entry = extract_candidate_contest(contest, index) contests["candidate"].append(entry) + elif isinstance(contest, BallotMeasureContest): + entry = extract_ballot_measure_contest(contest, index) + contests["ballot_measure"].append(entry) else: # Ignore other contest types print(f"Skipping contest of type {contest.model__type}") From f1fd991d1d22df5e221049a5f636d96502bd0390 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 16:55:09 -0700 Subject: [PATCH 09/48] Add ballot text; don't return offices or parties for candidates. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index c787ef5..22f74c7 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -139,8 +139,9 @@ def extract_candidate_contest(contest: CandidateContest, index): "vote_type": contest.vote_variation.value, "district": district, "candidates": [candidate_name(_) for _ in candidates], - "offices": offices, - "parties": parties, + # Leave out offices and parties for now + # "offices": offices, + # "parties": parties, "write_ins": write_ins, } return result @@ -155,11 +156,13 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): choice = text_content(selection.selection) choices.append(choice) district = contest_election_district(contest, index) + full_text = text_content(contest.full_text) result = { "title": contest.name, "type": "ballot measure", "district": district, "choices": choices, + "text": full_text, } return result From 309db0eca33f4a25d10fabe83d39f1b4a36b06c4 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 17:17:24 -0700 Subject: [PATCH 10/48] Add README for ballot-lab-data script. --- src/electos/ballotmaker/scripts/README.md | 42 +++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/electos/ballotmaker/scripts/README.md b/src/electos/ballotmaker/scripts/README.md index 9dcb046..d30d018 100644 --- a/src/electos/ballotmaker/scripts/README.md +++ b/src/electos/ballotmaker/scripts/README.md @@ -1 +1,43 @@ Extract data from NIST EDF models for use by BallotLab. + +## Requirements + +- The script requires `nist-datamodels`, using a version that has element indexes. + +## Inputs + +Filenames are of the format `{test-case-source}_{ballot-style-id}.json`. +Note the use of `-` to separate words, and `_` to separate the name parts. + +Inputs are EDF JSON files. Get them from: + +- https://github.com/TrustTheVote-Project/NIST-1500-100-103-examples/blob/main/test_cases/ + +The script is `ballot-lab-data.py`. It takes an EDF test case, and the index of +the ballot style to use (a number from 1 to N, that defaults to 1.). + +To run it: + +- Install `nist-datamodels`, using a version that has element indexes. +- Run: + + python ballot-lab-data.py [] + + e.g. + + python ballot-lab-data.py june_test_case.json 1 + +## Outputs + +- Output is JSON files with contests, grouped by contest type. +- The `VotingVariation` in the EDF is `vote_type` here. +- Write-ins don't affect the candidate list. They are returned as a count. + Presumably they would all be the same and all that's needed is their number. + They can be ignored.` +- The fields were selected to match what is needed for `plurality` candidate + contests and a little extra. We can add other `VoteVariation`s and ballot + measure contests as needed. + +## Complications + +- There are no `Header`s or `OrderedHeader`s in the test cases. From f5090b8e0cd60ab4082c37e3edd4aeb7f2b56ac9 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sun, 21 Aug 2022 18:10:40 -0700 Subject: [PATCH 11/48] Add party names to candidates. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 22f74c7..28648be 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -83,6 +83,13 @@ def candidate_name(candidate: Candidate): return name +def candidate_party(candidate: Candidate, index): + """Get the name of the party of a candidate as it appears on a ballot.""" + party = index.by_id(candidate.party_id) + name = text_content(party.name) if party else "" + return name + + def candidate_contest_offices(contest: CandidateContest, index): """Get any offices associated with a candidate contest.""" offices = [] @@ -138,7 +145,10 @@ def extract_candidate_contest(contest: CandidateContest, index): "type": "candidate", "vote_type": contest.vote_variation.value, "district": district, - "candidates": [candidate_name(_) for _ in candidates], + "candidates": [ + { "name": candidate_name(_), "party": candidate_party(_, index) } + for _ in candidates + ], # Leave out offices and parties for now # "offices": offices, # "parties": parties, From 55b5b384498739f97b1e44af8b3750bc08a96559 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 23 Aug 2022 16:28:58 -0700 Subject: [PATCH 12/48] Add votes allowed on candidates races (even if it's the default). Fixes BallotLab #82. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 28648be..3db154c 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -144,6 +144,8 @@ def extract_candidate_contest(contest: CandidateContest, index): "title": contest.name, "type": "candidate", "vote_type": contest.vote_variation.value, + # Include even when default is 1: don't require caller to track that. + "votes_allowed": contest.votes_allowed, "district": district, "candidates": [ { "name": candidate_name(_), "party": candidate_party(_, index) } From a262592b06c874e08ddc0df316fd784443277a0d Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 23 Aug 2022 16:31:22 -0700 Subject: [PATCH 13/48] Add party abbreviations to candidates. Fixes BallotLab #83. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 3db154c..a3cf618 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -84,10 +84,15 @@ def candidate_name(candidate: Candidate): def candidate_party(candidate: Candidate, index): - """Get the name of the party of a candidate as it appears on a ballot.""" + """Get the name and abbreviation of the party of a candidate as it appears on a ballot.""" party = index.by_id(candidate.party_id) name = text_content(party.name) if party else "" - return name + abbreviation = text_content(party.abbreviation) if party and party.abbreviation else "" + result = { + "name": name, + "abbreviation": abbreviation, + } + return result def candidate_contest_offices(contest: CandidateContest, index): From 4a2731604891fc44d7a8e6adbb2819eb9bbc9e34 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 23 Aug 2022 16:36:06 -0700 Subject: [PATCH 14/48] Include '@id's with contests. Fixes BallotLab #86. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index a3cf618..b1ea29d 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -146,6 +146,7 @@ def extract_candidate_contest(contest: CandidateContest, index): if selection.is_write_in: write_ins += 1 result = { + "id": contest.model__id, "title": contest.name, "type": "candidate", "vote_type": contest.vote_variation.value, From 15efb15ccb9011b7602dbc01c25507c8b0612013 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 23 Aug 2022 16:48:19 -0700 Subject: [PATCH 15/48] Use 'ContestSelectionId's instead of a count for write-ins. Fixes BallotLab #84. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index b1ea29d..1647203 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -134,7 +134,7 @@ def extract_candidate_contest(contest: CandidateContest, index): candidates = [] offices = candidate_contest_offices(contest, index) parties = candidate_contest_parties(contest, index) - write_ins = 0 + write_ins = [] for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ f"Unexpected non-candidate selection: {type(selection).__name__}" @@ -144,7 +144,7 @@ def extract_candidate_contest(contest: CandidateContest, index): candidate = index.by_id(id_) candidates.append(candidate) if selection.is_write_in: - write_ins += 1 + write_ins.append(selection.model__id) result = { "id": contest.model__id, "title": contest.name, From 4355de7e51f87b28b57979e63141269e2c531eaf Mon Sep 17 00:00:00 2001 From: Ion Y Date: Wed, 24 Aug 2022 13:58:36 -0700 Subject: [PATCH 16/48] Add selection IDs to all candidates, not just write-ins. Updates #84. --- .../ballotmaker/scripts/ballot-lab-data.py | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 1647203..cf667fc 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -95,6 +95,27 @@ def candidate_party(candidate: Candidate, index): return result +def candidate_contest_candidates(contest: CandidateContest, index): + candidates = [] + write_ins = [] + for selection in contest.contest_selection: + assert isinstance(selection, CandidateSelection), \ + f"Unexpected non-candidate selection: {type(selection).__name__}" + # Write-ins have no candidate IDs + if selection.candidate_ids: + for id_ in selection.candidate_ids: + candidate = index.by_id(id_) + item = { + "id": selection.model__id, + "name": candidate_name(candidate), + "party": candidate_party(candidate, index), + } + candidates.append(item) + if selection.is_write_in: + write_ins.append(selection.model__id) + return candidates, write_ins + + def candidate_contest_offices(contest: CandidateContest, index): """Get any offices associated with a candidate contest.""" offices = [] @@ -131,20 +152,9 @@ def contest_election_district(contest: Contest, index): def extract_candidate_contest(contest: CandidateContest, index): """Extract candidate contest information needed for ballots.""" district = contest_election_district(contest, index) - candidates = [] offices = candidate_contest_offices(contest, index) parties = candidate_contest_parties(contest, index) - write_ins = [] - for selection in contest.contest_selection: - assert isinstance(selection, CandidateSelection), \ - f"Unexpected non-candidate selection: {type(selection).__name__}" - # Write-ins have no candidate IDs - if selection.candidate_ids: - for id_ in selection.candidate_ids: - candidate = index.by_id(id_) - candidates.append(candidate) - if selection.is_write_in: - write_ins.append(selection.model__id) + candidates, write_ins = candidate_contest_candidates(contest, index) result = { "id": contest.model__id, "title": contest.name, @@ -153,11 +163,7 @@ def extract_candidate_contest(contest: CandidateContest, index): # Include even when default is 1: don't require caller to track that. "votes_allowed": contest.votes_allowed, "district": district, - "candidates": [ - { "name": candidate_name(_), "party": candidate_party(_, index) } - for _ in candidates - ], - # Leave out offices and parties for now + "candidates": candidates, # "offices": offices, # "parties": parties, "write_ins": write_ins, From 26deeaffb73cf137ef6fbce4bdaa576f6a92289b Mon Sep 17 00:00:00 2001 From: Ion Y Date: Wed, 24 Aug 2022 15:20:54 -0700 Subject: [PATCH 17/48] Properly set up multi-candidate slates/tickets. Fixes BallotLab #85. This isn't quite right: fails if a slate has candidates from different parties. Deferring on that case for now. --- .../ballotmaker/scripts/ballot-lab-data.py | 53 ++++++++++++++----- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index cf667fc..cf8ea3f 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -1,3 +1,4 @@ +from itertools import groupby from typing import List, Union from electos.datamodels.nist.models.edf import * @@ -96,23 +97,49 @@ def candidate_party(candidate: Candidate, index): def candidate_contest_candidates(contest: CandidateContest, index): - candidates = [] - write_ins = [] + """Get candidates for contest, grouped by slate/ticket. + + A slate will collect candidate names together, but have a single ID for + the contest selection, and a single party. + + Todo: + Handle the case where candidates on a slate don't share a party. + There's no clear guarantee of a 1:1 relationship between slates and parties. + """ + candidates_solo = [] + # Collect individual candidates for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ f"Unexpected non-candidate selection: {type(selection).__name__}" # Write-ins have no candidate IDs - if selection.candidate_ids: - for id_ in selection.candidate_ids: - candidate = index.by_id(id_) - item = { - "id": selection.model__id, - "name": candidate_name(candidate), - "party": candidate_party(candidate, index), - } - candidates.append(item) - if selection.is_write_in: - write_ins.append(selection.model__id) + if not selection.candidate_ids: + continue + for id_ in selection.candidate_ids: + candidate = index.by_id(id_) + candidate = { + "id": selection.model__id, + "name": candidate_name(candidate), + "party": candidate_party(candidate, index), + } + candidates_solo.append(candidate) + # Group candidates by slate. + # + # Candidates on the same slate will share the 'ContestSelection' ID + # Don't try to collect by party. + candidates_by_slate = [] + for _, slate in groupby(candidates_solo, lambda _: _["id"]): + slate = list(slate) + # Bail out if candidates on a slate don't share a party + assert len({_["party"]["name"] for _ in slate}) == 1, \ + f"Candidates in '{slate[0]['party']['name']}' slate don't all share the same party" + candidate = { + "id": slate[0]["id"], + "name": [candidate["name"] for candidate in slate], + "party": slate[0]["party"], + } + candidates_by_slate.append(candidate) + candidates = candidates_by_slate + write_ins = [_.model__id for _ in contest.contest_selection if _.is_write_in] return candidates, write_ins From fb03e3feda903b91555a5a03b1d72c02f49e8e72 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Wed, 24 Aug 2022 16:16:41 -0700 Subject: [PATCH 18/48] Merge write-ins into candidte contests. Mark write-in status as boolean (matching the contents of the EDF). No issue attached. --- .../ballotmaker/scripts/ballot-lab-data.py | 38 +++++++++---------- 1 file changed, 18 insertions(+), 20 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index cf8ea3f..df20475 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -106,22 +106,20 @@ def candidate_contest_candidates(contest: CandidateContest, index): Handle the case where candidates on a slate don't share a party. There's no clear guarantee of a 1:1 relationship between slates and parties. """ - candidates_solo = [] # Collect individual candidates + candidates_solo = [] for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ f"Unexpected non-candidate selection: {type(selection).__name__}" - # Write-ins have no candidate IDs - if not selection.candidate_ids: - continue - for id_ in selection.candidate_ids: - candidate = index.by_id(id_) - candidate = { - "id": selection.model__id, - "name": candidate_name(candidate), - "party": candidate_party(candidate, index), - } - candidates_solo.append(candidate) + result = {} + result["id"] = selection.model__id + if selection.candidate_ids: + for id_ in selection.candidate_ids: + candidate = index.by_id(id_) + result["name"] = candidate_name(candidate) + result["party"] = candidate_party(candidate, index) + result["is_write_in"] = bool(selection.is_write_in) + candidates_solo.append(result) # Group candidates by slate. # # Candidates on the same slate will share the 'ContestSelection' ID @@ -129,18 +127,19 @@ def candidate_contest_candidates(contest: CandidateContest, index): candidates_by_slate = [] for _, slate in groupby(candidates_solo, lambda _: _["id"]): slate = list(slate) - # Bail out if candidates on a slate don't share a party - assert len({_["party"]["name"] for _ in slate}) == 1, \ - f"Candidates in '{slate[0]['party']['name']}' slate don't all share the same party" candidate = { "id": slate[0]["id"], - "name": [candidate["name"] for candidate in slate], - "party": slate[0]["party"], } + if "party" in slate[0]: + assert len({_["party"]["name"] for _ in slate}) == 1, \ + f"Candidates in '{slate[0]['party']['name']}' slate don't all share the same party" + candidate["name"] = [_["name"] for _ in slate] + candidate["party"] = slate[0]["party"] + candidate["write_in"] = slate[0]["is_write_in"] candidates_by_slate.append(candidate) candidates = candidates_by_slate write_ins = [_.model__id for _ in contest.contest_selection if _.is_write_in] - return candidates, write_ins + return candidates def candidate_contest_offices(contest: CandidateContest, index): @@ -181,7 +180,7 @@ def extract_candidate_contest(contest: CandidateContest, index): district = contest_election_district(contest, index) offices = candidate_contest_offices(contest, index) parties = candidate_contest_parties(contest, index) - candidates, write_ins = candidate_contest_candidates(contest, index) + candidates = candidate_contest_candidates(contest, index) result = { "id": contest.model__id, "title": contest.name, @@ -193,7 +192,6 @@ def extract_candidate_contest(contest: CandidateContest, index): "candidates": candidates, # "offices": offices, # "parties": parties, - "write_ins": write_ins, } return result From 1547160a9ec71a34a465deb6fca05480faaec1a3 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 27 Aug 2022 13:23:11 -0700 Subject: [PATCH 19/48] Multi-candidate slates: Fix dropping all candidates before the last one. The error was introduced in the process of folding write-ins into contests. - Remove convoluted extra step that makes it harder to do this correctly. - Candidate names are grouped in arrays. Now do the same for parties. - Use 'is_write_in' for the write-in boolean field. --- .../ballotmaker/scripts/ballot-lab-data.py | 53 +++++++++---------- 1 file changed, 24 insertions(+), 29 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index df20475..64fda85 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -99,46 +99,41 @@ def candidate_party(candidate: Candidate, index): def candidate_contest_candidates(contest: CandidateContest, index): """Get candidates for contest, grouped by slate/ticket. - A slate will collect candidate names together, but have a single ID for - the contest selection, and a single party. + A slate has: + + - A single ID for the contest selection + - Collects candidate names into an array. + - Collects candidate parties into an array. + + Notes: + - There's no clear guarantee of a 1:1 relationship between slates and parties. Todo: - Handle the case where candidates on a slate don't share a party. - There's no clear guarantee of a 1:1 relationship between slates and parties. + - Allow combining shared parties into a single entry. """ # Collect individual candidates - candidates_solo = [] + candidates = [] for selection in contest.contest_selection: assert isinstance(selection, CandidateSelection), \ f"Unexpected non-candidate selection: {type(selection).__name__}" - result = {} - result["id"] = selection.model__id + names = [] + parties = [] if selection.candidate_ids: for id_ in selection.candidate_ids: candidate = index.by_id(id_) - result["name"] = candidate_name(candidate) - result["party"] = candidate_party(candidate, index) - result["is_write_in"] = bool(selection.is_write_in) - candidates_solo.append(result) - # Group candidates by slate. - # - # Candidates on the same slate will share the 'ContestSelection' ID - # Don't try to collect by party. - candidates_by_slate = [] - for _, slate in groupby(candidates_solo, lambda _: _["id"]): - slate = list(slate) - candidate = { - "id": slate[0]["id"], + name = candidate_name(candidate) + if name: + names.append(name) + party = candidate_party(candidate, index) + if party: + parties.append(party) + result = { + "id": selection.model__id, + "name": names, + "party": parties, + "is_write_in": bool(selection.is_write_in) } - if "party" in slate[0]: - assert len({_["party"]["name"] for _ in slate}) == 1, \ - f"Candidates in '{slate[0]['party']['name']}' slate don't all share the same party" - candidate["name"] = [_["name"] for _ in slate] - candidate["party"] = slate[0]["party"] - candidate["write_in"] = slate[0]["is_write_in"] - candidates_by_slate.append(candidate) - candidates = candidates_by_slate - write_ins = [_.model__id for _ in contest.contest_selection if _.is_write_in] + candidates.append(result) return candidates From 78f933aa6ff66be5efb21c8a6b7e872b9c7c95ba Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 27 Aug 2022 13:45:43 -0700 Subject: [PATCH 20/48] Multi-candidate slates: Use party IDs to collapse identical parties. Handles case where candidates in a slate have different parties. --- .../ballotmaker/scripts/ballot-lab-data.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 64fda85..5ef0bd4 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -86,14 +86,16 @@ def candidate_name(candidate: Candidate): def candidate_party(candidate: Candidate, index): """Get the name and abbreviation of the party of a candidate as it appears on a ballot.""" - party = index.by_id(candidate.party_id) + # Note: party ID is returned to allow de-duplicating parties in callers. + id_ = candidate.party_id + party = index.by_id(id_) name = text_content(party.name) if party else "" abbreviation = text_content(party.abbreviation) if party and party.abbreviation else "" result = { "name": name, "abbreviation": abbreviation, } - return result + return result, id_ def candidate_contest_candidates(contest: CandidateContest, index): @@ -104,12 +106,11 @@ def candidate_contest_candidates(contest: CandidateContest, index): - A single ID for the contest selection - Collects candidate names into an array. - Collects candidate parties into an array. + - If all candidates in a race share a single party they are combined into + one entry in the array. Notes: - There's no clear guarantee of a 1:1 relationship between slates and parties. - - Todo: - - Allow combining shared parties into a single entry. """ # Collect individual candidates candidates = [] @@ -118,15 +119,17 @@ def candidate_contest_candidates(contest: CandidateContest, index): f"Unexpected non-candidate selection: {type(selection).__name__}" names = [] parties = [] + _party_ids = set() if selection.candidate_ids: for id_ in selection.candidate_ids: candidate = index.by_id(id_) name = candidate_name(candidate) if name: names.append(name) - party = candidate_party(candidate, index) - if party: + party, _party_id = candidate_party(candidate, index) + if party and _party_id not in _party_ids: parties.append(party) + _party_ids.add(_party_id) result = { "id": selection.model__id, "name": names, From e65864686426cc66e04723c513b336ff8c749f56 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 27 Aug 2022 13:50:31 -0700 Subject: [PATCH 21/48] Multi-candidate slates: Only collapse party IDs if ALL parties are identical. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 5ef0bd4..29aad1e 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -108,6 +108,7 @@ def candidate_contest_candidates(contest: CandidateContest, index): - Collects candidate parties into an array. - If all candidates in a race share a single party they are combined into one entry in the array. + - If any candidates differ from the others, parties are listed separately. Notes: - There's no clear guarantee of a 1:1 relationship between slates and parties. @@ -127,9 +128,12 @@ def candidate_contest_candidates(contest: CandidateContest, index): if name: names.append(name) party, _party_id = candidate_party(candidate, index) - if party and _party_id not in _party_ids: - parties.append(party) - _party_ids.add(_party_id) + parties.append(party) + _party_ids.add(_party_id) + # If there's only one party ID, all candidates share the same party. + # If there's any divergence track them all individually. + if len(_party_ids) == 1: + parties = parties[:1] result = { "id": selection.model__id, "name": names, From d48ddf7f60da822b213695ef7b8ca7c9b6931cd3 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 27 Aug 2022 13:52:59 -0700 Subject: [PATCH 22/48] Drop null fields of parties; use an empty dictionary if a party is missing. --- .../ballotmaker/scripts/ballot-lab-data.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 29aad1e..a7306e1 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -85,16 +85,20 @@ def candidate_name(candidate: Candidate): def candidate_party(candidate: Candidate, index): - """Get the name and abbreviation of the party of a candidate as it appears on a ballot.""" + """Get the name and abbreviation of the party of a candidate as it appears on a ballot. + + Drop either field from result if it isn't present. + """ # Note: party ID is returned to allow de-duplicating parties in callers. id_ = candidate.party_id party = index.by_id(id_) - name = text_content(party.name) if party else "" - abbreviation = text_content(party.abbreviation) if party and party.abbreviation else "" - result = { - "name": name, - "abbreviation": abbreviation, - } + name = text_content(party.name) if party else None + abbreviation = text_content(party.abbreviation) if party and party.abbreviation else None + result = {} + if name: + result["name"] = name + if abbreviation: + result["abbreviation"] = abbreviation return result, id_ From 4d1bfb4188954172848c7ced94e1e9bdc97d7e84 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Sat, 27 Aug 2022 15:17:08 -0700 Subject: [PATCH 23/48] Add IDs to ballot measures contests. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index a7306e1..0ddb5d5 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -213,6 +213,7 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): district = contest_election_district(contest, index) full_text = text_content(contest.full_text) result = { + "id": contest.model__id, "title": contest.name, "type": "ballot measure", "district": district, From 8fd93373d252b55ece4fcaf495144e6b72945889 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 29 Aug 2022 16:48:37 -0700 Subject: [PATCH 24/48] Add IDs to ballot measure contest selections. Fixes BallotLab #86. --- src/electos/ballotmaker/scripts/ballot-lab-data.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/scripts/ballot-lab-data.py index 0ddb5d5..a1e1718 100644 --- a/src/electos/ballotmaker/scripts/ballot-lab-data.py +++ b/src/electos/ballotmaker/scripts/ballot-lab-data.py @@ -209,7 +209,10 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): assert isinstance(selection, BallotMeasureSelection), \ f"Unexpected non-ballot measure selection: {type(selection).__name__}" choice = text_content(selection.selection) - choices.append(choice) + choices.append({ + "id": selection.model__id, + "choice": choice, + }) district = contest_election_district(contest, index) full_text = text_content(contest.full_text) result = { From 6a88c509d40aa6c21d52531b3dd9ac93c6f29836 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 30 Aug 2022 16:45:08 -0700 Subject: [PATCH 25/48] Update README for 'ballot-lab-data' script. --- src/electos/ballotmaker/scripts/README.md | 49 ++++++++++++----------- 1 file changed, 25 insertions(+), 24 deletions(-) diff --git a/src/electos/ballotmaker/scripts/README.md b/src/electos/ballotmaker/scripts/README.md index d30d018..f840ce5 100644 --- a/src/electos/ballotmaker/scripts/README.md +++ b/src/electos/ballotmaker/scripts/README.md @@ -1,43 +1,44 @@ -Extract data from NIST EDF models for use by BallotLab. +Data to use for BallotLab inputs. The data is extracted from EDF test cases +and constrained to the data model Ballot Lab is using. -## Requirements +Output file naming format is `{test-case-source}_{ballot-style-id}.json`. +Note the use of `-` to separate words, and `_` to separate the name parts. -- The script requires `nist-datamodels`, using a version that has element indexes. +All the current examples are taken from these EDF files: -## Inputs +- https://github.com/TrustTheVote-Project/NIST-1500-100-103-examples/blob/main/test_cases/june_test_case.json -Filenames are of the format `{test-case-source}_{ballot-style-id}.json`. -Note the use of `-` to separate words, and `_` to separate the name parts. +To run it: -Inputs are EDF JSON files. Get them from: +- Install the BallotLab fork and change to the 'edf-data-to-ballot' branch. -- https://github.com/TrustTheVote-Project/NIST-1500-100-103-examples/blob/main/test_cases/ + git clone https://github.com/ion-oset/BallotLab -b edf-data-to-ballot -The script is `ballot-lab-data.py`. It takes an EDF test case, and the index of -the ballot style to use (a number from 1 to N, that defaults to 1.). +- Install the project dependencies: -To run it: + poetry install -- Install `nist-datamodels`, using a version that has element indexes. - Run: - python ballot-lab-data.py [] + python scripts/ballot-lab-data.py e.g. - python ballot-lab-data.py june_test_case.json 1 + python scripts/ballot-lab-data.py june_test_case.json 1 -## Outputs +Structure of output: -- Output is JSON files with contests, grouped by contest type. -- The `VotingVariation` in the EDF is `vote_type` here. -- Write-ins don't affect the candidate list. They are returned as a count. - Presumably they would all be the same and all that's needed is their number. - They can be ignored.` +- Output is a series of contests, grouped by contest type (candidate, ballot + measure) +- Within a contest type order of records is preserved. +- The `VotingVariation` in the EDF is `vote_type` here. It can be filtered. + - `vote_type` of `plurality` is the simplest kind of ballot. + - Ignore `n-of-m` and `ranked-choice` until later. +- Write-ins are integrated into the candidate list. - The fields were selected to match what is needed for `plurality` candidate - contests and a little extra. We can add other `VoteVariation`s and ballot - measure contests as needed. + contests and a little extra. + - To add fields or modify them we should modify `extract_{contest type}_contest`. -## Complications +Notes: -- There are no `Header`s or `OrderedHeader`s in the test cases. +- There are no `Header`s or `OrderedHeader`s in `june_test_case.json`. From 827b2c9efdf21e6394d66b3873c2ae952c67a64a Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 15:46:53 -0700 Subject: [PATCH 26/48] Move 'scripts' to 'data' and rename script. --- src/electos/ballotmaker/{scripts => data}/README.md | 0 src/electos/ballotmaker/{scripts => data}/ballot-lab-data.py | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/electos/ballotmaker/{scripts => data}/README.md (100%) rename src/electos/ballotmaker/{scripts => data}/ballot-lab-data.py (100%) diff --git a/src/electos/ballotmaker/scripts/README.md b/src/electos/ballotmaker/data/README.md similarity index 100% rename from src/electos/ballotmaker/scripts/README.md rename to src/electos/ballotmaker/data/README.md diff --git a/src/electos/ballotmaker/scripts/ballot-lab-data.py b/src/electos/ballotmaker/data/ballot-lab-data.py similarity index 100% rename from src/electos/ballotmaker/scripts/ballot-lab-data.py rename to src/electos/ballotmaker/data/ballot-lab-data.py From 8d6d4318f2ee0d5ad01c2b64e569d97dc31190e0 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 15:55:35 -0700 Subject: [PATCH 27/48] Separate EDF to data into a script and a module. --- .../ballotmaker/data/edf-to-ballot-data.py | 64 +++++++++++++++++++ .../data/{ballot-lab-data.py => extractor.py} | 62 +----------------- 2 files changed, 65 insertions(+), 61 deletions(-) create mode 100644 src/electos/ballotmaker/data/edf-to-ballot-data.py rename src/electos/ballotmaker/data/{ballot-lab-data.py => extractor.py} (83%) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py new file mode 100644 index 0000000..54bc04e --- /dev/null +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -0,0 +1,64 @@ +import argparse +import json +from pathlib import Path + +from electos.datamodels.nist.indexes import ElementIndex +from electos.datamodels.nist.models.edf import ElectionReport + +from electos.ballotmaker.data.extractor import ( + all_ballot_styles, + ballot_style_id, + gather_contests, +) + + +def report(root, index, nth, **opts): + """Generate data needed by BallotLab.""" + ballot_styles = list(all_ballot_styles(root, index)) + if not (1 <= nth <= len(ballot_styles)): + print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") + return + ballot_style = ballot_styles[nth - 1] + data = {} + id_ = ballot_style_id(ballot_style) + data["ballot_style"] = id_ + contests = gather_contests(ballot_style, index) + if not contests: + print(f"No contests found for ballot style: {id_}\n") + data["contests"] = contests + print(json.dumps(data, indent = 4)) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument( + "file", type = Path, + help = "Test case data (JSON)" + ) + parser.add_argument( + "nth", nargs = "?", type = int, default = 1, + help = "Index of the ballot style, starting from 1 (default: 1)" + ) + parser.add_argument( + "--debug", action = "store_true", + help = "Enable debugging output and stack traces" + ) + opts = parser.parse_args() + file = opts.file + opts = vars(opts) + + try: + with file.open() as input: + text = input.read() + data = json.loads(text) + edf = ElectionReport(**data) + index = ElementIndex(edf, "ElectionResults") + report(edf, index, **opts) + except Exception as ex: + if opts["debug"]: + raise ex + print("error:", ex) + + +if __name__ == '__main__': + main() diff --git a/src/electos/ballotmaker/data/ballot-lab-data.py b/src/electos/ballotmaker/data/extractor.py similarity index 83% rename from src/electos/ballotmaker/data/ballot-lab-data.py rename to src/electos/ballotmaker/data/extractor.py index a1e1718..1c94274 100644 --- a/src/electos/ballotmaker/data/ballot-lab-data.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -1,8 +1,7 @@ -from itertools import groupby from typing import List, Union -from electos.datamodels.nist.models.edf import * from electos.datamodels.nist.indexes import ElementIndex +from electos.datamodels.nist.models.edf import * # --- Base Types @@ -242,62 +241,3 @@ def gather_contests(ballot_style: BallotStyle, index): # Ignore other contest types print(f"Skipping contest of type {contest.model__type}") return contests - - -# --- Main - -import argparse -import json -from pathlib import Path - - -def report(root, index, nth, **opts): - """Generate data needed by BallotLab""" - ballot_styles = list(all_ballot_styles(root, index)) - if not (1 <= nth <= len(ballot_styles)): - print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") - return - ballot_style = ballot_styles[nth - 1] - data = {} - id_ = ballot_style_id(ballot_style) - data["ballot_style"] = id_ - contests = gather_contests(ballot_style, index) - if not contests: - print(f"No contests found for ballot style: {id_}\n") - data["contests"] = contests - print(json.dumps(data, indent = 4)) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument( - "file", type = Path, - help = "Test case data (JSON)" - ) - parser.add_argument( - "nth", nargs = "?", type = int, default = 1, - help = "Index of the ballot style, starting from 1 (default: 1)" - ) - parser.add_argument( - "--debug", action = "store_true", - help = "Enable debugging output and stack traces" - ) - opts = parser.parse_args() - file = opts.file - opts = vars(opts) - - try: - with file.open() as input: - text = input.read() - data = json.loads(text) - edf = ElectionReport(**data) - index = ElementIndex(edf, "ElectionResults") - report(edf, index, **opts) - except Exception as ex: - if opts["debug"]: - raise ex - print("error:", ex) - - -if __name__ == '__main__': - main() From 2bec0c11c89ee68a76d207d6864ae55536d0ca99 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 15:58:35 -0700 Subject: [PATCH 28/48] Use explicit EDF model imports ballot data extractor. --- src/electos/ballotmaker/data/extractor.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 1c94274..7649630 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -1,7 +1,18 @@ from typing import List, Union from electos.datamodels.nist.indexes import ElementIndex -from electos.datamodels.nist.models.edf import * +from electos.datamodels.nist.models.edf import ( + BallotMeasureContest, + BallotMeasureSelection, + BallotStyle, + Candidate, + CandidateContest, + CandidateSelection, + ElectionReport, + InternationalizedText, + OrderedContest, + OrderedHeader, +) # --- Base Types From 05fac8ffe6161dd159cf9f2b4e3a882cd00a216e Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 16:04:48 -0700 Subject: [PATCH 29/48] Simplify naming of ballot data extractor functions. --- .../ballotmaker/data/edf-to-ballot-data.py | 12 ++++----- src/electos/ballotmaker/data/extractor.py | 26 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index 54bc04e..a7d0634 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -6,23 +6,23 @@ from electos.datamodels.nist.models.edf import ElectionReport from electos.ballotmaker.data.extractor import ( - all_ballot_styles, - ballot_style_id, - gather_contests, + ballot_style_external_id, + extract_ballot_styles, + extract_contests, ) def report(root, index, nth, **opts): """Generate data needed by BallotLab.""" - ballot_styles = list(all_ballot_styles(root, index)) + ballot_styles = list(extract_ballot_styles(root, index)) if not (1 <= nth <= len(ballot_styles)): print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") return ballot_style = ballot_styles[nth - 1] data = {} - id_ = ballot_style_id(ballot_style) + id_ = ballot_style_external_id(ballot_style) data["ballot_style"] = id_ - contests = gather_contests(ballot_style, index) + contests = extract_contests(ballot_style, index) if not contests: print(f"No contests found for ballot style: {id_}\n") data["contests"] = contests diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 7649630..c0bf0b9 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -58,13 +58,7 @@ def walk_ordered_headers(content: List[OrderedContent]): # --- Ballot Properties -def all_ballot_styles(election_report: ElectionReport, index): - """Yield all ballot styles.""" - for ballot_style in index.by_type("BallotStyle"): - yield ballot_style - - -def ballot_style_id(ballot_style: BallotStyle): +def ballot_style_external_id(ballot_style: BallotStyle): """Get the text of a ballot style's external identifier if any.""" if ballot_style.external_identifier: assert len(ballot_style.external_identifier) == 1, \ @@ -187,12 +181,12 @@ def contest_election_district(contest: Contest, index): return district -# Gather & Extract +# --- Extraction # -# Results are data needed for ballot generation. +# Gather and select data needed for ballot generation. def extract_candidate_contest(contest: CandidateContest, index): - """Extract candidate contest information needed for ballots.""" + """Extract candidate contest subset needed for a ballot.""" district = contest_election_district(contest, index) offices = candidate_contest_offices(contest, index) parties = candidate_contest_parties(contest, index) @@ -213,7 +207,7 @@ def extract_candidate_contest(contest: CandidateContest, index): def extract_ballot_measure_contest(contest: BallotMeasureContest, index): - """Extract ballot measure contest information needed for ballots.""" + """Extract ballot measure contest subset needed for a ballot.""" choices = [] for selection in contest.contest_selection: assert isinstance(selection, BallotMeasureSelection), \ @@ -236,8 +230,8 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): return result -def gather_contests(ballot_style: BallotStyle, index): - """Extract all contest information needed for ballots.""" +def extract_contests(ballot_style: BallotStyle, index): + """Extract contest subset needed for ballots.""" contests = { kind: [] for kind in ("candidate", "ballot_measure") } @@ -252,3 +246,9 @@ def gather_contests(ballot_style: BallotStyle, index): # Ignore other contest types print(f"Skipping contest of type {contest.model__type}") return contests + + +def extract_ballot_styles(election_report: ElectionReport, index): + """Extract all ballot styles.""" + for ballot_style in index.by_type("BallotStyle"): + yield ballot_style From 67d3933becdb13fdaca95a69e65510ae5eba1286 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 16:44:36 -0700 Subject: [PATCH 30/48] Data model for ballot measure choices. --- src/electos/ballotmaker/data/models.py | 45 ++++++++++++++++ tests/models/test_ballot_choices.py | 72 ++++++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 src/electos/ballotmaker/data/models.py create mode 100644 tests/models/test_ballot_choices.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py new file mode 100644 index 0000000..d8b8ceb --- /dev/null +++ b/src/electos/ballotmaker/data/models.py @@ -0,0 +1,45 @@ +"""Ballot data models.""" + +from dataclasses import dataclass +from typing import List + + +# --- Type assertions +# +# Python dataclasses don't check field types at runtime, but we want to avoid +# errors. + + +def _check_type(instance, field, type_): + """Raise 'TypeError' if 'instance.field' isn't of class 'type'.""" + value = getattr(instance, field) + if not isinstance(value, type_): + raise TypeError( + f"Field '{field}' is not of type '{type_.__name__}': {value}" + ) + + +def _check_type_hint(instance, field, type_): + """Raise 'TypeError' if 'instance.field' isn't of type model 'type'.""" + value = getattr(instance, field) + if not isinstance(value, type_): + raise TypeError( + f"Field '{field}' is not of type '{type_._name}': {value}" + ) + + +# --- Ballot contest data models + + +@dataclass +class BallotChoiceData: + + """Data for ballot measure selections.""" + + id: str + choice: str + + + def __post_init__(self): + _check_type(self, "id", str) + _check_type(self, "choice", str) diff --git a/tests/models/test_ballot_choices.py b/tests/models/test_ballot_choices.py new file mode 100644 index 0000000..8a4179b --- /dev/null +++ b/tests/models/test_ballot_choices.py @@ -0,0 +1,72 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from electos.ballotmaker.data.models import ( + BallotChoiceData, +) + + +# Tests + +BALLOT_CHOICE_TESTS = [ + ( + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + raises_none(), + ), + # Missing id + ( + { + "choice": "yes", + }, + raises(TypeError, match = "required positional argument: 'id'"), + ), + # Missing choice + ( + { + "id": "ballot-measure-1--yes", + }, + raises(TypeError, match = "required positional argument: 'choice'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "2 required positional arguments: 'id' and 'choice'"), + ), + # id is not a string + ( + { + "id": 1, + "choice": "yes", + }, + raises(TypeError, match = "Field 'id' is not of type 'str'"), + ), + # choice is not a string + ( + { + "id": "ballot-measure-1--yes", + "choice": [], + }, + raises(TypeError, match = "Field 'choice' is not of type 'str'"), + ), +] + + +@pytest.mark.parametrize("data, raises", BALLOT_CHOICE_TESTS) +def test_ballot_choice(data, raises): + with raises: + item = BallotChoiceData(**data) + + +def test_ballot_choice_fields(): + data = { + "id": "ballot-measure-1--yes", + "choice": "yes" + } + item = BallotChoiceData(**data) + assert item.id == data["id"] + assert item.choice == data["choice"] From 9921c2d46f54729229423926468cdfd2cf6a7212 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 17:30:11 -0700 Subject: [PATCH 31/48] Data model for ballot measure contests. --- src/electos/ballotmaker/data/models.py | 22 ++ tests/models/test_ballot_contests.py | 287 +++++++++++++++++++++++++ 2 files changed, 309 insertions(+) create mode 100644 tests/models/test_ballot_contests.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index d8b8ceb..472a6fd 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -43,3 +43,25 @@ class BallotChoiceData: def __post_init__(self): _check_type(self, "id", str) _check_type(self, "choice", str) + + +@dataclass +class BallotMeasureContestData: + + """Data for ballot measure contests.""" + + id: str + type: str + title: str + district: str + text: str + choices: List[BallotChoiceData] + + def __post_init__(self): + _check_type(self, "id", str) + _check_type(self, "type", str) + _check_type(self, "title", str) + _check_type(self, "district", str) + _check_type(self, "text", str) + _check_type_hint(self, "choices", List) + self.choices = [BallotChoiceData(**_) for _ in self.choices] diff --git a/tests/models/test_ballot_contests.py b/tests/models/test_ballot_contests.py new file mode 100644 index 0000000..48b1d74 --- /dev/null +++ b/tests/models/test_ballot_contests.py @@ -0,0 +1,287 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from electos.ballotmaker.data.models import ( + BallotChoiceData, + BallotMeasureContestData, +) + + +# Tests + +BALLOT_MEASURE_CONTEST_TESTS = [ + # Two choices + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--no", + "choice": "no", + }, + ], + }, + raises_none(), + ), + # Empty choices + pytest.param( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(ValueError, match = "Insufficient number of ballot choices"), + marks = pytest.mark.xfail(reason = "Empty 'choices' list"), + ), + # Only one choice + pytest.param( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + ], + }, + raises(ValueError, match = "Insufficient number of ballot choices"), + marks = pytest.mark.xfail(reason = "Only one choice in 'choices' list"), + ), + # One choice, duplicated + pytest.param( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + ], + }, + raises(ValueError, match = "Duplicate ballot choices"), + marks = pytest.mark.xfail(reason = "Duplicate ballot choices"), + ), + # Missing id + ( + { + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "required positional argument: 'id'"), + ), + # Missing type + ( + { + "id": "ballot-measure-1", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "required positional argument: 'type'"), + ), + # Missing title + ( + { + "type": "ballot-measure", + "id": "ballot-measure-1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "required positional argument: 'title'"), + ), + # Missing district + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "required positional argument: 'district'"), + ), + # Missing text + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "choices": [], + }, + raises(TypeError, match = "required positional argument: 'text'"), + ), + # Missing choices + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + }, + raises(TypeError, match = "required positional argument: 'choices'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "required positional arguments: 'id', 'type', 'title', 'district', 'text', and 'choices'"), + ), + # id is not a string + ( + { + "id": 1, + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "Field 'id' is not of type 'str'"), + ), + # type is not a string + ( + { + "id": "ballot-measure-1", + "type": 1, + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "Field 'type' is not of type 'str'"), + ), + # title is not a string + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": 2, + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "Field 'title' is not of type 'str'"), + ), + # district is not a string + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": [], + "text": "Ballot measure text", + "choices": [], + }, + raises(TypeError, match = "Field 'district' is not of type 'str'"), + ), + # text is not a string + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": {}, + "choices": [], + }, + raises(TypeError, match = "Field 'text' is not of type 'str'"), + ), + # choices is not a list + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": {}, + }, + raises(TypeError, match = "Field 'choices' is not of type 'List'"), + ), + # choice is not a dictionary that can convert to a BallotChoiceData + ( + { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [{}], + }, + raises(TypeError, match = "missing 2 required positional arguments: 'id' and 'choice'"), + ), +] + + +@pytest.mark.parametrize("data, raises", BALLOT_MEASURE_CONTEST_TESTS) +def test_ballot_measure_contest(data, raises): + with raises: + item = BallotMeasureContestData(**data) + + +def test_ballot_measure_contest_fields(): + data = { + "id": "ballot-measure-1", + "type": "ballot-measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes" + }, + { + "id": "ballot-measure-1--no", + "choice": "no" + }, + ] + } + item = BallotMeasureContestData(**data) + # Scalar fields match + assert item.id == data["id"] + assert item.type == data["type"] + assert item.title == data["title"] + assert item.district == data["district"] + assert item.text == data["text"] + # Not the same type: data model converts each party to an object + assert item.choices != data["choices"] + # Scalar field values + assert item.type == "ballot-measure" + # Lengths and fields are the same. + assert len(item.choices) == len(data["choices"]) + assert all(isinstance(_, BallotChoiceData) for _ in item.choices) + for actual, expected in zip(item.choices, data["choices"]): + assert actual.id == expected["id"] + assert actual.choice == expected["choice"] From 4e7283f2a6bb156f6764e949459052700048ce5d Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 18:09:22 -0700 Subject: [PATCH 32/48] Data model for candidate parties. --- src/electos/ballotmaker/data/models.py | 16 ++++++ tests/models/test_parties.py | 72 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/models/test_parties.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 472a6fd..911713f 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -65,3 +65,19 @@ def __post_init__(self): _check_type(self, "text", str) _check_type_hint(self, "choices", List) self.choices = [BallotChoiceData(**_) for _ in self.choices] + + +# --- Candidate contest data models + + +@dataclass +class PartyData: + + """Data for parties candidates are in.""" + + name: str + abbreviation: str + + def __post_init__(self): + _check_type(self, "name", str) + _check_type(self, "abbreviation", str) diff --git a/tests/models/test_parties.py b/tests/models/test_parties.py new file mode 100644 index 0000000..0cdaf6e --- /dev/null +++ b/tests/models/test_parties.py @@ -0,0 +1,72 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from electos.ballotmaker.data.models import ( + PartyData, +) + + +# Tests + +PARTY_TESTS = [ + ( + { + "name": "Un-Committed Party", + "abbreviation": "UCP", + }, + raises_none(), + ), + # Missing name + ( + { + "abbreviation": "UCP", + }, + raises(TypeError, match = "required positional argument: 'name'"), + ), + # Missing abbreviation + ( + { + "name": "Un-Committed Party", + }, + raises(TypeError, match = "required positional argument: 'abbreviation'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "2 required positional arguments: 'name' and 'abbreviation'"), + ), + # name is not a string + ( + { + "name": 1, + "abbreviation": "UCP", + }, + raises(TypeError, match = "Field 'name' is not of type 'str'"), + ), + # abbreviation is not a string + ( + { + "name": "Un-Committed Party", + "abbreviation": [], + }, + raises(TypeError, match = "Field 'abbreviation' is not of type 'str'"), + ), +] + + +@pytest.mark.parametrize("data, raises", PARTY_TESTS) +def test_party(data, raises): + with raises: + item = PartyData(**data) + + +def test_party_fields(): + data = { + "name": "Un-Committed Party", + "abbreviation": "UCP", + } + item = PartyData(**data) + assert item.name == data["name"] + assert item.abbreviation == data["abbreviation"] From e0994191f959a70ae99b081e928e6a389c14b54c Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 18:59:01 -0700 Subject: [PATCH 33/48] Data model for candidate choices. --- src/electos/ballotmaker/data/models.py | 28 +++ tests/models/test_candidate_choices.py | 314 +++++++++++++++++++++++++ 2 files changed, 342 insertions(+) create mode 100644 tests/models/test_candidate_choices.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 911713f..0105696 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -28,6 +28,15 @@ def _check_type_hint(instance, field, type_): ) +def _check_type_list(instance, field, type_): + """Raise 'TypeError' if 'instance.field' contents aren't of type 'type'.""" + values = getattr(instance, field) + if not all(isinstance(value, type_) for value in values): + raise TypeError( + f"Values in field '{field}' are not all of type '{type_.__name__}': {values}" + ) + + # --- Ballot contest data models @@ -81,3 +90,22 @@ class PartyData: def __post_init__(self): _check_type(self, "name", str) _check_type(self, "abbreviation", str) + + +@dataclass +class CandidateChoiceData: + + """Data for candidate contest selections.""" + + id: str + name: List[str] + party: List[PartyData] + is_write_in: bool + + def __post_init__(self): + _check_type(self, "id", str) + _check_type_hint(self, "name", List) + _check_type_list(self, "name", str) + _check_type_hint(self, "party", List) + self.party = [PartyData(**_) for _ in self.party] + _check_type(self, "is_write_in", bool) diff --git a/tests/models/test_candidate_choices.py b/tests/models/test_candidate_choices.py new file mode 100644 index 0000000..a2cfddc --- /dev/null +++ b/tests/models/test_candidate_choices.py @@ -0,0 +1,314 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from electos.ballotmaker.data.models import ( + CandidateChoiceData, + PartyData, +) + + +# Tests + +CANDIDATE_CHOICE_TESTS = [ + # Single candidate, no party + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "party": [], + "is_write_in": False, + }, + raises_none(), + ), + # Multiple candidates, no party + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + "Betty Beta", + ], + "party": [], + "is_write_in": False, + }, + raises_none(), + ), + # Multiple candidates, single party + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + "Betty Beta", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + "is_write_in": False, + }, + raises_none(), + ), + # Multiple candidates, same number of parties + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + "Elizabeth Epsilon", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + { + "name": "Fermion Party", + "abbreviation": "FRM", + }, + ], + "is_write_in": False, + }, + raises_none(), + ), + # Multiple candidates, differing number of parties + # Note: should have the same number of parties iff no. of candidates > 1 + pytest.param( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + "Elizabeth Epsilon", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + { + "name": "Hadron Party", + "abbreviation": "HAD", + }, + { + "name": "Fermion Party", + "abbreviation": "FRM", + }, + ], + "is_write_in": False, + }, + raises(ValueError, match = "Counts of names and parties don't match"), + marks = pytest.mark.xfail(reason = "Mismatched counts of candidate names and parties"), + ), + # 'name' shouldn't be empty if 'is_write_in' is False + pytest.param( + { + "id": "candidate-contest-1--candidate-1", + "name": [], + "party": [], + "is_write_in": False, + }, + raises(ValueError, match = "Candidate name is empty"), + marks = pytest.mark.xfail(reason = "Empty 'name' list"), + ), + # 'name' is empty if 'is_write_in' is True + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [], + "party": [], + "is_write_in": True, + }, + raises_none(), + ), + # 'name' must be empty if 'is_write_in' is True + pytest.param( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "party": [], + "is_write_in": True, + }, + raises(ValueError, match = "Write-in cannot have a 'name'"), + marks = pytest.mark.xfail(reason = "Write-ins can't have candidate names"), + ), + # 'party' must be empty if 'is_write_in' is True + pytest.param( + { + "id": "candidate-contest-1--candidate-1", + "name": [], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + "is_write_in": True, + }, + raises(ValueError, match = "Write-in cannot have a 'party'"), + marks = pytest.mark.xfail(reason = "Write-ins can't have candidate parties"), + ), + # Missing ID + ( + { + "name": [ + "Anthony Alpha", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + "is_write_in": False, + }, + raises(TypeError, match = "required positional argument: 'id'"), + ), + # Missing name + ( + { + "id": "candidate-contest-1--candidate-1", + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + "is_write_in": False, + }, + raises(TypeError, match = "required positional argument: 'name'"), + ), + # Missing party + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "is_write_in": False, + }, + raises(TypeError, match = "required positional argument: 'party'"), + ), + # Missing 'is write-in' + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + }, + raises(TypeError, match = "required positional argument: 'is_write_in'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "required positional arguments: 'id', 'name', 'party', and 'is_write_in'"), + ), + # id is not a string + ( + { + "id": 1, + "name": [ + "Anthony Alpha", + ], + "party": [], + "is_write_in": False, + }, + raises(TypeError, match = "Field 'id' is not of type 'str'"), + ), + # name is not a list + ( + { + "id": "candidate-contest-1--candidate-1", + "name": "Anthony Alpha", + "party": [], + "is_write_in": False, + }, + raises(TypeError, match = "Field 'name' is not of type 'List'"), + ), + # Contents of 'name' are not all strings + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + { "name": "Betty Beta" }, + ], + "party": [], + "is_write_in": False, + }, + raises(TypeError, match = "Values in field 'name' are not all of type 'str'"), + ), + # party is not a list + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "party": "", + "is_write_in": False, + }, + raises(TypeError, match = "Field 'party' is not of type 'List'"), + ), + # 'is write-in' is not a boolean + ( + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + ], + "party": [], + "is_write_in": None, + }, + raises(TypeError, match = "Field 'is_write_in' is not of type 'bool'"), + ), +] + + +@pytest.mark.parametrize("data, raises", CANDIDATE_CHOICE_TESTS) +def test_candidate_choice(data, raises): + with raises: + item = CandidateChoiceData(**data) + + +def test_candidate_choice_fields(): + data = { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Anthony Alpha", + "Betty Beta", + ], + "party": [ + { + "name": "Lepton Party", + "abbreviation": "LEP", + }, + ], + "is_write_in": False, + } + item = CandidateChoiceData(**data) + assert item.id == data["id"] + assert item.name == data["name"] + # Not the same type: data model converts each party to an object + assert item.party != data["party"] + # Lengths and fields are the same. + assert len(item.party) == len(data["party"]) + assert all(isinstance(_, PartyData) for _ in item.party) + for actual, expected in zip(item.party, data["party"]): + assert actual.name == expected["name"] + assert actual.abbreviation == expected["abbreviation"] + assert item.is_write_in == data["is_write_in"] From 5dc5ca8f66a957207792e95035e84a91a1fc68bc Mon Sep 17 00:00:00 2001 From: Ion Y Date: Fri, 9 Sep 2022 19:28:40 -0700 Subject: [PATCH 34/48] Data model for candidate contests. --- src/electos/ballotmaker/data/models.py | 24 ++ tests/models/test_candidate_contests.py | 523 ++++++++++++++++++++++++ 2 files changed, 547 insertions(+) create mode 100644 tests/models/test_candidate_contests.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 0105696..12305c6 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -109,3 +109,27 @@ def __post_init__(self): _check_type_hint(self, "party", List) self.party = [PartyData(**_) for _ in self.party] _check_type(self, "is_write_in", bool) + + +@dataclass +class CandidateContestData: + + """Data for candidate contests.""" + + id: str + type: str + title: str + district: str + vote_type: str + votes_allowed: str + candidates: List[CandidateChoiceData] + + def __post_init__(self): + _check_type(self, "id", str) + _check_type(self, "type", str) + _check_type(self, "title", str) + _check_type(self, "district", str) + _check_type(self, "vote_type", str) + _check_type(self, "votes_allowed", int) + _check_type_hint(self, "candidates", List) + self.candidates = [CandidateChoiceData(**_) for _ in self.candidates] diff --git a/tests/models/test_candidate_contests.py b/tests/models/test_candidate_contests.py new file mode 100644 index 0000000..934dd6c --- /dev/null +++ b/tests/models/test_candidate_contests.py @@ -0,0 +1,523 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from dataclasses import asdict + +from electos.ballotmaker.data.models import ( + CandidateChoiceData, + CandidateContestData, +) + + +# Tests + +CANDIDATE_CONTEST_TESTS = [ + # Single candidate + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + ], + "is_write_in": False, + }, + ], + }, + raises_none(), + ), + # Multiple candidates + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Cosmo Spacely", + ], + "party": [ + { + "name": "The Lepton Partyn", + "abbreviation": "LEP", + }, + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-1--candidate-2", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + ], + "is_write_in": False, + }, + ], + }, + raises_none(), + ), + # Write-in + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--write-in-1", + "name": [], + "party": [], + "is_write_in": True, + }, + ], + }, + raises_none(), + ), + # Candidate + write-in + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + } + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-1--write-in-1", + "name": [], + "party": [], + "is_write_in": True, + }, + ], + }, + raises_none(), + ), + # Empty candidates + pytest.param( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [], + }, + raises(ValueError, match = "Insufficient number of candidates"), + marks = pytest.mark.xfail(reason = "Empty 'candidates' list"), + ), + # Single candidate, duplicated + pytest.param( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + }, + ], + "is_write_in": False, + }, + ], + }, + raises(ValueError, match = "Duplicate candidate"), + marks = pytest.mark.xfail(reason = "Duplicate candidate"), + ), + # Missing id + ( + { + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'id'"), + ), + # Missing type + ( + { + "id": "candidate-contest-1", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'type'"), + ), + # Missing title + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'title'"), + ), + # Missing district + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'district'"), + ), + # Missing vote type + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'vote_type'"), + ), + # Missing votes allowed + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + raises(TypeError, match = "required positional argument: 'votes_allowed'"), + ), + # Missing candidates + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + }, + raises(TypeError, match = "required positional argument: 'candidates'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "required positional arguments: 'id', 'type', 'title', 'district', 'vote_type', 'votes_allowed', and 'candidates'"), + ), + # id is not a string + ( + { + "id": 1, + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [], + }, + raises(TypeError, match = "Field 'id' is not of type 'str'"), + ), + # type not a string + ( + { + "id": "candidate-contest-1", + "type": 2, + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [], + }, + raises(TypeError, match = "Field 'type' is not of type 'str'"), + ), + # title is not a string + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": 3, + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [], + }, + raises(TypeError, match = "Field 'title' is not of type 'str'"), + ), + # district is not a string + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": 4, + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [], + }, + raises(TypeError, match = "Field 'district' is not of type 'str'"), + ), + # "vote type" is not a string + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": 5, + "votes_allowed": 1, + "candidates": [], + }, + raises(TypeError, match = "Field 'vote_type' is not of type 'str'"), + ), + # "votes allowed" not an integer + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": "one", + "candidates": [], + }, + raises(TypeError, match = "Field 'votes_allowed' is not of type 'int'"), + ), + # candidates is not a list + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": {}, + }, + raises(TypeError, match = "Field 'candidates' is not of type 'List'"), + ), + # candidate is not a dictionary that can convert to a CandidateChoiceData + ( + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [{}], + }, + raises(TypeError, match = "missing 4 required positional arguments: 'id', 'name', 'party', and 'is_write_in'"), + ), +] + + +@pytest.mark.parametrize("data, raises", CANDIDATE_CONTEST_TESTS) +def test_candidate_contest(data, raises): + with raises: + item = CandidateContestData(**data) + + +def test_candidate_contest_fields(): + data = { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell", + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD", + } + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-1--write-in-1", + "name": [], + "party": [], + "is_write_in": True, + }, + ], + } + item = CandidateContestData(**data) + # Scalar fields match + assert item.id == data["id"] + assert item.type == data["type"] + assert item.title == data["title"] + assert item.vote_type == data["vote_type"] + assert item.votes_allowed == data["votes_allowed"] + assert item.district == data["district"] + # Not the same type: data model converts each party to an object + assert item.candidates != data["candidates"] + # Lengths and fields are the same. + assert len(item.candidates) == len(data["candidates"]) + assert all(isinstance(_, CandidateChoiceData) for _ in item.candidates) + for actual, expected in zip(item.candidates, data["candidates"]): + actual = asdict(actual) + assert actual == expected From a112e03f8e143c50273331bcd152a1d88c8c9e61 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 09:09:48 -0700 Subject: [PATCH 35/48] Simplify test assertions on nested data by comparing dictionaries. --- tests/models/test_ballot_contests.py | 6 ++++-- tests/models/test_candidate_choices.py | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/models/test_ballot_contests.py b/tests/models/test_ballot_contests.py index 48b1d74..a46d6e3 100644 --- a/tests/models/test_ballot_contests.py +++ b/tests/models/test_ballot_contests.py @@ -3,6 +3,8 @@ from pytest import raises from contextlib import nullcontext as raises_none +from dataclasses import asdict + from electos.ballotmaker.data.models import ( BallotChoiceData, BallotMeasureContestData, @@ -283,5 +285,5 @@ def test_ballot_measure_contest_fields(): assert len(item.choices) == len(data["choices"]) assert all(isinstance(_, BallotChoiceData) for _ in item.choices) for actual, expected in zip(item.choices, data["choices"]): - assert actual.id == expected["id"] - assert actual.choice == expected["choice"] + actual = asdict(actual) + assert actual == expected diff --git a/tests/models/test_candidate_choices.py b/tests/models/test_candidate_choices.py index a2cfddc..e965961 100644 --- a/tests/models/test_candidate_choices.py +++ b/tests/models/test_candidate_choices.py @@ -3,6 +3,8 @@ from pytest import raises from contextlib import nullcontext as raises_none +from dataclasses import asdict + from electos.ballotmaker.data.models import ( CandidateChoiceData, PartyData, @@ -309,6 +311,6 @@ def test_candidate_choice_fields(): assert len(item.party) == len(data["party"]) assert all(isinstance(_, PartyData) for _ in item.party) for actual, expected in zip(item.party, data["party"]): - assert actual.name == expected["name"] - assert actual.abbreviation == expected["abbreviation"] + actual = asdict(actual) + assert actual == expected assert item.is_write_in == data["is_write_in"] From 78738336603966ac39e50456bd34b944d35d87e3 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 09:46:36 -0700 Subject: [PATCH 36/48] Data model for ballot styles. --- src/electos/ballotmaker/data/models.py | 39 +- tests/models/test_ballot_style.py | 491 +++++++++++++++++++++++++ 2 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 tests/models/test_ballot_style.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 12305c6..83b31e4 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -1,7 +1,7 @@ """Ballot data models.""" from dataclasses import dataclass -from typing import List +from typing import List, Union # --- Type assertions @@ -133,3 +133,40 @@ def __post_init__(self): _check_type(self, "votes_allowed", int) _check_type_hint(self, "candidates", List) self.candidates = [CandidateChoiceData(**_) for _ in self.candidates] + + +@dataclass +class BallotStyleData: + + """Date for contests associated with a ballot style.""" + + BALLOT_MEASURE = "ballot measure" + CANDIDATE = "candidate" + + # Note: Don't use separate fields for the types of contests. + # There's no guarantee the types will be clearly separated in an EDF. + # (The NIST SP-1500-100 JSON Schema uses unions too.) + + id: str + scopes: List[str] + contests: List[Union[BallotMeasureContestData, CandidateContestData]] + + def __post_init__(self): + _check_type(self, "id", str) + _check_type_hint(self, "scopes", List) + _check_type_list(self, "scopes", str) + _check_type_hint(self, "contests", List) + contests = [] + for contest in self.contests: + if not isinstance(contest, dict): + raise TypeError(f"Contest is not a dictionary: '{contest}'") + if "type" not in contest: + raise KeyError(f"Contest has no 'type' field: '{contest}'") + if contest["type"] not in (self.BALLOT_MEASURE, self.CANDIDATE): + raise ValueError(f"Unhandled contest type: '{contest['type']}'") + if contest["type"] == self.BALLOT_MEASURE: + contest = BallotMeasureContestData(**contest) + elif contest["type"] == self.CANDIDATE: + contest = CandidateContestData(**contest) + contests.append(contest) + self.contests = contests diff --git a/tests/models/test_ballot_style.py b/tests/models/test_ballot_style.py new file mode 100644 index 0000000..eac2e0b --- /dev/null +++ b/tests/models/test_ballot_style.py @@ -0,0 +1,491 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from dataclasses import asdict + +from electos.ballotmaker.data.models import ( + BallotMeasureContestData, + BallotStyleData, + CandidateContestData, +) + + +# Tests + +BALLOT_STYLE_TESTS = [ + # Single candidate contest + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + raises_none(), + ), + # Single ballot measure contest + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "ballot-measure-1", + "type": "ballot measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--no", + "choice": "no", + }, + ], + }, + ], + }, + raises_none(), + ), + # Multiple contests, conventional order + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + { + "id": "candidate-contest-2", + "type": "candidate", + "title": "President of the United States", + "district": "United States of America", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-2--candidate-1", + "name": [ + "Anthony Alpha", + "Betty Beta" + ], + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-2--candidate-2", + "name": [ + "Gloria Gamma", + "David Delta" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + ], + "is_write_in": False, + }, + ], + }, + { + "id": "ballot-measure-1", + "type": "ballot measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--no", + "choice": "no", + }, + ], + }, + ], + }, + raises_none(), + ), + # Multiple contests, candidates and write-ins intermingled + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + { + "id": "ballot-measure-1", + "type": "ballot measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--no", + "choice": "no", + }, + ], + }, + { + "id": "candidate-contest-2", + "type": "candidate", + "title": "President of the United States", + "district": "United States of America", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-2--candidate-1", + "name": [ + "Anthony Alpha", + "Betty Beta" + ], + "party": [ + { + "name": "The Lepton Party", + "abbreviation": "LEP" + } + ], + "is_write_in": False, + }, + { + "id": "candidate-contest-2--candidate-2", + "name": [ + "Gloria Gamma", + "David Delta" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + } + ], + "is_write_in": False, + }, + ], + }, + ], + }, + raises_none(), + ), + # Empty contests + pytest.param( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [], + }, + raises(ValueError, match = "'contests' cannot be empty"), + marks = pytest.mark.xfail(reason = "Empty 'contest' list"), + ), + # Missing id + ( + { + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + raises(TypeError, match = "1 required positional argument: 'id'"), + ), + # Missing scopes + ( + { + "id": "precinct_2_spacetown", + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + raises(TypeError, match = "1 required positional argument: 'scopes'"), + ), + # Missing contests + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + }, + raises(TypeError, match = "1 required positional argument: 'contests'"), + ), + # Empty object + ( + {}, + raises(TypeError, match = "3 required positional arguments: 'id', 'scopes', and 'contests'"), + ), + # contests is not a list + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": {}, + }, + raises(TypeError, match = "Field 'contests' is not of type 'List'"), + ), + # Contest is not a dict + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [[]], + }, + raises(TypeError, match = "Contest is not a dictionary"), + ), + # Contest has no type + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [{}], + }, + raises(KeyError, match = "Contest has no 'type' field"), + ), + # Unhandled contest type + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [{ "type": "" }], + }, + raises(ValueError, match = "Unhandled contest type: ''"), + ), + # Ballot measure contest + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [{ "type": BallotStyleData.BALLOT_MEASURE }], + }, + raises(TypeError, match = "missing 5 required positional arguments: 'id', 'title', 'district', 'text', and 'choices'"), + ), + # Candidate contest + ( + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [{ "type": BallotStyleData.CANDIDATE }], + }, + raises(TypeError, match = "missing 6 required positional arguments: 'id', 'title', 'district', 'vote_type', 'votes_allowed', and 'candidates'"), + ), +] + + +@pytest.mark.parametrize("data, raises", BALLOT_STYLE_TESTS) +def test_ballot_style(data, raises): + with raises: + item = BallotStyleData(**data) + + +def test_ballot_style_fields(): + data = { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-1", + "type": "candidate", + "title": "Mayor of Orbit City", + "district": "Orbit City", + "vote_type": "plurality", + "votes_allowed": 1, + "candidates": [ + { + "id": "candidate-contest-1--candidate-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + { + "id": "ballot-measure-1", + "type": "ballot measure", + "title": "Ballot Measure #1", + "district": "Spacetown", + "text": "Ballot measure text", + "choices": [ + { + "id": "ballot-measure-1--yes", + "choice": "yes", + }, + { + "id": "ballot-measure-1--no", + "choice": "no", + }, + ], + }, + ], + } + item = BallotStyleData(**data) + assert item.id == data["id"] + assert item.scopes == data["scopes"] + # Not the same type: data model converts each party to an object + assert item.contests != data["contests"] + # Lengths are the same. + assert len(item.contests) == len(data["contests"]) + # Types are mixed + assert isinstance(item.contests[0], CandidateContestData) + assert isinstance(item.contests[1], BallotMeasureContestData) + for actual, expected in zip(item.contests, data["contests"]): + actual = asdict(actual) + assert actual == expected From 3621f3268a5d0f3820b995597a98e82b65ffb722 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 10:00:42 -0700 Subject: [PATCH 37/48] Data model for elections. --- src/electos/ballotmaker/data/models.py | 24 ++ tests/models/test_elections.py | 371 +++++++++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 tests/models/test_elections.py diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 83b31e4..20c32f0 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -170,3 +170,27 @@ def __post_init__(self): contest = CandidateContestData(**contest) contests.append(contest) self.contests = contests + + +@dataclass +class ElectionData: + + """Data for elections.""" + + # Dates are not 'datetime' for simplicity and because it's assumed the date + # is formatter correctly. That can be changed. + + name: str + type: str + start_date: str + end_date: str + ballot_styles: List[BallotStyleData] + + + def __post_init__(self): + _check_type(self, "name", str) + _check_type(self, "type", str) + _check_type(self, "start_date", str) + _check_type(self, "end_date", str) + _check_type_hint(self, "ballot_styles", List) + self.ballot_styles = [BallotStyleData(**_) for _ in self.ballot_styles] diff --git a/tests/models/test_elections.py b/tests/models/test_elections.py new file mode 100644 index 0000000..7356a3a --- /dev/null +++ b/tests/models/test_elections.py @@ -0,0 +1,371 @@ +import pytest + +from pytest import raises +from contextlib import nullcontext as raises_none + +from dataclasses import asdict + +from electos.ballotmaker.data.models import ( + BallotStyleData, + ElectionData, +) + + +# Tests + +ELECTION_TESTS = [ + ( + { + "name": "General Election", + "type": "general", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + }, + raises_none() + ), + # Empty ballot styles + pytest.param( + { + "name": "General Election", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "type": "general", + "ballot_styles": [], + }, + raises(ValueError, match = "Ballot styles are empty"), + marks = pytest.mark.xfail(reason = "Empty 'ballot_styles' list") + ), + # Missing name + ( + { + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "type": "general", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + }, + raises(TypeError, match = "required positional argument: 'name'") + ), + # Missing type + ( + { + "name": "General Election", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + }, + raises(TypeError, match = "required positional argument: 'type'") + ), + # Missing start date + ( + { + "name": "General Election", + "end_date": "2024-11-05", + "type": "general", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + }, + raises(TypeError, match = "required positional argument: 'start_date'") + ), + # Missing end date + ( + { + "name": "General Election", + "start_date": "2024-11-05", + "type": "general", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + }, + raises(TypeError, match = "required positional argument: 'end_date'") + ), + # Missing ballot styles + ( + { + "name": "General Election", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "type": "general", + }, + raises(TypeError, match = "required positional argument: 'ballot_styles'") + ), + # Empty object + ( + {}, + raises(TypeError, match = "5 required positional arguments: 'name', 'type', 'start_date', 'end_date', and 'ballot_styles'") + ), + # name is not a string + ( + { + "name": 1, + "type": "general", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [], + }, + raises(TypeError, match = "Field 'name' is not of type 'str'"), + ), + # type is not a string + ( + { + "name": "General Election", + "type": 2, + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [], + }, + raises(TypeError, match = "Field 'type' is not of type 'str'"), + ), + # 'start date' is not a string + ( + { + "name": "General Election", + "type": "general", + "start_date": 3, + "end_date": "2024-11-05", + "ballot_styles": [], + }, + raises(TypeError, match = "Field 'start_date' is not of type 'str'"), + ), + # 'end date' is not a string + ( + { + "name": "General Election", + "type": "general", + "start_date": "2024-11-05", + "end_date": 4, + "ballot_styles": [], + }, + raises(TypeError, match = "Field 'end_date' is not of type 'str'"), + ), + # 'ballot styles' is not a list + ( + { + "name": "General Election", + "type": "general", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": {}, + }, + raises(TypeError, match = "Field 'ballot_styles' is not of type 'List'"), + ), +] + + +@pytest.mark.parametrize("data, raises", ELECTION_TESTS) +def test_election(data, raises): + with raises: + item = ElectionData(**data) + + +def test_election_fields(): + data = { + "name": "General Election", + "type": "general", + "start_date": "2024-11-05", + "end_date": "2024-11-05", + "ballot_styles": [ + { + "id": "precinct_2_spacetown", + "scopes": [ + "spacetown-precinct", + ], + "contests": [ + { + "id": "candidate-contest-orbit-city-mayor", + "title": "Mayor of Orbit City", + "type": "candidate", + "vote_type": "plurality", + "votes_allowed": 1, + "district": "Orbit City", + "candidates": [ + { + "id": "candidate-choice-1", + "name": [ + "Spencer Cogswell" + ], + "party": [ + { + "name": "The Hadron Party of Farallon", + "abbreviation": "HAD" + }, + ], + "is_write_in": False, + }, + ], + }, + ], + }, + ], + } + item = ElectionData(**data) + # Scalar fields match + assert item.name == data["name"] + assert item.type == data["type"] + assert item.start_date == data["start_date"] + assert item.end_date == data["end_date"] + # Not the same type: data model converts each party to an object + assert item.ballot_styles != data["ballot_styles"] + # Lengths and fields are the same. + assert len(item.ballot_styles) == len(data["ballot_styles"]) + assert all(isinstance(_, BallotStyleData) for _ in item.ballot_styles) + for actual, expected in zip(item.ballot_styles, data["ballot_styles"]): + actual = asdict(actual) + assert actual == expected From 893af78666cc6da84d34f1375e4e76bc97a9c955 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 10:35:08 -0700 Subject: [PATCH 38/48] Use a base type for contest models. Partly to share fields but primarily for correctness: contests have to be a single list independent of sub-type because that's how EDFs work. Splitting the types into multiple lists would lose the order. --- src/electos/ballotmaker/data/models.py | 59 +++++++++++++++----------- tests/models/test_ballot_style.py | 5 ++- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 20c32f0..5d38cf4 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -1,6 +1,7 @@ """Ballot data models.""" from dataclasses import dataclass +from enum import Enum from typing import List, Union @@ -37,6 +38,31 @@ def _check_type_list(instance, field, type_): ) +# --- Contest model base type + +@dataclass +class ContestData: + + """Shared data for contests.""" + + id: str + type: str + title: str + district: str + + def __post_init__(self): + _check_type(self, "id", str) + _check_type(self, "type", str) + _check_type(self, "title", str) + _check_type(self, "district", str) + + +class ContestType(Enum): + + BALLOT_MEASURE = "ballot measure" + CANDIDATE = "candidate" + + # --- Ballot contest data models @@ -48,29 +74,21 @@ class BallotChoiceData: id: str choice: str - def __post_init__(self): _check_type(self, "id", str) _check_type(self, "choice", str) @dataclass -class BallotMeasureContestData: +class BallotMeasureContestData(ContestData): """Data for ballot measure contests.""" - id: str - type: str - title: str - district: str text: str choices: List[BallotChoiceData] def __post_init__(self): - _check_type(self, "id", str) - _check_type(self, "type", str) - _check_type(self, "title", str) - _check_type(self, "district", str) + super().__post_init__() _check_type(self, "text", str) _check_type_hint(self, "choices", List) self.choices = [BallotChoiceData(**_) for _ in self.choices] @@ -112,23 +130,16 @@ def __post_init__(self): @dataclass -class CandidateContestData: +class CandidateContestData(ContestData): """Data for candidate contests.""" - id: str - type: str - title: str - district: str vote_type: str votes_allowed: str candidates: List[CandidateChoiceData] def __post_init__(self): - _check_type(self, "id", str) - _check_type(self, "type", str) - _check_type(self, "title", str) - _check_type(self, "district", str) + super().__post_init__() _check_type(self, "vote_type", str) _check_type(self, "votes_allowed", int) _check_type_hint(self, "candidates", List) @@ -140,9 +151,6 @@ class BallotStyleData: """Date for contests associated with a ballot style.""" - BALLOT_MEASURE = "ballot measure" - CANDIDATE = "candidate" - # Note: Don't use separate fields for the types of contests. # There's no guarantee the types will be clearly separated in an EDF. # (The NIST SP-1500-100 JSON Schema uses unions too.) @@ -157,16 +165,17 @@ def __post_init__(self): _check_type_list(self, "scopes", str) _check_type_hint(self, "contests", List) contests = [] + contest_types = [_.value for _ in ContestType] for contest in self.contests: if not isinstance(contest, dict): raise TypeError(f"Contest is not a dictionary: '{contest}'") if "type" not in contest: raise KeyError(f"Contest has no 'type' field: '{contest}'") - if contest["type"] not in (self.BALLOT_MEASURE, self.CANDIDATE): + if contest["type"] not in contest_types: raise ValueError(f"Unhandled contest type: '{contest['type']}'") - if contest["type"] == self.BALLOT_MEASURE: + if contest["type"] == ContestType.BALLOT_MEASURE.value: contest = BallotMeasureContestData(**contest) - elif contest["type"] == self.CANDIDATE: + elif contest["type"] == ContestType.CANDIDATE.value: contest = CandidateContestData(**contest) contests.append(contest) self.contests = contests diff --git a/tests/models/test_ballot_style.py b/tests/models/test_ballot_style.py index eac2e0b..82ef6d8 100644 --- a/tests/models/test_ballot_style.py +++ b/tests/models/test_ballot_style.py @@ -9,6 +9,7 @@ BallotMeasureContestData, BallotStyleData, CandidateContestData, + ContestType, ) @@ -403,7 +404,7 @@ "scopes": [ "spacetown-precinct", ], - "contests": [{ "type": BallotStyleData.BALLOT_MEASURE }], + "contests": [{ "type": ContestType.BALLOT_MEASURE.value }], }, raises(TypeError, match = "missing 5 required positional arguments: 'id', 'title', 'district', 'text', and 'choices'"), ), @@ -414,7 +415,7 @@ "scopes": [ "spacetown-precinct", ], - "contests": [{ "type": BallotStyleData.CANDIDATE }], + "contests": [{ "type": ContestType.CANDIDATE.value }], }, raises(TypeError, match = "missing 6 required positional arguments: 'id', 'title', 'district', 'vote_type', 'votes_allowed', and 'candidates'"), ), From e03c3bbd78b34ae4fa0604228ef04ccd66615e73 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 13:34:54 -0700 Subject: [PATCH 39/48] Add derived candidate and ballot measure contest properties for ballot styles. --- src/electos/ballotmaker/data/models.py | 15 +++++++++++++++ tests/models/test_ballot_style.py | 2 ++ 2 files changed, 17 insertions(+) diff --git a/src/electos/ballotmaker/data/models.py b/src/electos/ballotmaker/data/models.py index 5d38cf4..272dd00 100644 --- a/src/electos/ballotmaker/data/models.py +++ b/src/electos/ballotmaker/data/models.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from enum import Enum +from functools import cached_property from typing import List, Union @@ -181,6 +182,20 @@ def __post_init__(self): self.contests = contests + @cached_property + def ballot_measure_contests(self): + return [ + _ for _ in self.contests if _.type == ContestType.BALLOT_MEASURE.value + ] + + + @cached_property + def candidate_contests(self): + return [ + _ for _ in self.contests if _.type == ContestType.CANDIDATE.value + ] + + @dataclass class ElectionData: diff --git a/tests/models/test_ballot_style.py b/tests/models/test_ballot_style.py index 82ef6d8..4de635b 100644 --- a/tests/models/test_ballot_style.py +++ b/tests/models/test_ballot_style.py @@ -487,6 +487,8 @@ def test_ballot_style_fields(): # Types are mixed assert isinstance(item.contests[0], CandidateContestData) assert isinstance(item.contests[1], BallotMeasureContestData) + assert item.candidate_contests == [item.contests[0]] + assert item.ballot_measure_contests == [item.contests[1]] for actual, expected in zip(item.contests, data["contests"]): actual = asdict(actual) assert actual == expected From a7f19c1b6d7b572364fbbfaa3997284083b03b8d Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 16:00:58 -0700 Subject: [PATCH 40/48] Reorder contest fields in ballot data extractor to match ballot data model. Do some minor code cleanup. --- src/electos/ballotmaker/data/extractor.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index c0bf0b9..9813063 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -70,6 +70,7 @@ def ballot_style_external_id(ballot_style: BallotStyle): def ballot_style_gp_units(ballot_style: BallotStyle, index): + """Yield geo-political units for a ballot style.""" for id_ in ballot_style.gp_unit_ids: gp_unit = index.by_id(id_) yield gp_unit @@ -91,7 +92,7 @@ def candidate_name(candidate: Candidate): def candidate_party(candidate: Candidate, index): """Get the name and abbreviation of the party of a candidate as it appears on a ballot. - Drop either field from result if it isn't present. + Drop either field from result if it isn't present. """ # Note: party ID is returned to allow de-duplicating parties in callers. id_ = candidate.party_id @@ -193,12 +194,12 @@ def extract_candidate_contest(contest: CandidateContest, index): candidates = candidate_contest_candidates(contest, index) result = { "id": contest.model__id, - "title": contest.name, "type": "candidate", + "title": contest.name, + "district": district, "vote_type": contest.vote_variation.value, # Include even when default is 1: don't require caller to track that. "votes_allowed": contest.votes_allowed, - "district": district, "candidates": candidates, # "offices": offices, # "parties": parties, @@ -221,11 +222,11 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): full_text = text_content(contest.full_text) result = { "id": contest.model__id, - "title": contest.name, "type": "ballot measure", + "title": contest.name, "district": district, - "choices": choices, "text": full_text, + "choices": choices, } return result From 341df82d9f443475268b453f3dfe15fff43c1a64 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 16:18:38 -0700 Subject: [PATCH 41/48] Don't return an empty party dictionary when there is no party. An empty dictionary adds a special case to 'PartyData' construction. --- src/electos/ballotmaker/data/extractor.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 9813063..b3d7ab4 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -137,8 +137,9 @@ def candidate_contest_candidates(contest: CandidateContest, index): if name: names.append(name) party, _party_id = candidate_party(candidate, index) - parties.append(party) - _party_ids.add(_party_id) + if party: + parties.append(party) + _party_ids.add(_party_id) # If there's only one party ID, all candidates share the same party. # If there's any divergence track them all individually. if len(_party_ids) == 1: From 002e41564449972aac61cfef97638a50c41b135d Mon Sep 17 00:00:00 2001 From: Ion Y Date: Mon, 12 Sep 2022 18:01:13 -0700 Subject: [PATCH 42/48] Re-combine candidate and ballot measure contests in extractor. --- src/electos/ballotmaker/data/edf-to-ballot-data.py | 2 +- src/electos/ballotmaker/data/extractor.py | 7 +------ 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index a7d0634..06feb26 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -22,7 +22,7 @@ def report(root, index, nth, **opts): data = {} id_ = ballot_style_external_id(ballot_style) data["ballot_style"] = id_ - contests = extract_contests(ballot_style, index) + contests = list(extract_contests(ballot_style, index)) if not contests: print(f"No contests found for ballot style: {id_}\n") data["contests"] = contests diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index b3d7ab4..ad025a3 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -234,20 +234,15 @@ def extract_ballot_measure_contest(contest: BallotMeasureContest, index): def extract_contests(ballot_style: BallotStyle, index): """Extract contest subset needed for ballots.""" - contests = { - kind: [] for kind in ("candidate", "ballot_measure") - } for contest in ballot_style_contests(ballot_style, index): if isinstance(contest, CandidateContest): entry = extract_candidate_contest(contest, index) - contests["candidate"].append(entry) elif isinstance(contest, BallotMeasureContest): entry = extract_ballot_measure_contest(contest, index) - contests["ballot_measure"].append(entry) else: # Ignore other contest types print(f"Skipping contest of type {contest.model__type}") - return contests + yield entry def extract_ballot_styles(election_report: ElectionReport, index): From b173842306590f842fcde1b88e33875ec8f54ce3 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 19:42:20 -0700 Subject: [PATCH 43/48] Re-implement and simplify the ballot extractor interface. - Unify extraction functions: - Use direct field accesses for properties, index for references. - Yield data dictionaries instead of returning lists. - Add in election data. - Entry point is now 'extract_ballot_data', which returns a list. --- .../ballotmaker/data/edf-to-ballot-data.py | 31 ++++------------ src/electos/ballotmaker/data/extractor.py | 36 +++++++++++++++++-- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index 06feb26..56327c0 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -5,27 +5,12 @@ from electos.datamodels.nist.indexes import ElementIndex from electos.datamodels.nist.models.edf import ElectionReport -from electos.ballotmaker.data.extractor import ( - ballot_style_external_id, - extract_ballot_styles, - extract_contests, -) +from electos.ballotmaker.data.extractor import extract_ballot_data -def report(root, index, nth, **opts): +def report(document, index, **opts): """Generate data needed by BallotLab.""" - ballot_styles = list(extract_ballot_styles(root, index)) - if not (1 <= nth <= len(ballot_styles)): - print(f"Ballot styles: {nth} is out of range [1-{len(ballot_styles)}]") - return - ballot_style = ballot_styles[nth - 1] - data = {} - id_ = ballot_style_external_id(ballot_style) - data["ballot_style"] = id_ - contests = list(extract_contests(ballot_style, index)) - if not contests: - print(f"No contests found for ballot style: {id_}\n") - data["contests"] = contests + data = extract_ballot_data(document, index) print(json.dumps(data, indent = 4)) @@ -35,10 +20,6 @@ def main(): "file", type = Path, help = "Test case data (JSON)" ) - parser.add_argument( - "nth", nargs = "?", type = int, default = 1, - help = "Index of the ballot style, starting from 1 (default: 1)" - ) parser.add_argument( "--debug", action = "store_true", help = "Enable debugging output and stack traces" @@ -51,9 +32,9 @@ def main(): with file.open() as input: text = input.read() data = json.loads(text) - edf = ElectionReport(**data) - index = ElementIndex(edf, "ElectionResults") - report(edf, index, **opts) + document = ElectionReport(**data) + index = ElementIndex(document, "ElectionResults") + report(document, index, **opts) except Exception as ex: if opts["debug"]: raise ex diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index ad025a3..21a3387 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -8,6 +8,7 @@ Candidate, CandidateContest, CandidateSelection, + Election, ElectionReport, InternationalizedText, OrderedContest, @@ -245,7 +246,36 @@ def extract_contests(ballot_style: BallotStyle, index): yield entry -def extract_ballot_styles(election_report: ElectionReport, index): +def extract_ballot_styles(election: Election, index): """Extract all ballot styles.""" - for ballot_style in index.by_type("BallotStyle"): - yield ballot_style + for ballot_style in election.ballot_style: + data = { + "id": ballot_style_external_id(ballot_style), + "scopes": [ _.model__id for _ in ballot_style_gp_units(ballot_style, index) ], + "contests": [ _ for _ in extract_contests(ballot_style, index) ], + } + yield data + + +def extract_election_data(election_report: ElectionReport, index): + """Extract all elections.""" + # In most cases there isn't more than one 'Election' in a report, but the + # standard allows more than one, so handle them. + for election in election_report.election: + data = { + "name": text_content(election.name), + "type": election.type.value, + "start_date": election.start_date.strftime("%Y-%m-%d"), + "end_date": election.end_date.strftime("%Y-%m-%d"), + "ballot_styles": [_ for _ in extract_ballot_styles(election, index)], + } + yield data + + +def extract_ballot_data(election_report: ElectionReport, index: ElementIndex): + """Extract election data. + This is the primary entry point for the extractor. + """ + index = index or ElementIndex(election_report, "ElectionResults") + election_data = [ _ for _ in extract_election_data(election_report, index) ] + return election_data From 5ace8d6123143f666a514b158d55ef3328d616c8 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 20:04:54 -0700 Subject: [PATCH 44/48] Move document and index creation into 'report'. --- src/electos/ballotmaker/data/edf-to-ballot-data.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index 56327c0..0d68c5c 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -8,10 +8,12 @@ from electos.ballotmaker.data.extractor import extract_ballot_data -def report(document, index, **opts): +def report(data, **opts): """Generate data needed by BallotLab.""" - data = extract_ballot_data(document, index) - print(json.dumps(data, indent = 4)) + document = ElectionReport(**data) + index = ElementIndex(document, "ElectionResults") + ballot_data = extract_ballot_data(document, index) + print(json.dumps(ballot_data, indent = 4)) def main(): @@ -32,9 +34,7 @@ def main(): with file.open() as input: text = input.read() data = json.loads(text) - document = ElectionReport(**data) - index = ElementIndex(document, "ElectionResults") - report(document, index, **opts) + report(data, **opts) except Exception as ex: if opts["debug"]: raise ex From 04e0d3e1bf11dc2aefb7509f01895c30ab51773a Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 20:16:45 -0700 Subject: [PATCH 45/48] Make 'ElectionReport' and 'ElementIndex' internal to the extractor. The caller only receives an 'ElectionData' back. --- src/electos/ballotmaker/data/edf-to-ballot-data.py | 9 +++------ src/electos/ballotmaker/data/extractor.py | 10 +++++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index 0d68c5c..0647722 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -1,18 +1,15 @@ import argparse import json +from dataclasses import asdict from pathlib import Path -from electos.datamodels.nist.indexes import ElementIndex -from electos.datamodels.nist.models.edf import ElectionReport - from electos.ballotmaker.data.extractor import extract_ballot_data def report(data, **opts): """Generate data needed by BallotLab.""" - document = ElectionReport(**data) - index = ElementIndex(document, "ElectionResults") - ballot_data = extract_ballot_data(document, index) + ballot_data = extract_ballot_data(data) + ballot_data = [asdict(_) for _ in ballot_data] print(json.dumps(ballot_data, indent = 4)) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 21a3387..e157cc5 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -1,4 +1,4 @@ -from typing import List, Union +from typing import Dict, List, Union from electos.datamodels.nist.indexes import ElementIndex from electos.datamodels.nist.models.edf import ( @@ -272,10 +272,14 @@ def extract_election_data(election_report: ElectionReport, index): yield data -def extract_ballot_data(election_report: ElectionReport, index: ElementIndex): +def extract_ballot_data(data: Dict, index: ElementIndex = None) -> ElectionData: """Extract election data. + This is the primary entry point for the extractor. """ + election_report = ElectionReport(**data) index = index or ElementIndex(election_report, "ElectionResults") - election_data = [ _ for _ in extract_election_data(election_report, index) ] + election_data = [ + ElectionData(**_) for _ in extract_election_data(election_report, index) + ] return election_data From 5350723a2871f53f07c14a193f55f14749c08886 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 20:18:09 -0700 Subject: [PATCH 46/48] Document the entry point of the extractor API. --- src/electos/ballotmaker/data/extractor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index e157cc5..8001a36 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -276,6 +276,15 @@ def extract_ballot_data(data: Dict, index: ElementIndex = None) -> ElectionData: """Extract election data. This is the primary entry point for the extractor. + + Parameters: + data: An EDF / election report dictionary. + index: An ElementIndex. + If empty (the default), create a new index from the election report. + Use this parameter only if there's already an existing index. + + Returns: + Election data model for use in ballot rendering. """ election_report = ElectionReport(**data) index = index or ElementIndex(election_report, "ElectionResults") From 7e2f748d74d9a329dbb615776c74c8623aa84673 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 20:40:18 -0700 Subject: [PATCH 47/48] Turn ballot extractor into a class. - Public API is now 'extract'. - Prefix all internal methods as private. - Prefix utility functions but don't make them methods. - Make the element index an instance variable. - Re-wrap text to fit within line length. Note: In a class method order usually puts public functions at the top. Not doing that here to avoid making the change diff useless. --- .../ballotmaker/data/edf-to-ballot-data.py | 5 +- src/electos/ballotmaker/data/extractor.py | 479 +++++++++--------- 2 files changed, 251 insertions(+), 233 deletions(-) diff --git a/src/electos/ballotmaker/data/edf-to-ballot-data.py b/src/electos/ballotmaker/data/edf-to-ballot-data.py index 0647722..42963a7 100644 --- a/src/electos/ballotmaker/data/edf-to-ballot-data.py +++ b/src/electos/ballotmaker/data/edf-to-ballot-data.py @@ -3,12 +3,13 @@ from dataclasses import asdict from pathlib import Path -from electos.ballotmaker.data.extractor import extract_ballot_data +from electos.ballotmaker.data.extractor import BallotDataExtractor def report(data, **opts): """Generate data needed by BallotLab.""" - ballot_data = extract_ballot_data(data) + extractor = BallotDataExtractor() + ballot_data = extractor.extract(data) ballot_data = [asdict(_) for _ in ballot_data] print(json.dumps(ballot_data, indent = 4)) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 8001a36..10463e0 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -1,5 +1,6 @@ from typing import Dict, List, Union +from electos.ballotmaker.data.models import ElectionData from electos.datamodels.nist.indexes import ElementIndex from electos.datamodels.nist.models.edf import ( BallotMeasureContest, @@ -28,267 +29,283 @@ # --- Utilities -def text_content(item): +def _text_content(item): """Return joined lines from internationalized text.""" assert isinstance(item, InternationalizedText) text = "\n".join(_.content for _ in item.text) return text -def walk_ordered_contests(content: List[OrderedContent]): +def _walk_ordered_contests(content: List[OrderedContent]): """Walk ordered content yielding contests.""" for item in content: if isinstance(item, OrderedContest): yield item elif isinstance(item, OrderedHeader): - yield from walk_ordered_contests(item.ordered_content) + yield from _walk_ordered_contests(item.ordered_content) else: raise TypeError(f"Unexpected type: {type(item).__name__}") -def walk_ordered_headers(content: List[OrderedContent]): +def _walk_ordered_headers(content: List[OrderedContent]): """Walk ordered content yielding headers.""" for item in content: if isinstance(item, OrderedHeader): yield item - yield from walk_ordered_headers(item.ordered_content) + yield from _walk_ordered_headers(item.ordered_content) else: raise TypeError(f"Unexpected type: {type(item).__name__}") -# --- Ballot Properties +# --- Extractor +class BallotDataExtractor: -def ballot_style_external_id(ballot_style: BallotStyle): - """Get the text of a ballot style's external identifier if any.""" - if ballot_style.external_identifier: - assert len(ballot_style.external_identifier) == 1, \ - "Not ready to handle multiple BallotStyle external IDs" - name = ballot_style.external_identifier[0].value - else: - name = "" - return name + """Extract election data from an EDF.""" -def ballot_style_gp_units(ballot_style: BallotStyle, index): - """Yield geo-political units for a ballot style.""" - for id_ in ballot_style.gp_unit_ids: - gp_unit = index.by_id(id_) - yield gp_unit + def __init__(self): + pass -def ballot_style_contests(ballot_style: BallotStyle, index): - """Yield the contests of a ballot style.""" - for item in walk_ordered_contests(ballot_style.ordered_content): - contest = index.by_id(item.contest_id) - yield contest - - -def candidate_name(candidate: Candidate): - """Get the name of a candidate as it appears on a ballot.""" - name = text_content(candidate.ballot_name) - return name - - -def candidate_party(candidate: Candidate, index): - """Get the name and abbreviation of the party of a candidate as it appears on a ballot. - - Drop either field from result if it isn't present. - """ - # Note: party ID is returned to allow de-duplicating parties in callers. - id_ = candidate.party_id - party = index.by_id(id_) - name = text_content(party.name) if party else None - abbreviation = text_content(party.abbreviation) if party and party.abbreviation else None - result = {} - if name: - result["name"] = name - if abbreviation: - result["abbreviation"] = abbreviation - return result, id_ - - -def candidate_contest_candidates(contest: CandidateContest, index): - """Get candidates for contest, grouped by slate/ticket. - - A slate has: - - - A single ID for the contest selection - - Collects candidate names into an array. - - Collects candidate parties into an array. - - If all candidates in a race share a single party they are combined into - one entry in the array. - - If any candidates differ from the others, parties are listed separately. - - Notes: - - There's no clear guarantee of a 1:1 relationship between slates and parties. - """ - # Collect individual candidates - candidates = [] - for selection in contest.contest_selection: - assert isinstance(selection, CandidateSelection), \ - f"Unexpected non-candidate selection: {type(selection).__name__}" - names = [] + def _ballot_style_external_id(self, ballot_style: BallotStyle): + """Get the text of a ballot style's external identifier if any.""" + if ballot_style.external_identifier: + assert len(ballot_style.external_identifier) == 1, \ + "Not ready to handle multiple BallotStyle external IDs" + name = ballot_style.external_identifier[0].value + else: + name = "" + return name + + + def _ballot_style_gp_units(self, ballot_style: BallotStyle): + """Yield geo-political units for a ballot style.""" + for id_ in ballot_style.gp_unit_ids: + gp_unit = self._index.by_id(id_) + yield gp_unit + + + def _ballot_style_contests(self, ballot_style: BallotStyle): + """Yield the contests of a ballot style.""" + for item in _walk_ordered_contests(ballot_style.ordered_content): + contest = self._index.by_id(item.contest_id) + yield contest + + + def _candidate_name(self, candidate: Candidate): + """Get the name of a candidate as it appears on a ballot.""" + name = _text_content(candidate.ballot_name) + return name + + + def _candidate_party(self, candidate: Candidate): + """Get the name and abbreviation of the party of a candidate as it appears on a ballot. + + Drop either field from result if it isn't present. + """ + # Note: party ID is returned to allow de-duplicating parties in callers. + id_ = candidate.party_id + party = self._index.by_id(id_) + name = _text_content(party.name) if party else None + abbreviation = ( + _text_content(party.abbreviation) if party and party.abbreviation else None + ) + result = {} + if name: + result["name"] = name + if abbreviation: + result["abbreviation"] = abbreviation + return result, id_ + + + def _candidate_contest_candidates(self, contest: CandidateContest): + """Get candidates for contest, grouped by slate/ticket. + + A slate has: + + - A single ID for the contest selection + - Collects candidate names into an array. + - Collects candidate parties into an array. + - If all candidates in a race share a single party they are combined into + one entry in the array. + - If any candidates differ from the others, parties are listed separately. + + Notes: + - There's no clear guarantee of a 1:1 relationship between slates/tickets + and parties. + """ + # Collect individual candidates + candidates = [] + for selection in contest.contest_selection: + assert isinstance(selection, CandidateSelection), \ + f"Unexpected non-candidate selection: {type(selection).__name__}" + names = [] + parties = [] + _party_ids = set() + if selection.candidate_ids: + for id_ in selection.candidate_ids: + candidate = self._index.by_id(id_) + name = self._candidate_name(candidate) + if name: + names.append(name) + party, _party_id = self._candidate_party(candidate) + if party: + parties.append(party) + _party_ids.add(_party_id) + # If there's only one party ID, all candidates share the same party. + # If there's any divergence track them all individually. + if len(_party_ids) == 1: + parties = parties[:1] + result = { + "id": selection.model__id, + "name": names, + "party": parties, + "is_write_in": bool(selection.is_write_in) + } + candidates.append(result) + return candidates + + + def _candidate_contest_offices(self, contest: CandidateContest): + """Get any offices associated with a candidate contest.""" + offices = [] + if contest.office_ids: + for id_ in contest.office_ids: + office = self._index.by_id(id_) + name = _text_content(office.name) + offices.append(name) + return offices + + + def _candidate_contest_parties(self, contest: CandidateContest): + """Get any parties associated with a candidate contest.""" parties = [] - _party_ids = set() - if selection.candidate_ids: - for id_ in selection.candidate_ids: - candidate = index.by_id(id_) - name = candidate_name(candidate) - if name: - names.append(name) - party, _party_id = candidate_party(candidate, index) - if party: - parties.append(party) - _party_ids.add(_party_id) - # If there's only one party ID, all candidates share the same party. - # If there's any divergence track them all individually. - if len(_party_ids) == 1: - parties = parties[:1] + if contest.primary_party_ids: + for id_ in contest.primary_party_ids: + party = self._index.by_id(id_) + name = _text_content(party.name) + parties.append(name) + return parties + + + def _contest_election_district(self, contest: Contest): + """Get the district name of a contest.""" + district = self._index.by_id(contest.election_district_id) + district = _text_content(district.name) + return district + + + def _extract_candidate_contest(self, contest: CandidateContest): + """Extract candidate contest subset needed for a ballot.""" + district = self._contest_election_district(contest) + offices = self._candidate_contest_offices(contest) + parties = self._candidate_contest_parties(contest) + candidates = self._candidate_contest_candidates(contest) result = { - "id": selection.model__id, - "name": names, - "party": parties, - "is_write_in": bool(selection.is_write_in) - } - candidates.append(result) - return candidates - - -def candidate_contest_offices(contest: CandidateContest, index): - """Get any offices associated with a candidate contest.""" - offices = [] - if contest.office_ids: - for id_ in contest.office_ids: - office = index.by_id(id_) - name = text_content(office.name) - offices.append(name) - return offices - - -def candidate_contest_parties(contest: CandidateContest, index): - """Get any parties associated with a candidate contest.""" - parties = [] - if contest.primary_party_ids: - for id_ in contest.primary_party_ids: - party = index.by_id(id_) - name = text_content(party.name) - parties.append(name) - return parties - - -def contest_election_district(contest: Contest, index): - """Get the district name of a contest.""" - district = index.by_id(contest.election_district_id) - district = text_content(district.name) - return district - - -# --- Extraction -# -# Gather and select data needed for ballot generation. - -def extract_candidate_contest(contest: CandidateContest, index): - """Extract candidate contest subset needed for a ballot.""" - district = contest_election_district(contest, index) - offices = candidate_contest_offices(contest, index) - parties = candidate_contest_parties(contest, index) - candidates = candidate_contest_candidates(contest, index) - result = { - "id": contest.model__id, - "type": "candidate", - "title": contest.name, - "district": district, - "vote_type": contest.vote_variation.value, - # Include even when default is 1: don't require caller to track that. - "votes_allowed": contest.votes_allowed, - "candidates": candidates, - # "offices": offices, - # "parties": parties, - } - return result - - -def extract_ballot_measure_contest(contest: BallotMeasureContest, index): - """Extract ballot measure contest subset needed for a ballot.""" - choices = [] - for selection in contest.contest_selection: - assert isinstance(selection, BallotMeasureSelection), \ - f"Unexpected non-ballot measure selection: {type(selection).__name__}" - choice = text_content(selection.selection) - choices.append({ - "id": selection.model__id, - "choice": choice, - }) - district = contest_election_district(contest, index) - full_text = text_content(contest.full_text) - result = { - "id": contest.model__id, - "type": "ballot measure", - "title": contest.name, - "district": district, - "text": full_text, - "choices": choices, - } - return result - - -def extract_contests(ballot_style: BallotStyle, index): - """Extract contest subset needed for ballots.""" - for contest in ballot_style_contests(ballot_style, index): - if isinstance(contest, CandidateContest): - entry = extract_candidate_contest(contest, index) - elif isinstance(contest, BallotMeasureContest): - entry = extract_ballot_measure_contest(contest, index) - else: - # Ignore other contest types - print(f"Skipping contest of type {contest.model__type}") - yield entry - - -def extract_ballot_styles(election: Election, index): - """Extract all ballot styles.""" - for ballot_style in election.ballot_style: - data = { - "id": ballot_style_external_id(ballot_style), - "scopes": [ _.model__id for _ in ballot_style_gp_units(ballot_style, index) ], - "contests": [ _ for _ in extract_contests(ballot_style, index) ], + "id": contest.model__id, + "type": "candidate", + "title": contest.name, + "district": district, + "vote_type": contest.vote_variation.value, + # Include even when default is 1: don't require caller to track that. + "votes_allowed": contest.votes_allowed, + "candidates": candidates, + # "offices": offices, + # "parties": parties, } - yield data - - -def extract_election_data(election_report: ElectionReport, index): - """Extract all elections.""" - # In most cases there isn't more than one 'Election' in a report, but the - # standard allows more than one, so handle them. - for election in election_report.election: - data = { - "name": text_content(election.name), - "type": election.type.value, - "start_date": election.start_date.strftime("%Y-%m-%d"), - "end_date": election.end_date.strftime("%Y-%m-%d"), - "ballot_styles": [_ for _ in extract_ballot_styles(election, index)], + return result + + + def _extract_ballot_measure_contest(self, contest: BallotMeasureContest): + """Extract ballot measure contest subset needed for a ballot.""" + choices = [] + for selection in contest.contest_selection: + assert isinstance(selection, BallotMeasureSelection), \ + f"Unexpected non-ballot measure selection: {type(selection).__name__}" + choice = _text_content(selection.selection) + choices.append({ + "id": selection.model__id, + "choice": choice, + }) + district = self._contest_election_district(contest) + full_text = _text_content(contest.full_text) + result = { + "id": contest.model__id, + "type": "ballot measure", + "title": contest.name, + "district": district, + "text": full_text, + "choices": choices, } - yield data - - -def extract_ballot_data(data: Dict, index: ElementIndex = None) -> ElectionData: - """Extract election data. - - This is the primary entry point for the extractor. - - Parameters: - data: An EDF / election report dictionary. - index: An ElementIndex. - If empty (the default), create a new index from the election report. - Use this parameter only if there's already an existing index. - - Returns: - Election data model for use in ballot rendering. - """ - election_report = ElectionReport(**data) - index = index or ElementIndex(election_report, "ElectionResults") - election_data = [ - ElectionData(**_) for _ in extract_election_data(election_report, index) - ] - return election_data + return result + + + def _extract_contests(self, ballot_style: BallotStyle): + """Extract contest subset needed for ballots.""" + for contest in self._ballot_style_contests(ballot_style): + if isinstance(contest, CandidateContest): + entry = self._extract_candidate_contest(contest) + elif isinstance(contest, BallotMeasureContest): + entry = self._extract_ballot_measure_contest(contest) + else: + # Ignore other contest types + print(f"Skipping contest of type {contest.model__type}") + yield entry + + + def _extract_ballot_styles(self, election: Election): + """Extract all ballot styles.""" + for ballot_style in election.ballot_style: + data = { + "id": self._ballot_style_external_id(ballot_style), + "scopes": [ + _.model__id + for _ in self._ballot_style_gp_units(ballot_style) + ], + "contests": [ + _ + for _ in self._extract_contests(ballot_style) + ], + } + yield data + + + def _extract_election_data(self, election_report: ElectionReport): + """Extract all elections.""" + # In most cases there isn't more than one 'Election' in a report, but the + # standard allows more than one, so handle them. + for election in election_report.election: + data = { + "name": _text_content(election.name), + "type": election.type.value, + "start_date": election.start_date.strftime("%Y-%m-%d"), + "end_date": election.end_date.strftime("%Y-%m-%d"), + "ballot_styles": [ + _ for _ in self._extract_ballot_styles(election) + ], + } + yield data + + + def extract(self, data: Dict, index: ElementIndex = None) -> ElectionData: + """Extract election data. + + This is the primary entry point for the extractor. + + Parameters: + data: An EDF / election report dictionary. + index: An ElementIndex. + If empty (the default), create a new index from the election report. + Use this parameter only if there's already an existing index. + + Returns: + Election data model for use in ballot rendering. + """ + election_report = ElectionReport(**data) + self._index = index or ElementIndex(election_report, "ElectionResults") + election_data = [ + ElectionData(**_) + for _ in self._extract_election_data(election_report) + ] + return election_data From 1195709847099a378f3dc787187c6985575a5127 Mon Sep 17 00:00:00 2001 From: Ion Y Date: Tue, 13 Sep 2022 21:01:43 -0700 Subject: [PATCH 48/48] Rename extractor internal functions start with '_extract'. --- src/electos/ballotmaker/data/extractor.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/electos/ballotmaker/data/extractor.py b/src/electos/ballotmaker/data/extractor.py index 10463e0..c4372da 100644 --- a/src/electos/ballotmaker/data/extractor.py +++ b/src/electos/ballotmaker/data/extractor.py @@ -196,7 +196,7 @@ def _contest_election_district(self, contest: Contest): return district - def _extract_candidate_contest(self, contest: CandidateContest): + def _candidate_contest_of(self, contest: CandidateContest): """Extract candidate contest subset needed for a ballot.""" district = self._contest_election_district(contest) offices = self._candidate_contest_offices(contest) @@ -217,7 +217,7 @@ def _extract_candidate_contest(self, contest: CandidateContest): return result - def _extract_ballot_measure_contest(self, contest: BallotMeasureContest): + def _ballot_measure_contest_of(self, contest: BallotMeasureContest): """Extract ballot measure contest subset needed for a ballot.""" choices = [] for selection in contest.contest_selection: @@ -241,20 +241,20 @@ def _extract_ballot_measure_contest(self, contest: BallotMeasureContest): return result - def _extract_contests(self, ballot_style: BallotStyle): + def _contests(self, ballot_style: BallotStyle): """Extract contest subset needed for ballots.""" for contest in self._ballot_style_contests(ballot_style): if isinstance(contest, CandidateContest): - entry = self._extract_candidate_contest(contest) + entry = self._candidate_contest_of(contest) elif isinstance(contest, BallotMeasureContest): - entry = self._extract_ballot_measure_contest(contest) + entry = self._ballot_measure_contest_of(contest) else: # Ignore other contest types print(f"Skipping contest of type {contest.model__type}") yield entry - def _extract_ballot_styles(self, election: Election): + def _election_ballot_styles(self, election: Election): """Extract all ballot styles.""" for ballot_style in election.ballot_style: data = { @@ -265,13 +265,13 @@ def _extract_ballot_styles(self, election: Election): ], "contests": [ _ - for _ in self._extract_contests(ballot_style) + for _ in self._contests(ballot_style) ], } yield data - def _extract_election_data(self, election_report: ElectionReport): + def _elections(self, election_report: ElectionReport): """Extract all elections.""" # In most cases there isn't more than one 'Election' in a report, but the # standard allows more than one, so handle them. @@ -282,7 +282,7 @@ def _extract_election_data(self, election_report: ElectionReport): "start_date": election.start_date.strftime("%Y-%m-%d"), "end_date": election.end_date.strftime("%Y-%m-%d"), "ballot_styles": [ - _ for _ in self._extract_ballot_styles(election) + _ for _ in self._election_ballot_styles(election) ], } yield data @@ -306,6 +306,6 @@ def extract(self, data: Dict, index: ElementIndex = None) -> ElectionData: self._index = index or ElementIndex(election_report, "ElectionResults") election_data = [ ElectionData(**_) - for _ in self._extract_election_data(election_report) + for _ in self._elections(election_report) ] return election_data