diff --git a/src/vtp/README.md b/src/vtp/README.md index d8a560e..a2ca14f 100644 --- a/src/vtp/README.md +++ b/src/vtp/README.md @@ -118,49 +118,89 @@ $ conda activate vtp.01 To run a mock election, run the setup_vtp_demo.py script (which per python's local install described above is installed in the python environment as _setup-vtp-demo_). This script will nominally create a mock election with four VTP scanner _apps_ and one VTP tabulation server _app_ as if all ballots were being cast in a single voting center with four separate and independent ballot scanners. By default it will place the git repos in /opt/VotetrackerPlus with the 5 clients (the four scanner apps and one server app) in the _clients_ folder with the two local git upstream bare repositories in the _tabulation-server_ folder. ``` -% setup-vtp-demo -l /opt/VoteTrackerPlus/demo.10 +% setup-vtp-demo -e ../VTP-mock-election.US.14 Running "git rev-parse --show-toplevel" Running "git config --get remote.origin.url" -Running "git config --get remote.origin.url" -Running "git clone --bare git@github.com:TrustTheVote-Project/VTP-mock-election.US.10.git" -Running "git clone --bare git@github.com:TrustTheVote-Project/VTP-root-repo.git" -Running "git clone --recurse-submodules /opt/VoteTrackerPlus/demo.10/tabulation-server/VTP-mock-election.US.10.git" -Submodule path 'VoteTrackerPlus': checked out 'bfa814d1577b77d2bb4e5d685823333fdc4a0b38' -Running "git clone --recurse-submodules /opt/VoteTrackerPlus/demo.10/tabulation-server/VTP-mock-election.US.10.git" -Submodule path 'VoteTrackerPlus': checked out 'bfa814d1577b77d2bb4e5d685823333fdc4a0b38' -Running "git clone --recurse-submodules /opt/VoteTrackerPlus/demo.10/tabulation-server/VTP-mock-election.US.10.git" -Submodule path 'VoteTrackerPlus': checked out 'bfa814d1577b77d2bb4e5d685823333fdc4a0b38' -Running "git clone --recurse-submodules /opt/VoteTrackerPlus/demo.10/tabulation-server/VTP-mock-election.US.10.git" -Submodule path 'VoteTrackerPlus': checked out 'bfa814d1577b77d2bb4e5d685823333fdc4a0b38' -Running "git clone --recurse-submodules /opt/VoteTrackerPlus/demo.10/tabulation-server/VTP-mock-election.US.10.git" -Submodule path 'VoteTrackerPlus': checked out 'bfa814d1577b77d2bb4e5d685823333fdc4a0b38' -Running "git init" -Initialized empty Git repository in /opt/VoteTrackerPlus/demo.10/.git/ -Running "git submodule add /opt/VoteTrackerPlus/demo.10/tabulation-server/VTP-mock-election.US.10.git clients/scanner.00/VTP-mock-election.US.10" -Adding existing repo at 'clients/scanner.00/VTP-mock-election.US.10' to the index -Running "git submodule add /opt/VoteTrackerPlus/demo.10/tabulation-server/VTP-mock-election.US.10.git clients/scanner.01/VTP-mock-election.US.10" -Adding existing repo at 'clients/scanner.01/VTP-mock-election.US.10' to the index -Running "git submodule add /opt/VoteTrackerPlus/demo.10/tabulation-server/VTP-mock-election.US.10.git clients/scanner.02/VTP-mock-election.US.10" -Adding existing repo at 'clients/scanner.02/VTP-mock-election.US.10' to the index -Running "git submodule add /opt/VoteTrackerPlus/demo.10/tabulation-server/VTP-mock-election.US.10.git clients/scanner.03/VTP-mock-election.US.10" -Adding existing repo at 'clients/scanner.03/VTP-mock-election.US.10' to the index -Running "git submodule add /opt/VoteTrackerPlus/demo.10/tabulation-server/VTP-mock-election.US.10.git clients/server/VTP-mock-election.US.10" -Adding existing repo at 'clients/server/VTP-mock-election.US.10' to the index -Adding a .gitignore -Running "git add .gitignore" - +Running "git clone --bare git@github.com:TrustTheVote-Project/VTP-mock-election.US.14.git" +Running "git clone /opt/VoteTrackerPlus/demo.01/tabulation-server/VTP-mock-election.US.14.git" +Running "git clone /opt/VoteTrackerPlus/demo.01/tabulation-server/VTP-mock-election.US.14.git" +Running "git clone /opt/VoteTrackerPlus/demo.01/tabulation-server/VTP-mock-election.US.14.git" +Running "git clone /opt/VoteTrackerPlus/demo.01/tabulation-server/VTP-mock-election.US.14.git" +Running "git clone /opt/VoteTrackerPlus/demo.01/tabulation-server/VTP-mock-election.US.14.git" ``` The resulting directory tree looks like this: ``` -/opt/VotetrackerPlus/demo.01/clients/scanner.00/VTP-mock-election.US./VoteTrackerPlus - scanner.01/VTP-mock-election.US./VoteTrackerPlus - scanner.02/VTP-mock-election.US./VoteTrackerPlus - scanner.03/VTP-mock-election.US./VoteTrackerPlus - server/VTP-mock-election.US./VoteTrackerPlus -/opt/VotetrackerPlus/demo.01/tabulation-server/VTP-mock-election.US..git - VoteTrackerPlus.git +% cd /opt/VoteTrackerPlus/demo.01 +% tree +. +├── guid-client-store +├── mock-clients +│ ├── scanner.00 +│ │ └── VTP-mock-election.US.14 +│ │ ├── GGOs +│ │ │ └── states +│ │ │ └── Massachusetts +│ │ │ ├── GGOs +│ │ │ │ ├── counties +│ │ │ │ │ └── Middlesex +│ │ │ │ │ └── config.yaml +│ │ │ │ └── towns +│ │ │ │ └── Concord +│ │ │ │ ├── CVRs +│ │ │ │ │ └── contest.json +│ │ │ │ ├── address_map.yaml +│ │ │ │ └── config.yaml +│ │ │ └── config.yaml +│ │ ├── LICENSE +│ │ ├── Makefile +│ │ ├── README.md +│ │ └── config.yaml +│ ├── scanner.01 +│ │ └── VTP-mock-election.US.14 +[... ditto ...] +│ ├── scanner.02 +│ │ └── VTP-mock-election.US.14 +[... ditto ...] +│ ├── scanner.03 +│ │ └── VTP-mock-election.US.14 +[... ditto ...] +│ └── server +│ └── VTP-mock-election.US.14 +[... ditto ...] +└── tabulation-server + └── VTP-mock-election.US.14.git + ├── HEAD + ├── config + ├── description + ├── hooks + │ ├── applypatch-msg.sample + │ ├── commit-msg.sample + │ ├── fsmonitor-watchman.sample + │ ├── post-update.sample + │ ├── pre-applypatch.sample + │ ├── pre-commit.sample + │ ├── pre-merge-commit.sample + │ ├── pre-push.sample + │ ├── pre-rebase.sample + │ ├── pre-receive.sample + │ ├── prepare-commit-msg.sample + │ ├── push-to-checkout.sample + │ └── update.sample + ├── info + │ └── exclude + ├── objects + │ ├── info + │ └── pack + │ ├── pack-036a7570a9631e18f0435e1070dc5258af19a6a3.idx + │ └── pack-036a7570a9631e18f0435e1070dc5258af19a6a3.pack + ├── packed-refs + └── refs + ├── heads + └── tags + +67 directories, 65 files ``` The git repositories in the _clients_ subfolder all have workspaces as that is where the various commands run to simulate an individual ballot scanner application. The two bare repostitories in tabulation-server mimick the actual voting center local (bare) git remote repositories for both the VTP scanner and server apps. @@ -172,40 +212,40 @@ Here is an example of running a 4 VTP scanner and 1 VTP server app mock demo ele ```bash # In terminal window #1, run a VTP tabulation server # Note - this assumes the explicit setup steps above - note the poetry pyproject.toml location -$ cd repos/VTP-mock-election.US.10/VoteTrackerPlus +$ cd repos/VTP-mock-election.US.14/VoteTrackerPlus $ poetry shell $ cd /opt/VotetrackerPlus/demo.01/clients/server/VoteTrackerPlus -$ run-mock-election -s California -t Alameda -a "123 Main Street" -d server +$ run-mock-election -s Massachusetts -t Concord -a "123 Main Street" -d server # In terminal window #2, run a VTP scanner in mock election mode -$ cd repos/VTP-mock-election.US.10/VoteTrackerPlus +$ cd repos/VTP-mock-election.US.14/VoteTrackerPlus $ poetry shell $ cd /opt/VotetrackerPlus/demo.01/clients/scanner.01/VoteTrackerPlus # Auto cast 100 random ballots -$ run-mock-election -s California -t Alameda -a "123 Main Street" -d scanner -i 100 +$ run-mock-election -s Massachusetts -t Concord -a "123 Main Street" -d scanner -i 100 # In terminal window #3, run a second VTP scanner in mock election mode -$ cd repos/VTP-mock-election.US.10/VoteTrackerPlus +$ cd repos/VTP-mock-election.US.14/VoteTrackerPlus $ poetry shell $ cd /opt/VotetrackerPlus/demo.01/clients/scanner.02/VoteTrackerPlus # Auto cast 100 random ballots -$ run-mock-election -s California -t Alameda -a "123 Main Street" -d scanner -i 100 +$ run-mock-election -s Massachusetts -t Concord -a "123 Main Street" -d scanner -i 100 # In terminal window #4, run an interactive VTP scanner to cast ballots -$ cd repos/VTP-mock-election.US.10/VoteTrackerPlus +$ cd repos/VTP-mock-election.US.14/VoteTrackerPlus $ poetry shell $ cd /opt/VotetrackerPlus/demo.01/clients/scanner.00/VoteTrackerPlus # To manually vote and cast one ballot, run vote.py. The receipt.csv will be printed to a file # and the row offset will be printed to the screen (STDOUT). -$ vote -s California -t Alameda -a "123 Main Street" +$ vote -s Massachusetts -t Concord -a "123 Main Street" ``` The last few lines printed by ./vote.py should look something like this: ``` ############ -### Receipt file: /opt/VoteTrackerPlus/demo.01/clients/scanner.00/VoteTrackerPlus/ElectionData/GGOs/states/California/GGOs/towns/Alameda/CVRs/receipt.csv +### Receipt file: /opt/VoteTrackerPlus/demo.01/clients/scanner.00/VoteTrackerPlus/ElectionData/GGOs/states/Massachusetts/GGOs/towns/Concord/CVRs/receipt.csv ### Voter's row: 78 ############ ``` @@ -213,25 +253,24 @@ The last few lines printed by ./vote.py should look something like this: See [../../docs/E2EV.md][E2EV.md] for more details regarding casting and inspecting ballots. To validate the digests on/in the ballot receipt (use your row, not 78): ``` -$ verify-ballot-receipt -f /opt/VoteTrackerPlus/demo.01/clients/scanner.00/VoteTrackerPlus/ElectionData/GGOs/states/California/GGOs/towns/Alameda/CVRs/receipt.csv -r 78 +$ verify-ballot-receipt -f /opt/VoteTrackerPlus/demo.01/clients/scanner.00/VoteTrackerPlus/ElectionData/GGOs/states/Massachusetts/GGOs/towns/Concord/CVRs/receipt.csv -r 78 ``` An random example ballot is saved off in ElectionData/receipts/receipt.74.csv. When that receipt is verified, the output currently looks like the following: ``` -$ verify-ballot-receipt -f ../ElectionData/receipts/receipt.74.csv -r 74 +% verify-ballot-receipt -f receipts/receipt.59.csv -r 59 Running "git rev-parse --show-toplevel" +Running "git pull" +Already up to date. Running "git cat-file --buffer --batch-check=%(objectname) %(objecttype)" -Contest '0000 - US president' (fad4eb1c97b5f547a921c377d8d683d0837f7ff8) is vote 71 out of 146 votes -Contest '0003 - County Clerk' (7d3e7f992628931d416de2095e0420436ce8f53f) is vote 100 out of 146 votes -Contest '0005 - mayor' (c9734a3be4ef3533b4c1df0f14305bebe118b031) is vote 96 out of 146 votes -Contest '0006 - Question 1 - school budget override' (92b70d29cbd677418ffd6166e5c455dedcf4033b) is vote 45 out of 146 votes -Contest '0007 - Question 2 - new firehouse land purchase' (e4ae73730cf6d00e499af328d17c41f88599711c) is vote 65 out of 146 votes -The following contests are not merged to main yet: -0001 - US senate (0a9682dccf6ab5cb83d8a5ce43786e74514ce3ef) -0002 - governor (ef1b88c931222669997639a0c45f26a4ff0a7342) +Contest '0000 - U.S. President' (20ec3a9080ce8d4167b41843b1ffc6905a172263) is vote 304 out of 304 votes +Contest '0001 - U.S. Senate' (8bef5f87658c40bbe7dcda814422a59e844b204d) is vote 303 out of 303 votes +Contest '0002 - Governor' (f088442581dfac4332d8633239c0272f83f8ee2a) is vote 303 out of 303 votes +Contest '0003 - County Clerk' (dacba213d14d28e5fb6dc4c5d8be88d37b6c8166) is vote 304 out of 304 votes +Contest '0004 - Question 1 - should the starting time of the annual town meeting be moved to 6:30 PM?' (2cbf5011576f0a6dc49817c5619df237726358e0) is vote 304 out of 304 votes ############ -### Ballot receipt VALID - no digest errors found +[GOOD]: ballot receipt VALID - no digest errors found ############ ``` @@ -250,28 +289,30 @@ $ tally-contests tally_contests.py can be restricted to a single contest or report on all the contests that span all the ballot types. It also supports a verbose switch so that one can see details about the tally. This is helpful with RCV as one can then inspect the RCV rounds and what is happening to the candidates: ```bash -% tally-contests -c 0000 -v 3 +% tally-contests -c 0001 Running "git rev-parse --show-toplevel" Running "git pull" Already up to date. Running "git log --topo-order --no-merges --pretty=format:%H%B" -Scanned 186 contests for contest (US president) uid=0000, tally=rcv, max=1, win-by>0.5 +Scanned 303 contests for contest (U.S. Senate) uid=0001, tally=rcv, max=1, win-by>0.5 RCV: round 0 -[('Phil Scott', 38), ('Mitt Romney', 36), ('Kamala Harris', 34), ("Beta O'rourke", 30), ('Cory Booker', 28), ('Ron DeSantis', 20)] +Total vote count: 303 +[('Gloria Gamma', 65), ('Anthony Alpha', 53), ('David Delta', 47), ('Emily Echo', 47), ('Francis Foxtrot', 47), ('Betty Beta', 44)] RCV: round 1 -[('Phil Scott', 41), ('Mitt Romney', 40), ('Kamala Harris', 38), ("Beta O'rourke", 37), ('Cory Booker', 30), ('Ron DeSantis', 0)] +Total vote count: 303 +[('Gloria Gamma', 71), ('Anthony Alpha', 65), ('Francis Foxtrot', 57), ('David Delta', 55), ('Emily Echo', 55), ('Betty Beta', 0)] RCV: round 2 -[('Mitt Romney', 49), ('Kamala Harris', 47), ("Beta O'rourke", 46), ('Phil Scott', 44), ('Cory Booker', 0), ('Ron DeSantis', 0)] +Total vote count: 303 +[('Anthony Alpha', 106), ('Gloria Gamma', 102), ('Francis Foxtrot', 95), ('Emily Echo', 0), ('David Delta', 0), ('Betty Beta', 0)] RCV: round 3 -[("Beta O'rourke", 64), ('Mitt Romney', 62), ('Kamala Harris', 60), ('Phil Scott', 0), ('Cory Booker', 0), ('Ron DeSantis', 0)] -RCV: round 4 -Contest US president (uid=0000): - ('Mitt Romney', 94) - ("Beta O'rourke", 92) - ('Kamala Harris', 0) - ('Phil Scott', 0) - ('Cory Booker', 0) - ('Ron DeSantis', 0) +Total vote count: 303 +Final results for contest U.S. Senate (uid=0001): + ('Gloria Gamma', 152) + ('Anthony Alpha', 151) + ('Francis Foxtrot', 0) + ('Emily Echo', 0) + ('David Delta', 0) + ('Betty Beta', 0) ``` FYI - with -v4 and RCV contests, how each specific voter's ranked choice selection gets re-directed from their last place loosing candidate to their next choice candidate is printed, offering full transparency to RVC contests. See [../../docs/E2EV.md][E2EV.md] for more details. diff --git a/src/vtp/core/common.py b/src/vtp/core/common.py index 2f0e03e..83767dc 100644 --- a/src/vtp/core/common.py +++ b/src/vtp/core/common.py @@ -18,9 +18,9 @@ """A kitchen sync for VTP classes for the moment""" # pylint: disable=too-few-public-methods -import json -# Other imports: critical, error, warning, info, debug +# standard imports +import json import logging import os import re @@ -140,20 +140,80 @@ def verify_election_data_dir(election_data_dir: str): ) @staticmethod - def get_guid_dir(guid: str) -> str: - """Return the default runtime location for a guid based workspace""" + def get_generic_ro_edf_dir() -> str: + """ + Will return a generic EDF workspace so to be able to execute + generic/readonly commands. It is 'readonly' because any + number of processes could be executing in this one git + workspace at the same time and if any them wrote anything, it + would be bad. + """ + edf_path = os.path.join( + Globals.get("DEFAULT_RUNTIME_LOCATION"), + Globals.get("MOCK_CLIENT_DIRNAME"), + "scanner.00", + ) + # Need to verify that there is only _one_ directory in the edf_path + dirs = [ + name + for name in os.listdir(edf_path) + if os.path.isdir(os.path.join(edf_path, name)) + ] + if len(dirs) > 1: + raise ValueError( + f"The mock client directory ({edf_path}) ", + "contains multiple subdirs - there can only be one ", + "as there should only be one EDF clone in this directory", + ) + if len(dirs) == 0: + raise ValueError( + f"The mock client directory ({edf_path}) ", + "is empty - there needs to be exactly one git clone ", + "of a ElectionData repo", + ) + return os.path.join(edf_path, dirs[0]) + + @staticmethod + def get_guid_based_edf_dir(guid: str) -> str: + """ + Return the default runtime location for a guid based + workspace. The actual ElectionData clone directory can be + named anything. HOWEVER it is assumed (REQUIRED) that there + is only one clone in this directory, which is reasonable given + that the whole tree from '/' is nominally created by the + setup-vtp-demo operation. + """ if len(guid) != 40: - raise ValueError(f"The provided guid ({guid}) is not 40 characters long") + raise ValueError(f"The provided guid is not 40 characters long: {guid}") if not re.match("^[0-9a-f]+$", guid): raise ValueError( - f"The provided guid ({guid}) contains characters other than [0-9a-f]" + f"The provided guid contains characters other than [0-9a-f]: {guid}" ) - return os.path.join( + edf_path = os.path.join( Globals.get("DEFAULT_RUNTIME_LOCATION"), Globals.get("GUID_CLIENT_DIRNAME"), guid[:2], guid[2:], ) + # Need to verify that the _only_ directory in edf_path is a + # valid EDF tree via some clone + dirs = [ + name + for name in os.listdir(edf_path) + if os.path.isdir(os.path.join(edf_path, name)) + ] + if len(dirs) > 1: + raise ValueError( + f"The provided guid ({guid}) based path ({edf_path}) ", + "contains multiple subdirs - there can only be one", + ) + if len(dirs) == 0: + raise ValueError( + f"The guid directory ({edf_path}) ", + "is empty - there needs to be exactly one git clone ", + "of a ElectionData repo", + ) + return os.path.join(edf_path, dirs[0]) # pylint: disable=too-few-public-methods # ZZZ - remove this later @@ -295,5 +355,18 @@ def cvr_parse_git_log_output( recording = False return git_log_cvrs + @staticmethod + def convert_show_output(output_lines: list) -> dict: + """ + Will convert the native text output of a CVR git commit to a + dictionary with a header key and a payload key. The header is + the default three text lines and the payload is the CVS JSON + payload. + """ + contest_cvr = {} + contest_cvr["header"] = output_lines[:3] + contest_cvr["payload"] = json.loads("".join(output_lines[4:])) + return contest_cvr + # EOF diff --git a/src/vtp/core/contest.py b/src/vtp/core/contest.py index bda477a..2cb083b 100644 --- a/src/vtp/core/contest.py +++ b/src/vtp/core/contest.py @@ -18,7 +18,8 @@ """How to manage a VTP specific contest""" import json -import logging + +# import logging import operator import re from fractions import Fraction @@ -354,7 +355,7 @@ def get_choices_from_round(choices, what=""): return [choice[1] for choice in choices] return [choice[0] for choice in choices] - def __init__(self, a_git_cvr): + def __init__(self, a_git_cvr, imprimir): """Given a contest as parsed from the git log, a.k.a the contest digest and CVR json payload, will construct a Tally. A tally object can validate and tally a contest. @@ -362,7 +363,13 @@ def __init__(self, a_git_cvr): Note - the constructor is per specific contest and tally results of the contest are stored in an attribute of the object. + + The imprimir is how STDOUT is being handled as defined by + the outer ops class/object. That object just passes down its + print function to the Tally constructor so that each (contest) + tally can handle printing as desired. """ + self.imprimir = imprimir self.digest = a_git_cvr["digest"] self.contest = a_git_cvr["CVR"] Contest.check_cvr_blob_syntax(self.contest, digest=self.digest) @@ -460,10 +467,10 @@ def tally_a_plurality_contest(self, contest, provenance_digest): self.selection_counts[choice] += 1 self.vote_count += 1 if provenance_digest: - logging.info("Counted %s: choice=%s", provenance_digest, choice) + self.imprimir(f"Counted {provenance_digest}: choice={choice}") else: if provenance_digest: - logging.info("No-vote %s: BLANK", provenance_digest) + self.imprimir(f"No-vote {provenance_digest}: BLANK") def tally_a_rcv_contest(self, contest, provenance_digest): """RCV tally""" @@ -477,10 +484,10 @@ def tally_a_rcv_contest(self, contest, provenance_digest): self.selection_counts[choice] += 1 self.vote_count += 1 if provenance_digest: - logging.info("Counted %s: choice=%s", provenance_digest, choice) + self.imprimir(f"Counted {provenance_digest}: choice={choice}") else: if provenance_digest: - logging.info("No vote %s: BLANK", provenance_digest) + self.imprimir(f"No vote {provenance_digest}: BLANK") def safely_determine_last_place_names(self, current_round: int) -> list: """Safely determine the next set of last_place_names for which @@ -495,7 +502,7 @@ def safely_determine_last_place_names(self, current_round: int) -> list: number of votes (as in, pick 3 of 5 and a RCV round tie results in 1 or 2 choices instead of 3). """ - logging.info("%s", self.rcv_round[current_round]) + self.imprimir(f"{self.rcv_round[current_round]}") # Step 1: remove self.obe_choices from current round working_copy = [] @@ -593,31 +600,29 @@ def next_rcv_round_precheck(self, last_place_names: list, this_round: int) -> in # print the condition and simply return might be the better # design option. Doing that. if not last_place_names: - logging.info("No more choices/candidates to recast - no more RCV rounds") + self.imprimir("No more choices/candidates to recast - no more RCV rounds") return 1 if this_round > 64: raise TallyException("RCV rounds exceeded safety limit of 64 rounds") if this_round >= len(self.rcv_round[0]): - logging.info("There are no more RCV rounds") + self.imprimir("There are no more RCV rounds") return 1 if not non_zero_count_choices: - logging.info("There are no votes for any choice") + self.imprimir("There are no votes for any choice") return 1 if non_zero_count_choices < self.get("max"): - logging.info( - "There are only %s viable choices left which is less than the contest max (%s)", - non_zero_count_choices, - self.get("max"), + self.imprimir( + f"There are only {non_zero_count_choices} viable choices " + f"left which is less than the contest max ({self.get('max')})" ) return 1 if non_zero_count_choices == self.get("max"): - logging.info( - "The contest max number of choices (%s)has been reached", - self.get("max"), + self.imprimir( + f"The contest max number of choices ({self.get('max')}) has been reached" ) return 1 if non_zero_count_choices == 1: - logging.info( + self.imprimir( "There is only one remaining viable choice left - halting more RCV rounds", ) return 1 @@ -630,18 +635,16 @@ def next_rcv_round_precheck(self, last_place_names: list, this_round: int) -> in # choices left, this is a runner-up tie which is still ok - # return and print that. if non_zero_count_choices - len(last_place_names) == 0: - logging.info("This contest ends in a %s way tie", non_zero_count_choices) + self.imprimir(f"This contest ends in a {non_zero_count_choices} way tie") return 1 # If len(last_place_names) leaves less than the max but one or # more choices left, this is a tie on losing. Not sure what # to do, so print that and return. if non_zero_count_choices - len(last_place_names) < self.get("max"): - logging.info( - "There is a last place tie (%s way) which results " - "in LESS THAN the max (%s) of choices", - len(last_place_names), - non_zero_count_choices, + self.imprimir( + f"There is a last place tie ({len(last_place_names)} way) which results " + f"in LESS THAN the max ({non_zero_count_choices}) of choices" ) return 1 @@ -656,14 +659,18 @@ def recast_votes(self, last_place_names: list, contest_batch: list, checks: list this RCV round. If there is no next choice, the there is no recast and the vote is dropped. """ + # ZZZ - VTP is not yet defining a logger and still using RootLogger - loglevel = re.search(r"\((.+)\)", str(logging.getLogger())).group(1) + # loglevel = re.search(r"\((.+)\)", str(logging.getLogger())).group(1) + # note: loglevel is set to INFO, DEBUG, etc and was used originally + # used below to optionally print more debugging info + # Loop over CVRs for uid in contest_batch: contest = uid["CVR"] digest = uid["digest"] if digest in checks: - logging.debug("INSPECTING: %s (contest=%s)", digest, contest["name"]) + self.imprimir(f"INSPECTING: {digest} (contest={contest['name']})", 4) # Note - if there is no selection, there is no selection if not contest["selection"]: continue @@ -694,21 +701,17 @@ def recast_votes(self, last_place_names: list, contest_batch: list, checks: list # set-in-stone ordering w.r.t. selection new_choice_name = self.select_name_from_choices(new_selection) self.selection_counts[new_choice_name] += 1 - if digest in checks or loglevel == "DEBUG": - logging.info( - "RCV: %s (contest=%s) last place pop and count (%s -> %s)", - digest, - contest["name"], - last_place_name, - new_choice_name, + # original variant: if digest in checks or loglevel == "DEBUG": + if digest in checks or self.imprimir("", 9) >= 4: + self.imprimir( + f"RCV: {digest} (contest={contest['name']}) last place " + f"pop and count ({last_place_name} -> {new_choice_name})" ) else: - if digest in checks or loglevel == "DEBUG": - logging.info( - "RCV: %s (contest=%s) last place pop and drop (%s -> BLANK)", - digest, - contest["name"], - last_place_name, + if digest in checks or self.imprimir("", 9) >= 4: + self.imprimir( + f"RCV: {digest} (contest={contest['name']}) last place " + f"pop and drop ({last_place_name} -> BLANK)" ) def handle_another_rcv_round( @@ -719,7 +722,7 @@ def handle_another_rcv_round( slice off that choice off and re-count the now first selection choice (if there is one) """ - logging.info("RCV: round %s", this_round) + self.imprimir(f"RCV: round {this_round}") # ZZZ - create a function to validate incoming last place # names and call that. Maybe in the furure once more is know @@ -744,7 +747,7 @@ def handle_another_rcv_round( self.rcv_round.append([]) # Get the correct current total vote count for this round total_current_vote_count = self.get_total_vote_count(this_round) - logging.info("Total vote count: %s", total_current_vote_count) + self.imprimir(f"Total vote count: {total_current_vote_count}") for choice in Tally.get_choices_from_round(self.rcv_round[this_round]): # Note the test is '>' and NOT '>=' if ( @@ -820,7 +823,11 @@ def parse_all_contests(self, contest_batch: list, checks: list): "The following CVRs have structural errors:" f"{errors}" ) - def tallyho(self, contest_batch: list, checks: list): + def tallyho( + self, + contest_batch: list, + checks: list, + ): """ Will verify and tally the suppllied unique contest across all the CVRs. contest_batch is the list of contest CVRs from git @@ -829,9 +836,9 @@ def tallyho(self, contest_batch: list, checks: list): """ # Read all the contests, validate, and count votes if self.contest["tally"] == "plurality": - logging.info("Plurality - one round") + self.imprimir("Plurality - one round") else: - logging.info("RCV: round 0") + self.imprimir("RCV: round 0") self.parse_all_contests(contest_batch, checks) # For all tallies order what has been counted so far (a tuple) @@ -855,7 +862,7 @@ def tallyho(self, contest_batch: list, checks: list): # Get the correct current total vote count for this round total_current_vote_count = self.get_total_vote_count(0) - logging.info("Total vote count: %s", total_current_vote_count) + self.imprimir(f"Total vote count: {total_current_vote_count}") # Determine winners if any ... for choice in Tally.get_choices_from_round(self.rcv_round[0]): @@ -886,13 +893,15 @@ def tallyho(self, contest_batch: list, checks: list): def print_results(self): """Will print the results of the tally""" - print(f"Contest {self.contest['name']} (uid={self.contest['uid']}):") + self.imprimir( + f"Final results for contest {self.contest['name']} (uid={self.contest['uid']}):" + ) # import pdb; pdb.set_trace() # Note - better to print the last self.rcv_round than # self.winner_order since the former is a full count across all # choices while the latter is a partial list for result in self.rcv_round[-2]: - print(f" {result}") + self.imprimir(f" {result}") # EOF diff --git a/src/vtp/core/election_config.py b/src/vtp/core/election_config.py index f71a101..ec75bbd 100644 --- a/src/vtp/core/election_config.py +++ b/src/vtp/core/election_config.py @@ -17,9 +17,8 @@ """The VTP ElectionConfig class - everything needed to parse the config.yaml tree.""" -import logging - # standard imports +import logging import os import os.path import re @@ -101,15 +100,9 @@ class ElectionConfig: _uids = {} _nextuid = 0 - # Hrmph - for the moment let there be only one ElectionData tree - # (or Election Data File - EDF) configuration per VTP execution. - # This may need to changed later. Note that this is NOT the same - # requirement as one and only one election_data_dir. There can be - # many of those - and there needs to be many since there needs to - # be many git clones/workspaces. But all the git - # clones/workspaces need to be exact (same commit) clones. Since - # they are all the same clone, any instance needs only to be - # scanned once and only once. + # A private cache of ElectionConfig data of length one so that + # repeatably hitting the same EDF (Election Data File) is + # optimized. _election_data = None @staticmethod @@ -121,12 +114,32 @@ def configure_election(election_data_dir: str): """ # Safety check Common.verify_election_data_dir(election_data_dir) - # Only parse the tree if it hasn't been done yet - if ElectionConfig._election_data is None: - # Call the constrcutor - sets the absolute path to election_data_dir - ElectionConfig._election_data = ElectionConfig(election_data_dir) - # Parses the actual election_data_dir - ElectionConfig._election_data.parse_configs() + # Always call the constructor - sets the absolute path to + # election_data_dir. It will call git rev-parse but at the + # moment that is required to determine the exact root of the + # ElectionData tree (as the CWD can move around etc). + incoming_ec = ElectionConfig(election_data_dir) + # Now, if the git_rootdir is different than the previous + # constructor call, parse the new tree even though the EDF is + # the same. Two design notes: 1) if the git_rootdir is stored + # some place else or differently other than in the + # ElectionConfig object, then different EDF workspaces could + # share the same EDF data while supporting multiple EDF git + # workspaces (required with the web-api interface); and 2) + # caching the last constructor call avoids multiple scans of + # the same ElectionData tree. But if in a web based demo, the + # incoming requests will be dynamically varying over different + # guid based workspaces etc, so either keep a stack of them + # which all could consume memory or just optimize for repeated + # hits by the same client (into the same workspace). So, for + # now, implementing the latter. + if ElectionConfig._election_data is not None and ( + incoming_ec.git_rootdir == ElectionConfig._election_data.git_rootdir + ): + return ElectionConfig._election_data + # Parses the actual election_data_dir + ElectionConfig._election_data = incoming_ec + ElectionConfig._election_data.parse_configs() # Returns self return ElectionConfig._election_data diff --git a/src/vtp/ops/accept_ballot_operation.py b/src/vtp/ops/accept_ballot_operation.py index d00e52c..679a037 100644 --- a/src/vtp/ops/accept_ballot_operation.py +++ b/src/vtp/ops/accept_ballot_operation.py @@ -26,6 +26,7 @@ """ # Standard imports +import csv import logging import os import random @@ -43,25 +44,14 @@ class AcceptBallotOperation(Operation): - """Implementation of 'accept-ballot'.""" - - def __init__( - self, - election_data_dir: str = "", - guid: str = "", - verbosity: int = 3, - printonly: bool = False, - ): - """ - Primarily to module-ize the scripts and keep things simple, - idiomatic, and in different namespaces. - """ - super().__init__(election_data_dir, verbosity, printonly, guid) + """ + Implementation of the 'accept-ballot' operation. + """ def get_random_branchpoint(self, branch): - """Return a random branchpoint on the supplied branch - - Requires the CWD to be the parent of the CVRs directory. + """ + Return a random branchpoint on the supplied branch Requires + the CWD to be the parent of the CVRs directory. """ result = Shellout.run( ["git", "log", branch, "--pretty=format:'%h'"], @@ -335,6 +325,13 @@ def inner_loop(): # return all three return ballot_receipt, voters_row, receipt_file + def convert_csv_to_2d_list(self, ballot_check_cvs: list) -> list[list[str]]: + """Convert a 1-D csv format list to a 2-D list of list format""" + my_list = [] + for row in csv.reader(ballot_check_cvs, delimiter=",", quotechar='"'): + my_list.append(row) + return my_list + # pylint: disable=duplicate-code # pylint: disable=too-many-locals def run( @@ -509,10 +506,15 @@ def run( ) # For now, print the location and the voter's index - print(f"############\n### Receipt file: {receipt_file}") - print(f"### Voter's row: {index}\n############") - # And return two - return ballot_check, index + print("############") + print(f"### Receipt file: {receipt_file}") + print(f"### Voter's row: {index}") + print("############") + # And return them. Note that ballot_check is in csv format + # when writing to a file. However, when returning is it more + # convenient for it to be normal 2-D array - + # list[list[str]]. So convert it first. + return self.convert_csv_to_2d_list(ballot_check), index # EOF diff --git a/src/vtp/ops/cast_ballot_operation.py b/src/vtp/ops/cast_ballot_operation.py index 30d7f9e..35c0f6c 100644 --- a/src/vtp/ops/cast_ballot_operation.py +++ b/src/vtp/ops/cast_ballot_operation.py @@ -50,19 +50,6 @@ class CastBallotOperation(Operation): description (immediately below this) in the source file. """ - def __init__( - self, - election_data_dir: str = "", - guid: str = "", - verbosity: int = 3, - printonly: bool = False, - ): - """ - Primarily to module-ize the scripts and keep things simple, - idiomatic, and in different namespaces. - """ - super().__init__(election_data_dir, verbosity, printonly, guid) - def make_random_selection(self, the_ballot, the_contest): """Will randomly make selections on a contest""" # get the possible choices @@ -274,13 +261,16 @@ def run( with Shellout.changed_cwd(the_election_config.get("git_rootdir")): a_ballot.read_a_blank_ballot("", the_election_config, blank_ballot) else: + if isinstance(an_address, str): + # need to convert the csv string to an Address + an_address = Address(csv=an_address) # Use the specified address an_address.map_ggos(the_election_config) # get the ballot for the specified address a_ballot.read_a_blank_ballot(an_address, the_election_config) if return_bb: - return str(a_ballot) + return a_ballot # If still here, prompt the user to vote for each contest contests = self.loop_over_contests(a_ballot, demo_mode) diff --git a/src/vtp/ops/create_blank_ballot_operation.py b/src/vtp/ops/create_blank_ballot_operation.py index 3cb13bf..e0bf52c 100644 --- a/src/vtp/ops/create_blank_ballot_operation.py +++ b/src/vtp/ops/create_blank_ballot_operation.py @@ -41,19 +41,6 @@ class CreateBlankBallotOperation(Operation): description (immediately below this) in the source file. """ - def __init__( - self, - election_data_dir: str = "", - guid: str = "", - verbosity: int = 3, - printonly: bool = False, - ): - """ - Primarily to module-ize the scripts and keep things simple, - idiomatic, and in different namespaces. - """ - super().__init__(election_data_dir, verbosity, printonly, guid) - # pylint: disable=duplicate-code def run( self, diff --git a/src/vtp/ops/generate_all_blank_ballots_operation.py b/src/vtp/ops/generate_all_blank_ballots_operation.py index a75a7ac..a023522 100644 --- a/src/vtp/ops/generate_all_blank_ballots_operation.py +++ b/src/vtp/ops/generate_all_blank_ballots_operation.py @@ -41,19 +41,6 @@ class GenerateAllBlankBallotsOperation(Operation): generate-all-blank-ballots help output. """ - def __init__( - self, - election_data_dir: str = "", - guid: str = "", - verbosity: int = 3, - printonly: bool = False, - ): - """ - Primarily to module-ize the scripts and keep things simple, - idiomatic, and in different namespaces. - """ - super().__init__(election_data_dir, verbosity, printonly, guid) - # pylint: disable=duplicate-code def run(self): """Main function - see -h for more info""" diff --git a/src/vtp/ops/merge_contests_operation.py b/src/vtp/ops/merge_contests_operation.py index 505f65c..e81ce87 100644 --- a/src/vtp/ops/merge_contests_operation.py +++ b/src/vtp/ops/merge_contests_operation.py @@ -44,19 +44,6 @@ class MergeContestsOperation(Operation): description (immediately below this) in the source file. """ - def __init__( - self, - election_data_dir: str = "", - guid: str = "", - verbosity: int = 3, - printonly: bool = False, - ): - """ - Primarily to module-ize the scripts and keep things simple, - idiomatic, and in different namespaces. - """ - super().__init__(election_data_dir, verbosity, printonly, guid) - def merge_contest_branch(self, branch: str, remote: bool): """Merge a specific branch""" # If the VTP server is processing contests from different diff --git a/src/vtp/ops/operation.py b/src/vtp/ops/operation.py index 380b150..5538eea 100644 --- a/src/vtp/ops/operation.py +++ b/src/vtp/ops/operation.py @@ -24,8 +24,9 @@ class Operation: """ Generic operation base class constructor - covers - election_data_dir, verbosity, and printonly. Also will configure - (glbal) logging and validate the existance of election_data_dir. + election_data_dir, guid, verbosity, and printonly. Also will + configure (global) logging and validate the existance of + election_data_dir. """ def __init__( @@ -33,15 +34,28 @@ def __init__( election_data_dir: str = "", verbosity: int = 3, printonly: bool = False, - guid: str = "", + stdout_printing: bool = True, ): - if guid: - self.election_data_dir = Common.get_guid_dir(guid) - else: - self.election_data_dir = election_data_dir + self.election_data_dir = election_data_dir self.printonly = printonly self.verbosity = verbosity # Configure logging Common.configure_logging(verbosity) # Validate the election_data_dir arg here and now Common.verify_election_data_dir(self.election_data_dir) + # Configure printing + self.stdout_printing = stdout_printing + self.stdout_output = [] + + def imprimir(self, a_line: str, incoming_printlevel: int = 3): + """Either prints a line of text to STDOUT or appends it to a list""" + if incoming_printlevel <= self.verbosity: + if self.stdout_printing: + print(a_line) + else: + self.stdout_output.append(a_line) + return self.verbosity + + def get_imprimir(self) -> list: + """Return the stored output string""" + return self.stdout_output diff --git a/src/vtp/ops/run_mock_election_operation.py b/src/vtp/ops/run_mock_election_operation.py index 41c1c9e..b206e59 100644 --- a/src/vtp/ops/run_mock_election_operation.py +++ b/src/vtp/ops/run_mock_election_operation.py @@ -48,19 +48,6 @@ class RunMockElectionOperation(Operation): description (immediately below this) in the source file. """ - def __init__( - self, - election_data_dir: str = "", - guid: str = "", - verbosity: int = 3, - printonly: bool = False, - ): - """ - Primarily to module-ize the scripts and keep things simple, - idiomatic, and in different namespaces. - """ - super().__init__(election_data_dir, verbosity, printonly, guid) - # pylint: disable=too-many-arguments # pylint: disable=too-many-locals # pylint: disable=too-many-branches @@ -272,7 +259,7 @@ def server_mockup( logging.info("Sleeping for 10 (iteration=%s)", count) time.sleep(10) elapsed_time = time.time() - start_time - if elapsed_time > seconds: + if not iterations and elapsed_time > seconds: break if flush_mode in [1, 2]: print("Cleaning up remaining unmerged ballots") diff --git a/src/vtp/ops/setup_vtp_demo_operation.py b/src/vtp/ops/setup_vtp_demo_operation.py index 5e14f96..82cba6a 100644 --- a/src/vtp/ops/setup_vtp_demo_operation.py +++ b/src/vtp/ops/setup_vtp_demo_operation.py @@ -25,6 +25,7 @@ # Standard imports import logging import os +import re import secrets # Project imports @@ -42,10 +43,31 @@ class SetupVtpDemoOperation(Operation): description (immediately below this) in the source file. """ + @staticmethod + def get_all_guid_workspaces() -> list: + """ + Will return a list of all the existing guid workspaces + """ + guid_dir = os.path.join( + Globals.get("DEFAULT_RUNTIME_LOCATION"), + Globals.get("GUID_CLIENT_DIRNAME"), + ) + file_list = os.listdir(guid_dir) + guids = [] + for thing in file_list: + path = os.path.join(guid_dir, thing) + if not os.path.isdir(path) or not re.match("^[0-9a-f]{2}$", thing): + continue + for subdir in os.listdir(path): + if os.path.isdir(os.path.join(path, subdir)) and re.match( + "^[0-9a-f]{38}$", subdir + ): + guids.append(thing + subdir) + return guids + def __init__( self, election_data_dir: str = "", - guid: str = "", verbosity: int = 3, printonly: bool = False, ): @@ -53,7 +75,7 @@ def __init__( Primarily to module-ize the scripts and keep things simple, idiomatic, and in different namespaces. """ - super().__init__(election_data_dir, verbosity, printonly, guid) + super().__init__(election_data_dir, verbosity, printonly) # The absolute path to the local bare clone of the upstream # GitHub ElectionData remote repo self.tabulation_local_upstream_absdir = "" diff --git a/src/vtp/ops/show_contests_operation.py b/src/vtp/ops/show_contests_operation.py index 13282ab..4630c7b 100644 --- a/src/vtp/ops/show_contests_operation.py +++ b/src/vtp/ops/show_contests_operation.py @@ -37,19 +37,6 @@ class ShowContestsOperation(Operation): description (immediately below this) in the source file. """ - def __init__( - self, - election_data_dir: str = "", - guid: str = "", - verbosity: int = 3, - printonly: bool = False, - ): - """ - Primarily to module-ize the scripts and keep things simple, - idiomatic, and in different namespaces. - """ - super().__init__(election_data_dir, verbosity, printonly, guid) - def validate_digests(self, digests, the_election_config, error_digests): """Will scan the supplied digests for validity. Will print and return the invalid digests. @@ -93,7 +80,7 @@ def validate_digests(self, digests, the_election_config, error_digests): raise ValueError(f"Found {errors} invalid digest(s)") # pylint: disable=duplicate-code - def run(self, contest_check: str = ""): + def run(self, contest_check: str = "") -> list: """Main function - see -h for more info""" # Create a VTP ElectionData object if one does not already exist @@ -107,7 +94,20 @@ def run(self, contest_check: str = ""): ] # show/log the digests with Shellout.changed_cwd(the_election_config.get("git_rootdir")): - Shellout.run(["git", "show", "-s"] + valid_digests, check=True) + output_lines = ( + Shellout.run( + ["git", "show", "-s"] + valid_digests, + text=True, + check=True, + capture_output=True, + ) + .stdout.strip() + .splitlines() + ) + if self.stdout_printing: + for line in output_lines: + self.imprimir(line) + return output_lines # For future reference just in case . . . diff --git a/src/vtp/ops/tally_contests_operation.py b/src/vtp/ops/tally_contests_operation.py index 764fc0a..a47fea9 100644 --- a/src/vtp/ops/tally_contests_operation.py +++ b/src/vtp/ops/tally_contests_operation.py @@ -20,11 +20,10 @@ """Logic of operation for tallying contests.""" # Standard imports -import logging # Project imports from vtp.core.ballot import Ballot -from vtp.core.common import Common, Shellout +from vtp.core.common import Shellout from vtp.core.contest import Tally from vtp.core.election_config import ElectionConfig from vtp.core.exceptions import TallyException @@ -41,30 +40,14 @@ class TallyContestsOperation(Operation): description (immediately below this) in the source file. """ - def __init__( - self, - election_data_dir: str = "", - guid: str = "", - verbosity: int = 3, - printonly: bool = False, - ): - """ - Primarily to module-ize the scripts and keep things simple, - idiomatic, and in different namespaces. - """ - super().__init__(election_data_dir, verbosity, printonly, guid) - # pylint: disable=duplicate-code def run( self, contest_uid: str = "", track_contests: str = "", - ): + ) -> list: """Main function - see -h for more info""" - # Configure logging - Common.configure_logging(self.verbosity) - # Create a VTP ElectionData object if one does not already exist the_election_config = ElectionConfig.configure_election(self.election_data_dir) @@ -94,15 +77,14 @@ def run( if contest_batches[contest_batch][0]["CVR"]["uid"] != contest_uid: continue # Create a Tally object for this specific contest - the_tally = Tally(contest_batches[contest_batch][0]) - logging.info( - "Scanned %s contests for contest (%s) uid=%s, tally=%s, max=%s, win-by>%s", - len(contest_batches[contest_batch]), - contest_batches[contest_batch][0]["CVR"]["name"], - contest_batches[contest_batch][0]["CVR"]["uid"], - contest_batches[contest_batch][0]["CVR"]["tally"], - the_tally.get("max"), - the_tally.get("win-by"), + the_tally = Tally(contest_batches[contest_batch][0], self.imprimir) + self.imprimir( + f"Scanned {len(contest_batches[contest_batch])} contests " + f"for contest ({contest_batches[contest_batch][0]['CVR']['name']}) " + f"uid={contest_batches[contest_batch][0]['CVR']['uid']}, " + f"tally={contest_batches[contest_batch][0]['CVR']['tally']}, " + f"max={the_tally.get('max')}, " + f"win-by>{the_tally.get('win-by')}" ) # Tally all the contests for this contest # import pdb; pdb.set_trace() @@ -111,9 +93,10 @@ def run( # Print stuff the_tally.print_results() except TallyException as tally_error: - logging.error( - "[ERROR]: %s\nContinuing with other contests ...", tally_error - ) + self.imprimir(f"[ERROR]: {tally_error}") + self.imprimir("Continuing with other contests ...") + # can always return the output + return self.stdout_output # EOF diff --git a/src/vtp/ops/verify_ballot_receipt_operation.py b/src/vtp/ops/verify_ballot_receipt_operation.py index 5b8f756..338fbb9 100644 --- a/src/vtp/ops/verify_ballot_receipt_operation.py +++ b/src/vtp/ops/verify_ballot_receipt_operation.py @@ -26,7 +26,7 @@ # Project imports from vtp.core.ballot import Ballot -from vtp.core.common import Common, Shellout +from vtp.core.common import Shellout from vtp.core.election_config import ElectionConfig # Local imports @@ -40,19 +40,6 @@ class VerifyBallotReceiptOperation(Operation): description (immediately below this) in the source file. """ - def __init__( - self, - election_data_dir: str = "", - guid: str = "", - verbosity: int = 3, - printonly: bool = False, - ): - """ - Primarily to module-ize the scripts and keep things simple, - idiomatic, and in different namespaces. - """ - super().__init__(election_data_dir, verbosity, printonly, guid) - # pylint: disable=too-many-arguments # self is not technically an arg kind-of def validate_ballot_lines( self, lines, headers, uids, the_election_config, error_digests @@ -89,22 +76,15 @@ def validate_ballot_lines( for line in results: digest, commit_type = line.split() if commit_type == "missing": - logging.error( - "[ERROR]: missing digest: row %s column %s contest=%s digest=%s", - row, - column, - headers[column - 1], - digest, + self.imprimir( + f"[ERROR]: missing digest: row {row} column {column} " + f"contest={headers[column - 1]} digest={digest}" ) error_digests.add(digest) elif commit_type != "commit": - logging.error( - "[ERROR]: invalid digest type: row %s column %s contest=%s digest=%s type=%s", - row, - column, - headers[column - 1], - digest, - commit_type, + self.imprimir( + f"[ERROR]: invalid digest type: row {row} column {column} " + f"contest={headers[column - 1]} digest={digest} type={commit_type}" ) error_digests.add(digest) column += 1 @@ -165,22 +145,16 @@ def vet_rows( # keep incrementing column regardless continue if digest not in cvrs: - logging.error( - "[ERROR]: missing digest in main branch: row %s contest=%s digest=%s", - index, - headers[column], - digest, + self.imprimir( + f"[ERROR]: missing digest in main branch: row {index} " + f"contest={headers[column]} digest={digest}" ) error_digests.add(digest) continue if cvrs[digest]["CVR"]["uid"] != uids[column]: - logging.error( - "[ERROR]: bad contest uid: row %s column %s contest %s != %s digest=%s", - row, - column, - headers[column], - cvrs[digest]["CVR"]["uid"], - digest, + self.imprimir( + f"[ERROR]: bad contest uid: row {row} column {column} contest " + f"{headers[column]} != {cvrs[digest]['CVR']['uid']} digest={digest}" ) error_digests.add(digest) continue @@ -189,10 +163,11 @@ def vet_rows( # pylint: disable=too-many-locals def verify_ballot_receipt( self, - receipt_file, - the_election_config, - row_index, - show_cvr, + receipt_file: str, + receipt_data: list[list[str]], + the_election_config: ElectionConfig, + row_index: str, + show_cvr: bool, ): """Will verify all the rows in a ballot receipt""" @@ -209,10 +184,18 @@ def verify_ballot_receipt( # import pdb; pdb.set_trace() # Create a ballot to read the receipt file - a_ballot = Ballot() - lines = a_ballot.read_receipt_csv( - the_election_config, receipt_file=receipt_file - ) + if receipt_file: + a_ballot = Ballot() + lines = a_ballot.read_receipt_csv( + the_election_config, receipt_file=receipt_file + ) + elif receipt_data: + lines = receipt_data + else: + raise ValueError( + "verify_ballot_receipt: requires either a receipt_file or receipt_data " + "- neither was supplied" + ) headers = lines.pop(0) uids = [re.match(r"([0-9]+)", column).group(0) for column in headers] error_digests = set() @@ -259,7 +242,7 @@ def vet_a_row(): found = False for c_count, contest in enumerate(contest_batches[uid]): if contest["digest"] in requested_row: - print( + self.imprimir( f"Contest '{contest['CVR']['uid']} - {contest['CVR']['name']}' " f"({contest['digest']}) is vote {contest_votes - c_count} out " f"of {contest_votes} votes" @@ -269,9 +252,9 @@ def vet_a_row(): if found is False: unmerged_uids[uid] = u_count if unmerged_uids: - print("The following contests are not merged to main yet:") + self.imprimir("The following contests are not merged to main yet:") for uid, offset in unmerged_uids.items(): - print(f"{headers[offset]} ({requested_digests[offset]})") + self.imprimir(f"{headers[offset]} ({requested_digests[offset]})") # If a row is specified, will print the context index in the # actual contest tally - which basically tells the voter 'your @@ -280,10 +263,8 @@ def vet_a_row(): valid_digests = [] for digest in lines[int(row_index) - 1]: if digest in error_digests: - logging.error( - "[ERROR]: cannot print CVR for %s (row %s) - it is invalid", - digest, - row_index, + self.imprimir( + "[ERROR]: cannot print CVR for {digest} (row {row_index}) - it is invalid" ) continue valid_digests.append(digest) @@ -299,31 +280,26 @@ def vet_a_row(): vet_a_row() # Summerize + self.imprimir("############") if error_digests: - logging.error( - "############\n" + self.imprimir( "[ERROR]: ballot receipt INVALID - the supplied ballot receipt has " - "%s errors.\n############", - len(error_digests), + "{len(error_digests)} errors." ) else: - print( - "############\n" - "[GOOD]: ballot receipt VALID - no digest errors found\n############" - ) + self.imprimir("[GOOD]: ballot receipt VALID - no digest errors found") + self.imprimir("############") # pylint: disable=duplicate-code def run( self, receipt_file: str = "", + receipt_data: list[list[str]] = None, row: str = "", cvr: bool = False, - ): + ) -> list[str]: """Main function - see -h for more info""" - # Configure logging - Common.configure_logging(self.verbosity) - # Create a VTP ElectionData object if one does not already exist the_election_config = ElectionConfig.configure_election(self.election_data_dir) @@ -337,10 +313,13 @@ def run( # Can read the receipt file directly without any Ballot info self.verify_ballot_receipt( receipt_file=receipt_file, + receipt_data=receipt_data, the_election_config=the_election_config, row_index=row, show_cvr=cvr, ) + # can always return the output + return self.stdout_output # EOF diff --git a/src/vtp/ops/vote_operation.py b/src/vtp/ops/vote_operation.py index 2879dcf..2e32f96 100644 --- a/src/vtp/ops/vote_operation.py +++ b/src/vtp/ops/vote_operation.py @@ -42,19 +42,6 @@ class VoteOperation(Operation): description (immediately below this) in the source file. """ - def __init__( - self, - election_data_dir: str = "", - guid: str = "", - verbosity: int = 3, - printonly: bool = False, - ): - """ - Primarily to module-ize the scripts and keep things simple, - idiomatic, and in different namespaces. - """ - super().__init__(election_data_dir, verbosity, printonly, guid) - # pylint: disable=duplicate-code def run( self,