From e7f7fd52c7ff8c18cc977787fbb3c5265f0ba652 Mon Sep 17 00:00:00 2001 From: sandy currier Date: Fri, 29 Mar 2024 14:54:54 -0400 Subject: [PATCH] Feature/milestone2 (#112) * initial steps to supporting html stdout * initial steps for removing the logging package - use Operations.imprimir everywhere * improve some printing info * step 1 of 2 - removing the logging package * actually step 2 of 3 - next step to add verbose overrides back in * actually, step 3 of 4 - rototill plus pylint cleanup * trying to add more type annotation * more printing and shellout cleanup and debugging * yet more verbosity debugging * chaser * while here, might as well clean up the contest key names * more Global and contest keyword cleanup * first commit to remove the Contests iterator class - and the crowd goes wild :-); this alters the format of the ElectionData config.yaml file syntax * more minor debugging repair * casting a ballot is beginning to work (again) * yet more generic debugging - submitting contestCVR's * more cleanup post ballot/contest object rototill * an all-at-once refactoring; 1) made verbosity consistent throughout; 2) fixed shell_out verbosity handling; removed the class Common (no longer used); 3) create a single verbosity default * updating poetry * limping around the inability to get singleton design pattern to work * chaser - cleaning up more (mostly printing) leftover's from previous rototilling * the default max for RCV contests should be 1 as well * cleaning up some more CLI printing * switching max to max_selections; cleaning up RCV bugs regarding max_selections * more RCV tally debugging * initial working pass of html coloring for tally * initial tally and verify html support * minor tweak to keep in sync with VTP-web-client * looks like for the time being, no html support needed in backend (?) --- _tools/build/poetry_poetry.lock | 368 +++---- _tools/build/setuptools_pyproject.toml | 2 +- docs/E2EV.md | 16 +- requirements.txt | 350 +++---- src/vtp/README.md | 2 +- src/vtp/cli/_arguments.py | 19 +- src/vtp/cli/create_blank_ballot.py | 1 + src/vtp/cli/run_mock_election.py | 1 + src/vtp/cli/tally_contests.py | 2 + src/vtp/cli/verify_ballot_receipt.py | 2 + src/vtp/core/address.py | 36 +- src/vtp/core/ballot.py | 257 ++--- src/vtp/core/common.py | 275 +----- src/vtp/core/contest.py | 915 ++++-------------- src/vtp/core/election_config.py | 158 +-- src/vtp/core/tally.py | 646 +++++++++++++ src/vtp/ops/accept_ballot_operation.py | 138 ++- src/vtp/ops/cast_ballot_operation.py | 84 +- src/vtp/ops/create_blank_ballot_operation.py | 41 +- .../generate_all_blank_ballots_operation.py | 29 +- src/vtp/ops/merge_contests_operation.py | 128 ++- src/vtp/ops/operation.py | 312 +++++- src/vtp/ops/run_mock_election_operation.py | 62 +- src/vtp/ops/setup_vtp_demo_operation.py | 55 +- src/vtp/ops/show_contests_operation.py | 38 +- src/vtp/ops/tally_contests_operation.py | 58 +- .../ops/verify_ballot_receipt_operation.py | 98 +- src/vtp/ops/vote_operation.py | 20 +- 28 files changed, 2132 insertions(+), 1981 deletions(-) create mode 100644 src/vtp/core/tally.py diff --git a/_tools/build/poetry_poetry.lock b/_tools/build/poetry_poetry.lock index 80c24a3..b15b0ec 100644 --- a/_tools/build/poetry_poetry.lock +++ b/_tools/build/poetry_poetry.lock @@ -96,14 +96,14 @@ uvloop = ["uvloop (>=0.15.2)"] [[package]] name = "certifi" -version = "2023.11.17" +version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." category = "dev" optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"}, - {file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"}, + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] [[package]] @@ -235,64 +235,64 @@ files = [ [[package]] name = "coverage" -version = "7.4.0" +version = "7.4.4" description = "Code coverage measurement for Python" category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "coverage-7.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a"}, - {file = "coverage-7.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516"}, - {file = "coverage-7.4.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae"}, - {file = "coverage-7.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43"}, - {file = "coverage-7.4.0-cp310-cp310-win32.whl", hash = "sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451"}, - {file = "coverage-7.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca"}, - {file = "coverage-7.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc"}, - {file = "coverage-7.4.0-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09"}, - {file = "coverage-7.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26"}, - {file = "coverage-7.4.0-cp311-cp311-win32.whl", hash = "sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614"}, - {file = "coverage-7.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143"}, - {file = "coverage-7.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446"}, - {file = "coverage-7.4.0-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a"}, - {file = "coverage-7.4.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa"}, - {file = "coverage-7.4.0-cp312-cp312-win32.whl", hash = "sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450"}, - {file = "coverage-7.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0"}, - {file = "coverage-7.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e"}, - {file = "coverage-7.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1"}, - {file = "coverage-7.4.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e"}, - {file = "coverage-7.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105"}, - {file = "coverage-7.4.0-cp38-cp38-win32.whl", hash = "sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2"}, - {file = "coverage-7.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555"}, - {file = "coverage-7.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42"}, - {file = "coverage-7.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed"}, - {file = "coverage-7.4.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058"}, - {file = "coverage-7.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f"}, - {file = "coverage-7.4.0-cp39-cp39-win32.whl", hash = "sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932"}, - {file = "coverage-7.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e"}, - {file = "coverage-7.4.0-pp38.pp39.pp310-none-any.whl", hash = "sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6"}, - {file = "coverage-7.4.0.tar.gz", hash = "sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e"}, + {file = "coverage-7.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2"}, + {file = "coverage-7.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562"}, + {file = "coverage-7.4.4-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87"}, + {file = "coverage-7.4.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c"}, + {file = "coverage-7.4.4-cp310-cp310-win32.whl", hash = "sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d"}, + {file = "coverage-7.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf"}, + {file = "coverage-7.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f"}, + {file = "coverage-7.4.4-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384"}, + {file = "coverage-7.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b"}, + {file = "coverage-7.4.4-cp311-cp311-win32.whl", hash = "sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286"}, + {file = "coverage-7.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76"}, + {file = "coverage-7.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70"}, + {file = "coverage-7.4.4-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48"}, + {file = "coverage-7.4.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9"}, + {file = "coverage-7.4.4-cp312-cp312-win32.whl", hash = "sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0"}, + {file = "coverage-7.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384"}, + {file = "coverage-7.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409"}, + {file = "coverage-7.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7"}, + {file = "coverage-7.4.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c"}, + {file = "coverage-7.4.4-cp38-cp38-win32.whl", hash = "sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e"}, + {file = "coverage-7.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d"}, + {file = "coverage-7.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e"}, + {file = "coverage-7.4.4-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd"}, + {file = "coverage-7.4.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade"}, + {file = "coverage-7.4.4-cp39-cp39-win32.whl", hash = "sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57"}, + {file = "coverage-7.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c"}, + {file = "coverage-7.4.4-pp38.pp39.pp310-none-any.whl", hash = "sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677"}, + {file = "coverage-7.4.4.tar.gz", hash = "sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49"}, ] [package.dependencies] @@ -322,18 +322,19 @@ optimize = ["orjson"] [[package]] name = "dill" -version = "0.3.7" +version = "0.3.8" description = "serialize all of Python" category = "dev" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "dill-0.3.7-py3-none-any.whl", hash = "sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e"}, - {file = "dill-0.3.7.tar.gz", hash = "sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03"}, + {file = "dill-0.3.8-py3-none-any.whl", hash = "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7"}, + {file = "dill-0.3.8.tar.gz", hash = "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca"}, ] [package.extras] graph = ["objgraph (>=1.7.2)"] +profile = ["gprof2dot (>=2022.7.29)"] [[package]] name = "docutils" @@ -388,23 +389,23 @@ files = [ [[package]] name = "importlib-metadata" -version = "7.0.1" +version = "7.1.0" description = "Read metadata from Python packages" category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "importlib_metadata-7.0.1-py3-none-any.whl", hash = "sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e"}, - {file = "importlib_metadata-7.0.1.tar.gz", hash = "sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc"}, + {file = "importlib_metadata-7.1.0-py3-none-any.whl", hash = "sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570"}, + {file = "importlib_metadata-7.1.0.tar.gz", hash = "sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2"}, ] [package.dependencies] zipp = ">=0.5" [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] perf = ["ipython"] -testing = ["flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)", "pytest-ruff"] +testing = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy", "pytest-perf (>=0.9.2)", "pytest-ruff (>=0.2.1)"] [[package]] name = "iniconfig" @@ -500,72 +501,72 @@ files = [ [[package]] name = "markupsafe" -version = "2.1.4" +version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de8153a7aae3835484ac168a9a9bdaa0c5eee4e0bc595503c95d53b942879c84"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e888ff76ceb39601c59e219f281466c6d7e66bd375b4ec1ce83bcdc68306796b"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b838c37ba596fcbfca71651a104a611543077156cb0a26fe0c475e1f152ee8"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac1ebf6983148b45b5fa48593950f90ed6d1d26300604f321c74a9ca1609f8e"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbad3d346df8f9d72622ac71b69565e621ada2ce6572f37c2eae8dacd60385d"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5291d98cd3ad9a562883468c690a2a238c4a6388ab3bd155b0c75dd55ece858"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a7cc49ef48a3c7a0005a949f3c04f8baa5409d3f663a1b36f0eba9bfe2a0396e"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83041cda633871572f0d3c41dddd5582ad7d22f65a72eacd8d3d6d00291df26"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-win32.whl", hash = "sha256:0c26f67b3fe27302d3a412b85ef696792c4a2386293c53ba683a89562f9399b0"}, - {file = "MarkupSafe-2.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:a76055d5cb1c23485d7ddae533229039b850db711c554a12ea64a0fd8a0129e2"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e9e3c4020aa2dc62d5dd6743a69e399ce3de58320522948af6140ac959ab863"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0042d6a9880b38e1dd9ff83146cc3c9c18a059b9360ceae207805567aacccc69"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d03fea4c4e9fd0ad75dc2e7e2b6757b80c152c032ea1d1de487461d8140efc"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ab3a886a237f6e9c9f4f7d272067e712cdb4efa774bef494dccad08f39d8ae6"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf5ebbec056817057bfafc0445916bb688a255a5146f900445d081db08cbabb"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e1a0d1924a5013d4f294087e00024ad25668234569289650929ab871231668e7"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7902211afd0af05fbadcc9a312e4cf10f27b779cf1323e78d52377ae4b72bea"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c669391319973e49a7c6230c218a1e3044710bc1ce4c8e6eb71f7e6d43a2c131"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-win32.whl", hash = "sha256:31f57d64c336b8ccb1966d156932f3daa4fee74176b0fdc48ef580be774aae74"}, - {file = "MarkupSafe-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:54a7e1380dfece8847c71bf7e33da5d084e9b889c75eca19100ef98027bd9f56"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a76cd37d229fc385738bd1ce4cba2a121cf26b53864c1772694ad0ad348e509e"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:987d13fe1d23e12a66ca2073b8d2e2a75cec2ecb8eab43ff5624ba0ad42764bc"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5244324676254697fe5c181fc762284e2c5fceeb1c4e3e7f6aca2b6f107e60dc"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78bc995e004681246e85e28e068111a4c3f35f34e6c62da1471e844ee1446250"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d176cfdfde84f732c4a53109b293d05883e952bbba68b857ae446fa3119b4f"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9917691f410a2e0897d1ef99619fd3f7dd503647c8ff2475bf90c3cf222ad74"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f06e5a9e99b7df44640767842f414ed5d7bedaaa78cd817ce04bbd6fd86e2dd6"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396549cea79e8ca4ba65525470d534e8a41070e6b3500ce2414921099cb73e8d"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-win32.whl", hash = "sha256:f6be2d708a9d0e9b0054856f07ac7070fbe1754be40ca8525d5adccdbda8f475"}, - {file = "MarkupSafe-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:5045e892cfdaecc5b4c01822f353cf2c8feb88a6ec1c0adef2a2e705eef0f656"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a07f40ef8f0fbc5ef1000d0c78771f4d5ca03b4953fc162749772916b298fc4"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d18b66fe626ac412d96c2ab536306c736c66cf2a31c243a45025156cc190dc8a"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:698e84142f3f884114ea8cf83e7a67ca8f4ace8454e78fe960646c6c91c63bfa"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a3b78a5af63ec10d8604180380c13dcd870aba7928c1fe04e881d5c792dc4e"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:15866d7f2dc60cfdde12ebb4e75e41be862348b4728300c36cdf405e258415ec"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6aa5e2e7fc9bc042ae82d8b79d795b9a62bd8f15ba1e7594e3db243f158b5565"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:54635102ba3cf5da26eb6f96c4b8c53af8a9c0d97b64bdcb592596a6255d8518"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-win32.whl", hash = "sha256:3583a3a3ab7958e354dc1d25be74aee6228938312ee875a22330c4dc2e41beb0"}, - {file = "MarkupSafe-2.1.4-cp37-cp37m-win_amd64.whl", hash = "sha256:d6e427c7378c7f1b2bef6a344c925b8b63623d3321c09a237b7cc0e77dd98ceb"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bf1196dcc239e608605b716e7b166eb5faf4bc192f8a44b81e85251e62584bd2"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4df98d4a9cd6a88d6a585852f56f2155c9cdb6aec78361a19f938810aa020954"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b835aba863195269ea358cecc21b400276747cc977492319fd7682b8cd2c253d"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23984d1bdae01bee794267424af55eef4dfc038dc5d1272860669b2aa025c9e3"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c98c33ffe20e9a489145d97070a435ea0679fddaabcafe19982fe9c971987d5"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9896fca4a8eb246defc8b2a7ac77ef7553b638e04fbf170bff78a40fa8a91474"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b0fe73bac2fed83839dbdbe6da84ae2a31c11cfc1c777a40dbd8ac8a6ed1560f"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c7556bafeaa0a50e2fe7dc86e0382dea349ebcad8f010d5a7dc6ba568eaaa789"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-win32.whl", hash = "sha256:fc1a75aa8f11b87910ffd98de62b29d6520b6d6e8a3de69a70ca34dea85d2a8a"}, - {file = "MarkupSafe-2.1.4-cp38-cp38-win_amd64.whl", hash = "sha256:3a66c36a3864df95e4f62f9167c734b3b1192cb0851b43d7cc08040c074c6279"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:765f036a3d00395a326df2835d8f86b637dbaf9832f90f5d196c3b8a7a5080cb"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:21e7af8091007bf4bebf4521184f4880a6acab8df0df52ef9e513d8e5db23411"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5c31fe855c77cad679b302aabc42d724ed87c043b1432d457f4976add1c2c3e"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7653fa39578957bc42e5ebc15cf4361d9e0ee4b702d7d5ec96cdac860953c5b4"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47bb5f0142b8b64ed1399b6b60f700a580335c8e1c57f2f15587bd072012decc"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:fe8512ed897d5daf089e5bd010c3dc03bb1bdae00b35588c49b98268d4a01e00"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:36d7626a8cca4d34216875aee5a1d3d654bb3dac201c1c003d182283e3205949"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b6f14a9cd50c3cb100eb94b3273131c80d102e19bb20253ac7bd7336118a673a"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-win32.whl", hash = "sha256:c8f253a84dbd2c63c19590fa86a032ef3d8cc18923b8049d91bcdeeb2581fbf6"}, - {file = "MarkupSafe-2.1.4-cp39-cp39-win_amd64.whl", hash = "sha256:8b570a1537367b52396e53325769608f2a687ec9a4363647af1cded8928af959"}, - {file = "MarkupSafe-2.1.4.tar.gz", hash = "sha256:3aae9af4cac263007fd6309c64c6ab4506dd2b79382d9d19a1994f9240b8db4f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, ] [[package]] @@ -582,39 +583,39 @@ files = [ [[package]] name = "mypy" -version = "1.8.0" +version = "1.9.0" description = "Optional static typing for Python" category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "mypy-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3"}, - {file = "mypy-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4"}, - {file = "mypy-1.8.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d"}, - {file = "mypy-1.8.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9"}, - {file = "mypy-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae"}, - {file = "mypy-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3"}, - {file = "mypy-1.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817"}, - {file = "mypy-1.8.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d"}, - {file = "mypy-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd"}, - {file = "mypy-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55"}, - {file = "mypy-1.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218"}, - {file = "mypy-1.8.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3"}, - {file = "mypy-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6"}, - {file = "mypy-1.8.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66"}, - {file = "mypy-1.8.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6"}, - {file = "mypy-1.8.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d"}, - {file = "mypy-1.8.0-cp38-cp38-win_amd64.whl", hash = "sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8"}, - {file = "mypy-1.8.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259"}, - {file = "mypy-1.8.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b"}, - {file = "mypy-1.8.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592"}, - {file = "mypy-1.8.0-cp39-cp39-win_amd64.whl", hash = "sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a"}, - {file = "mypy-1.8.0-py3-none-any.whl", hash = "sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d"}, - {file = "mypy-1.8.0.tar.gz", hash = "sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f"}, + {file = "mypy-1.9.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed"}, + {file = "mypy-1.9.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150"}, + {file = "mypy-1.9.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374"}, + {file = "mypy-1.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3"}, + {file = "mypy-1.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc"}, + {file = "mypy-1.9.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129"}, + {file = "mypy-1.9.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612"}, + {file = "mypy-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd"}, + {file = "mypy-1.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6"}, + {file = "mypy-1.9.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185"}, + {file = "mypy-1.9.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913"}, + {file = "mypy-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b"}, + {file = "mypy-1.9.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2"}, + {file = "mypy-1.9.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e"}, + {file = "mypy-1.9.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04"}, + {file = "mypy-1.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02"}, + {file = "mypy-1.9.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4"}, + {file = "mypy-1.9.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d"}, + {file = "mypy-1.9.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf"}, + {file = "mypy-1.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9"}, + {file = "mypy-1.9.0-py3-none-any.whl", hash = "sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e"}, + {file = "mypy-1.9.0.tar.gz", hash = "sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974"}, ] [package.dependencies] @@ -676,14 +677,14 @@ dev = ["black", "mypy", "pytest"] [[package]] name = "packaging" -version = "23.2" +version = "24.0" description = "Core utilities for Python packages" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, - {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, + {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, + {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, ] [[package]] @@ -700,30 +701,30 @@ files = [ [[package]] name = "platformdirs" -version = "4.1.0" +version = "4.2.0" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.1.0-py3-none-any.whl", hash = "sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380"}, - {file = "platformdirs-4.1.0.tar.gz", hash = "sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420"}, + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, ] [package.extras] -docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] [[package]] name = "pluggy" -version = "1.3.0" +version = "1.4.0" description = "plugin and hook calling mechanisms for python" category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, - {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, + {file = "pluggy-1.4.0-py3-none-any.whl", hash = "sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981"}, + {file = "pluggy-1.4.0.tar.gz", hash = "sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be"}, ] [package.extras] @@ -1125,54 +1126,55 @@ files = [ [[package]] name = "tomlkit" -version = "0.12.3" +version = "0.12.4" description = "Style preserving TOML library" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "tomlkit-0.12.3-py3-none-any.whl", hash = "sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba"}, - {file = "tomlkit-0.12.3.tar.gz", hash = "sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4"}, + {file = "tomlkit-0.12.4-py3-none-any.whl", hash = "sha256:5cd82d48a3dd89dee1f9d64420aa20ae65cfbd00668d6f094d7578a78efbb77b"}, + {file = "tomlkit-0.12.4.tar.gz", hash = "sha256:7ca1cfc12232806517a8515047ba66a19369e71edf2439d0f5824f91032b6cc3"}, ] [[package]] name = "types-pyyaml" -version = "6.0.12.12" +version = "6.0.12.20240311" description = "Typing stubs for PyYAML" category = "dev" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "types-PyYAML-6.0.12.12.tar.gz", hash = "sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062"}, - {file = "types_PyYAML-6.0.12.12-py3-none-any.whl", hash = "sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24"}, + {file = "types-PyYAML-6.0.12.20240311.tar.gz", hash = "sha256:a9e0f0f88dc835739b0c1ca51ee90d04ca2a897a71af79de9aec5f38cb0a5342"}, + {file = "types_PyYAML-6.0.12.20240311-py3-none-any.whl", hash = "sha256:b845b06a1c7e54b8e5b4c683043de0d9caf205e7434b3edc678ff2411979b8f6"}, ] [[package]] name = "typing-extensions" -version = "4.9.0" +version = "4.10.0" description = "Backported and Experimental Type Hints for Python 3.8+" category = "main" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.9.0-py3-none-any.whl", hash = "sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd"}, - {file = "typing_extensions-4.9.0.tar.gz", hash = "sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783"}, + {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, + {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, ] [[package]] name = "urllib3" -version = "2.1.0" +version = "2.2.1" description = "HTTP library with thread-safe connection pooling, file post, and more." category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "urllib3-2.1.0-py3-none-any.whl", hash = "sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3"}, - {file = "urllib3-2.1.0.tar.gz", hash = "sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54"}, + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, ] [package.extras] brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] @@ -1258,19 +1260,19 @@ files = [ [[package]] name = "zipp" -version = "3.17.0" +version = "3.18.1" description = "Backport of pathlib-compatible object wrapper for zip files" category = "dev" optional = false python-versions = ">=3.8" files = [ - {file = "zipp-3.17.0-py3-none-any.whl", hash = "sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31"}, - {file = "zipp-3.17.0.tar.gz", hash = "sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0"}, + {file = "zipp-3.18.1-py3-none-any.whl", hash = "sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b"}, + {file = "zipp-3.18.1.tar.gz", hash = "sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-lint"] -testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy (>=0.9.1)", "pytest-ruff"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] +testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-ignore-flaky", "pytest-mypy", "pytest-ruff (>=0.2.1)"] [metadata] lock-version = "2.0" diff --git a/_tools/build/setuptools_pyproject.toml b/_tools/build/setuptools_pyproject.toml index fd2f086..6e33d2a 100644 --- a/_tools/build/setuptools_pyproject.toml +++ b/_tools/build/setuptools_pyproject.toml @@ -1,6 +1,6 @@ [project] name = "votetrackerplus" -version = "0.1.0" +version = "0.2.0" description = "VoteTracker+ - a distributed, open-source, public ballot and Cast Vote Record integrity and tracking system" authors = [ { name = "Sandy Currier", email = "windoverwater@users.noreply.github.com" } diff --git a/docs/E2EV.md b/docs/E2EV.md index 2f262a6..120fd81 100644 --- a/docs/E2EV.md +++ b/docs/E2EV.md @@ -178,7 +178,7 @@ 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 348 contests for contest (US president) uid=0000, tally=rcv, max=1, win-by>0.5 +Scanned 348 contests for contest (US president) uid=0000, tally=rcv, max=1, win_by>0.5 RCV: round 0 Total vote count: 348 [('Mitt Romney', 74), ('Kamala Harris', 62), ('Cory Booker', 61), ('Phil Scott', 60), ("Beta O'rourke", 46), ('Ron DeSantis', 45)] @@ -212,7 +212,7 @@ 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 348 contests for contest (US president) uid=0000, tally=rcv, max=1, win-by>0.5 +Scanned 348 contests for contest (US president) uid=0000, tally=rcv, max=1, win_by>0.5 RCV: round 0 Counted 793fc652bfea0cc8590e2c618bdcaf8605db26e7: choice=Beta O'rourke Total vote count: 348 @@ -236,7 +236,7 @@ Contest US president (uid=0000): ('Phil Scott', 0) ("Beta O'rourke", 0) ('Ron DeSantis', 0) -Scanned 347 contests for contest (US senate) uid=0001, tally=rcv, max=1, win-by>0.5 +Scanned 347 contests for contest (US senate) uid=0001, tally=rcv, max=1, win_by>0.5 RCV: round 0 Counted c09d2105936fe408379fcd7332fb88020f107a53: choice=Alexandria Ocasio-Cortez Total vote count: 347 @@ -251,20 +251,20 @@ Contest US senate (uid=0001): ('Larry Hogan', 167) ('Pramila Jayapal', 0) ('Greg Abbott', 0) -Scanned 347 contests for contest (governor) uid=0002, tally=plurality, max=1, win-by>0.5 +Scanned 347 contests for contest (governor) uid=0002, tally=plurality, max=1, win_by>0.5 Plurality - one round Counted dfacf455e5081208b16a5f19bfa5b62465e8558d: choice=Bernie Sanders Contest governor (uid=0002): ('Brian Kemp', 186) ('Bernie Sanders', 161) -Scanned 348 contests for contest (County Clerk) uid=0003, tally=plurality, max=1, win-by>0.5 +Scanned 348 contests for contest (County Clerk) uid=0003, tally=plurality, max=1, win_by>0.5 Plurality - one round Counted c70ddbeaf2a035d6354288b54d8ac9a05f499a11: choice=Huckleberry Finn Contest County Clerk (uid=0003): ('Huckleberry Finn', 128) ('Jean-Luc Picard', 119) ('Peggy Carter', 101) -Scanned 347 contests for contest (mayor) uid=0005, tally=rcv, max=1, win-by>0.5 +Scanned 347 contests for contest (mayor) uid=0005, tally=rcv, max=1, win_by>0.5 RCV: round 0 Counted 5f56fd3cd03ddc88e7934f2b58a267570d889212: choice=Twenty Eight Total vote count: 347 @@ -279,13 +279,13 @@ Contest mayor (uid=0005): ('Twenty Eight', 163) ('Jane Doe', 0) ('Twenty Seven', 0) -Scanned 348 contests for contest (Question 1 - school budget override) uid=0006, tally=plurality, max=1, win-by>0.5 +Scanned 348 contests for contest (Question 1 - school budget override) uid=0006, tally=plurality, max=1, win_by>0.5 Plurality - one round Counted 1d66b23c07c6368833527ecc5d39c158ef6f4430: choice=no Contest Question 1 - school budget override (uid=0006): ('no', 187) ('yes', 161) -Scanned 347 contests for contest (Question 2 - new firehouse land purchase) uid=0007, tally=plurality, max=1, win-by>2/3 +Scanned 347 contests for contest (Question 2 - new firehouse land purchase) uid=0007, tally=plurality, max=1, win_by>2/3 Plurality - one round Counted 46b0e916db2da2acfda582cbb539beecbc3bf23d: choice=no Contest Question 2 - new firehouse land purchase (uid=0007): diff --git a/requirements.txt b/requirements.txt index b279f59..77dd3e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -30,9 +30,9 @@ black==23.12.1 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:c88b3711d12905b74206227109272673edce0cb29f27e1385f33b0163c414bba \ --hash=sha256:dd15245c8b68fe2b6bd0f32c1556509d11bb33aec9b5d0866dd8e2ed3dba09c2 \ --hash=sha256:e0aaf6041986767a5e0ce663c7a2f0e9eaf21e6ff87a5f95cbf3675bfd4c41d2 -certifi==2023.11.17 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1 \ - --hash=sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474 +certifi==2024.2.2 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f \ + --hash=sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1 charset-normalizer==3.3.2 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027 \ --hash=sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087 \ @@ -130,65 +130,65 @@ click==8.1.7 ; python_version >= "3.9" and python_version < "4.0" \ colorama==0.4.6 ; python_version >= "3.9" and python_version < "4.0" and platform_system == "Windows" or python_version >= "3.9" and python_version < "4.0" and sys_platform == "win32" \ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6 -coverage[toml]==7.4.0 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:04387a4a6ecb330c1878907ce0dc04078ea72a869263e53c72a1ba5bbdf380ca \ - --hash=sha256:0676cd0ba581e514b7f726495ea75aba3eb20899d824636c6f59b0ed2f88c471 \ - --hash=sha256:0e8d06778e8fbffccfe96331a3946237f87b1e1d359d7fbe8b06b96c95a5407a \ - --hash=sha256:0eb3c2f32dabe3a4aaf6441dde94f35687224dfd7eb2a7f47f3fd9428e421058 \ - --hash=sha256:109f5985182b6b81fe33323ab4707011875198c41964f014579cf82cebf2bb85 \ - --hash=sha256:13eaf476ec3e883fe3e5fe3707caeb88268a06284484a3daf8250259ef1ba143 \ - --hash=sha256:164fdcc3246c69a6526a59b744b62e303039a81e42cfbbdc171c91a8cc2f9446 \ - --hash=sha256:26776ff6c711d9d835557ee453082025d871e30b3fd6c27fcef14733f67f0590 \ - --hash=sha256:26f66da8695719ccf90e794ed567a1549bb2644a706b41e9f6eae6816b398c4a \ - --hash=sha256:29f3abe810930311c0b5d1a7140f6395369c3db1be68345638c33eec07535105 \ - --hash=sha256:316543f71025a6565677d84bc4df2114e9b6a615aa39fb165d697dba06a54af9 \ - --hash=sha256:36b0ea8ab20d6a7564e89cb6135920bc9188fb5f1f7152e94e8300b7b189441a \ - --hash=sha256:3cc9d4bc55de8003663ec94c2f215d12d42ceea128da8f0f4036235a119c88ac \ - --hash=sha256:485e9f897cf4856a65a57c7f6ea3dc0d4e6c076c87311d4bc003f82cfe199d25 \ - --hash=sha256:5040148f4ec43644702e7b16ca864c5314ccb8ee0751ef617d49aa0e2d6bf4f2 \ - --hash=sha256:51456e6fa099a8d9d91497202d9563a320513fcf59f33991b0661a4a6f2ad450 \ - --hash=sha256:53d7d9158ee03956e0eadac38dfa1ec8068431ef8058fe6447043db1fb40d932 \ - --hash=sha256:5a10a4920def78bbfff4eff8a05c51be03e42f1c3735be42d851f199144897ba \ - --hash=sha256:5b14b4f8760006bfdb6e08667af7bc2d8d9bfdb648351915315ea17645347137 \ - --hash=sha256:5b2ccb7548a0b65974860a78c9ffe1173cfb5877460e5a229238d985565574ae \ - --hash=sha256:697d1317e5290a313ef0d369650cfee1a114abb6021fa239ca12b4849ebbd614 \ - --hash=sha256:6ae8c9d301207e6856865867d762a4b6fd379c714fcc0607a84b92ee63feff70 \ - --hash=sha256:707c0f58cb1712b8809ece32b68996ee1e609f71bd14615bd8f87a1293cb610e \ - --hash=sha256:74775198b702868ec2d058cb92720a3c5a9177296f75bd97317c787daf711505 \ - --hash=sha256:756ded44f47f330666843b5781be126ab57bb57c22adbb07d83f6b519783b870 \ - --hash=sha256:76f03940f9973bfaee8cfba70ac991825611b9aac047e5c80d499a44079ec0bc \ - --hash=sha256:79287fd95585ed36e83182794a57a46aeae0b64ca53929d1176db56aacc83451 \ - --hash=sha256:799c8f873794a08cdf216aa5d0531c6a3747793b70c53f70e98259720a6fe2d7 \ - --hash=sha256:7d360587e64d006402b7116623cebf9d48893329ef035278969fa3bbf75b697e \ - --hash=sha256:80b5ee39b7f0131ebec7968baa9b2309eddb35b8403d1869e08f024efd883566 \ - --hash=sha256:815ac2d0f3398a14286dc2cea223a6f338109f9ecf39a71160cd1628786bc6f5 \ - --hash=sha256:83c2dda2666fe32332f8e87481eed056c8b4d163fe18ecc690b02802d36a4d26 \ - --hash=sha256:846f52f46e212affb5bcf131c952fb4075b55aae6b61adc9856222df89cbe3e2 \ - --hash=sha256:936d38794044b26c99d3dd004d8af0035ac535b92090f7f2bb5aa9c8e2f5cd42 \ - --hash=sha256:9864463c1c2f9cb3b5db2cf1ff475eed2f0b4285c2aaf4d357b69959941aa555 \ - --hash=sha256:995ea5c48c4ebfd898eacb098164b3cc826ba273b3049e4a889658548e321b43 \ - --hash=sha256:a1526d265743fb49363974b7aa8d5899ff64ee07df47dd8d3e37dcc0818f09ed \ - --hash=sha256:a56de34db7b7ff77056a37aedded01b2b98b508227d2d0979d373a9b5d353daa \ - --hash=sha256:a7c97726520f784239f6c62506bc70e48d01ae71e9da128259d61ca5e9788516 \ - --hash=sha256:b8e99f06160602bc64da35158bb76c73522a4010f0649be44a4e167ff8555952 \ - --hash=sha256:bb1de682da0b824411e00a0d4da5a784ec6496b6850fdf8c865c1d68c0e318dd \ - --hash=sha256:bf477c355274a72435ceb140dc42de0dc1e1e0bf6e97195be30487d8eaaf1a09 \ - --hash=sha256:bf635a52fc1ea401baf88843ae8708591aa4adff875e5c23220de43b1ccf575c \ - --hash=sha256:bfd5db349d15c08311702611f3dccbef4b4e2ec148fcc636cf8739519b4a5c0f \ - --hash=sha256:c530833afc4707fe48524a44844493f36d8727f04dcce91fb978c414a8556cc6 \ - --hash=sha256:cc6d65b21c219ec2072c1293c505cf36e4e913a3f936d80028993dd73c7906b1 \ - --hash=sha256:cd3c1e4cb2ff0083758f09be0f77402e1bdf704adb7f89108007300a6da587d0 \ - --hash=sha256:cfd2a8b6b0d8e66e944d47cdec2f47c48fef2ba2f2dff5a9a75757f64172857e \ - --hash=sha256:d0ca5c71a5a1765a0f8f88022c52b6b8be740e512980362f7fdbb03725a0d6b9 \ - --hash=sha256:e7defbb9737274023e2d7af02cac77043c86ce88a907c58f42b580a97d5bcca9 \ - --hash=sha256:e9d1bf53c4c8de58d22e0e956a79a5b37f754ed1ffdbf1a260d9dcfa2d8a325e \ - --hash=sha256:ea81d8f9691bb53f4fb4db603203029643caffc82bf998ab5b59ca05560f4c06 +coverage[toml]==7.4.4 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:00838a35b882694afda09f85e469c96367daa3f3f2b097d846a7216993d37f4c \ + --hash=sha256:0513b9508b93da4e1716744ef6ebc507aff016ba115ffe8ecff744d1322a7b63 \ + --hash=sha256:09c3255458533cb76ef55da8cc49ffab9e33f083739c8bd4f58e79fecfe288f7 \ + --hash=sha256:09ef9199ed6653989ebbcaacc9b62b514bb63ea2f90256e71fea3ed74bd8ff6f \ + --hash=sha256:09fa497a8ab37784fbb20ab699c246053ac294d13fc7eb40ec007a5043ec91f8 \ + --hash=sha256:0f9f50e7ef2a71e2fae92774c99170eb8304e3fdf9c8c3c7ae9bab3e7229c5cf \ + --hash=sha256:137eb07173141545e07403cca94ab625cc1cc6bc4c1e97b6e3846270e7e1fea0 \ + --hash=sha256:1f384c3cc76aeedce208643697fb3e8437604b512255de6d18dae3f27655a384 \ + --hash=sha256:201bef2eea65e0e9c56343115ba3814e896afe6d36ffd37bab783261db430f76 \ + --hash=sha256:38dd60d7bf242c4ed5b38e094baf6401faa114fc09e9e6632374388a404f98e7 \ + --hash=sha256:3b799445b9f7ee8bf299cfaed6f5b226c0037b74886a4e11515e569b36fe310d \ + --hash=sha256:3ea79bb50e805cd6ac058dfa3b5c8f6c040cb87fe83de10845857f5535d1db70 \ + --hash=sha256:40209e141059b9370a2657c9b15607815359ab3ef9918f0196b6fccce8d3230f \ + --hash=sha256:41c9c5f3de16b903b610d09650e5e27adbfa7f500302718c9ffd1c12cf9d6818 \ + --hash=sha256:54eb8d1bf7cacfbf2a3186019bcf01d11c666bd495ed18717162f7eb1e9dd00b \ + --hash=sha256:598825b51b81c808cb6f078dcb972f96af96b078faa47af7dfcdf282835baa8d \ + --hash=sha256:5fc1de20b2d4a061b3df27ab9b7c7111e9a710f10dc2b84d33a4ab25065994ec \ + --hash=sha256:623512f8ba53c422fcfb2ce68362c97945095b864cda94a92edbaf5994201083 \ + --hash=sha256:690db6517f09336559dc0b5f55342df62370a48f5469fabf502db2c6d1cffcd2 \ + --hash=sha256:69eb372f7e2ece89f14751fbcbe470295d73ed41ecd37ca36ed2eb47512a6ab9 \ + --hash=sha256:73bfb9c09951125d06ee473bed216e2c3742f530fc5acc1383883125de76d9cd \ + --hash=sha256:742a76a12aa45b44d236815d282b03cfb1de3b4323f3e4ec933acfae08e54ade \ + --hash=sha256:7c95949560050d04d46b919301826525597f07b33beba6187d04fa64d47ac82e \ + --hash=sha256:8130a2aa2acb8788e0b56938786c33c7c98562697bf9f4c7d6e8e5e3a0501e4a \ + --hash=sha256:8a2b2b78c78293782fd3767d53e6474582f62443d0504b1554370bde86cc8227 \ + --hash=sha256:8ce1415194b4a6bd0cdcc3a1dfbf58b63f910dcb7330fe15bdff542c56949f87 \ + --hash=sha256:9ca28a302acb19b6af89e90f33ee3e1906961f94b54ea37de6737b7ca9d8827c \ + --hash=sha256:a4cdc86d54b5da0df6d3d3a2f0b710949286094c3a6700c21e9015932b81447e \ + --hash=sha256:aa5b1c1bfc28384f1f53b69a023d789f72b2e0ab1b3787aae16992a7ca21056c \ + --hash=sha256:aadacf9a2f407a4688d700e4ebab33a7e2e408f2ca04dbf4aef17585389eff3e \ + --hash=sha256:ae71e7ddb7a413dd60052e90528f2f65270aad4b509563af6d03d53e979feafd \ + --hash=sha256:b14706df8b2de49869ae03a5ccbc211f4041750cd4a66f698df89d44f4bd30ec \ + --hash=sha256:b1a93009cb80730c9bca5d6d4665494b725b6e8e157c1cb7f2db5b4b122ea562 \ + --hash=sha256:b2991665420a803495e0b90a79233c1433d6ed77ef282e8e152a324bbbc5e0c8 \ + --hash=sha256:b2c5edc4ac10a7ef6605a966c58929ec6c1bd0917fb8c15cb3363f65aa40e677 \ + --hash=sha256:b4d33f418f46362995f1e9d4f3a35a1b6322cb959c31d88ae56b0298e1c22357 \ + --hash=sha256:b91cbc4b195444e7e258ba27ac33769c41b94967919f10037e6355e998af255c \ + --hash=sha256:c74880fc64d4958159fbd537a091d2a585448a8f8508bf248d72112723974cbd \ + --hash=sha256:c901df83d097649e257e803be22592aedfd5182f07b3cc87d640bbb9afd50f49 \ + --hash=sha256:cac99918c7bba15302a2d81f0312c08054a3359eaa1929c7e4b26ebe41e9b286 \ + --hash=sha256:cc4f1358cb0c78edef3ed237ef2c86056206bb8d9140e73b6b89fbcfcbdd40e1 \ + --hash=sha256:ccd341521be3d1b3daeb41960ae94a5e87abe2f46f17224ba5d6f2b8398016cf \ + --hash=sha256:ce4b94265ca988c3f8e479e741693d143026632672e3ff924f25fab50518dd51 \ + --hash=sha256:cf271892d13e43bc2b51e6908ec9a6a5094a4df1d8af0bfc360088ee6c684409 \ + --hash=sha256:d5ae728ff3b5401cc320d792866987e7e7e880e6ebd24433b70a33b643bb0384 \ + --hash=sha256:d71eec7d83298f1af3326ce0ff1d0ea83c7cb98f72b577097f9083b20bdaf05e \ + --hash=sha256:d898fe162d26929b5960e4e138651f7427048e72c853607f2b200909794ed978 \ + --hash=sha256:d89d7b2974cae412400e88f35d86af72208e1ede1a541954af5d944a8ba46c57 \ + --hash=sha256:dfa8fe35a0bb90382837b238fff375de15f0dcdb9ae68ff85f7a63649c98527e \ + --hash=sha256:e0be5efd5127542ef31f165de269f77560d6cdef525fffa446de6f7e9186cfb2 \ + --hash=sha256:fdfafb32984684eb03c2d83e1e51f64f0906b11e64482df3c5db936ce3839d48 \ + --hash=sha256:ff7687ca3d7028d8a5f0ebae95a6e4827c5616b31a4ee1192bdfde697db110d4 deepdiff==6.7.1 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:58396bb7a863cbb4ed5193f548c56f18218060362311aa1dc36397b2f25108bd \ --hash=sha256:b367e6fa6caac1c9f500adc79ada1b5b1242c50d5f716a1a4362030197847d30 -dill==0.3.7 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:76b122c08ef4ce2eedcd4d1abd8e641114bfc6c2867f49f3c41facf65bf19f5e \ - --hash=sha256:cc1c8b182eb3013e24bd475ff2e9295af86c1a38eb1aff128dac8962a9ce3c03 +dill==0.3.8 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca \ + --hash=sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7 docutils==0.19 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6 \ --hash=sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc @@ -201,9 +201,9 @@ idna==3.6 ; python_version >= "3.9" and python_version < "4.0" \ imagesize==1.4.1 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b \ --hash=sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a -importlib-metadata==7.0.1 ; python_version >= "3.9" and python_version < "3.10" \ - --hash=sha256:4805911c3a4ec7c3966410053e9ec6a1fecd629117df5adee56dfc9432a1081e \ - --hash=sha256:f238736bb06590ae52ac1fab06a3a9ef1d8dce2b7a35b5ab329371d6c8f5d2cc +importlib-metadata==7.1.0 ; python_version >= "3.9" and python_version < "3.10" \ + --hash=sha256:30962b96c0c223483ed6cc7280e7f0199feb01a0e40cfae4d4450fc6fab1f570 \ + --hash=sha256:b78938b926ee8d5f020fc4772d487045805a55ddbad2ecf21c6d60938dc7fcd2 iniconfig==2.0.0 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3 \ --hash=sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374 @@ -251,119 +251,119 @@ lazy-object-proxy==1.10.0 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:e98c8af98d5707dcdecc9ab0863c0ea6e88545d42ca7c3feffb6b4d1e370c7ba \ --hash=sha256:edb45bb8278574710e68a6b021599a10ce730d156e5b254941754a9cc0b17d03 \ --hash=sha256:fec03caabbc6b59ea4a638bee5fce7117be8e99a4103d9d5ad77f15d6f81020c -markupsafe==2.1.4 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:0042d6a9880b38e1dd9ff83146cc3c9c18a059b9360ceae207805567aacccc69 \ - --hash=sha256:0c26f67b3fe27302d3a412b85ef696792c4a2386293c53ba683a89562f9399b0 \ - --hash=sha256:0fbad3d346df8f9d72622ac71b69565e621ada2ce6572f37c2eae8dacd60385d \ - --hash=sha256:15866d7f2dc60cfdde12ebb4e75e41be862348b4728300c36cdf405e258415ec \ - --hash=sha256:1c98c33ffe20e9a489145d97070a435ea0679fddaabcafe19982fe9c971987d5 \ - --hash=sha256:21e7af8091007bf4bebf4521184f4880a6acab8df0df52ef9e513d8e5db23411 \ - --hash=sha256:23984d1bdae01bee794267424af55eef4dfc038dc5d1272860669b2aa025c9e3 \ - --hash=sha256:31f57d64c336b8ccb1966d156932f3daa4fee74176b0fdc48ef580be774aae74 \ - --hash=sha256:3583a3a3ab7958e354dc1d25be74aee6228938312ee875a22330c4dc2e41beb0 \ - --hash=sha256:36d7626a8cca4d34216875aee5a1d3d654bb3dac201c1c003d182283e3205949 \ - --hash=sha256:396549cea79e8ca4ba65525470d534e8a41070e6b3500ce2414921099cb73e8d \ - --hash=sha256:3a66c36a3864df95e4f62f9167c734b3b1192cb0851b43d7cc08040c074c6279 \ - --hash=sha256:3aae9af4cac263007fd6309c64c6ab4506dd2b79382d9d19a1994f9240b8db4f \ - --hash=sha256:3ab3a886a237f6e9c9f4f7d272067e712cdb4efa774bef494dccad08f39d8ae6 \ - --hash=sha256:47bb5f0142b8b64ed1399b6b60f700a580335c8e1c57f2f15587bd072012decc \ - --hash=sha256:49a3b78a5af63ec10d8604180380c13dcd870aba7928c1fe04e881d5c792dc4e \ - --hash=sha256:4df98d4a9cd6a88d6a585852f56f2155c9cdb6aec78361a19f938810aa020954 \ - --hash=sha256:5045e892cfdaecc5b4c01822f353cf2c8feb88a6ec1c0adef2a2e705eef0f656 \ - --hash=sha256:5244324676254697fe5c181fc762284e2c5fceeb1c4e3e7f6aca2b6f107e60dc \ - --hash=sha256:54635102ba3cf5da26eb6f96c4b8c53af8a9c0d97b64bdcb592596a6255d8518 \ - --hash=sha256:54a7e1380dfece8847c71bf7e33da5d084e9b889c75eca19100ef98027bd9f56 \ - --hash=sha256:55d03fea4c4e9fd0ad75dc2e7e2b6757b80c152c032ea1d1de487461d8140efc \ - --hash=sha256:698e84142f3f884114ea8cf83e7a67ca8f4ace8454e78fe960646c6c91c63bfa \ - --hash=sha256:6aa5e2e7fc9bc042ae82d8b79d795b9a62bd8f15ba1e7594e3db243f158b5565 \ - --hash=sha256:7653fa39578957bc42e5ebc15cf4361d9e0ee4b702d7d5ec96cdac860953c5b4 \ - --hash=sha256:765f036a3d00395a326df2835d8f86b637dbaf9832f90f5d196c3b8a7a5080cb \ - --hash=sha256:78bc995e004681246e85e28e068111a4c3f35f34e6c62da1471e844ee1446250 \ - --hash=sha256:7a07f40ef8f0fbc5ef1000d0c78771f4d5ca03b4953fc162749772916b298fc4 \ - --hash=sha256:8b570a1537367b52396e53325769608f2a687ec9a4363647af1cded8928af959 \ - --hash=sha256:987d13fe1d23e12a66ca2073b8d2e2a75cec2ecb8eab43ff5624ba0ad42764bc \ - --hash=sha256:9896fca4a8eb246defc8b2a7ac77ef7553b638e04fbf170bff78a40fa8a91474 \ - --hash=sha256:9e9e3c4020aa2dc62d5dd6743a69e399ce3de58320522948af6140ac959ab863 \ - --hash=sha256:a0b838c37ba596fcbfca71651a104a611543077156cb0a26fe0c475e1f152ee8 \ - --hash=sha256:a4d176cfdfde84f732c4a53109b293d05883e952bbba68b857ae446fa3119b4f \ - --hash=sha256:a76055d5cb1c23485d7ddae533229039b850db711c554a12ea64a0fd8a0129e2 \ - --hash=sha256:a76cd37d229fc385738bd1ce4cba2a121cf26b53864c1772694ad0ad348e509e \ - --hash=sha256:a7cc49ef48a3c7a0005a949f3c04f8baa5409d3f663a1b36f0eba9bfe2a0396e \ - --hash=sha256:abf5ebbec056817057bfafc0445916bb688a255a5146f900445d081db08cbabb \ - --hash=sha256:b0fe73bac2fed83839dbdbe6da84ae2a31c11cfc1c777a40dbd8ac8a6ed1560f \ - --hash=sha256:b6f14a9cd50c3cb100eb94b3273131c80d102e19bb20253ac7bd7336118a673a \ - --hash=sha256:b83041cda633871572f0d3c41dddd5582ad7d22f65a72eacd8d3d6d00291df26 \ - --hash=sha256:b835aba863195269ea358cecc21b400276747cc977492319fd7682b8cd2c253d \ - --hash=sha256:bf1196dcc239e608605b716e7b166eb5faf4bc192f8a44b81e85251e62584bd2 \ - --hash=sha256:c669391319973e49a7c6230c218a1e3044710bc1ce4c8e6eb71f7e6d43a2c131 \ - --hash=sha256:c7556bafeaa0a50e2fe7dc86e0382dea349ebcad8f010d5a7dc6ba568eaaa789 \ - --hash=sha256:c8f253a84dbd2c63c19590fa86a032ef3d8cc18923b8049d91bcdeeb2581fbf6 \ - --hash=sha256:d18b66fe626ac412d96c2ab536306c736c66cf2a31c243a45025156cc190dc8a \ - --hash=sha256:d5291d98cd3ad9a562883468c690a2a238c4a6388ab3bd155b0c75dd55ece858 \ - --hash=sha256:d5c31fe855c77cad679b302aabc42d724ed87c043b1432d457f4976add1c2c3e \ - --hash=sha256:d6e427c7378c7f1b2bef6a344c925b8b63623d3321c09a237b7cc0e77dd98ceb \ - --hash=sha256:dac1ebf6983148b45b5fa48593950f90ed6d1d26300604f321c74a9ca1609f8e \ - --hash=sha256:de8153a7aae3835484ac168a9a9bdaa0c5eee4e0bc595503c95d53b942879c84 \ - --hash=sha256:e1a0d1924a5013d4f294087e00024ad25668234569289650929ab871231668e7 \ - --hash=sha256:e7902211afd0af05fbadcc9a312e4cf10f27b779cf1323e78d52377ae4b72bea \ - --hash=sha256:e888ff76ceb39601c59e219f281466c6d7e66bd375b4ec1ce83bcdc68306796b \ - --hash=sha256:f06e5a9e99b7df44640767842f414ed5d7bedaaa78cd817ce04bbd6fd86e2dd6 \ - --hash=sha256:f6be2d708a9d0e9b0054856f07ac7070fbe1754be40ca8525d5adccdbda8f475 \ - --hash=sha256:f9917691f410a2e0897d1ef99619fd3f7dd503647c8ff2475bf90c3cf222ad74 \ - --hash=sha256:fc1a75aa8f11b87910ffd98de62b29d6520b6d6e8a3de69a70ca34dea85d2a8a \ - --hash=sha256:fe8512ed897d5daf089e5bd010c3dc03bb1bdae00b35588c49b98268d4a01e00 +markupsafe==2.1.5 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf \ + --hash=sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff \ + --hash=sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f \ + --hash=sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3 \ + --hash=sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532 \ + --hash=sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f \ + --hash=sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617 \ + --hash=sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df \ + --hash=sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4 \ + --hash=sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906 \ + --hash=sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f \ + --hash=sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4 \ + --hash=sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8 \ + --hash=sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371 \ + --hash=sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2 \ + --hash=sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465 \ + --hash=sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52 \ + --hash=sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6 \ + --hash=sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169 \ + --hash=sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad \ + --hash=sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2 \ + --hash=sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0 \ + --hash=sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029 \ + --hash=sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f \ + --hash=sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a \ + --hash=sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced \ + --hash=sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5 \ + --hash=sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c \ + --hash=sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf \ + --hash=sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9 \ + --hash=sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb \ + --hash=sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad \ + --hash=sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3 \ + --hash=sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1 \ + --hash=sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46 \ + --hash=sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc \ + --hash=sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a \ + --hash=sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee \ + --hash=sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900 \ + --hash=sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5 \ + --hash=sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea \ + --hash=sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f \ + --hash=sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5 \ + --hash=sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e \ + --hash=sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a \ + --hash=sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f \ + --hash=sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50 \ + --hash=sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a \ + --hash=sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b \ + --hash=sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4 \ + --hash=sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff \ + --hash=sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2 \ + --hash=sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46 \ + --hash=sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b \ + --hash=sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf \ + --hash=sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5 \ + --hash=sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5 \ + --hash=sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab \ + --hash=sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd \ + --hash=sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68 mccabe==0.7.0 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \ --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e mypy-extensions==1.0.0 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d \ --hash=sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782 -mypy==1.8.0 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:028cf9f2cae89e202d7b6593cd98db6759379f17a319b5faf4f9978d7084cdc6 \ - --hash=sha256:2afecd6354bbfb6e0160f4e4ad9ba6e4e003b767dd80d85516e71f2e955ab50d \ - --hash=sha256:2b5b6c721bd4aabaadead3a5e6fa85c11c6c795e0c81a7215776ef8afc66de02 \ - --hash=sha256:42419861b43e6962a649068a61f4a4839205a3ef525b858377a960b9e2de6e0d \ - --hash=sha256:42c6680d256ab35637ef88891c6bd02514ccb7e1122133ac96055ff458f93fc3 \ - --hash=sha256:485a8942f671120f76afffff70f259e1cd0f0cfe08f81c05d8816d958d4577d3 \ - --hash=sha256:4c886c6cce2d070bd7df4ec4a05a13ee20c0aa60cb587e8d1265b6c03cf91da3 \ - --hash=sha256:4e6d97288757e1ddba10dd9549ac27982e3e74a49d8d0179fc14d4365c7add66 \ - --hash=sha256:4ef4be7baf08a203170f29e89d79064463b7fc7a0908b9d0d5114e8009c3a259 \ - --hash=sha256:51720c776d148bad2372ca21ca29256ed483aa9a4cdefefcef49006dff2a6835 \ - --hash=sha256:52825b01f5c4c1c4eb0db253ec09c7aa17e1a7304d247c48b6f3599ef40db8bd \ - --hash=sha256:538fd81bb5e430cc1381a443971c0475582ff9f434c16cd46d2c66763ce85d9d \ - --hash=sha256:5c1538c38584029352878a0466f03a8ee7547d7bd9f641f57a0f3017a7c905b8 \ - --hash=sha256:6ff8b244d7085a0b425b56d327b480c3b29cafbd2eff27316a004f9a7391ae07 \ - --hash=sha256:7178def594014aa6c35a8ff411cf37d682f428b3b5617ca79029d8ae72f5402b \ - --hash=sha256:720a5ca70e136b675af3af63db533c1c8c9181314d207568bbe79051f122669e \ - --hash=sha256:7f1478736fcebb90f97e40aff11a5f253af890c845ee0c850fe80aa060a267c6 \ - --hash=sha256:855fe27b80375e5c5878492f0729540db47b186509c98dae341254c8f45f42ae \ - --hash=sha256:8963b83d53ee733a6e4196954502b33567ad07dfd74851f32be18eb932fb1cb9 \ - --hash=sha256:9261ed810972061388918c83c3f5cd46079d875026ba97380f3e3978a72f503d \ - --hash=sha256:99b00bc72855812a60d253420d8a2eae839b0afa4938f09f4d2aa9bb4654263a \ - --hash=sha256:ab3c84fa13c04aeeeabb2a7f67a25ef5d77ac9d6486ff33ded762ef353aa5592 \ - --hash=sha256:afe3fe972c645b4632c563d3f3eff1cdca2fa058f730df2b93a35e3b0c538218 \ - --hash=sha256:d19c413b3c07cbecf1f991e2221746b0d2a9410b59cb3f4fb9557f0365a1a817 \ - --hash=sha256:df9824ac11deaf007443e7ed2a4a26bebff98d2bc43c6da21b2b64185da011c4 \ - --hash=sha256:e46f44b54ebddbeedbd3d5b289a893219065ef805d95094d16a0af6630f5d410 \ - --hash=sha256:f5ac9a4eeb1ec0f1ccdc6f326bcdb464de5f80eb07fb38b5ddd7b0de6bc61e55 +mypy==1.9.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:0235391f1c6f6ce487b23b9dbd1327b4ec33bb93934aa986efe8a9563d9349e6 \ + --hash=sha256:190da1ee69b427d7efa8aa0d5e5ccd67a4fb04038c380237a0d96829cb157913 \ + --hash=sha256:2418488264eb41f69cc64a69a745fad4a8f86649af4b1041a4c64ee61fc61129 \ + --hash=sha256:3a3c007ff3ee90f69cf0a15cbcdf0995749569b86b6d2f327af01fd1b8aee9dc \ + --hash=sha256:3cc5da0127e6a478cddd906068496a97a7618a21ce9b54bde5bf7e539c7af974 \ + --hash=sha256:48533cdd345c3c2e5ef48ba3b0d3880b257b423e7995dada04248725c6f77374 \ + --hash=sha256:49c87c15aed320de9b438ae7b00c1ac91cd393c1b854c2ce538e2a72d55df150 \ + --hash=sha256:4d3dbd346cfec7cb98e6cbb6e0f3c23618af826316188d587d1c1bc34f0ede03 \ + --hash=sha256:571741dc4194b4f82d344b15e8837e8c5fcc462d66d076748142327626a1b6e9 \ + --hash=sha256:587ce887f75dd9700252a3abbc9c97bbe165a4a630597845c61279cf32dfbf02 \ + --hash=sha256:5d741d3fc7c4da608764073089e5f58ef6352bedc223ff58f2f038c2c4698a89 \ + --hash=sha256:5e6061f44f2313b94f920e91b204ec600982961e07a17e0f6cd83371cb23f5c2 \ + --hash=sha256:61758fabd58ce4b0720ae1e2fea5cfd4431591d6d590b197775329264f86311d \ + --hash=sha256:653265f9a2784db65bfca694d1edd23093ce49740b2244cde583aeb134c008f3 \ + --hash=sha256:68edad3dc7d70f2f17ae4c6c1b9471a56138ca22722487eebacfd1eb5321d612 \ + --hash=sha256:81a10926e5473c5fc3da8abb04119a1f5811a236dc3a38d92015cb1e6ba4cb9e \ + --hash=sha256:85ca5fcc24f0b4aeedc1d02f93707bccc04733f21d41c88334c5482219b1ccb3 \ + --hash=sha256:a260627a570559181a9ea5de61ac6297aa5af202f06fd7ab093ce74e7181e43e \ + --hash=sha256:aceb1db093b04db5cd390821464504111b8ec3e351eb85afd1433490163d60cd \ + --hash=sha256:b685154e22e4e9199fc95f298661deea28aaede5ae16ccc8cbb1045e716b3e04 \ + --hash=sha256:d357423fa57a489e8c47b7c85dfb96698caba13d66e086b412298a1a0ea3b0ed \ + --hash=sha256:d4d5ddc13421ba3e2e082a6c2d74c2ddb3979c39b582dacd53dd5d9431237185 \ + --hash=sha256:e49499be624dead83927e70c756970a0bc8240e9f769389cdf5714b0784ca6bf \ + --hash=sha256:e54396d70be04b34f31d2edf3362c1edd023246c82f1730bbf8768c28db5361b \ + --hash=sha256:f88566144752999351725ac623471661c9d1cd8caa0134ff98cceeea181789f4 \ + --hash=sha256:f8a67616990062232ee4c3952f41c779afac41405806042a8126fe96e098419f \ + --hash=sha256:fe28657de3bfec596bbeef01cb219833ad9d38dd5393fc649f4b366840baefe6 networkx==2.8.8 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:230d388117af870fce5647a3c52401fcf753e94720e6ea6b4197a5355648885e \ --hash=sha256:e435dfa75b1d7195c7b8378c3859f0445cd88c6b0375c181ed66823a9ceb7524 ordered-set==4.1.0 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562 \ --hash=sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8 -packaging==23.2 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5 \ - --hash=sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7 +packaging==24.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5 \ + --hash=sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9 pathspec==0.12.1 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08 \ --hash=sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712 -platformdirs==4.1.0 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:11c8f37bcca40db96d8144522d925583bdb7a31f7b0e37e3ed4318400a8e2380 \ - --hash=sha256:906d548203468492d432bcb294d4bc2fff751bf84971fbb2c10918cc206ee420 -pluggy==1.3.0 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12 \ - --hash=sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7 +platformdirs==4.2.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068 \ + --hash=sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768 +pluggy==1.4.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:7db9f7b503d67d1c5b95f59773ebb58a8c1c288129a88665838012cfb07b8981 \ + --hash=sha256:8c85c2876142a764e5b7548e7d9a0e0ddb46f5185161049a79b7e974454223be pygments==2.17.2 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c \ --hash=sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367 @@ -459,18 +459,18 @@ stdiomask==0.0.6 ; python_version >= "3.9" and python_version < "4.0" \ tomli==2.0.1 ; python_version >= "3.9" and python_full_version <= "3.11.0a6" \ --hash=sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc \ --hash=sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f -tomlkit==0.12.3 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:75baf5012d06501f07bee5bf8e801b9f343e7aac5a92581f20f80ce632e6b5a4 \ - --hash=sha256:b0a645a9156dc7cb5d3a1f0d4bab66db287fcb8e0430bdd4664a095ea16414ba -types-pyyaml==6.0.12.12 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:334373d392fde0fdf95af5c3f1661885fa10c52167b14593eb856289e1855062 \ - --hash=sha256:c05bc6c158facb0676674b7f11fe3960db4f389718e19e62bd2b84d6205cfd24 -typing-extensions==4.9.0 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:23478f88c37f27d76ac8aee6c905017a143b0b1b886c3c9f66bc2fd94f9f5783 \ - --hash=sha256:af72aea155e91adfc61c3ae9e0e342dbc0cba726d6cba4b6c72c1f34e47291cd -urllib3==2.1.0 ; python_version >= "3.9" and python_version < "4.0" \ - --hash=sha256:55901e917a5896a349ff771be919f8bd99aff50b79fe58fec595eb37bbc56bb3 \ - --hash=sha256:df7aa8afb0148fa78488e7899b2c59b5f4ffcfa82e6c54ccb9dd37c1d7b52d54 +tomlkit==0.12.4 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:5cd82d48a3dd89dee1f9d64420aa20ae65cfbd00668d6f094d7578a78efbb77b \ + --hash=sha256:7ca1cfc12232806517a8515047ba66a19369e71edf2439d0f5824f91032b6cc3 +types-pyyaml==6.0.12.20240311 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:a9e0f0f88dc835739b0c1ca51ee90d04ca2a897a71af79de9aec5f38cb0a5342 \ + --hash=sha256:b845b06a1c7e54b8e5b4c683043de0d9caf205e7434b3edc678ff2411979b8f6 +typing-extensions==4.10.0 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475 \ + --hash=sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb +urllib3==2.2.1 ; python_version >= "3.9" and python_version < "4.0" \ + --hash=sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d \ + --hash=sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19 wrapt==1.16.0 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:0d2691979e93d06a95a26257adb7bfd0c93818e89b1406f5a28f36e0d8c1e1fc \ --hash=sha256:14d7dc606219cdd7405133c713f2c218d4252f2a469003f8c46bb92d5d095d81 \ @@ -542,6 +542,6 @@ wrapt==1.16.0 ; python_version >= "3.9" and python_version < "4.0" \ --hash=sha256:f6b2d0c6703c988d334f297aa5df18c45e97b0af3679bb75059e0e0bd8b1069d \ --hash=sha256:f8212564d49c50eb4565e502814f694e240c55551a5f1bc841d4fcaabb0a9b8a \ --hash=sha256:ffa565331890b90056c01db69c0fe634a776f8019c143a5ae265f9c6bc4bd6d4 -zipp==3.17.0 ; python_version >= "3.9" and python_version < "3.10" \ - --hash=sha256:0e923e726174922dce09c53c59ad483ff7bbb8e572e00c7f7c46b88556409f31 \ - --hash=sha256:84e64a1c28cf7e91ed2078bb8cc8c259cb19b76942096c8d7b84947690cabaf0 +zipp==3.18.1 ; python_version >= "3.9" and python_version < "3.10" \ + --hash=sha256:206f5a15f2af3dbaee80769fb7dc6f249695e940acca08dfb2a4769fe61e538b \ + --hash=sha256:2884ed22e7d8961de1c9a05142eb69a247f120291bc0206a00a7642f09b5b715 diff --git a/src/vtp/README.md b/src/vtp/README.md index b945741..c1f1eaf 100644 --- a/src/vtp/README.md +++ b/src/vtp/README.md @@ -303,7 +303,7 @@ 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 303 contests for contest (U.S. Senate) uid=0001, 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 Total vote count: 303 [('Gloria Gamma', 65), ('Anthony Alpha', 53), ('David Delta', 47), ('Emily Echo', 47), ('Francis Foxtrot', 47), ('Betty Beta', 44)] diff --git a/src/vtp/cli/_arguments.py b/src/vtp/cli/_arguments.py index 4857e9c..0775d43 100644 --- a/src/vtp/cli/_arguments.py +++ b/src/vtp/cli/_arguments.py @@ -17,6 +17,9 @@ """Argument handling.""" +# local imports +from vtp.core.common import Globals + class Arguments: @@ -116,7 +119,7 @@ def add_printonly(parser): ) @staticmethod - def add_verbosity(parser, verbosity=3): + def add_verbosity(parser, verbosity=Globals.get("DEFAULT_VERBOSITY")): """Add verbosity option""" parser.add_argument( "-v", @@ -125,3 +128,17 @@ def add_verbosity(parser, verbosity=3): default=verbosity, help=f"0 critical, 1 error, 2 warning, 3 info, 4 debug (def={verbosity})", ) + + @staticmethod + def add_output_style(parser): + """Set the STDOUT text style""" + parser.add_argument( + "-o", + "--output_style", + type=str, + default="text", + help=( + "'text' is normal text; 'html' will decorate output with html " + "markers (def='text')" + ), + ) diff --git a/src/vtp/cli/create_blank_ballot.py b/src/vtp/cli/create_blank_ballot.py index caf2431..2aa0a33 100755 --- a/src/vtp/cli/create_blank_ballot.py +++ b/src/vtp/cli/create_blank_ballot.py @@ -70,6 +70,7 @@ def main(): substreet=parsed_args.substreet, town=parsed_args.town, state=parsed_args.state, + csv=parsed_args.csv, ) # do it diff --git a/src/vtp/cli/run_mock_election.py b/src/vtp/cli/run_mock_election.py index d19158e..af0348a 100755 --- a/src/vtp/cli/run_mock_election.py +++ b/src/vtp/cli/run_mock_election.py @@ -136,6 +136,7 @@ def main(): substreet=parsed_args.substreet, town=parsed_args.town, state=parsed_args.state, + csv=parsed_args.csv, ) # do it diff --git a/src/vtp/cli/tally_contests.py b/src/vtp/cli/tally_contests.py index 30f572f..83ea15c 100755 --- a/src/vtp/cli/tally_contests.py +++ b/src/vtp/cli/tally_contests.py @@ -68,6 +68,7 @@ def parse_arguments(): default="", help="a comma separated list of contests checks to track", ) + Arguments.add_output_style(parser) Arguments.add_verbosity(parser) parsed_args = parser.parse_args() @@ -94,6 +95,7 @@ def main(): # do it tco = TallyContestsOperation( election_data_dir=parsed_args.election_data_dir, + output_style=parsed_args.output_style, verbosity=parsed_args.verbosity, printonly=False, ) diff --git a/src/vtp/cli/verify_ballot_receipt.py b/src/vtp/cli/verify_ballot_receipt.py index 15e3301..e20e739 100755 --- a/src/vtp/cli/verify_ballot_receipt.py +++ b/src/vtp/cli/verify_ballot_receipt.py @@ -72,6 +72,7 @@ def parse_arguments(): action="store_true", help="display the contents of the CVRs when specifying a row", ) + Arguments.add_output_style(parser) Arguments.add_verbosity(parser) parsed_args = parser.parse_args() @@ -92,6 +93,7 @@ def main(): # do it vbro = VerifyBallotReceiptOperation( election_data_dir=parsed_args.election_data_dir, + output_style=parsed_args.output_style, verbosity=parsed_args.verbosity, printonly=False, ) diff --git a/src/vtp/core/address.py b/src/vtp/core/address.py index daf40cb..99f9226 100644 --- a/src/vtp/core/address.py +++ b/src/vtp/core/address.py @@ -40,18 +40,20 @@ class supports return the Address as either a string or a _keys = ["number", "street", "substreet", "town", "state", "country", "zipcode"] @staticmethod - def convert_address_to_num_street(address): + def convert_address_to_num_street(address: str): """Convert a street address string to number and street""" return re.split(r"\s+", address, 1) @staticmethod - def create_generic_address(config, subdir, ggos): + def create_generic_address(config, subdir: str, ggos: list): """Will create/return a generic address nominally from the list of ggos """ - # Walk the address in DAG order from root to the prescribed leafs + # ZZZ strange pylint error on config:dict above + # import pdb; pdb.set_trace() nodes = [] count = 0 + # Walk the address in DAG order from root to the prescribed leafs for _ in Globals.get("REQUIRED_GGO_ADDRESS_FIELDS"): # Get the basename of the node via its subdir name = subdir.split(os.path.sep)[((count + 1) * 3) - 1] @@ -64,16 +66,16 @@ def create_generic_address(config, subdir, ggos): # pylint: disable=too-many-arguments def __init__( self, - address="", - number="", - street="", - substreet="", - town="", - state="", - country="", - zipcode="", - csv="", - generic_address=False, + address: str = "", + number: str = "", + street: str = "", + substreet: str = "", + town: str = "", + state: str = "", + country: str = "", + zipcode: str = "", + csv: str = "", + generic_address: bool = False, ): """At the moment only support a few ways of creating an Address: a csv string and reasonable set of specified fields. @@ -98,6 +100,7 @@ def __init__( self.ballot_node = "" self.ballot_subdir = "" + # import pdb; pdb.set_trace() if not csv: if not generic_address and address: ( @@ -144,7 +147,7 @@ def __str__(self): nice_string += " " + self.address[key] return nice_string.strip() - def get(self, name): + def get(self, name: str): """A generic getter - will raise a NameError if name is not defined""" if name in Address._keys: return self.address[name] @@ -160,10 +163,9 @@ def get(self, name): return self.ballot_subdir raise NameError(f"Name {name} not accepted/defined for get()") - def set(self, name, value): + def set(self, name: str, value: str = ""): """A generic setter - will raise a NameError if name is not defined""" if name in Address._keys: - value = "" if value is None else value self.address[name] = value else: raise NameError(f"Name {name} not accepted/defined for Address.set()") @@ -192,7 +194,7 @@ def match(self, regex): ) ) - def map_ggos(self, config, skip_ggos=False, ggos=None): + def map_ggos(self, config: dict, skip_ggos: bool = False, ggos: list = None): """Will map an address onto the ElectionConfig data. If skip_ggos is True, will completely skip setting the active_ggos field. If an explicit list of GGOs is provided, diff --git a/src/vtp/core/ballot.py b/src/vtp/core/ballot.py index a68a8e7..330d0be 100644 --- a/src/vtp/core/ballot.py +++ b/src/vtp/core/ballot.py @@ -19,7 +19,6 @@ import csv import json -import logging import os from copy import deepcopy @@ -30,93 +29,6 @@ from .contest import Contest -class Contests: - """An iteratable object for the contests in a ballot""" - - def __init__(self, a_ballot): - """ - Need to cache enough data here so to be able to iterate AND - create valid Contest objects - """ - self.ballot_ref = a_ballot - # Need the (ordered) list of ggos supplying contests - self.ggos = [*a_ballot.get("contests")] - self.ggo_max = len(self.ggos) - self.ggo_index = 0 - self.contest_index = 0 - self.contest_max = len(a_ballot.get("contests")[self.ggos[0]]) - - # import pdb; pdb.set_trace() - # inner_blob = next(iter((a_ballot.get('contests')[self.ggos[0]][0].values()))) - # self.contest_max = len(inner_blob['max']) if 'max' in inner_blob else 1 - - def __iter__(self): - """boilerplate""" - # start - be kind and reset things - self.ggo_index = 0 - self.contest_index = 0 - self.contest_max = len(self.ballot_ref.get("contests")[self.ggos[0]]) - return self - - def __next__(self): - """ - Because of the blobiness nature of the data model of a contest - within a ballot dictionary, this is ugly. Need to iterate over - the correct ordered list of ggos (self.ggos) and within each of - those iterations, iterate over the contests which is an ordered - list of single entry dictionaries. - - Note - the code below post increments the index alues which is - not the common pattern ? - """ - if self.ggo_max == 0: - # just in case - raise StopIteration - # cache this ggo - ggo = self.ggos[self.ggo_index] - # if there is a contest here, return it - if self.contest_index < self.contest_max: - # contest_content = \ - # next(iter((self.ballot_ref.get('contests')[ggo][self.contest_index].values()))) - # return the next contest in this ggo group - this_contest = Contest( - self.ballot_ref.get("contests")[ggo][self.contest_index], - ggo, - self.contest_index, - accept_all_keys=True, - ) - self.contest_index += 1 - return this_contest - - # If here, bump the ggo_index and reset the contest index and try again - self.ggo_index += 1 - self.contest_index = 0 - - # Now test to see if there is a next ggo group and if so, return - # its first contest - if self.ggo_index < self.ggo_max: - ggo = self.ggos[self.ggo_index] - self.contest_max = len(self.ballot_ref.get("contests")[ggo]) - if self.contest_index < self.contest_max: - this_contest = Contest( - self.ballot_ref.get("contests")[ggo][self.contest_index], - ggo, - 0, - accept_all_keys=True, - ) - self.contest_index += 1 - return this_contest - # done - be kind and reset things - self.ggo_index = 0 - self.contest_index = 0 - self.contest_max = len(self.ballot_ref.get("contests")[self.ggos[0]]) - raise StopIteration - - def len(self): - """Not my language, but still very cool""" - return sum(1 for _ in self) - - class Ballot: """A class to hold a ballot. A ballot is always a function of an address defined within the context of VTP election configuration @@ -209,23 +121,24 @@ def verify_ballot_outer_keys(ballot): f"{','.join(missing_keys)}" ) - def __init__(self): + def __init__(self, operation_self): """Constructor - just creates the dictionary and returns the object. """ - self.contests = {} + self.contests = [] self.active_ggos = [] self.ballot_subdir = "" self.ballot_node = "" self.ballot_filename = "" + self.operation_self = operation_self def verify_cast_ballot_data(self, config): - """Will validate an incoming cast ballot (a ballot CVR, not a - contest CVR) against the upstream blank ballot. This is done - by first verifying the ballot syntax, including the selection - node. Then after del'ing the selection node from the - incoming_cast_ballot, a key sorted json.dump of that against - the source blank ballot is performed. + """Will validate an incoming cast ballot against the + associated blank ballot. This is done by first verifying the + ballot syntax, including the selection node. Then after + del'ing the selection node from the incoming_cast_ballot, a + key sorted json.dump of that against the source blank ballot + is performed. If incoming_cast_ballot is not JSON or broken, this function will raise an error. @@ -235,7 +148,7 @@ def verify_cast_ballot_data(self, config): # Ballot.verify_ballot_outer_keys(self) # Get the blank ballot - the_bb = BlankBallot() + the_bb = BlankBallot(self.operation_self) the_bb.read_a_blank_ballot( None, config, @@ -248,12 +161,11 @@ def verify_cast_ballot_data(self, config): # ... and make a dict out of it blank = the_bb.dict() # Create a local dict copy that we can manipulate - cast = deepcopy(self.dict()) + cast_ballot = deepcopy(self.dict()) # 1) Loop over contests and a) validate the selection, b) that # the blank_ballot is legit, and c) that it matches - contests = Contests(cast) - for contest in contests: + for contest in cast_ballot["contests"]: # Note - if selection is not a valid key, a KeyError will be raised if not isinstance(contest.get("selection"), list): raise KeyError( @@ -270,18 +182,13 @@ def verify_cast_ballot_data(self, config): ) # Now remove the selection contest.delete_contest_field("selection") - # Rats - the absence of 'max' is allowed and is - # interpreted as 1. So in cast if max is 1, delete it so - # it can match the parent blank ballot - if contest.get("max") == 1: - contest.delete_contest_field("max") # 2) Compare incoming_cast_ballot to the associated blank # ballot. Since the blank ballot needs to be read in, it is # easier to add the selection node to that than to make a deep # copy of the cast ballot and remove the selection node from # that. - result = DeepDiff(blank, cast) + result = DeepDiff(blank, cast_ballot) # import pdb; pdb.set_trace() if result: raise KeyError( @@ -290,16 +197,24 @@ def verify_cast_ballot_data(self, config): f"{result}" ) - def set_ballot_data(self, ballot): - """Will set the ballot data""" - Ballot.verify_ballot_outer_keys(ballot) - self.contests = ballot["contests"] - self.active_ggos = ballot["active_ggos"] - self.ballot_subdir = ballot["ballot_subdir"] - self.ballot_node = ballot["ballot_node"] - self.ballot_filename = ballot["ballot_filename"] - - def get(self, name): + def set_ballot_data(self, incoming_ballot_json, a_cast_ballot: bool = False): + """ + Will set this Ballot instance to the incoming ballot json. + This _assumes_ that incoming_ballot_json is all json and has + not yet been converted to contest objects. + """ + Ballot.verify_ballot_outer_keys(incoming_ballot_json) + self.active_ggos = incoming_ballot_json["active_ggos"] + self.ballot_subdir = incoming_ballot_json["ballot_subdir"] + self.ballot_node = incoming_ballot_json["ballot_node"] + self.ballot_filename = incoming_ballot_json["ballot_filename"] + # now handle the contests (with or without a selection entry) + # Need to create Contest (objects) for each contest + self.contests = [] + for contest in incoming_ballot_json["contests"]: + self.contests.append(Contest(contest), a_cast_ballot=a_cast_ballot) + + def get(self, name: str): """A generic getter - will raise a NameError if name is invalid""" if name in ["ggos", "active_ggos"]: return self.active_ggos @@ -313,14 +228,14 @@ def get(self, name): return self.ballot_filename raise NameError(f"Name {name} not accepted/defined for Ballot.get()") - def get_contest_name_by_uid(self, uid): + def get_contest_name_by_uid(self, uid: str): """Given a blank ballot or better, will return the contest name given a uid. Will raise an error if the ballot does not contain that uid. """ - for contest in Contests(self): + for contest in self.contests: if uid == contest.get("uid"): - return contest.get("name") + return contest.get("contest_name") raise KeyError( f"There is no matching contest uid ({uid}) in the supplied balloot" ) @@ -348,54 +263,17 @@ def __str__(self): } return json.dumps(ballot, sort_keys=True, indent=4, ensure_ascii=False) - def clear_selection(self, contest): - """Clear the selection (as when self adjudicating)""" - self.contests[contest.get("ggo")][contest.get("index")][contest.get("name")][ - "selection" - ] = [] - - def add_selection(self, contest, selection_offset): - """Will add the specified contest choice (offset into the ordered - choices array) to the specified contest. This is an - 'add' since in plurality one may be voting for more than one - choice, or in RCV one needs to rank the choices. In both the - order is the rank but in plurality rank does not matter. + def get_contest_index(self, contest: dict): """ - # Some minimal sanity checking - if selection_offset > len(contest.get("choices")): - raise ValueError( - f"The choice offset ({selection_offset}) is greater " - f"than the number of choices ({len(contest.get('choices'))})" - ) - if selection_offset < 0: - raise ValueError( - f"Only positive offsets are supported ({selection_offset})" - ) - contest_index = contest.get("index") - contest_ggo = contest.get("ggo") - contest_name = contest.get("name") - if ( - "selection" - not in self.contests[contest_ggo][contest_index][contest_name].keys() - ): - self.contests[contest_ggo][contest_index][contest_name]["selection"] = [] - # pylint: disable=line-too-long - elif ( - selection_offset - in self.contests[contest_ggo][contest_index][contest_name]["selection"] - ): - raise ValueError( - ( - f"The selection ({selection_offset}) has already been " - f"selected for contest ({contest_name}) " - f"for GGO ({contest_ggo})" - ) - ) - # For end voter UX, add the selection as the offset + ': ' + - # name just because a string is more understandable than json - # list syntax - self.contests[contest_ggo][contest_index][contest_name]["selection"].append( - str(selection_offset) + ": " + contest.get("choices")[selection_offset] + Will return the contest's contests array index (via the + contest's uid) + """ + uid = contest.get("uid") + for index, value in enumerate(self.contests): + if value["uid"] == uid: + return index + raise ValueError( + f"Internal error - a contest uid ({uid}) was not found in containing ballot" ) def get_cvr_parent_dir(self, config): @@ -415,14 +293,18 @@ def read_a_cast_ballot(self, address, config, ballot_file=""): ballot_file = Ballot.gen_cast_ballot_location( config, address.get("ballot_subdir") ) - logging.debug("Reading %s", ballot_file) + self.operation_self.imprimir(f"Reading {ballot_file}", 5) with open(ballot_file, "r", encoding="utf8") as file: json_doc = json.load(file) - self.contests = json_doc["contests"] + contests = json_doc["contests"] self.active_ggos = json_doc["active_ggos"] self.ballot_subdir = json_doc["ballot_subdir"] self.ballot_node = json_doc["ballot_node"] self.ballot_filename = json_doc["ballot_filename"] + # Need to create Contest (objects) for each contest + self.contests = [] + for contest in contests: + self.contests.append(Contest(contest)) def write_a_cast_ballot(self, config): """ @@ -430,9 +312,13 @@ def write_a_cast_ballot(self, config): """ ballot_file = Ballot.gen_cast_ballot_location(config, self.ballot_subdir) os.makedirs(os.path.dirname(ballot_file), exist_ok=True) + # need to convert the Contests into a dictionary + contests = [] + for contest in self.contests: + contests.append(contest.get("contest")) # might was well write out everything, yes? the_aggregate = { - "contests": self.contests, + "contests": contests, "active_ggos": self.active_ggos, "ballot_subdir": self.ballot_subdir, "ballot_node": self.ballot_node, @@ -448,7 +334,7 @@ def write_contest(self, contest, config): """Write out the voter's contest""" contest_file = Ballot.gen_contest_location(config, self.ballot_subdir) # Prepend the dictionary with a CVR key - the_aggregate = {"CVR": contest.get("dict")} + the_aggregate = {"contestCVR": contest.get("dict")} # The parent directory better exist or something is wrong with open(contest_file, "w", encoding="utf8") as outfile: json.dump( @@ -486,8 +372,7 @@ def write_receipt_md( receipt_file = receipt_file.rstrip(".md") + "-qr.md" url_root = "/".join( [ - Globals.get("QR_ENDPOINT_ROOT"), - os.path.basename(config.get("git_rootdir")), + Globals.get("ELECTION_UPSTREAM_REMOTE"), "commit", ] ) @@ -576,10 +461,28 @@ class - the implicit algorithm used to map an address to the """ # With the list of active GGOs, add in the contests for each one + candidate_order = [] + question_order = [] for node in address.get("active_ggos"): cfg = config.get_node(node, "config") if "contests" in cfg: - self.contests[node] = cfg["contests"] + for contest in cfg["contests"]: + # first fill in the rest of the autofilled fields + contest["ggo"] = node + # ZZZ - at this point the question of order is still + # an underconstrained TBD. For now, while maintaining + # the relative order, have the non-question contests + # come first and the question contests come last. + # Note - the whole GGO thing probably should be + # deleted assuming the contest uid question can be + # solved in a more reasonable manner. + # import pdb; pdb.set_trace() + if contest["contest_type"] == "question": + question_order.append(contest) + else: + candidate_order.append(contest) + self.contests = candidate_order + self.contests.extend(question_order) # To determine the location of the blank ballot, the real # solution is probably something like determining the @@ -651,14 +554,18 @@ def read_a_blank_ballot(self, address, config, ballot_file="", style="json"): self.active_ggos, self.ballot_subdir, style ) if style == "json": - logging.debug("Reading %s", ballot_file) + self.operation_self.imprimir(f"Reading {ballot_file}", 5) with open(ballot_file, "r", encoding="utf8") as file: json_doc = json.load(file) - self.contests = json_doc["contests"] + contests = json_doc["contests"] self.active_ggos = json_doc["active_ggos"] self.ballot_subdir = json_doc["ballot_subdir"] self.ballot_node = json_doc["ballot_node"] self.ballot_filename = json_doc["ballot_filename"] + # Need to create Contest (objects) for each contest + self.contests = [] + for contest in contests: + self.contests.append(Contest(contest)) else: raise NotImplementedError(f"Unsupported Ballot type ({style}) for reading") diff --git a/src/vtp/core/common.py b/src/vtp/core/common.py index ceaa991..727fa5c 100644 --- a/src/vtp/core/common.py +++ b/src/vtp/core/common.py @@ -20,13 +20,7 @@ # pylint: disable=too-few-public-methods # standard imports -import json -import logging import os -import re -import subprocess -import sys -from contextlib import contextmanager class Globals: @@ -73,6 +67,8 @@ class Globals: "REQUIRED_NG_ADDRESS_FIELDS": ["street", "number"], # Whether or not VTP has been locally installed "VTP_LOCAL_INSTALL": True, + # The default verbosity + "DEFAULT_VERBOSITY": 3, # Where the bin directory is relative from the root of _this_ repo "BIN_DIR": "src/vtp", # How long to wait for a git shell command to complete - maybe a bad idea @@ -102,8 +98,11 @@ class Globals: "MOCK_CLIENT_DIRNAME": "mock-clients", # Default runtime location of everything "DEFAULT_RUNTIME_LOCATION": "/opt/VoteTrackerPlus/demo.01", - # Default git web service endpoint for QR codes - "QR_ENDPOINT_ROOT": "https://github.com/TrustTheVote-Project", + # The election date-time for all ElectionData commits + "ELECTION_DATETIME": "2024-11-05T12:00:00", + # The arbitrary election data string + "ELECTION_UPSTREAM_REMOTE": "https://github.com/TrustTheVote-Project/" + + "VTP-mock-election.US.16", } @staticmethod @@ -111,32 +110,10 @@ def get(name): """A generic getter""" return Globals._config[name] - -class Common: - """Common functions without a better home at this time""" - - # logging should only be configured once and only once (until a - # logger is set up) - _configured = False - @staticmethod - def configure_logging(verbosity): - """How VTP is (currently) using logging""" - if Common._configured: - return - verbose = { - 0: logging.CRITICAL, - 1: logging.ERROR, - 2: logging.WARNING, - 3: logging.INFO, - 4: logging.DEBUG, - } - logging.basicConfig( - format="%(message)s", - level=verbose[verbosity], - stream=sys.stdout, - ) - Common._configured = True + def set_election_upstream_remote(value): + """Set the ELECTION_UPSTREAM_REMOTE""" + Globals._config["ELECTION_UPSTREAM_REMOTE"] = value @staticmethod def verify_election_data_dir(election_data_dir: str): @@ -147,235 +124,3 @@ def verify_election_data_dir(election_data_dir: str): raise ValueError( f"The provided --election_data value ({election_data_dir}) does not exist" ) - - @staticmethod - 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 is not 40 characters long: {guid}") - if not re.match("^[0-9a-f]+$", guid): - raise ValueError( - f"The provided guid contains characters other than [0-9a-f]: {guid}" - ) - 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 -class Shellout: - """ - A class to wrap the control & management of shell subprocesses, - nominally git commands. - """ - - @staticmethod - def get_script_name(script, the_election_config): - """ - Given a python script name, either return the poetry local - install name or the relative path from the default execution - CWD. - """ - if Globals.get("VTP_LOCAL_INSTALL"): - return re.sub("_", "-", script).rstrip(".py") - return os.path.join( - the_election_config.get("git_rootdir"), Globals.get("BIN_DIR"), script - ) - - @staticmethod - def run(argv, printonly=False, verbosity=3, no_touch_stds=False, **kwargs): - """Run a shell command with logging and error handling. Raises a - CalledProcessError if the shell command fails - the caller needs to - deal with that. Can also raise a TimeoutExpired exception. - - Nominally returns a CompletedProcess instance. - - See for example https://docs.python.org/3.9/library/subprocess.html - """ - # Note - it is ok to pass ints and floats down through argv - # here, but they need to be individually converted to strings - # regardless since _everything_ below wants to see strings. - argv_string = [str(arg) for arg in argv] - if verbosity >= 3: - logging.info('Running "%s"', " ".join(argv_string)) - if printonly: - return subprocess.CompletedProcess(argv_string, 0, stdout="", stderr="") - # the caller desides on whether check is set or not - # pylint: disable=subprocess-run-check - if not no_touch_stds: - if "capture_output" not in kwargs: - if "stdout" not in kwargs and verbosity < 3: - kwargs["stdout"] = subprocess.DEVNULL - if "stderr" not in kwargs and verbosity <= 3: - kwargs["stderr"] = subprocess.DEVNULL - if "timeout" not in kwargs: - kwargs["timeout"] = Globals.get("SHELL_TIMEOUT") - return subprocess.run(argv_string, **kwargs) - - @staticmethod - @contextmanager - def changed_cwd(path): - """Context manager for temporarily changing the CWD""" - oldpwd = os.getcwd() - try: - os.chdir(path) - logging.debug("Entering dir (%s):", path) - yield - finally: - os.chdir(oldpwd) - logging.debug("Leaving dir (%s):", path) - - @staticmethod - @contextmanager - def changed_branch(branch): - """ - Context manager for temporarily encapsulating a potential git - branch change. Will explicitly switch to the specified branch - before yielding. - """ - Shellout.run(["git", "checkout", branch], check=True) - logging.debug("Entering branch (%s):", branch) - try: - yield - finally: - # switch the branch back - Shellout.run(["git", "checkout", branch], check=True) - logging.debug("Leaving branch (%s):", branch) - - @staticmethod - # ZZZ - could use an optional filter_by_uid argument which is a set object - def cvr_parse_git_log_output( - git_log_command, election_config, grouped_by_uid=True, verbosity=3 - ): - """Will execute the supplied git log command and process the - output of those commits that are CVRs. Will return a - dictionary keyed on the contest UID that is a list of CVRs. - The CVR is just the CVR from the git log with a 'digest' key - added. - - Note the the order of the list is git log order and not - randomized FWIIW. - """ - # Will process all the CVR commits on the main branch and tally - # all the contests found. - git_log_cvrs = {} - with Shellout.changed_cwd(election_config.get("git_rootdir")): - if verbosity >= 3: - logging.info('Running "%s"', " ".join(git_log_command)) - with subprocess.Popen( - git_log_command, stdout=subprocess.PIPE, text=True, encoding="utf8" - ) as git_output: - # read lines until there is a complete json object, then - # add the object for that contest. - block = "" - digest = "" - recording = False - # question - how to get "for line in - # git_output.stdout.readline():" not to effectively return - # the characters in line as opposed to the entire line - # itself? - while True: - line = git_output.stdout.readline() - if not line: - break - if match := re.match("^([a-f0-9]{40}){", line): - digest = match.group(1) - recording = True - block = "{" - continue - if recording: - block += line.strip() - if re.match("^}", line): - # this loads the contest under the CVR key - cvr = json.loads(block) - if grouped_by_uid: - cvr["digest"] = digest - if cvr["CVR"]["uid"] in git_log_cvrs: - git_log_cvrs[cvr["CVR"]["uid"]].append(cvr) - else: - git_log_cvrs[cvr["CVR"]["uid"]] = [cvr] - else: - git_log_cvrs[digest] = cvr - block = "" - digest = "" - 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 2cb083b..82dee85 100644 --- a/src/vtp/core/contest.py +++ b/src/vtp/core/contest.py @@ -18,33 +18,48 @@ """How to manage a VTP specific contest""" import json - -# import logging -import operator import re -from fractions import Fraction # local -from .exceptions import TallyException +from .common import Globals class Contest: - """A wrapper around the rules of engagement regarding a specific contest""" + """ + A class to handle the rules of engagement regarding a specific + contest. A contest is a dict. + + Some historical design notes. A ballot originally had contests + being a funky dict in version 0.1.0. However, in version 0.2.0 + contests was changed to an ordered list, greatly simplifying some + low level logic. The git log entry in 0.1.0 also ended up having + an analogous dictionary with an outer "CVR" key. This has also + become OBE and so in 0.2.0 the git log entry is now just a simple + one level dictionary. + + At the same time in an effort to simplify things, the contest + selection logic was moved from the Ballot class to this class + while changing the Ballot class to _always_ create Contest objects + when it encounters a contest. + """ # Legitimate Contest keys. Note 'selection', 'uid', 'cloak', and # 'name' are not legitimate keys for blank ballots _config_keys = [ "choices", "tally", - "win-by", - "max", - "write-in", + "win_by", + "max_selections", + "write_in", "description", "contest_type", - "ticket_offices", + "ticket_titles", + "election_upstream_remote", + "contest_name", + "ggo", ] _blank_ballot_keys = _config_keys + ["uid"] - _cast_keys = _blank_ballot_keys + ["selection", "name", "cast_branch", "ggo"] + _cast_keys = _blank_ballot_keys + ["selection", "cast_branch"] _choice_keys = ["name", "party", "ticket_names"] # A simple numerical n digit uid @@ -52,41 +67,35 @@ class Contest: _nextuid = 0 @staticmethod - def set_uid(a_contest_blob, ggo): + def set_uid(a_contest_blob: dict, ggo: str): """Will add a contest uid (only good within the context of this specific election) to the supplied contest. """ - name = next(iter(a_contest_blob)) - if "uid" in a_contest_blob[name]: - raise IndexError(f"The uid of contest {name} is already set") - a_contest_blob[name]["uid"] = str(Contest._nextuid).rjust(4, "0") + if "uid" in a_contest_blob: + raise IndexError( + f"The uid of contest {a_contest_blob['contest_name']} is already set" + ) + a_contest_blob["uid"] = str(Contest._nextuid).rjust(4, "0") if Contest._nextuid not in Contest._uids: Contest._uids[Contest._nextuid] = {} - Contest._uids[Contest._nextuid]["name"] = name + Contest._uids[Contest._nextuid]["contest_name"] = a_contest_blob["contest_name"] Contest._uids[Contest._nextuid]["ggo"] = ggo Contest._nextuid += 1 @staticmethod # pylint: disable=too-many-branches - def check_contest_choices(choices: list, a_c_blob: dict): + def check_contest_choices(choices: list, a_contest_blob: dict): """ Will validate the syntax of the contest choices. To validate a ticket contest, the outer context node now contains the - ticket_offices while the inner node choice contains the paired - ticket_names. A a_c_blob can be either a_contest_blob or + ticket_titles while the inner node choice contains the paired + ticket_names. A a_contest_blob can be either a_contest_blob or a_cvr_blob. """ - # Note - a a_contest_blob is a length one dict keyed on - # contest name - the desired dict is the value of this one and - # only key. But if the a_c_blob is a a_contest_blob, then it - # is already the desired dict. Sorry. - if "cast_branch" in a_c_blob: - contest_dict = a_c_blob - else: - contest_dict = next(iter(a_c_blob.values())) # if this is a ticket contest, need to validate uber ticket syntax check_ticket = ( - "contest_type" in contest_dict and contest_dict["contest_type"] == "ticket" + "contest_type" in a_contest_blob + and a_contest_blob["contest_type"] == "ticket" ) for choice in choices: if isinstance(choice, str): @@ -104,93 +113,119 @@ def check_contest_choices(choices: list, a_c_blob: dict): "Contest type is a ticket contest but does not contain ticket_names" ) if len(choice["ticket_names"]) != len( - contest_dict["ticket_offices"] + a_contest_blob["ticket_titles"] ): raise KeyError( - "when either 'ticket_names' or 'ticket_offices' are specified" + "when either 'ticket_names' or 'ticket_titles' are specified" "the length of each array mush match - " f"{len(choice['ticket_names'])} != {len(choice['ticket_names'])}" ) if not isinstance(choice["ticket_names"], list): raise KeyError("the key 'ticket_names' can only be a list") - if not isinstance(contest_dict["ticket_offices"], list): + if not isinstance(a_contest_blob["ticket_titles"], list): raise KeyError("the key 'ticket_names' can only be a list") elif "ticket_names" in choice: raise KeyError( - "Contest type is not a ticket contest but contains ticket_names" + "contest_type is not a ticket contest but contains ticket_names" ) continue if isinstance(choice, bool): continue @staticmethod - def check_contest_blob_syntax( - a_contest_blob, filename="", digest="", accept_all_keys=False - ): - """ - Will check the synatx of a contest somewhat and conveniently - return the contest name - """ - ### ZZZ - should sanity check the name - name = next(iter(a_contest_blob)) - - if filename: - legal_fields = Contest._blank_ballot_keys - elif digest or accept_all_keys: - legal_fields = Contest._cast_keys - else: - legal_fields = Contest._config_keys - bad_keys = [key for key in a_contest_blob[name] if key not in legal_fields] - if bad_keys: - if filename: - raise KeyError( - f"File ({filename}): " - "the following keys are not valid Contest keys: " - f"{','.join(bad_keys)}" - ) - if digest: - raise KeyError( - f"Commit digest ({digest}): " - "the following keys are not valid Contest keys: " - f"{','.join(bad_keys)}" - ) + def check_contest_type(a_contest_blob: dict): + """Will validate the value of contest_type""" + if "contest_type" not in a_contest_blob or a_contest_blob[ + "contest_type" + ] not in [ + "candidate", + "ticket", + "question", + ]: raise KeyError( - "the following keys are not valid Contest keys: " - f"{','.join(bad_keys)}" + f"contest_type ({a_contest_blob['contest_type']}) must be specified " + "as either: candidate, ticket, or question" ) - # Need to validate choices sub data structure as well - Contest.check_contest_choices(a_contest_blob[name]["choices"], a_contest_blob) - # good enough - return name @staticmethod - def check_cvr_blob_syntax(a_cvr_blob, filename="", digest=""): + def check_selection(a_contest_blob: dict): + """Will check the syntaz of the selection array""" + + @staticmethod + # pylint: disable=too-many-branches + def check_contest_blob_syntax( + a_contest_blob: dict, + filename: str = "", + digest: str = "", + set_defaults: bool = False, + ): """ - Will check the synatx of a cvr + Will check the syntax of a contest. + + Note - the filename and digest parameters only adjust + potential error messages. + + If set_defaults is set, missing default values will be set. + Four default adjustments can be made: max_selections, win_by, + the "name" key for each choice, and election_upstream_remote """ - bad_keys = [key for key in a_cvr_blob if key not in Contest._cast_keys] + legal_fields = Contest._cast_keys + bad_keys = [key for key in a_contest_blob if key not in legal_fields] if bad_keys: if filename: raise KeyError( f"File ({filename}): " - "the following keys are not valid Contest keys: " + f"the following keys are not valid Contest keys: " f"{','.join(bad_keys)}" ) if digest: raise KeyError( f"Commit digest ({digest}): " - "the following keys are not valid Contest keys: " + f"the following keys are not valid Contest keys: " f"{','.join(bad_keys)}" ) raise KeyError( - "the following keys are not valid Contest keys: " + f"the following keys are not valid Contest keys: " f"{','.join(bad_keys)}" ) # Need to validate choices sub data structure as well - Contest.check_contest_choices(a_cvr_blob["choices"], a_cvr_blob) + Contest.check_contest_choices(a_contest_blob["choices"], a_contest_blob) + Contest.check_contest_type(a_contest_blob) + if "selection" in a_contest_blob: + Contest.check_selection(a_contest_blob) + if set_defaults: + # If max_selections is not set, set it + # import pdb; pdb.set_trace() + if "max_selections" not in a_contest_blob: + if a_contest_blob["tally"] == "plurality": + a_contest_blob["max_selections"] = 1 + else: + a_contest_blob["max_selections"] = len(a_contest_blob["choices"]) + # If win_by is not set + if "win_by" not in a_contest_blob: + # ZZZ - it is unclear what win_by may actually want to + # mean from a UX POV. For now, simple set it to "max" + # for plurality and "0.5" for IRV(1), which implies + # the winning plurality choice and the first IRV(1) + # candidate past the 50% mark. Note - a value of + # "max" in IRV(1) could mean after all rounds TBD. + if a_contest_blob["contest_type"] == "plurality": + a_contest_blob["win_by"] = "max" + else: + # Note - RCV tallies are technically IRV(1) + # tallies currently + a_contest_blob["win_by"] = "0.5" + # If the contest choice is a string, convert it to dict (name) + for index, choice in enumerate(a_contest_blob["choices"]): + if isinstance(choice, str): + a_contest_blob["choices"][index] = {"name": choice} + # For voter UX, add ELECTION_UPSTREAM_REMOTE + a_contest_blob["election_upstream_remote"] = Globals.get( + "ELECTION_UPSTREAM_REMOTE" + ) @staticmethod - def get_choices_from_contest(choices): + def get_choices_from_contest(choices: list): """Will smartly return just the pure list of choices sans all values and sub dictionaries. An individual choice can either be a simple string, a regulare 1D dictionary, or it turns out @@ -208,13 +243,13 @@ def get_choices_from_contest(choices): ) @staticmethod - def split_selection(selection): + def split_selection(selection: str): """Will split the selection into (2) parts again.""" offset, name = re.split(r":\s+", selection, 1) return int(offset), name @staticmethod - def extract_offest_from_selection(selection): + def extract_offest_from_selection(selection: str): """ Will extract the int selection choice from the verbose selection string @@ -222,35 +257,31 @@ def extract_offest_from_selection(selection): return int(Contest.split_selection(selection)[0]) @staticmethod - def extract_name_from_selection(selection): + def extract_name_from_selection(selection: str): """ Will extract the name selection choice from the verbose selection string """ return Contest.split_selection(selection)[1] - def __init__(self, a_contest_blob, ggo, contests_index, accept_all_keys=False): + def __init__( + self, + a_contest_blob: dict, + cast_branch: str = "", + set_defaults: bool = False, + ): """Construct the object placing the contest info in an attribute while recording the meta data """ - self.name = Contest.check_contest_blob_syntax( - a_contest_blob, "", accept_all_keys=accept_all_keys + # ZZZ import pdb; pdb.set_trace() + # Note - Contest.check_contest_blob_syntax will set missing defaults + Contest.check_contest_blob_syntax( + a_contest_blob, + set_defaults=set_defaults, ) - self.contest = a_contest_blob[self.name] - self.ggo = ggo - self.index = contests_index - self.cast_branch = "" + self.contest = a_contest_blob + self.cast_branch = cast_branch self.cloak = False - # set defaults - if "max" not in self.contest: - if self.contest["tally"] == "plurality": - self.contest["max"] = 1 - # Some constructor time sanity checks - if "max" in self.contest and self.contest["max"] < 1: - raise ValueError( - f"Illegal value for Contest max ({self.contest['max']}) " - "- must be greater than 0" - ) def __str__(self): """Return the contest contents as a print-able json string - careful ...""" @@ -258,650 +289,116 @@ def __str__(self): contest_dict = { key: self.contest[key] for key in Contest._cast_keys if key in self.contest } - contest_dict.update( - {"name": self.name, "ggo": self.ggo, "cast_branch": self.cast_branch} - ) + contest_dict.update({"cast_branch": self.cast_branch}) return json.dumps(contest_dict, sort_keys=True, indent=4, ensure_ascii=False) - def is_contest_a_ticket_choice(self, choice_index=0): - """Returns whether or not the contest is ticket based""" - if ( - isinstance(self.contest["choices"][choice_index], dict) - and "ticket_names" in self.contest["choices"][choice_index] - ): - return True - return False - - def get_ticket_info(self, choice_index): - """Returns the ticket info as a 'ticket_names', 'ticket_offices' dictionary""" - # ticket info - if "ticket_names" in self.contest["choices"][choice_index]: - return { - "ticket_names": self.contest["choices"][choice_index]["ticket_names"], - "ticket_offices": self.contest["ticket_offices"], - } - return None - - def pretty_print_ticket(self, choice_index): - """Will pretty print a ticket to allow a voter to choose.""" + def pretty_print_a_ticket(self, choice_index: int): + """Will pretty print a ticket""" ticket = [] - ticket_info = self.get_ticket_info(choice_index) - for ticket_index, name in enumerate(ticket_info["ticket_names"]): - ticket.append(f"{name} ({ticket_info['ticket_offices'][ticket_index]})") + for ticket_index, name in enumerate( + self.contest["choices"][choice_index]["ticket_names"] + ): + ticket.append(f"{name} ({self.contest['ticket_titles'][ticket_index]})") return "; ".join(ticket) - def get(self, name): - """Generic getter - can raise KeyError""" - # Return the choices - if name == "dict": + def get(self, thing: str): + """ + Generic getter - can raise KeyError. When the parameter is + 'dict', will return an aggregated dictionary similar to when + the object is printed. If the parameter is 'contest', it + returns the contest. + + Note - 'contest' does NOT create/return a copy while 'dict' + does not copy any deeper data structures. + """ + if thing == "dict": # return the combined psuedo dictionary similar to __str__ above contest_dict = { key: self.contest[key] for key in Contest._cast_keys if key in self.contest } - contest_dict.update( - {"name": self.name, "ggo": self.ggo, "cast_branch": self.cast_branch} - ) + contest_dict.update({"cast_branch": self.cast_branch}) return contest_dict - if name == "choices": + if thing == "contest": + return self.contest + if thing == "choices": return Contest.get_choices_from_contest(self.contest["choices"]) # Return contest 'meta' data - if name in ["name", "ggo", "index", "contest"]: - return getattr(self, name) - # max is optional still - if name == "max": - return self.contest["max"] if "max" in self.contest else 1 + if thing in ["cast_branch", "cloak"]: + return getattr(self, thing) # Note - a 'selection' is a aggregated string of the selected # offset and the 'name', which for a ticket based contest is # not useful. So support the extraction of just the offset. - if name == "selection-offset": + if thing == "selection-offset": return Contest.extract_offest_from_selection( getattr(self, "contest")["selection"] ) - # Else return contest data indexed by name - return getattr(self, "contest")[name] + # Else return contest data itself indexed by thing + return getattr(self, "contest")[thing] - def set(self, name, value): + def set(self, thing: str, value: str): """Generic setter - need to be able to set the cast_branch when committing the contest""" - if name in ["name", "ggo", "index", "contest", "cast_branch", "cloak"]: - setattr(self, name, value) + if thing in ["cast_branch", "cloak"]: + setattr(self, thing, value) return - raise ValueError(f"Illegal value for Contest attribute ({name})") + raise ValueError(f"Illegal value for Contest attribute ({thing})") - def delete_contest_field(self, name): + def clear_selection(self): + """Clear the selection (as when self adjudicating)""" + self.contest["selection"] = [] + + def delete_contest_field(self, thing: str): """Generic deleter - need to be able to delete nodes""" - if name in Contest._cast_keys: - if name in self.contest: - del self.contest[name] + if thing in Contest._cast_keys: + if thing in self.contest: + del self.contest[thing] return - raise ValueError(f"Illegal value for Contest attribute ({name})") - - -# pylint: disable=too-many-instance-attributes # (8/7 - not worth it at this time) -class Tally: - """ - A class to tally ballot contests a.k.a. CVRs. The three primary - functions of the class are the contructor, a tally function, and a - print-the-tally function. - """ - - @staticmethod - def get_choices_from_round(choices, what=""): - """Will smartly return just the pure list of choices sans all - values and sub dictionaries from a round + raise ValueError(f"Illegal value for Contest attribute ({thing})") + + def get_selections_indices(self): + """Will return the ordered list of index numbers for the selection array""" + indexes = [] + if "selection" in self.contest: + for sel in self.contest["selection"]: + indexes.append(Contest.extract_offest_from_selection(sel)) + return indexes + + def add_selection(self, selection_offset: int): + """Will add the specified contest choice, the offset into the ordered + choices array, to the specified contest. This is an + 'add' since in plurality one may be voting for more than one + choice, or in RCV one needs to rank the choices. In both the + order is the rank but in plurality rank does not matter. """ - if what == "count": - return [choice[1] for choice in choices] - return [choice[0] for choice in choices] - - 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. - - 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) - # Something to hold the actual tallies. During RCV rounds these - # will change with last place finishers being decremented to 0. - self.selection_counts = { - choice: 0 - for choice in Contest.get_choices_from_contest(self.contest["choices"]) - } - # Total vote count for this contest. RCV rounds will not effect - # this. - self.vote_count = 0 - # Ordered list of winners - a list of tuples and not dictionaries. - self.winner_order = [] - # Used in both plurality and rcv, but only round 0 is used in - # plurality. Note - rcv_round's are an ordered list of tuples - # as is winner_order. The code below expects the current round - # (beginning as an empty list) to exist within the list. - self.rcv_round = [] - self.rcv_round.append([]) - - # win-by and max are optional but have known defaults. - # Determine the win-by if is not specified by the - # ElectionConfig. - self.defaults = {} - self.defaults["max"] = 1 if "max" not in self.contest else self.contest["max"] - self.defaults["win-by"] = ( - (1.0 / float(1 + self.defaults["max"])) - if "win-by" not in self.contest - else Fraction(self.contest["win-by"]) - ) - - # Need to keep track of a selections/choices that are no longer - # viable - key=choice['name'] value=obe round - self.obe_choices = {} - - # At this point any contest tallied against this contest must - # match all the fields with the exception of selection and - # write-in, but that check is done in tallyho below. - if not (self.contest["tally"] == "plurality" or self.contest["tally"] == "rcv"): - raise NotImplementedError( - f"the specified tally ({self.contest['tally']}) is not yet implemented" - ) - - def get(self, name): - """Simple limited functionality getter""" - if name in ["max", "win-by"]: - return self.defaults[name] - if name in [ - "digest", - "contest", - "selection_counts", - "vote_count", - "winner_order", - "rcv_round", - ]: - return getattr(self, name) - raise NameError(f"Name {name} not accepted/defined for Tally.get()") - - def __str__(self): - """Return the Tally in a partially print-able json string - careful ...""" - # Note - keep cloak out of it until proven safe to include - tally_dict = { - "name": self.contest["name"], - "vote_count": self.vote_count, - "winner_order": self.winner_order, - } - return json.dumps(tally_dict, sort_keys=True, indent=4, ensure_ascii=False) - - def select_name_from_choices(self, selection): - """Will smartly return just the pure selection name sans all - values and sub dictionaries from a round - """ - pick = self.contest["choices"][Contest.extract_offest_from_selection(selection)] - if isinstance(pick, str): - return pick - if isinstance(pick, dict): - return pick["name"] - if isinstance(pick, bool): - return "True" if pick else "False" - raise ValueError(f"unknown/unsupported contest choices data structure ({pick})") - - def tally_a_plurality_contest(self, contest, provenance_digest): - """plurality tally""" - for count in range(self.defaults["max"]): - if 0 <= count < len(contest["selection"]): - # yes this can be one line, but the reader may - # be interested in verifying the explicit - # values - selection = contest["selection"][count] - # depending on version, selection could be an int or a string - if isinstance(selection, str): - selection = Contest.extract_offest_from_selection(selection) - choice = Contest.get_choices_from_contest(contest["choices"])[selection] - self.selection_counts[choice] += 1 - self.vote_count += 1 - if provenance_digest: - self.imprimir(f"Counted {provenance_digest}: choice={choice}") - else: - if provenance_digest: - self.imprimir(f"No-vote {provenance_digest}: BLANK") - - def tally_a_rcv_contest(self, contest, provenance_digest): - """RCV tally""" - if len(contest["selection"]): - # the voter can still leave a RCV contest blank - selection = contest["selection"][0] - # depending on version, selection could be an int or a string - if isinstance(selection, str): - selection = Contest.extract_offest_from_selection(selection) - choice = Contest.get_choices_from_contest(contest["choices"])[selection] - self.selection_counts[choice] += 1 - self.vote_count += 1 - if provenance_digest: - self.imprimir(f"Counted {provenance_digest}: choice={choice}") - else: - if 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 - to re-distribute the next RCV round of voting. Can raise - various exceptions. If possible will return the - last_place_names (which can be greater than length 1 if there - is tie amongst the losers of a round). - - Note - it is up to the caller to resolve RCV edge cases such - as multiple and simultaneous losers, a N-way tie of all - remaining choices, returning a tie which undercuts the max - number of votes (as in, pick 3 of 5 and a RCV round tie - results in 1 or 2 choices instead of 3). - """ - self.imprimir(f"{self.rcv_round[current_round]}") - - # Step 1: remove self.obe_choices from current round - working_copy = [] - for a_tuple in self.rcv_round[current_round]: - if a_tuple[0] not in self.obe_choices: - working_copy.append(a_tuple) - - # tep 2: walk the list backwards returning the set of counts - # with the same minimum count. - last_place_names = [] - previous_count = 0 - for offset, a_tuple in enumerate(reversed(working_copy)): - # Note - current_round is 0 indexed from left, which means - # it needs an additional decrement when indexing from the - # right - current_count = a_tuple[1] - if offset == 0 or current_count == previous_count: - last_place_names.append(a_tuple[0]) - previous_count = current_count - else: - break - # import pdb; pdb.set_trace() - return last_place_names - - def safely_remove_obe_selections(self, contest: dict): - """For the specified contest, will 'pop' the current first place - selection. If the next selection is already a loser, will pop - that as well. self.contest['selection'] may or may not have any - choices left (it can be empty, have one choice, or multiple - choices left). - - Prints nothing - assumes caller handles any info/debug printing. - """ - a_copy = contest["selection"].copy() - for selection in a_copy: - if ( - self.select_name_from_choices(selection) in self.obe_choices - and selection in contest["selection"] - ): - contest["selection"].remove(selection) - - def restore_proper_rcv_round_ordering(self, this_round: int): - """Restore the 'proper' ordering of the losers in the current - and previous rcv rounds. Note: at this point the - self.rcv_round has been sorted by count with the obe_choices - effectively randomized. Also note that new incoming - last_place_names are not yet in self.obe_choices and will not - be until post the safely_determine_last_place_names call - below. - """ - loser_order = [] - for loser in sorted( - self.obe_choices.items(), key=operator.itemgetter(1), reverse=True - ): - loser_order.append(loser) - # Replace the effectively improperly unordered losers with a - # properly ordered list of losers. One way is to replace the - # last N entries with the properly ordered losers. - if len(loser_order) > 1: - for index, item in enumerate(reversed(loser_order)): - self.rcv_round[this_round][-index - 1] = (item[0], 0) - - def get_total_vote_count(self, this_round: int): - """ - To get the correct denominator to determine the minimum - required win amount, all the _current_ candidate counts need - to be added since some ballots may either be blank OR have - less then the maximum number of rank choices. Note - - """ - return sum( - self.selection_counts[choice] - for choice in Tally.get_choices_from_round(self.rcv_round[this_round]) - ) - - # pylint: disable=too-many-return-statements # what is a poor man to do - def next_rcv_round_precheck(self, last_place_names: list, this_round: int) -> int: - """ - Run the checks against the incoming last_place_names to make - sure that it is ok to have another RCV round. Returns non 0 - if no more rounds should be performed. - """ - - # 'this_round' is actually the 'next round' with the very - # first round being round 0. So, the first time this can be - # called is the beginning of the second round (this_round = - # 1). - non_zero_count_choices = 0 - for choice in self.rcv_round[this_round - 1]: - non_zero_count_choices += 1 if choice[1] else 0 - - # If len(last_place_names) happens to be zero, raise an error. - # However, though raising an error 'could be' the best test - # prior to entering another round (calling this function - # here), not raising an error and allowing such edge case tp - # print the condition and simply return might be the better - # design option. Doing that. - if not last_place_names: - 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]): - self.imprimir("There are no more RCV rounds") - return 1 - if not non_zero_count_choices: - self.imprimir("There are no votes for any choice") - return 1 - if 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"): - self.imprimir( - f"The contest max number of choices ({self.get('max')}) has been reached" - ) - return 1 - if non_zero_count_choices == 1: - self.imprimir( - "There is only one remaining viable choice left - halting more RCV rounds", + # Some minimal sanity checking + if selection_offset > len(self.contest["choices"]): + raise ValueError( + f"The choice offset ({selection_offset}) is greater " + f"than the number of choices ({len(self.contest['choices'])})" ) - return 1 - - # Note - by the time the execution gets here, this rcv_round have been - # vote count ordered. But there could be any number of zero count - # choices depending on the (edge case) details. - - # If len(last_place_names) leaves the exact number of max - # 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: - 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"): - 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" + if selection_offset < 0: + raise ValueError( + f"Only positive offsets are supported ({selection_offset})" ) - return 1 - - # And, the recursive stack here should probably be returning a - # success/failure back out of the Contest.tallyho... - return 0 - - def recast_votes(self, last_place_names: list, contest_batch: list, checks: list): - """ - Loops over the list of CVRs of interest (a contest worth) and - recasts a voter's selection if that selection is a loser in - 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) - # 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: - self.imprimir(f"INSPECTING: {digest} (contest={contest['name']})", 4) - # Note - if there is no selection, there is no selection - if not contest["selection"]: - continue - for last_place_name in last_place_names: - # Note - as the rounds go by, the - # contest["selection"]'s will get trimmed to an empty - # list. Once empty, the vote/voter is done. - if ( - contest["selection"] - and self.select_name_from_choices(contest["selection"][0]) - == last_place_name - ): - # Safely pop the current first choice and reset - # contest['selection']. Note that - # self.obe_choices has _already_ been updated with - # this_round's OBE in the caller such that - # safely_remove_obe_selections will effectively - # remove last_place_name from contest['selection'] - self.safely_remove_obe_selections(contest) - # Regardless of the next choice, the current choice is decremented - self.selection_counts[last_place_name] -= 1 - # Either retarget the vote or let it drop - if len(contest["selection"]): - # The voter can still leave a RCV contest blank - # Note - selection is the new selection for this contest - new_selection = contest["selection"][0] - # Select from self.contest['choices'] as that is the - # 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 - # 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 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( - self, this_round: int, last_place_names: list, contest_batch: list, checks: list - ): - """For the lowest vote getter, for those CVR's that have - that as their current first/active-round choice, will - slice off that choice off and re-count the now first - selection choice (if there is one) - """ - 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 - # support GLOBAL configs to determine how edge cases are - # handled. That function can cause a return if the the - # current RCV tally should not proceed to more rounds. Or - # raise an RCV-tally error (which can be handled by the caller - # when printing - prints a warning). - if self.next_rcv_round_precheck(last_place_names, this_round): - return - - # Loop over contest_batch and actually re-cast votes - self.recast_votes(last_place_names, contest_batch, checks) - # Order the winners of this round. This is a tuple, not a - # list or dict. Note - the rcv round losers should not be - # re-ordered as there is value to retaining that order - self.rcv_round[this_round] = sorted( - self.selection_counts.items(), key=operator.itemgetter(1), reverse=True - ) - self.restore_proper_rcv_round_ordering(this_round) - # Create the next round list - 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) - 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 ( - float(self.selection_counts[choice]) / float(total_current_vote_count) - ) > self.defaults["win-by"]: - # A winner. Depending on the win-by (which is a - # function of max), there could be multiple - # winners in this round. - self.winner_order.append((choice, self.selection_counts[choice])) - # import pprint - # import pdb; pdb.set_trace() - # If there are anough winners, stop and return - if len(self.winner_order) >= self.defaults["max"]: - return - # If not, safely determine the next set of last_place_names and - # execute another RCV round. - last_place_names = self.safely_determine_last_place_names(this_round) - # Add this loser to the obe record - for last_place_name in last_place_names: - self.obe_choices[last_place_name] = this_round - self.handle_another_rcv_round( - this_round + 1, last_place_names, contest_batch, checks - ) - return - - def parse_all_contests(self, contest_batch: list, checks: list): - """Will parse all the contests validating each""" - errors = {} - for a_git_cvr in contest_batch: - contest = a_git_cvr["CVR"] - digest = a_git_cvr["digest"] - Contest.check_cvr_blob_syntax(contest, digest=digest) - # Maybe print an provenance log for the tally of this contest - provenance_digest = digest if digest in checks else "" - # Validate the values that should be the same as self - for field in ["choices", "tally", "win-by", "max", "ggo", "uid", "name"]: - if field in self.contest: - if self.contest[field] != contest[field]: - errors[digest].append( - f"{field} field does not match: " - f"{self.contest[field]} != {contest[field]}" - ) - elif field in contest: - errors[digest].append( - f"{field} field is not present in Tally object but " - "is present in digest" - ) - # Tally the contest - this is just the first pass of a - # tally. It just so happens that with pluraity tallies - # the tally can be completed with s single pass over over - # the CVRs. And that can be done here. But with more - # complicated tallies such as RCV, the additional passes - # are done outside of this for loop. - if contest["tally"] == "plurality": - self.tally_a_plurality_contest(contest, provenance_digest) - elif contest["tally"] == "rcv": - # Since this is the first round on a rcv tally, just - # grap the first selection - self.tally_a_rcv_contest(contest, provenance_digest) - else: - # This code block should never be executed as the - # constructor or the Validate values clause above will - # catch this type of error. It is here only as a - # safety check during development time when adding - # support for more tallies. - raise NotImplementedError( - f"the specified tally ({contest['tally']}) is not yet implemented" + if "selection" not in self.contest: + self.contest["selection"] = [] + elif selection_offset in self.get_selections_indices(): + raise ValueError( + ( + f"The selection ({selection_offset}) has already been " + f"selected for contest ({self.contest['contest_name']}) " + f"for GGO ({self.contest['ggo']})" ) - - # Will the potential CVR errors found, report them all - if errors: - raise TallyException( - "The following CVRs have structural errors:" f"{errors}" ) - - 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 - and checks is a list of optional CVR digests (from the voter) - to check. - """ - # Read all the contests, validate, and count votes - if self.contest["tally"] == "plurality": - self.imprimir("Plurality - one round") - else: - self.imprimir("RCV: round 0") - self.parse_all_contests(contest_batch, checks) - - # For all tallies order what has been counted so far (a tuple) - self.rcv_round[0] = sorted( - self.selection_counts.items(), key=operator.itemgetter(1), reverse=True - ) - self.rcv_round.append([]) - - # If plurality, the tally is done - if self.contest["tally"] == "plurality": - # record the winner order - self.winner_order = self.rcv_round[0] - return - - # The rest of this block handles RCV - - # See if another RCV round is necessary. When max=1 there is - # only one RCV winner. However, not only can max>1 but win-by - # might be 2/3 and not just a simple majority. So only if there - # are enough winners with enough votes is this contest done. - - # Get the correct current total vote count for this round - total_current_vote_count = self.get_total_vote_count(0) - 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]): - # Note the test is '>' and NOT '>=' - if ( - float(self.selection_counts[choice]) / float(total_current_vote_count) - ) > self.defaults["win-by"]: - # A winner. Depending on the win-by (which is a - # function of max), there could be multiple - # winners in this round. - self.winner_order.append((choice, self.selection_counts[choice])) - - # If there are anough winners, stop and return. - if self.winner_order and len(self.winner_order) >= self.defaults["max"]: - return - # More RCV rounds are needed. Loop until we have enough RCV - # winners. - - # Safely determine the next set of last_place_names and - # execute a RCV round. Note that all zero vote choices will - # already be sorted last in self.rcv_round[0]. - last_place_names = self.safely_determine_last_place_names(0) - for name in last_place_names: - self.obe_choices[name] = 0 - # Go. handle_another_rcv_round will return somehow at some point - self.handle_another_rcv_round(1, last_place_names, contest_batch, checks) - return - - def print_results(self): - """Will print the results of the tally""" - self.imprimir( - f"Final results for contest {self.contest['name']} (uid={self.contest['uid']}):" + # For end voter UX, add the selection as the offset + ': ' + + # name just because a string is more understandable than json + # list syntax + self.contest["selection"].append( + str(selection_offset) + + ": " + + self.contest["choices"][selection_offset]["name"] ) - # 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]: - self.imprimir(f" {result}") # EOF diff --git a/src/vtp/core/election_config.py b/src/vtp/core/election_config.py index 44245ab..a31ab2d 100644 --- a/src/vtp/core/election_config.py +++ b/src/vtp/core/election_config.py @@ -18,19 +18,18 @@ """The VTP ElectionConfig class - everything needed to parse the config.yaml tree.""" # standard imports -import logging import os -import os.path import re import networkx import yaml # local imports -from .common import Common, Globals, Shellout +from .common import Globals from .contest import Contest +# pylint: disable=too-many-instance-attributes class ElectionConfig: """A class to parse all the VTP election config.yaml files and return a VTP election config. @@ -106,19 +105,19 @@ class ElectionConfig: _election_data = None @staticmethod - def configure_election(election_data_dir: str): + def configure_election(operation_self: dict, election_data_dir: str): """ Return the existing ElectionData or parse a new one into existence. This is the entrypoint/wrapper into/around the ElectionData class/instance. """ # Safety check - Common.verify_election_data_dir(election_data_dir) + Globals.verify_election_data_dir(election_data_dir) # 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) + incoming_ec = ElectionConfig(operation_self, 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 @@ -144,7 +143,7 @@ def configure_election(election_data_dir: str): return ElectionConfig._election_data @staticmethod - def get_next_uid(ggo): + def get_next_uid(ggo: str): """Will return the next GGO uid (only good within the context of this specific election) """ @@ -156,7 +155,7 @@ def get_next_uid(ggo): return this_uid @staticmethod - def is_valid_ggo_string(arg): + def is_valid_ggo_string(arg: str): """Check to see if it is a string without illegal characters.""" if not isinstance(arg, str): raise TypeError(f"The GGO value is not a string ({arg})") @@ -165,7 +164,7 @@ def is_valid_ggo_string(arg): # ZZZ need a bunch more QA checks here and one day deal with unicode @staticmethod - def check_config_syntax(config, filename): + def check_config_syntax(config: dict, filename: str): """Validate the config.yaml syntax""" bad_keys = [key for key in config if not key in ElectionConfig._config_keys] if bad_keys: @@ -175,7 +174,7 @@ def check_config_syntax(config, filename): ) @staticmethod - def check_address_map_syntax(address_map, filename): + def check_address_map_syntax(address_map: dict, filename: str): """Validate the address_map.yaml syntax""" bad_keys = [ key for key in address_map if not key in ElectionConfig._address_map_keys @@ -200,40 +199,7 @@ def check_address_map_syntax(address_map, filename): f"supported: {bad_keys}" ) - @staticmethod - def read_address_map(filename): - """ - Read the address_map yaml file return the dictionary but - only if the file exists. Check the syntax. - """ - if os.path.isfile(filename): - logging.debug("Reading %s", filename) - with open(filename, "r", encoding="utf8") as map_file: - this_address_map = yaml.load(map_file, Loader=yaml.BaseLoader) - # sanity-check it - ElectionConfig.check_address_map_syntax(this_address_map, filename) - return this_address_map - return {} - - @staticmethod - def read_config_file(filename): - """ - Read the confgi yaml file return the dictionary and check the syntax. - """ - logging.debug("Reading %s", filename) - with open(filename, "r", encoding="utf8") as config_file: - config = yaml.load(config_file, Loader=yaml.BaseLoader) - # sanity-check it - ElectionConfig.check_config_syntax(config, filename) - # should really sanity check the contests too - if "contests" in config: - for contest in config["contests"]: - Contest.check_contest_blob_syntax(contest, filename) - Contest.set_uid(contest, ".") - # import pdb; pdb.set_trace() - return config - - def __init__(self, election_data_dir: str = "."): + def __init__(self, operation_self: dict, election_data_dir: str = "."): """Constructor for ElectionConfig. If no election_data_dir is supplied, then the CWD _MUST_ be in the current ElectionData tree (the election_data_dir) where the election is happening - @@ -243,23 +209,39 @@ def __init__(self, election_data_dir: str = "."): to read the tree. """ + # Need the (outer) operation_self as that contains the shell_out + # environment + self.operation_self = operation_self + # Determine the absolute PATH to the election_data_dir and # store the value in self.git_rootdir. if election_data_dir in ["", ".", None]: self.git_rootdir = os.getcwd() else: self.git_rootdir = os.path.realpath(election_data_dir) - with Shellout.changed_cwd(self.git_rootdir): - result = Shellout.run( + with self.operation_self.changed_cwd(self.git_rootdir): + # the path + result = self.operation_self.shell_out( ["git", "rev-parse", "--show-toplevel"], check=True, capture_output=True, text=True, + incoming_printlevel=5, + ) + result2 = self.operation_self.shell_out( + ["git", "rev-list", "--max-parents=0", "HEAD"], + check=True, + capture_output=True, + text=True, + incoming_printlevel=5, ) + + # Check result if result.stdout == "": raise EnvironmentError( "Cannot determine workspace top level via 'git rev-parse'" ) + # Set values based on result self.git_rootdir = result.stdout.strip() self.root_config_file = os.path.join( self.git_rootdir, @@ -270,24 +252,41 @@ def __init__(self, election_data_dir: str = "."): Globals.get("ADDRESS_MAP_FILE"), ) self.parsed_configs = ["."] - self.digraph = networkx.DiGraph() self.uid = None - # Also determine the initial commit to branch the CVRs and + + # Check result2 - determine the initial commit to branch the CVRs and # RECEIPTS from - with Shellout.changed_cwd(self.git_rootdir): - result = Shellout.run( - ["git", "rev-list", "--max-parents=0", "HEAD"], - check=True, - capture_output=True, - text=True, - ) - if result.stdout == "": + if result2.stdout == "": raise EnvironmentError( "Cannot determine workspace initial commit via 'git rev-list'" ) - self.git_initial_commit = result.stdout.strip() + self.git_initial_commit = result2.stdout.strip() + + # Check ELECTION_UPSTREAM_REMOTE + if Globals.get("ELECTION_UPSTREAM_REMOTE") == "": + # the name of the remote election data repo + with self.operation_self.changed_cwd(self.git_rootdir): + result = self.operation_self.shell_out( + ["git", "remote", "get-url", "origin"], + check=True, + capture_output=True, + text=True, + incoming_printlevel=5, + ) + if os.path.splitext(os.path.basename(result.stdout.strip()))[1] == ".git": + Globals.set_election_upstream_remote( + os.path.splitext(os.path.basename(result.stdout))[0] + ) + else: + raise EnvironmentError( + "Cannot determine workspace origin remote name via 'git remote get-url origin'" + ) + + # With the above set, can spend the time to determine the election data + # network graph + self.digraph = networkx.DiGraph() - def get(self, name): + def get(self, name: str): """A generic getter - will raise a NameError if name is not defined""" if name in ElectionConfig._config_keys: return getattr(self, "config")[name] @@ -304,7 +303,7 @@ def get(self, name): ) ) - def get_dag(self, what): + def get_dag(self, what: str): """An ElectionConfig get interface to the underlying DiGraph class.""" if what == "nodes": return self.digraph.nodes() @@ -321,7 +320,7 @@ def node(self, node): """Return the networkx node""" return self.digraph.nodes[node] - def get_node(self, node, what): + def get_node(self, node, what: str): """An ElectionConfig get interface to the underlying election configuration data.""" if what == "ALL": return { @@ -352,6 +351,37 @@ def __str__(self): """Return the serialization of this instance's ElectionConfig dictionary""" return str(list(self.get_dag("topo"))) + def read_address_map(self, filename: str): + """ + Read the address_map yaml file return the dictionary but + only if the file exists. Check the syntax. + """ + if os.path.isfile(filename): + self.operation_self.imprimir(f"Reading {filename}", 5) + with open(filename, "r", encoding="utf8") as map_file: + this_address_map = yaml.load(map_file, Loader=yaml.BaseLoader) + # sanity-check it + ElectionConfig.check_address_map_syntax(this_address_map, filename) + return this_address_map + return {} + + def read_config_file(self, filename: str): + """ + Read the confgi yaml file return the dictionary and check the syntax. + """ + self.operation_self.imprimir(f"Reading {filename}", 5) + with open(filename, "r", encoding="utf8") as config_file: + config = yaml.load(config_file, Loader=yaml.BaseLoader) + # sanity-check it + ElectionConfig.check_config_syntax(config, filename) + # should really sanity check the contests too + if "contests" in config: + for contest in config["contests"]: + Contest.check_contest_blob_syntax(contest, filename, set_defaults=True) + Contest.set_uid(contest, ".") + # import pdb; pdb.set_trace() + return config + # This defunct with the address_map design change - there are no # lnnger any such thing as an implicit address include - all # address_map includes are now explicit in the address_map.yaml @@ -386,10 +416,10 @@ def parse_configs(self): """ # read the root config and address_map files - config = ElectionConfig.read_config_file(self.root_config_file) + config = self.read_config_file(self.root_config_file) # read the root address_map and sanity check that - address_map = ElectionConfig.read_address_map(self.root_address_map_file) + address_map = self.read_address_map(self.root_address_map_file) def recursively_parse_tree(subdir, parent_node_name): """Something to recursivelty parse the GGO tree""" @@ -414,7 +444,7 @@ def recursively_parse_tree(subdir, parent_node_name): ggo_subdir_abspath, ggo, Globals.get("CONFIG_FILE") ) # read the child config - this_config = ElectionConfig.read_config_file(ggo_file) + this_config = self.read_config_file(ggo_file) # Do not hit a node twice - it is a config error if so next_subdir = os.path.join(subdir, ggo_kind, ggo) @@ -429,7 +459,7 @@ def recursively_parse_tree(subdir, parent_node_name): # Before recursing, read in address_map and add it to the node # Note - reading will check syntax - this_address_map = ElectionConfig.read_address_map( + this_address_map = self.read_address_map( os.path.join( ggo_subdir_abspath, ggo, Globals.get("ADDRESS_MAP_FILE") ) diff --git a/src/vtp/core/tally.py b/src/vtp/core/tally.py new file mode 100644 index 0000000..4896851 --- /dev/null +++ b/src/vtp/core/tally.py @@ -0,0 +1,646 @@ +# VoteTrackerPlus +# Copyright (C) 2022 Sandy Currier +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License along +# with this program; if not, write to the Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +"""How to manage a VTP specific contest""" + +import json +import operator + +# local +from .contest import Contest +from .exceptions import TallyException + + +# pylint: disable=too-many-instance-attributes # (8/7 - not worth it at this time) +class Tally: + """ + A class to tally ballot contests a.k.a. CVRs. The three primary + functions of the class are the contructor, a tally function, and a + print-the-tally function. + """ + + @staticmethod + def get_choices_from_round(choices, what: str = ""): + """Will smartly return just the pure list of choices sans all + values and sub dictionaries from a round + """ + if what == "count": + return [choice[1] for choice in choices] + return [choice[0] for choice in choices] + + def __init__(self, a_git_cvr: dict, operation_self: dict): + """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. + + Note - the constructor is per specific contest and tally + results of the contest are stored in an attribute of the + object. + + The operation_self 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. + """ + # import pdb; pdb.set_trace() + self.operation_self = operation_self + self.digest = a_git_cvr["digest"] + self.contest = a_git_cvr["contestCVR"] + Contest.check_contest_blob_syntax(self.contest, digest=self.digest) + # Something to hold the actual tallies. During RCV rounds these + # will change with last place finishers being decremented to 0. + self.selection_counts = { + choice: 0 + for choice in Contest.get_choices_from_contest(self.contest["choices"]) + } + # Total vote count for this contest. RCV rounds will not effect + # this. + self.vote_count = 0 + # Ordered list of winners - a list of tuples and not dictionaries. + self.winner_order = [] + # Used in both plurality and rcv, but only round 0 is used in + # plurality. Note - rcv_round's are an ordered list of tuples + # as is winner_order. The code below expects the current round + # (beginning as an empty list) to exist within the list. + self.rcv_round = [] + self.rcv_round.append([]) + + # ZZZ - cache these values + self.defaults = { + "max_selections": self.contest["max_selections"], + "win_by": self.contest["win_by"], + } + + # Need to keep track of a selections/choices that are no longer + # viable - key=choice['name'] value=obe round + self.obe_choices = {} + + # At this point any contest tallied against this contest must + # match all the fields with the exception of selection and + # write-in, but that check is done in tallyho below. + if not (self.contest["tally"] == "plurality" or self.contest["tally"] == "rcv"): + raise NotImplementedError( + f"the specified tally ({self.contest['tally']}) is not yet implemented" + ) + + def get(self, name: str): + """Simple limited functionality getter""" + # ZZZ import pdb; pdb.set_trace() + if name in ["max_selections", "win_by"]: + return self.defaults[name] + if name in [ + "digest", + "contest", + "selection_counts", + "vote_count", + "winner_order", + "rcv_round", + ]: + return getattr(self, name) + raise NameError(f"Name {name} not accepted/defined for Tally.get()") + + def __str__(self): + """Return the Tally in a partially print-able json string - careful ...""" + # Note - keep cloak out of it until proven safe to include + tally_dict = { + "contest_name": self.contest["contest_name"], + "vote_count": self.vote_count, + "winner_order": self.winner_order, + } + return json.dumps(tally_dict, sort_keys=True, indent=4, ensure_ascii=False) + + def select_name_from_choices(self, selection: str): + """Will smartly return just the pure selection name sans all + values and sub dictionaries from a round + """ + pick = self.contest["choices"][Contest.extract_offest_from_selection(selection)] + if isinstance(pick, str): + return pick + if isinstance(pick, dict): + return pick["name"] + if isinstance(pick, bool): + return "True" if pick else "False" + raise ValueError(f"unknown/unsupported contest choices data structure ({pick})") + + def tally_a_plurality_contest( + self, contest: dict, provenance_digest: str, vote_count: int + ): + """plurality tally""" + for count in range(self.defaults["max_selections"]): + if 0 <= count < len(contest["selection"]): + # yes this can be one line, but the reader may + # be interested in verifying the explicit + # values + selection = contest["selection"][count] + # depending on version, selection could be an int or a string + if isinstance(selection, str): + selection = Contest.extract_offest_from_selection(selection) + choice = Contest.get_choices_from_contest(contest["choices"])[selection] + self.selection_counts[choice] += 1 + self.vote_count += 1 + if provenance_digest: + self.operation_self.imprimir( + f"Counted {provenance_digest} as vote {vote_count}: choice={choice}", + 0, + ) + else: + if provenance_digest: + self.operation_self.imprimir( + f"No-vote {provenance_digest}: BLANK", 0 + ) + + def tally_a_rcv_contest( + self, contest: dict, provenance_digest: str, vote_count: int + ): + """RCV tally""" + if len(contest["selection"]): + # the voter can still leave a RCV contest blank + selection = contest["selection"][0] + # depending on version, selection could be an int or a string + if isinstance(selection, str): + selection = Contest.extract_offest_from_selection(selection) + choice = Contest.get_choices_from_contest(contest["choices"])[selection] + self.selection_counts[choice] += 1 + self.vote_count += 1 + if provenance_digest: + self.operation_self.imprimir( + f"Counted {provenance_digest} as vote {vote_count}: choice={choice}", + 0, + ) + else: + if provenance_digest: + self.operation_self.imprimir(f"No vote {provenance_digest}: BLANK", 0) + + def safely_determine_last_place_names(self, current_round: int) -> list: + """Safely determine the next set of last_place_names for which + to re-distribute the next RCV round of voting. Can raise + various exceptions. If possible will return the + last_place_names (which can be greater than length 1 if there + is tie amongst the losers of a round). + + Note - it is up to the caller to resolve RCV edge cases such + as multiple and simultaneous losers, a N-way tie of all + remaining choices, returning a tie which undercuts the max + number of votes (as in, pick 3 of 5 and a RCV round tie + results in 1 or 2 choices instead of 3). + """ + # Note - self.rcv_round[current_round] is the ordered array of + # all RCV choice tuples + for result in self.rcv_round[current_round]: + self.operation_self.imprimir(f" {result}") + # self.operation_self.imprimir(f"{self.rcv_round[current_round]}", 3) + + # Step 1: remove self.obe_choices from current round + working_copy = [] + for a_tuple in self.rcv_round[current_round]: + if a_tuple[0] not in self.obe_choices: + working_copy.append(a_tuple) + + # tep 2: walk the list backwards returning the set of counts + # with the same minimum count. + last_place_names = [] + previous_count = 0 + for offset, a_tuple in enumerate(reversed(working_copy)): + # Note - current_round is 0 indexed from left, which means + # it needs an additional decrement when indexing from the + # right + current_count = a_tuple[1] + if offset == 0 or current_count == previous_count: + last_place_names.append(a_tuple[0]) + previous_count = current_count + else: + break + # import pdb; pdb.set_trace() + return last_place_names + + def safely_remove_obe_selections(self, contest: dict): + """For the specified contest, will 'pop' the current first place + selection. If the next selection is already a loser, will pop + that as well. self.contest['selection'] may or may not have any + choices left (it can be empty, have one choice, or multiple + choices left). + + Prints nothing - assumes caller handles any info/debug printing. + """ + a_copy = contest["selection"].copy() + for selection in a_copy: + if ( + self.select_name_from_choices(selection) in self.obe_choices + and selection in contest["selection"] + ): + contest["selection"].remove(selection) + + def restore_proper_rcv_round_ordering(self, this_round: int): + """Restore the 'proper' ordering of the losers in the current + and previous rcv rounds. Note: at this point the + self.rcv_round has been sorted by count with the obe_choices + effectively randomized. Also note that new incoming + last_place_names are not yet in self.obe_choices and will not + be until post the safely_determine_last_place_names call + below. + """ + loser_order = [] + for loser in sorted( + self.obe_choices.items(), key=operator.itemgetter(1), reverse=True + ): + loser_order.append(loser) + # Replace the effectively improperly unordered losers with a + # properly ordered list of losers. One way is to replace the + # last N entries with the properly ordered losers. + if len(loser_order) > 1: + for index, item in enumerate(reversed(loser_order)): + self.rcv_round[this_round][-index - 1] = (item[0], 0) + + def get_total_vote_count(self, this_round: int): + """ + To get the correct denominator to determine the minimum + required win amount, all the _current_ candidate counts need + to be added since some ballots may either be blank OR have + less then the maximum number of rank choices. Note - + """ + return sum( + self.selection_counts[choice] + for choice in Tally.get_choices_from_round(self.rcv_round[this_round]) + ) + + # pylint: disable=too-many-return-statements # what is a poor man to do + def next_rcv_round_precheck(self, last_place_names: list, this_round: int) -> int: + """ + Run the checks against the incoming last_place_names to make + sure that it is ok to have another RCV round. Returns non 0 + if no more rounds should be performed. + """ + + # 'this_round' is actually the 'next round' with the very + # first round being round 0. So, the first time this can be + # called is the beginning of the second round (this_round = + # 1). + non_zero_count_choices = 0 + for choice in self.rcv_round[this_round - 1]: + non_zero_count_choices += 1 if choice[1] else 0 + + # If len(last_place_names) happens to be zero, raise an error. + # However, though raising an error 'could be' the best test + # prior to entering another round (calling this function + # here), not raising an error and allowing such edge case tp + # print the condition and simply return might be the better + # design option. Doing that. + if not last_place_names: + self.operation_self.imprimir( + "No more choices/candidates to recast - no more RCV rounds", 0 + ) + return 1 + if this_round > 64: + raise TallyException("RCV rounds exceeded safety limit of 64 rounds") + if this_round >= len(self.rcv_round[0]): + self.operation_self.imprimir("There are no more RCV rounds", 0) + return 1 + if not non_zero_count_choices: + self.operation_self.imprimir("There are no votes for any choice", 0) + return 1 + # if non_zero_count_choices: + # import pdb; pdb.set_trace() + # self.operation_self.imprimir( + # f"There are only {non_zero_count_choices} viable choices " + # "left which is less than the contest max selections " + # f"({self.get('max_selections')})", + # 0, + # ) + # return 0 + if non_zero_count_choices <= 2: + self.operation_self.imprimir( + f"There is only {non_zero_count_choices} remaining choices - " + "halting more RCV rounds", + 0, + ) + return 1 + + # Note - by the time the execution gets here, this rcv_round have been + # vote count ordered. But there could be any number of zero count + # choices depending on the (edge case) details. + + # If len(last_place_names) leaves the exact number of max + # 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: + self.operation_self.imprimir( + f"This contest ends in a {non_zero_count_choices} way tie", 0 + ) + 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 len(last_place_names) >= 2: + self.operation_self.imprimir( + f"There is a last place {len(last_place_names)} way tie.", + 2, + ) + if non_zero_count_choices > len(last_place_names): + # ZZZ is this the right thing to do? + # allow all the zero counts to go + return 0 + # else, stop + return 1 + + # And, the recursive stack here should probably be returning a + # success/failure back out of the Contest.tallyho... + return 0 + + def recast_votes(self, last_place_names: list, contest_batch: list, checks: list): + """ + Loops over the list of CVRs of interest (a contest worth) and + recasts a voter's selection if that selection is a loser in + this RCV round. If there is no next choice, the there is no + recast and the vote is dropped. + """ + + # Loop over CVRs + for vote_count, uid in enumerate(contest_batch): + contest = uid["contestCVR"] + digest = uid["digest"] + if digest in checks: + self.operation_self.imprimir( + f"INSPECTING: {digest} (contest={contest['contest_name']}) " + f"as vote {vote_count + 1}", + 3, + ) + # Note - if there is no selection, there is no selection + if not contest["selection"]: + continue + for last_place_name in last_place_names: + # Note - as the rounds go by, the + # contest["selection"]'s will get trimmed to an empty + # list. Once empty, the vote/voter is done. + if ( + contest["selection"] + and self.select_name_from_choices(contest["selection"][0]) + == last_place_name + ): + # Safely pop the current first choice and reset + # contest['selection']. Note that + # self.obe_choices has _already_ been updated with + # this_round's OBE in the caller such that + # safely_remove_obe_selections will effectively + # remove last_place_name from contest['selection'] + self.safely_remove_obe_selections(contest) + # Regardless of the next choice, the current choice is decremented + self.selection_counts[last_place_name] -= 1 + # Either retarget the vote or let it drop + if len(contest["selection"]): + # The voter can still leave a RCV contest blank + # Note - selection is the new selection for this contest + new_selection = contest["selection"][0] + # Select from self.contest['choices'] as that is the + # 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 + # original variant: if digest in checks or loglevel == "DEBUG": + if digest in checks or self.operation_self.verbosity >= 4: + self.operation_self.imprimir( + f"RCV: {digest} (contest={contest['contest_name']}) last place " + f"pop and count ({last_place_name} -> {new_choice_name})", + 0, + ) + else: + if digest in checks or self.operation_self.verbosity >= 4: + self.operation_self.imprimir( + f"RCV: {digest} (contest={contest['contest_name']}) last place " + f"pop and drop ({last_place_name} -> BLANK)", + 0, + ) + + def handle_another_rcv_round( + self, this_round: int, last_place_names: list, contest_batch: list, checks: list + ): + """For the lowest vote getter, for those CVR's that have + that as their current first/active-round choice, will + slice off that choice off and re-count the now first + selection choice (if there is one) + """ + self.operation_self.imprimir_formatting("empty_line") + if len(contest_batch) > 1: + self.operation_self.imprimir_formatting("horizontal_shortline") + else: + self.operation_self.imprimir_formatting("horizontal_line") + self.operation_self.imprimir(f"RCV: round {this_round}", 0) + + # ZZZ - create a function to validate incoming last place + # names and call that. Maybe in the furure once more is know + # support GLOBAL configs to determine how edge cases are + # handled. That function can cause a return if the the + # current RCV tally should not proceed to more rounds. Or + # raise an RCV-tally error (which can be handled by the caller + # when printing - prints a warning). + if self.next_rcv_round_precheck(last_place_names, this_round): + return + + # Loop over contest_batch and actually re-cast votes + self.recast_votes(last_place_names, contest_batch, checks) + # Order the winners of this round. This is a tuple, not a + # list or dict. Note - the rcv round losers should not be + # re-ordered as there is value to retaining that order + self.rcv_round[this_round] = sorted( + self.selection_counts.items(), key=operator.itemgetter(1), reverse=True + ) + self.restore_proper_rcv_round_ordering(this_round) + # Create the next round list + 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) + self.operation_self.imprimir(f"Total vote count: {total_current_vote_count}", 0) + for choice in Tally.get_choices_from_round(self.rcv_round[this_round]): + # Note the test is '>' and NOT '>=' + if ( + float(self.selection_counts[choice]) / float(total_current_vote_count) + ) > float(self.defaults["win_by"]): + # A winner. Depending on the win_by (which is a + # function of max), there could be multiple + # winners in this round. + self.winner_order.append((choice, self.selection_counts[choice])) + # import pprint + # import pdb; pdb.set_trace() + # If there are anough winners, stop and return + if self.winner_order: + return + # If not, safely determine the next set of last_place_names and + # execute another RCV round. + last_place_names = self.safely_determine_last_place_names(this_round) + # Add this loser to the obe record + for last_place_name in last_place_names: + self.obe_choices[last_place_name] = this_round + self.handle_another_rcv_round( + this_round + 1, last_place_names, contest_batch, checks + ) + return + + def parse_all_contests(self, contest_batch: list, checks: list): + """Will parse all the contests validating each""" + errors = {} + vote_count = 0 + for a_git_cvr in contest_batch: + vote_count += 1 + contest = a_git_cvr["contestCVR"] + digest = a_git_cvr["digest"] + Contest.check_contest_blob_syntax(contest, digest=digest) + # Maybe print an provenance log for the tally of this contest + provenance_digest = digest if digest in checks else "" + # Validate the values that should be the same as self + for field in [ + "choices", + "tally", + "win_by", + "max_selections", + "ggo", + "uid", + "contest_name", + "contest_type", + "election_upstream_remote", + ]: + if field in self.contest: + if self.contest[field] != contest[field]: + errors[digest].append( + f"{field} field does not match: " + f"{self.contest[field]} != {contest[field]}" + ) + elif field in contest: + errors[digest].append( + f"{field} field is not present in Tally object but " + "is present in digest" + ) + # Tally the contest - this is just the first pass of a + # tally. It just so happens that with plurality tallies + # the tally can be completed with a single pass over + # the CVRs. And that can be done here. But with more + # complicated tallies such as RCV, the additional passes + # are done outside of this for loop. + if contest["tally"] == "plurality": + self.tally_a_plurality_contest(contest, provenance_digest, vote_count) + elif contest["tally"] == "rcv": + # Since this is the first round on a rcv tally, just + # grap the first selection + self.tally_a_rcv_contest(contest, provenance_digest, vote_count) + else: + # This code block should never be executed as the + # constructor or the Validate values clause above will + # catch this type of error. It is here only as a + # safety check during development time when adding + # support for more tallies. + raise NotImplementedError( + f"the specified tally ({contest['tally']}) is not yet implemented" + ) + + # Will the potential CVR errors found, report them all + if errors: + raise TallyException( + "The following CVRs have structural errors:" f"{errors}" + ) + + 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 + and checks is a list of optional CVR digests (from the voter) + to check. + """ + # Read all the contests, validate, and count votes + if self.contest["tally"] == "plurality": + self.operation_self.imprimir("Plurality - one round", 0) + else: + self.operation_self.imprimir_formatting("empty_line") + if len(contest_batch) > 1: + self.operation_self.imprimir_formatting("horizontal_shortline") + else: + self.operation_self.imprimir_formatting("horizontal_line") + self.operation_self.imprimir("RCV: round 0", 0) + self.parse_all_contests(contest_batch, checks) + + # For all tallies order what has been counted so far (a tuple) + self.rcv_round[0] = sorted( + self.selection_counts.items(), key=operator.itemgetter(1), reverse=True + ) + self.rcv_round.append([]) + + # If plurality, the tally is done + if self.contest["tally"] == "plurality": + # record the winner order + self.winner_order = self.rcv_round[0] + return + + # The rest of this block handles RCV + + # See if another RCV round is necessary. Note that currently + # "RCV" actually means a IRV contests with only one winner. + # When support for other RCV contests is added, RCV will need + # to be replaced by something else (perhaps IRV1). However, + # not only can the number of winners be more than 1 but win_by + # might be 2/3 and not just a simple majority. So only if + # there are enough winners with enough votes is this contest + # done. + + # Get the correct current total vote count for this round + total_current_vote_count = self.get_total_vote_count(0) + self.operation_self.imprimir(f"Total vote count: {total_current_vote_count}", 0) + + # Determine winners if any ... + for choice in Tally.get_choices_from_round(self.rcv_round[0]): + # Note the test is '>' and NOT '>=' + if ( + float(self.selection_counts[choice]) / float(total_current_vote_count) + ) > float(self.defaults["win_by"]): + # A winner. Depending on the win_by (which is a + # function of max), there could be multiple + # winners in this round. + self.winner_order.append((choice, self.selection_counts[choice])) + + # If there are anough winners (IRV1 only has one winner as + # defined by there only being one loser) + if self.winner_order: + return + # More RCV rounds are needed. Loop until we have enough RCV + # winners. + + # Safely determine the next set of last_place_names and + # execute a RCV round. Note that all zero vote choices will + # already be sorted last in self.rcv_round[0]. + last_place_names = self.safely_determine_last_place_names(0) + for name in last_place_names: + self.obe_choices[name] = 0 + # Go. handle_another_rcv_round will return somehow at some point + self.handle_another_rcv_round(1, last_place_names, contest_batch, checks) + return + + def print_results(self): + """Will print the results of the tally""" + self.operation_self.imprimir( + f"Final results for contest {self.contest['contest_name']} " + f"(uid={self.contest['uid']}):", + 0, + ) + # 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]: + self.operation_self.imprimir(f" {result}") + + +# EOF diff --git a/src/vtp/ops/accept_ballot_operation.py b/src/vtp/ops/accept_ballot_operation.py index 5a47ee4..01c8d7c 100644 --- a/src/vtp/ops/accept_ballot_operation.py +++ b/src/vtp/ops/accept_ballot_operation.py @@ -27,7 +27,6 @@ # Standard imports import csv -import logging import os import random import secrets @@ -37,8 +36,8 @@ # Project imports from vtp.core.address import Address -from vtp.core.ballot import Ballot, Contests -from vtp.core.common import Globals, Shellout +from vtp.core.ballot import Ballot +from vtp.core.common import Globals from vtp.core.election_config import ElectionConfig from vtp.ops.merge_contests_operation import MergeContestsOperation @@ -56,8 +55,9 @@ 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. """ - result = Shellout.run( + result = self.shell_out( ["git", "log", branch, "--pretty=format:'%h'"], + incoming_printlevel=5, check=True, capture_output=True, text=True, @@ -113,42 +113,39 @@ def checkout_new_branch( # and attempt at a new unique branch branch = self.new_branch_name(contest, style) # Get the current branch for reference - current_branch = Shellout.run( + current_branch = self.shell_out( ["git", "rev-parse", "--abbrev-ref", "HEAD"], + incoming_printlevel=5, check=True, capture_output=True, text=True, ).stdout.strip() # if after 3 tries it still does not work, raise an error for _ in [0, 1, 2]: - cmd1 = Shellout.run( + cmd1 = self.shell_out( ["git", "checkout", "-b", branch, branchpoint], - printonly=self.printonly, - verbosity=self.verbosity, + incoming_printlevel=5, ) if cmd1.returncode == 0: # Created the local branch - see if it is push-able - cmd2 = Shellout.run( + cmd2 = self.shell_out( ["git", "push", "-u", "origin", branch], - printonly=self.printonly, - verbosity=self.verbosity, + incoming_printlevel=5, ) if cmd2.returncode == 0: # success return branch # At this point there was some type of push failure - delete the # local branch and try again - Shellout.run( + self.shell_out( ["git", "checkout", current_branch], check=True, - printonly=self.printonly, - verbosity=self.verbosity, + incoming_printlevel=5, ) - Shellout.run( + self.shell_out( ["git", "branch", "-D", branch], check=True, - printonly=self.printonly, - verbosity=self.verbosity, + incoming_printlevel=5, ) # At this point the local did not get created - try again branch = self.new_branch_name(contest, style) @@ -168,7 +165,7 @@ def get_unmerged_contests(self, config): # since this is per contest, there should only be about 100 or so # of them. head_commits = ( - Shellout.run( + self.shell_out( [ "git", "rev-list", @@ -179,6 +176,7 @@ def get_unmerged_contests(self, config): "--exclude=refs/remotes/origin/HEAD", "--all", ], + incoming_printlevel=5, check=True, capture_output=True, text=True, @@ -188,10 +186,10 @@ def get_unmerged_contests(self, config): ) # With that list of HEAD exclusion commits, list the rest of the # --yes-walk commits and scrape that for the commits of interest. - return Shellout.cvr_parse_git_log_output( + return self.cvr_parse_git_log_output( ["git", "log", "--no-walk", "--pretty=format:%H%B"] + head_commits, config, - verbosity=self.verbosity - 1, + incoming_printlevel=5, ) def get_cloaked_contests(self, contest, branch): @@ -206,7 +204,7 @@ def get_cloaked_contests(self, contest, branch): """ this_uid = contest.get("uid") cloak_target = contest.get("cloak") - return Shellout.run( + return self.shell_out( [ "git", "log", @@ -217,6 +215,7 @@ def get_cloaked_contests(self, contest, branch): f'--grep="uid": "{this_uid}"', f'--grep="cloak": "{cloak_target}"', ], + incoming_printlevel=5, check=True, capture_output=True, text=True, @@ -244,34 +243,31 @@ def contest_add_and_commit(self, branch, style="contest"): branch, Globals.get("RECEIPT_FILE_MD"), ) - Shellout.run( + self.shell_out( ["git", "add", payload_name], - printonly=self.printonly, - verbosity=self.verbosity, check=True, + incoming_printlevel=5, ) # Note - apparently git places the commit msg on STDERR - hide it if style == "contest": - Shellout.run( + self.shell_out( ["git", "commit", "-F", payload_name], - printonly=self.printonly, - verbosity=1, check=True, + incoming_printlevel=5, ) else: - Shellout.run( + self.shell_out( ["git", "commit", "-m", "Ballot Voucher"], - printonly=self.printonly, - verbosity=1, check=True, + incoming_printlevel=5, ) # Capture the digest - digest = Shellout.run( + digest = self.shell_out( ["git", "log", "-1", "--pretty=format:%H"], - printonly=self.printonly, check=True, capture_output=True, text=True, + incoming_printlevel=5, ).stdout.strip() return digest @@ -283,28 +279,27 @@ def create_ballot_receipt( a csv file with a header line with one row in particular being the voter's. """ - logging.debug("Ballot's digests:\n%s", contest_receipts) + self.imprimir(f"Ballot's digests:\n{contest_receipts}", 5) # Shuffled the unmerged_cvrs (an inplace shuffle) - only need to # shuffle the uids for this ballot. - # import pdb; pdb.set_trace() skip_receipt = False for uid in contest_receipts: # if there are no unmerged_cvrs, just warn if uid not in unmerged_cvrs: - logging.warning("Warning - no unmerged_cvrs yet for contest %s", uid) + self.imprimir(f"no unmerged_cvrs yet for contest {uid}", 2) skip_receipt = True continue if len(unmerged_cvrs[uid]) < Globals.get("BALLOT_RECEIPT_ROWS"): - logging.warning( - "Warning - not enough unmerged CVRs (%s) to print receipt for contest %s", - len(unmerged_cvrs[uid]), - uid, + self.imprimir( + f"not enough unmerged CVRs ({len(unmerged_cvrs[uid])}) " + f"to print receipt for contest {uid}", + 2, ) skip_receipt = True random.shuffle(unmerged_cvrs[uid]) # Create the ballot receipt if skip_receipt: - logging.warning("Skipping ballot receipt due to lack of unmerged CVRs") + self.imprimir("Skipping ballot receipt due to lack of unmerged CVRs", 2) return [], 0, "" ballot_receipt = [] @@ -389,7 +384,7 @@ def main_handle_contests( contest_receipts = {} branches = [] cloak_receipts = {} - with Shellout.changed_cwd(a_ballot.get_cvr_parent_dir(the_election_config)): + with self.changed_cwd(a_ballot.get_cvr_parent_dir(the_election_config)): # So, the CWD in this block is the state/town subfolder # It turns out that determining the other not yet merged to @@ -402,10 +397,9 @@ def main_handle_contests( # least expensive as the big reader is thus a stdout PIPE # loop. unmerged_cvrs = self.get_unmerged_contests(the_election_config) - contests = Contests(a_ballot) - for contest in contests: - # import pdb; pdb.set_trace() - with Shellout.changed_branch("main"): + # import pdb; pdb.set_trace() + for contest in a_ballot.get("contests"): + with self.changed_branch("main"): # get N other values for each contest for this ballot uid = contest.get("uid") # atomically create the branch locally and remotely @@ -444,17 +438,15 @@ def main_handle_contests( # as the others and cloaks as much as possible, then push as # atomically as possible all the contests. for branch in branches: - Shellout.run( + self.shell_out( ["git", "push", "origin", branch], - printonly=self.printonly, - verbosity=self.verbosity, + incoming_printlevel=5, ) # Delete the local as they build up too much. The local # reflog keeps track of the local branches - Shellout.run( + self.shell_out( ["git", "branch", "-d", branch], - printonly=self.printonly, - verbosity=self.verbosity, + incoming_printlevel=5, ) return contest_receipts, branches, unmerged_cvrs, cloak_receipts @@ -469,8 +461,8 @@ def main_handle_receipt( # When here the actual voucher file on disk wants to be a # markdown file for a web-api endpoint rather than the # original csv file defined above. - with Shellout.changed_cwd(a_ballot.get_cvr_parent_dir(the_election_config)): - with Shellout.changed_branch("main"): + with self.changed_cwd(a_ballot.get_cvr_parent_dir(the_election_config)): + with self.changed_branch("main"): # Create a unique branch for the receipt receipt_branch = self.checkout_new_branch( the_election_config, "", "main", "receipt" @@ -483,17 +475,15 @@ def main_handle_receipt( # Commit the voter's ballot voucher self.contest_add_and_commit(receipt_branch, "receipt") # Push the voucher - Shellout.run( + self.shell_out( ["git", "push", "origin", receipt_branch], - printonly=self.printonly, - verbosity=self.verbosity, + incoming_printlevel=5, ) # Create the QR image while still in the branch as exiting the # above with will nominally delete it qr_url = ( - f"{Globals.get('QR_ENDPOINT_ROOT')}/" - f"{os.path.basename(the_election_config.get('git_rootdir'))}" + f"{Globals.get('ELECTION_UPSTREAM_REMOTE')}/" f"/blob/{receipt_branch}/{a_ballot.get('ballot_subdir')}/" f"{receipt_branch}/{Globals.get('RECEIPT_FILE').rstrip('csv')}md" ) @@ -519,10 +509,9 @@ def main_handle_receipt( # At this point the local receipt_branch can be deleted as # the local branches build up too much. The local reflog # keeps track of the local branches - Shellout.run( + self.shell_out( ["git", "branch", "-d", receipt_branch], - printonly=self.printonly, - verbosity=self.verbosity, + incoming_printlevel=5, ) return receipt_branch, qr_img @@ -551,10 +540,13 @@ def run( """ # Create a VTP ElectionData object if one does not already exist - the_election_config = ElectionConfig.configure_election(self.election_data_dir) + the_election_config = ElectionConfig.configure_election( + self, + self.election_data_dir, + ) # Create a ballot - a_ballot = Ballot() + a_ballot = Ballot(self) # Note - it probably makes the most sense to validate an # incoming_cast_ballot against the set of target blank_ballots @@ -572,10 +564,10 @@ def run( # up in the same spot, nominally in the town subfolder. if cast_ballot: # Read the specified cast_ballot - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): + with self.changed_cwd(the_election_config.get("git_rootdir")): a_ballot.read_a_cast_ballot("", the_election_config, cast_ballot) elif cast_ballot_json: - a_ballot.set_ballot_data(cast_ballot_json) + a_ballot.set_ballot_data(cast_ballot_json, a_cast_ballot=True) else: # The json was not supplied - in this case read the cast # ballot from the default location. @@ -591,8 +583,8 @@ def run( a_ballot.verify_cast_ballot_data(the_election_config) # Set the three EV's - os.environ["GIT_AUTHOR_DATE"] = "2022-01-01T12:00:00" - os.environ["GIT_COMMITTER_DATE"] = "2022-01-01T12:00:00" + os.environ["GIT_AUTHOR_DATE"] = Globals.get("ELECTION_DATETIME") + os.environ["GIT_COMMITTER_DATE"] = Globals.get("ELECTION_DATETIME") os.environ["GIT_EDITOR"] = "true" # handle the contests (cloaking is not yet supported) @@ -645,7 +637,7 @@ def run( verbosity=self.verbosity, printonly=self.printonly, ) - logging.debug("Calling MergeContestsOperation.run (contest)") + self.imprimir("Calling MergeContestsOperation.run (contest)", 5) mco.run( branch="origin/" + branch, flush=False, @@ -660,7 +652,7 @@ def run( # If merging also merge the receipt file # if receipt_file_csv and version_receipts: # # ZZZ code to merge - # logging.debug("Calling MergeContestsOperation.run (receipt)") + # self.imprimir(f"Calling MergeContestsOperation.run (receipt)", 5) # mco.run( # branch="origin/" + receipt_branch, # flush=False, @@ -669,8 +661,12 @@ def run( # ) # For now, print the location and the voter's index - print(f"#### Receipt file: {receipt_file_csv}") - print(f"#### Voter's row: {index}") + if not receipt_file_csv: + receipt_file_csv = None + self.imprimir(f"#### Receipt file: {receipt_file_csv}", 0) + if index == 0: + index = None + self.imprimir(f"#### Voter's row: {index}", 0) # 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 - diff --git a/src/vtp/ops/cast_ballot_operation.py b/src/vtp/ops/cast_ballot_operation.py index 35c0f6c..37ccd04 100644 --- a/src/vtp/ops/cast_ballot_operation.py +++ b/src/vtp/ops/cast_ballot_operation.py @@ -26,7 +26,6 @@ """ # Standard imports -import logging import pprint import random @@ -34,8 +33,7 @@ # Project imports from vtp.core.address import Address -from vtp.core.ballot import Ballot, BlankBallot, Contests -from vtp.core.common import Shellout +from vtp.core.ballot import Ballot, BlankBallot from vtp.core.contest import Contest from vtp.core.election_config import ElectionConfig @@ -50,37 +48,32 @@ class CastBallotOperation(Operation): description (immediately below this) in the source file. """ - def make_random_selection(self, the_ballot, the_contest): + def make_random_selection(self, the_contest): """Will randomly make selections on a contest""" # get the possible choices choices = the_contest.get("choices") - tally = the_contest.get("tally") picks = list(range(len(choices))) - # For plurality and max=1, the first choice is the only - # choice. For plurality and max>1, the order does not matter + # For plurality and max_selections=1, the first choice is the only + # choice. For plurality and max_selections>1, the order does not matter # - a selection is a selection. For RCV, the order does # matter as that is the ranking. # # Choose something randomly random.shuffle(picks) - if "plurality" == tally: - loop = the_contest.get("max") - elif "rcv" == tally: - loop = len(choices) - else: - raise KeyError(f"Unspoorted tally ({tally})") + loop = the_contest.get("max_selections") while loop > 0: - the_ballot.add_selection(the_contest, picks.pop(0)) + # import pdb; pdb.set_trace() + the_contest.add_selection(picks.pop(0)) loop -= 1 - def get_user_selection(self, the_ballot, the_contest, count, total_contests): + def get_user_selection(self, the_contest, count, total_contests): """Print the contest and get the selection(s) from the user""" choices = the_contest.get("choices") tally = the_contest.get("tally") - max_votes = the_contest.get("max") + max_votes = the_contest.get("max_selections") # Print something print(f"################ ({count} of {total_contests})") - print(f"Contest {the_contest.get('uid')}: {the_contest.get('name')}") + print(f"Contest {the_contest.get('uid')}: {the_contest.get('contest_name')}") if tally == "plurality": print(f"- This is a {tally} tally") print( @@ -96,17 +89,15 @@ def get_user_selection(self, the_ballot, the_contest, count, total_contests): ) # Need to print the choices first up front - count = 0 for choice_index, choice in enumerate(choices): # If it is a ticket, need to pretty print the ticket - # import pdb; pdb.set_trace() - if the_contest.is_contest_a_ticket_choice(choice_index): + if the_contest.get("contest_type") == "ticket": print( - f" [{count}] {choice} - {the_contest.pretty_print_ticket(choice_index)}" + f" [{choice_index}] {choice} - " + f"{the_contest.pretty_print_a_ticket(choice_index)}" ) else: - print(f" [{count}] {choice}") - count += 1 + print(f" [{choice_index}] {choice}") def validate_multichoice(text): """Will validate the space separated user input choice @@ -152,9 +143,9 @@ def validate_multichoice(text): # If still here, set the selection. Since it is possible to self # adjudicate a contest, always explicitly clear the selection # before adding - the_ballot.clear_selection(the_contest) + the_contest.clear_selection() for sel in validated_selections: - the_ballot.add_selection(the_contest, sel) + the_contest.add_selection(sel) if tally == "plurality": if max_votes > 1: @@ -175,20 +166,20 @@ def loop_over_contests(self, a_ballot, demo_mode): """Will loop over the contests in a ballot and either ask the user for a choice or if in demo mode will randomly choose one. """ - contests = Contests(a_ballot) - total_contests = contests.len() + contests = a_ballot.get("contests") + total_contests = len(contests) count = 0 contest_uids = [] for contest in contests: contest_uids.append(contest.get("uid")) if demo_mode: - self.make_random_selection(a_ballot, contest) + self.make_random_selection(contest) else: # Display the tally type and choices and allow the user to manually # enter something. Might as well validate legal selections (in # this demo) as that is the long-term VTP vision. count += 1 - self.get_user_selection(a_ballot, contest, count, total_contests) + self.get_user_selection(contest, count, total_contests) # pylint: disable=too-many-nested-blocks if not demo_mode: # UX wise replicate the self adjudication experince. This is @@ -196,7 +187,9 @@ def loop_over_contests(self, a_ballot, demo_mode): while True: # Print the selections for contest in contests: - print(f"Contest {contest.get('uid')} - {contest.get('name')}:") + print( + f"Contest {contest.get('uid')} - {contest.get('contest_name')}:" + ) # Loop over selections - there can be more than # one but they are ALWAYS ordered if len(contest.get("selection")) == 0: @@ -206,14 +199,13 @@ def loop_over_contests(self, a_ballot, demo_mode): ) else: for selection in contest.get("selection"): - # import pdb; pdb.set_trace() offset, name = Contest.split_selection(selection) - if contest.is_contest_a_ticket_choice(offset): + if contest.get("contest_type") == "ticket": print( - f" {contest.pretty_print_ticket(offset)} - {name}" + f" [{offset}] {name} - {contest.pretty_print_a_ticket(offset)}" ) else: - print(f" {name}") + print(f" [{offset}] {name}") prompt = ( "Is this correct? " "Enter yes to accept the ballot, no to reject the ballot: " @@ -229,13 +221,11 @@ def loop_over_contests(self, a_ballot, demo_mode): count = 0 for contest in contests: count += 1 - self.get_user_selection( - a_ballot, contest, count, total_contests - ) + self.get_user_selection(contest, count, total_contests) else: for contest in contests: if contest.get("uid") == response: - self.get_user_selection(a_ballot, contest, 1, 1) + self.get_user_selection(contest, 1, 1) break # For a convenient side effect, return the contests return contests @@ -250,15 +240,17 @@ def run( """Main function - see -h for more info""" # Create a VTP ElectionData object if one does not already exist - the_election_config = ElectionConfig.configure_election(self.election_data_dir) + the_election_config = ElectionConfig.configure_election( + self, self.election_data_dir + ) # Create a ballot - a_ballot = BlankBallot() + a_ballot = BlankBallot(self) # process the provided address if blank_ballot: # Read the specified blank_ballot - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): + with self.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): @@ -274,7 +266,9 @@ def run( # If still here, prompt the user to vote for each contest contests = self.loop_over_contests(a_ballot, demo_mode) - logging.debug("And the ballot looks like:\n%s", pprint.pformat(a_ballot.dict())) + self.imprimir( + f"And the ballot looks like:\n{pprint.pformat(a_ballot.dict())}", 5 + ) # Validate at least something a_ballot.verify_cast_ballot_data(the_election_config) @@ -291,10 +285,10 @@ def run( a_ballot.get("ballot_node"), "config" )["voting centers"] for vote_center in voting_centers: - logging.info( - "Casting a %s contest ballot at VC %s", contests.len(), vote_center + self.imprimir( + f"Casting a {len(contests)} contest ballot at VC {vote_center}", 3 ) - logging.info("Cast ballot file: %s", ballot_file) + self.imprimir(f"Cast ballot file: {ballot_file}", 3) # return the cast ballot location return ballot_file diff --git a/src/vtp/ops/create_blank_ballot_operation.py b/src/vtp/ops/create_blank_ballot_operation.py index e0bf52c..0866cab 100644 --- a/src/vtp/ops/create_blank_ballot_operation.py +++ b/src/vtp/ops/create_blank_ballot_operation.py @@ -20,13 +20,11 @@ """Logic of operation for creating a blank ballot.""" # Standard imports -import logging import pprint # Project imports from vtp.core.address import Address from vtp.core.ballot import BlankBallot -from vtp.core.common import Common from vtp.core.election_config import ElectionConfig # Local imports @@ -49,45 +47,46 @@ def run( ): """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) + the_election_config = ElectionConfig.configure_election( + self, + self.election_data_dir, + ) # Set the ggos for the address an_address.map_ggos(the_election_config) # print some debugging info - logging.debug("The election config ggos are: %s", the_election_config) - logging.debug("And the address is: %s", str(an_address)) - logging.debug("And language is: %s", language) + self.imprimir(f"The election config ggos are: {the_election_config}", 4) + self.imprimir(f"And the address is: {str(an_address)}", 4) + self.imprimir(f"And language is: {language}", 4) # Construct a blank ballot - the_ballot = BlankBallot() + the_ballot = BlankBallot(self) the_ballot.create_blank_ballot(an_address, the_election_config) - logging.info("Active GGOs: %s", the_ballot.get("active_ggos")) - logging.debug( - "And the blank ballot looks like:\n%s", pprint.pformat(the_ballot.dict()) + self.imprimir(f"Active GGOs: {the_ballot.get('active_ggos')}", 4) + self.imprimir( + f"And the blank ballot looks like:\n{pprint.pformat(the_ballot.dict())}", + 5, ) # Maybe display some node info node = the_ballot.get("ballot_node") - logging.debug( - "And a/the node (%s) looks like:\n%s", - node, - pprint.pformat(the_election_config.get_node(node, "ALL")), + self.imprimir( + f"And a/the node ({node}) looks like:\n" + f"{pprint.pformat(the_election_config.get_node(node, 'ALL'))}", + 5, ) - logging.debug( - "And the edges are: %s", - pprint.pformat(the_election_config.get_dag("edges")), + self.imprimir( + f"And the edges are: {pprint.pformat(the_election_config.get_dag('edges'))}", + 5, ) # Write it out ballot_file = the_ballot.write_blank_ballot( the_election_config, printonly=self.printonly ) - logging.info("Blank ballot file: %s", ballot_file) + self.imprimir(f"Blank ballot file: {ballot_file}", 3) # EOF diff --git a/src/vtp/ops/generate_all_blank_ballots_operation.py b/src/vtp/ops/generate_all_blank_ballots_operation.py index a023522..36beccb 100644 --- a/src/vtp/ops/generate_all_blank_ballots_operation.py +++ b/src/vtp/ops/generate_all_blank_ballots_operation.py @@ -20,14 +20,12 @@ """Logic of operation for generating blank ballots.""" # Standard imports -import logging import os import pprint # Project imports from vtp.core.address import Address from vtp.core.ballot import BlankBallot -from vtp.core.common import Common from vtp.core.election_config import ElectionConfig # Local imports @@ -45,17 +43,17 @@ class GenerateAllBlankBallotsOperation(Operation): def run(self): """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) + the_election_config = ElectionConfig.configure_election( + self, self.election_data_dir + ) # Walk a topo sort of the DAG and for any node with # 'unique-ballots', add them all. If the subdir does not match # REQUIRED_GGO_ADDRESS_FIELDS, place the blank ballot for node in the_election_config.get_dag("topo"): address_map = the_election_config.get_node(node, "address_map") + # import pdb; pdb.set_trace() if "unique-ballots" in address_map: for unique_ballot in address_map["unique-ballots"]: subdir = the_election_config.get_node(node, "subdir") @@ -67,19 +65,18 @@ def run(self): generic_address = Address.create_generic_address( the_election_config, subdir, ggos ) - generic_ballot = BlankBallot() + generic_ballot = BlankBallot(self) generic_ballot.create_blank_ballot( generic_address, the_election_config ) - logging.info( - "Active GGOs for blank ballot (%s): %s", - generic_address, - generic_ballot.get("active_ggos"), - ) - logging.debug( - "And the blank ballot looks like:\n%s", - pprint.pformat(generic_ballot.dict()), + self.imprimir( + f"Active GGOs for blank ballot ({generic_address}): " + f"{generic_ballot.get('active_ggos')}", + 3, ) + self.imprimir("And the blank ballot looks like:\n", 5) + if self.verbosity >= 4: + pprint.pformat(generic_ballot.dict()) # Write it out if self.printonly: ballot_file = the_election_config.gen_blank_ballot_location( @@ -91,7 +88,7 @@ def run(self): ballot_file = generic_ballot.write_blank_ballot( the_election_config ) - logging.info("Blank ballot file: %s", ballot_file) + self.imprimir(f"Blank ballot file: {ballot_file}") # EOF diff --git a/src/vtp/ops/merge_contests_operation.py b/src/vtp/ops/merge_contests_operation.py index ca395f5..fecb9b7 100644 --- a/src/vtp/ops/merge_contests_operation.py +++ b/src/vtp/ops/merge_contests_operation.py @@ -24,13 +24,12 @@ """ # Standard imports -import logging import os import random import re # Project import -from vtp.core.common import Globals, Shellout +from vtp.core.common import Globals from vtp.core.election_config import ElectionConfig # Local imports @@ -47,53 +46,47 @@ class MergeContestsOperation(Operation): def merge_receipt_branch(self, branch: str, remote: bool): """Merge a specific receipt branch""" # This command is duplicate from merge_receipt_branch below - contest_file = Shellout.run( + contest_file = self.shell_out( ["git", "diff-tree", "--no-commit-id", "-r", "--name-only", branch], - verbosity=self.verbosity, + incoming_printlevel=5, capture_output=True, text=True, check=True, ).stdout.strip() # This command is duplicate from merge_receipt_branch below if not contest_file: - logging.error( - "Error - (receipt) " - "'git diff-tree --no-commit-d -r --name-only %s' returned no files." - " Skipping", - branch, + self.imprimir( + "(receipt) 'git diff-tree --no-commit-d -r --name-only " + f"{branch}' returned no files. Skipping", + 1, ) return # For receipts the content stays intact AND the branch is # unique, so there should never be a conflict on the branch - # it should always successfully auto-merge as there are not # file overlaps. - Shellout.run( - ["git", "merge", branch], - printonly=self.printonly, - verbosity=self.verbosity, + self.shell_out(["git", "merge", branch], incoming_printlevel=5) + self.shell_out( + ["git", "push", "origin", "main"], check=True, incoming_printlevel=5 ) - Shellout.run(["git", "push", "origin", "main"], self.printonly, check=True) # Delete the local and remote branch if this is a local branch if not remote: - Shellout.run( + self.shell_out( ["git", "push", "origin", "-d", branch], - printonly=self.printonly, - verbosity=self.verbosity, check=True, + incoming_printlevel=5, ) - Shellout.run( + self.shell_out( ["git", "branch", "-d", branch], - printonly=self.printonly, - verbosity=self.verbosity, check=True, + incoming_printlevel=5, ) else: # otherwise just delete the remote - Shellout.run( + self.shell_out( ["git", "push", "origin", "-d", branch.removeprefix("origin/")], - printonly=self.printonly, - verbosity=self.verbosity, check=True, + incoming_printlevel=5, ) def merge_contest_branch(self, branch: str, remote: bool): @@ -101,9 +94,9 @@ def merge_contest_branch(self, branch: str, remote: bool): # If the VTP server is processing contests from different # voting centers, then the contest.json could be in different # locations on different branches. - contest_file = Shellout.run( + contest_file = self.shell_out( ["git", "diff-tree", "--no-commit-id", "-r", "--name-only", branch], - verbosity=self.verbosity, + incoming_printlevel=5, capture_output=True, text=True, check=True, @@ -114,29 +107,27 @@ def merge_contest_branch(self, branch: str, remote: bool): # test condition in that if there is no file to merge, there is no # file to merge - pass. if not contest_file: - logging.error( - "Error - (contest) " - "'git diff-tree --no-commit-d -r --name-only %s' returned no files." - " Skipping", - branch, + self.imprimir( + "(contest) 'git diff-tree --no-commit-d -r --name-only " + f"{branch}' returned no files. Skipping", + 1, ) return # Merge the branch / file. Note - for contests there will # always be a conflict so this command will always return non # zero - Shellout.run( + self.shell_out( ["git", "merge", "--no-ff", "--no-commit", branch], - printonly=self.printonly, - verbosity=self.verbosity, + incoming_printlevel=5, ) # ZZZ - replace this with an run-time cryptographic value # derived from the run-time election private key (diffent from # the git commit run-time value). This will basically slam # the contents of the contest file to a second runtime digest # (the first one being contained in the commit itself). - result = Shellout.run( + result = self.shell_out( ["openssl", "rand", "-base64", "48"], - verbosity=self.verbosity, + incoming_printlevel=5, capture_output=True, text=True, check=True, @@ -150,45 +141,43 @@ def merge_contest_branch(self, branch: str, remote: bool): # merge outfile.write(str(result.stdout)) # Force the git add just in case - Shellout.run( + self.shell_out( ["git", "add", contest_file], - printonly=self.printonly, - verbosity=self.verbosity, check=True, + incoming_printlevel=5, ) # Note - apparently git places the commit msg on STDERR - hide it if not self.printonly: - logging.info( - "Running \"git commit -m 'auto commit - thank you for voting'\"" + self.imprimir( + "Running \"git commit -m 'auto commit - thank you for voting'\"", + 4, ) - Shellout.run( + self.shell_out( ["git", "commit", "-m", "auto commit - thank you for voting"], - printonly=self.printonly, - verbosity=1, check=True, + incoming_printlevel=5, + ) + self.shell_out( + ["git", "push", "origin", "main"], check=True, incoming_printlevel=5 ) - Shellout.run(["git", "push", "origin", "main"], self.printonly, check=True) # Delete the local and remote branch if this is a local branch if not remote: - Shellout.run( + self.shell_out( ["git", "push", "origin", "-d", branch], - printonly=self.printonly, - verbosity=self.verbosity, check=True, + incoming_printlevel=5, ) - Shellout.run( + self.shell_out( ["git", "branch", "-d", branch], - printonly=self.printonly, - verbosity=self.verbosity, check=True, + incoming_printlevel=5, ) else: # otherwise just delete the remote - Shellout.run( + self.shell_out( ["git", "push", "origin", "-d", branch.removeprefix("origin/")], - printonly=self.printonly, - verbosity=self.verbosity, check=True, + incoming_printlevel=5, ) # pylint: disable=too-many-arguments @@ -206,14 +195,15 @@ def randomly_merge_contests( if flush: count = len(batch) else: - logging.info( - "Contest %s not merged - only %s available", uid, len(batch) + self.imprimir( + f"Contest {uid} not merged - only {len(batch)} available", + 3, ) return 0 else: count = len(batch) - minimum_cast_cache loop = count - logging.info("Merging %s contests for contest %s", count, uid) + self.imprimir(f"Merging {count} contests for contest {uid}", 4) while loop: pick = random.randrange(len(batch)) branch = batch[pick] @@ -221,7 +211,7 @@ def randomly_merge_contests( # End of loop maintenance del batch[pick] loop -= 1 - logging.debug("Merged %s %s contests", count, uid) + self.imprimir(f"Merged {count} {uid} contests", 4) return count # pylint: disable=duplicate-code @@ -252,32 +242,34 @@ def run( """ # Create a VTP ElectionData object if one does not already exist - the_election_config = ElectionConfig.configure_election(self.election_data_dir) + the_election_config = ElectionConfig.configure_election( + self, + self.election_data_dir, + ) # Set the three EV's - os.environ["GIT_AUTHOR_DATE"] = "2022-01-01T12:00:00" - os.environ["GIT_COMMITTER_DATE"] = "2022-01-01T12:00:00" + os.environ["GIT_AUTHOR_DATE"] = Globals.get("ELECTION_DATETIME") + os.environ["GIT_COMMITTER_DATE"] = Globals.get("ELECTION_DATETIME") os.environ["GIT_EDITOR"] = "true" # For best results (so to use the 'correct' git submodule or # tranverse the correct symlink or not), use the CWD as when # accepting the ballot (accept_ballot.py). merged = 0 - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): + with self.changed_cwd(the_election_config.get("git_rootdir")): # So, the CWD in this block is the state/town subfolder # Pull the remote - Shellout.run( + self.shell_out( ["git", "pull"], - printonly=self.printonly, - verbosity=self.verbosity, check=True, + incoming_printlevel=5, ) if branch: if style == "contest": self.merge_contest_branch(branch, remote) else: self.merge_receipt_branch(branch, remote) - logging.info("Merged '%s'", branch) + self.imprimir(f"Merged '{branch}'", 4) return # Get the pending CVR branches cmds = ["git", "branch"] @@ -291,9 +283,9 @@ def run( # after that each result is strip'ed cvr_branches = [ this_branch.strip() - for this_branch in Shellout.run( + for this_branch in self.shell_out( cmds, - verbosity=self.verbosity, + incoming_printlevel=5, check=True, capture_output=True, text=True, @@ -335,7 +327,7 @@ def run( remote=remote, minimum_cast_cache=minimum_cast_cache, ) - logging.info("Merged %s contest branches", merged) + self.imprimir(f"Merged {merged} contest branches", 3) # EOF diff --git a/src/vtp/ops/operation.py b/src/vtp/ops/operation.py index 5538eea..dee0463 100644 --- a/src/vtp/ops/operation.py +++ b/src/vtp/ops/operation.py @@ -17,7 +17,33 @@ """Base class of operations.""" -from vtp.core.common import Common +# standard imports +import json +import os +import re +import subprocess +from contextlib import contextmanager + +# local imports +from vtp.core.common import Globals + +# ZZZ - not sure how to best do this - could not make it work. See: +# https://stackoverflow.com/questions/6760685/what-is-the-best-way-of-implementing-singleton-in-python +# from py_singleton import singleton +# +# class Singleton(type): +# """ +# Mmm, there should only really be one instance of Operation and not +# multiple, so maybe create a singleton class? +# """ +# _instances = {} +# def __call__(cls, *args, **kwargs): +# if cls not in cls._instances: +# cls._instances[cls] = super(Singleton, cls).__call__(*args, **kwargs) +# return cls._instances[cls] +# +# class Operation(metaclass=Singleton): +# pass # pylint: disable=too-few-public-methods @@ -29,33 +55,301 @@ class Operation: election_data_dir. """ + # class constants + _sha1_regex = re.compile(r"([0-9a-fA-F]{40})") + _hackitoergosum = { + "election_data_dir": None, + "printonly": None, + "verbosity": None, + "style": None, + "stdout_printing": None, + "stdout_output": [], + "initialized": False, + } + + # pylint: disable=too-many-arguments def __init__( self, election_data_dir: str = "", - verbosity: int = 3, + verbosity: int = Globals.get("DEFAULT_VERBOSITY"), printonly: bool = False, stdout_printing: bool = True, + output_style: str = "text", ): + """ + Design note: originally the logging package was used, But custom + output was added without retiring logging. And then having two + systems became a problem, and the logging package was dropped. + """ + # The verbosity levels: + # 0: ALWAYS - always print the line + # 1: ERROR - [ERROR] is prepended to the line + # 2: WARNING - [WARNING] is prepended to the line + # 3: INFO - minimal info + # 4: VERBOSE - more info + # 5: DEBUG - everything + + if Operation._hackitoergosum["initialized"]: + self.election_data_dir = Operation._hackitoergosum["election_data_dir"] + self.printonly = Operation._hackitoergosum["printonly"] + self.verbosity = Operation._hackitoergosum["verbosity"] + self.output_style = Operation._hackitoergosum["output_style"] + self.stdout_printing = Operation._hackitoergosum["stdout_printing"] + self.stdout_output = Operation._hackitoergosum["stdout_output"] + return + # import pdb; pdb.set_trace() self.election_data_dir = election_data_dir self.printonly = printonly self.verbosity = verbosity - # Configure logging - Common.configure_logging(verbosity) + self.output_style = output_style # Validate the election_data_dir arg here and now - Common.verify_election_data_dir(self.election_data_dir) + Globals.verify_election_data_dir(self.election_data_dir) # Configure printing self.stdout_printing = stdout_printing - self.stdout_output = [] + if output_style == "html": + self.stdout_output = ["

"] + else: + self.stdout_output = [] + Operation._hackitoergosum["election_data_dir"] = self.election_data_dir + Operation._hackitoergosum["printonly"] = self.printonly + Operation._hackitoergosum["verbosity"] = self.verbosity + Operation._hackitoergosum["output_style"] = self.output_style + Operation._hackitoergosum["stdout_printing"] = self.stdout_printing + Operation._hackitoergosum["stdout_ouput"] = self.stdout_output + Operation._hackitoergosum["initialized"] = True + + def imprimir_formatting( + self, + a_construct: str, + incoming_printlevel: int = Globals.get("DEFAULT_VERBOSITY"), + ): + """Will print various formatting constructs for UX. If + incoming_printlevel is less than or equal to self.verbosity, + the line prints. The default self.verbosity is nominally 3. + """ + a_line = "" + if incoming_printlevel <= self.verbosity: + if self.output_style == "html": + match a_construct: + case "horizontal_line": + a_line = "


" + case "horizontal_shortline": + a_line = '
' + case "empty_line": + a_line = "" + case "begin_good_box": + a_line = "*" * 12 + case "end_good_box": + a_line = "*" * 12 + case "begin_error_box": + a_line = "#" * 12 + case "end_error_box": + a_line = "#" * 12 + case _: + raise RuntimeError( + f"Error: unsupported printing construct {a_construct}" + ) + else: + match a_construct: + case "horizontal_line": + a_line = "-" * 78 + case "horizontal_shortline": + a_line = "-" * 32 + case "empty_line": + a_line = "" + case "begin_good_box": + a_line = "*" * 12 + case "end_good_box": + a_line = "*" * 12 + case "begin_error_box": + a_line = "#" * 12 + case "end_error_box": + a_line = "#" * 12 + case _: + raise RuntimeError( + f"Error: unsupported printing construct {a_construct}" + ) + if self.stdout_printing: + print(a_line) + else: + self.stdout_output.append(a_line) - def imprimir(self, a_line: str, incoming_printlevel: int = 3): - """Either prints a line of text to STDOUT or appends it to a list""" + def imprimir( + self, + a_line: str, + incoming_printlevel: int = Globals.get("DEFAULT_VERBOSITY"), + handle_hyperlinks: bool = False, + ): + """Either prints a line of text to STDOUT or appends it to a + list, in which case the output needs to be retrieved. If + incoming_printlevel is less than or equal to self.verbosity, + the line prints. The default self.verbosity is nominally 3. + """ if incoming_printlevel <= self.verbosity: + if self.output_style == "html": + # If self.output_style == "html", html-ize the line + # - add digest links for digests + # - add line breaks per line + # - convert an array to a table with css class=imprimir + # ZZZ + # import pdb; pdb.set_trace() + if handle_hyperlinks: + a_line = Operation._sha1_regex.sub( + r'\1', a_line + ) + match incoming_printlevel: + case 1: + a_line = '[ERROR] ' + a_line + case 2: + a_line = '[WARNING] ' + a_line + else: + match incoming_printlevel: + case 1: + a_line = "[ERROR] " + a_line + case 2: + a_line = "[WARNING] " + a_line 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 + + # The below were oringally in the Shellout package + + def shell_out( + self, + argv: list, + no_touch_stds: bool = False, + printonly_override: bool = False, + incoming_printlevel: int = Globals.get("DEFAULT_VERBOSITY"), + **kwargs, + ): + """Run a shell command with logging and error handling. + Raises a CalledProcessError if the shell command fails - the + caller needs to deal with that. Can also raise a + TimeoutExpired exception. + + Nominally returns a CompletedProcess instance. + + See for example + https://docs.python.org/3.9/library/subprocess.html + + If printonly_override is True, then self.printonly is ignored. + + If incoming_printlevel is less than or equal to + self.verbosity, the line prints similar to imprimir above ( + """ + # Note - it is ok to pass ints and floats down through argv + # here, but they need to be individually converted to strings + # regardless since _everything_ below wants to see strings. + argv_string = [str(arg) for arg in argv] + self.imprimir(f'Running ({" ".join(argv_string)})', incoming_printlevel) + if self.printonly and not printonly_override: + return subprocess.CompletedProcess(argv_string, 0, stdout="", stderr="") + # the caller decides on whether check is set or not + # pylint: disable=subprocess-run-check + if not no_touch_stds: + if "capture_output" not in kwargs: + if "stdout" not in kwargs and incoming_printlevel > self.verbosity: + kwargs["stdout"] = subprocess.DEVNULL + if "stderr" not in kwargs and incoming_printlevel > self.verbosity: + kwargs["stderr"] = subprocess.DEVNULL + if "timeout" not in kwargs: + kwargs["timeout"] = Globals.get("SHELL_TIMEOUT") + # import pdb; pdb.set_trace() + return subprocess.run(argv_string, **kwargs) + + @contextmanager + def changed_cwd(self, path: str): + """Context manager for temporarily changing the CWD""" + oldpwd = os.getcwd() + try: + os.chdir(path) + self.imprimir(f"Entering dir ({path})", 5) + yield + finally: + os.chdir(oldpwd) + self.imprimir(f"Leaving dir ({path})", 5) + + @contextmanager + def changed_branch(self, branch: str): + """ + Context manager for temporarily encapsulating a potential git + branch change. Will explicitly switch to the specified branch + before yielding. + """ + self.shell_out(["git", "checkout", branch], check=True, incoming_printlevel=5) + self.imprimir(f"Entering branch ({branch})", 5) + try: + yield + finally: + # switch the branch back + self.shell_out( + ["git", "checkout", branch], check=True, incoming_printlevel=5 + ) + self.imprimir(f"Leaving branch ({branch})", 5) + + # ZZZ - could use an optional filter_by_uid argument which is a set object + def cvr_parse_git_log_output( + self, + git_log_command: list, + election_config: dict, + grouped_by_uid: bool = True, + incoming_printlevel: int = -1, + ): + """Will execute the supplied git log command and process the + output of those commits that are CVRs. Will return a + dictionary keyed on the contest UID that is a list of CVRs. + The CVR is just the CVR from the git log with a 'digest' key + added. + + Note the the order of the list is git log order and not + randomized FWIIW. + """ + # Will process all the CVR commits on the main branch and tally + # all the contests found. + git_log_cvrs = {} + with self.changed_cwd(election_config.get("git_rootdir")): + self.imprimir(f'Running ({" ".join(git_log_command)})', incoming_printlevel) + with subprocess.Popen( + git_log_command, stdout=subprocess.PIPE, text=True, encoding="utf8" + ) as git_output: + # read lines until there is a complete json object, then + # add the object for that contest. + block = "" + digest = "" + recording = False + # question - how to get "for line in + # git_output.stdout.readline():" not to effectively return + # the characters in line as opposed to the entire line + # itself? + while True: + line = git_output.stdout.readline() + if not line: + break + if match := re.match("^([a-f0-9]{40}){", line): + digest = match.group(1) + recording = True + block = "{" + continue + if recording: + block += line.strip() + if re.match("^}", line): + # this loads the contest under the CVR key + cvr = json.loads(block) + if grouped_by_uid: + # import pdb; pdb.set_trace() + cvr["digest"] = digest + if cvr["contestCVR"]["uid"] in git_log_cvrs: + git_log_cvrs[cvr["contestCVR"]["uid"]].append(cvr) + else: + git_log_cvrs[cvr["contestCVR"]["uid"]] = [cvr] + else: + git_log_cvrs[digest] = cvr + block = "" + digest = "" + recording = False + return git_log_cvrs diff --git a/src/vtp/ops/run_mock_election_operation.py b/src/vtp/ops/run_mock_election_operation.py index c361183..80fac57 100644 --- a/src/vtp/ops/run_mock_election_operation.py +++ b/src/vtp/ops/run_mock_election_operation.py @@ -23,14 +23,12 @@ """ # Standard imports -import logging import os import time # Project imports from vtp.core.address import Address from vtp.core.ballot import Ballot -from vtp.core.common import Shellout from vtp.core.election_config import ElectionConfig from vtp.ops.accept_ballot_operation import AcceptBallotOperation from vtp.ops.cast_ballot_operation import CastBallotOperation @@ -70,7 +68,7 @@ def scanner_mockup( # a blank ballot location was specified (either directly or via an address) blank_ballots.append(ballot) else: - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): + with self.changed_cwd(the_election_config.get("git_rootdir")): for dirpath, _, files in os.walk("."): for filename in [ f @@ -89,29 +87,24 @@ def scanner_mockup( count += 1 for blank_ballot in blank_ballots: if duration: - logging.info( - "Iteration %s - processing %s", - count, - blank_ballot, + self.imprimir( + f"Iteration {count} - processing {blank_ballot}", + 3, ) else: - logging.info( - "Iteration %s of %s - processing %s", - count, - iterations, - blank_ballot, + self.imprimir( + f"Iteration {count} of {iterations} - processing {blank_ballot}", + 3, ) # - cast a ballot - # import pdb; pdb.set_trace() - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): - Shellout.run( + with self.changed_cwd(the_election_config.get("git_rootdir")): + self.shell_out( ["git", "pull"], - printonly=self.printonly, - verbosity=self.verbosity, - no_touch_stds=True, timeout=None, check=True, + incoming_printlevel=4, ) + # import pdb; pdb.set_trace() cast_ballot = CastBallotOperation( election_data_dir=self.election_data_dir, verbosity=self.verbosity, @@ -157,13 +150,11 @@ def scanner_mockup( ) # don't let too much garbage build up if count % 10 == 9: - Shellout.run( + self.shell_out( ["git", "gc"], - printonly=self.printonly, - verbosity=self.verbosity, - no_touch_stds=True, timeout=None, check=True, + incoming_printlevel=4, ) if iterations and count >= iterations: break @@ -190,21 +181,17 @@ def scanner_mockup( ) tally_contests.run() # clean up git just in case - Shellout.run( + self.shell_out( ["git", "remote", "prune", "origin"], - printonly=self.printonly, - verbosity=self.verbosity, - no_touch_stds=True, timeout=None, check=True, + incoming_printlevel=4, ) - Shellout.run( + self.shell_out( ["git", "gc"], - printonly=self.printonly, - verbosity=self.verbosity, - no_touch_stds=True, timeout=None, check=True, + incoming_printlevel=4, ) def server_mockup( @@ -227,14 +214,12 @@ def server_mockup( while True: count += 1 - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): - Shellout.run( + with self.changed_cwd(the_election_config.get("git_rootdir")): + self.shell_out( ["git", "pull"], - self.printonly, - self.verbosity, - no_touch_stds=True, timeout=None, check=True, + incoming_printlevel=4, ) if flush_mode == 2: merge_contests = MergeContestsOperation( @@ -264,7 +249,7 @@ def server_mockup( ) if iterations and count >= iterations: break - logging.info("Sleeping for 10 (iteration=%s)", count) + self.imprimir(f"Sleeping for 10 (iteration={count})", 3) time.sleep(10) elapsed_time = time.time() - start_time if not iterations and elapsed_time > seconds: @@ -318,7 +303,10 @@ def run( """ # Create a VTP ElectionData object if one does not already exist - the_election_config = ElectionConfig.configure_election(self.election_data_dir) + the_election_config = ElectionConfig.configure_election( + self, + self.election_data_dir, + ) # If an address was used, use that if an_address is not None: diff --git a/src/vtp/ops/setup_vtp_demo_operation.py b/src/vtp/ops/setup_vtp_demo_operation.py index 82cba6a..a14a291 100644 --- a/src/vtp/ops/setup_vtp_demo_operation.py +++ b/src/vtp/ops/setup_vtp_demo_operation.py @@ -23,13 +23,12 @@ """ # Standard imports -import logging import os import re import secrets # Project imports -from vtp.core.common import Common, Globals, Shellout +from vtp.core.common import Globals from vtp.core.election_config import ElectionConfig # Local imports @@ -100,17 +99,15 @@ def create_client_repos(self, clone_dirs, upstream_url): # submodules to be cloned. for clone_dir in clone_dirs: if not self.printonly: - with Shellout.changed_cwd(clone_dir): - Shellout.run( + with self.changed_cwd(clone_dir): + self.shell_out( ["git", "clone", upstream_url], - self.printonly, - verbosity=self.verbosity, check=True, ) else: - logging.debug("Entering dir (%s):", clone_dir) - logging.info("Running git clone %s", upstream_url) - logging.debug("Leaving dir (%s):", clone_dir) + self.imprimir(f"Entering dir ({clone_dir}):", 5) + self.imprimir(f"Running git clone {upstream_url}", 3) + self.imprimir(f"Leaving dir ({clone_dir}):", 5) def create_a_guid_workspace_folder(self, location: str): """creates guid workspace""" @@ -127,7 +124,7 @@ def create_a_guid_workspace_folder(self, location: str): path2 = os.path.join(path1, folder2) if not self.printonly: try: - logging.debug("creating (%s) if it does not exist", path1) + self.imprimir(f"creating ({path1}) if it does not exist", 5) os.mkdir(path1) except FileExistsError: pass @@ -142,7 +139,7 @@ def create_a_guid_workspace_folder(self, location: str): while True: count += 1 try: - logging.debug("creating (%s)", path2) + self.imprimir(f"creating ({path2})", 5) os.mkdir(path2) except FileExistsError as exc: if count > 3: @@ -156,13 +153,13 @@ def create_a_guid_workspace_folder(self, location: str): # success break else: - logging.debug("creating (%s) if it does not exist", path1) - logging.debug("creating (%s) if it does not exist", path2) + self.imprimir(f"creating ({path1}) if it does not exist", 5) + self.imprimir(f"creating ({path2}) if it does not exist", 5) # Clone the repo from the local clone, not the GitHub remote clone self.create_client_repos([path2], self.tabulation_local_upstream_absdir) # return the GUID - logging.debug("returning %s", guid) + self.imprimir(f"returning guid ({guid})", 5) return guid # pylint: disable=duplicate-code @@ -174,16 +171,16 @@ def run( ) -> 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) + the_election_config = ElectionConfig.configure_election( + self, self.election_data_dir + ) # Get the election data native GitHub remote clone name from _here_ - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): - election_data_remote_url = Shellout.run( + with self.changed_cwd(the_election_config.get("git_rootdir")): + election_data_remote_url = self.shell_out( ["git", "config", "--get", "remote.origin.url"], + incoming_printlevel=5, check=True, capture_output=True, text=True, @@ -222,23 +219,21 @@ def run( ]: full_dir = os.path.join(location, subdir) if not os.path.isdir(full_dir): - logging.debug("creating (%s)", full_dir) + self.imprimir(f"creating ({full_dir})", 5) if not self.printonly: os.mkdir(full_dir) # Second clone the bare upstream remote GitHub ElectionData repo if not self.printonly: - with Shellout.changed_cwd(bare_clone_path): - Shellout.run( + with self.changed_cwd(bare_clone_path): + self.shell_out( ["git", "clone", "--bare", election_data_remote_url], - self.printonly, - verbosity=self.verbosity, check=True, ) else: - logging.debug("Entering dir (%s):", bare_clone_path) - logging.info("Running git clone --bare %s", election_data_remote_url) - logging.debug("Leaving dir (%s):", bare_clone_path) + self.imprimir(f"Entering dir ({bare_clone_path}):", 5) + self.imprimir(f"Running git clone --bare {election_data_remote_url}", 3) + self.imprimir(f"Leaving dir ({bare_clone_path}):", 5) # Third create the mock scanner client subdirs clone_dirs = [] @@ -249,7 +244,7 @@ def run( "scanner." + f"{count:02d}", ) if not os.path.isdir(full_dir): - logging.debug("creating (%s)", full_dir) + self.imprimir(f"creating ({full_dir})", 5) if not self.printonly: os.mkdir(full_dir) clone_dirs.append(full_dir) @@ -261,7 +256,7 @@ def run( "server", ) if not os.path.isdir(full_dir): - logging.debug("creating (%s)", full_dir) + self.imprimir(f"creating ({full_dir})", 5) if not self.printonly: os.mkdir(full_dir) clone_dirs.append(full_dir) diff --git a/src/vtp/ops/show_contests_operation.py b/src/vtp/ops/show_contests_operation.py index 4630c7b..26b8a14 100644 --- a/src/vtp/ops/show_contests_operation.py +++ b/src/vtp/ops/show_contests_operation.py @@ -20,10 +20,8 @@ """Logic of operation for showing contests.""" # Standard imports -import logging # Project imports -from vtp.core.common import Shellout from vtp.core.election_config import ElectionConfig # Local imports @@ -43,16 +41,16 @@ def validate_digests(self, digests, the_election_config, error_digests): """ errors = 0 input_data = "\n".join(digests.split(",")) + "\n" - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): + with self.changed_cwd(the_election_config.get("git_rootdir")): output_lines = ( - Shellout.run( + self.shell_out( [ "git", "cat-file", "--batch-check=%(objectname) %(objecttype)", "--buffer", ], - verbosity=self.verbosity, + incoming_printlevel=5, input=input_data, text=True, check=True, @@ -64,15 +62,13 @@ def validate_digests(self, digests, the_election_config, error_digests): for count, line in enumerate(output_lines): digest, commit_type = line.split() if commit_type == "missing": - logging.error("[ERROR]: missing digest: n=%s digest=%s", count, digest) + self.imprimir(f"missing digest: n={count} digest={digest}", 1) error_digests.add(digest) errors += 1 elif commit_type != "commit": - logging.error( - "[ERROR]: invalid digest type: n=%s digest=%s type=%s", - count, - digest, - commit_type, + self.imprimir( + f"invalid digest type: n={count} digest={digest} type={commit_type}", + 1, ) error_digests.add(digest) errors += 1 @@ -84,7 +80,9 @@ 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 - the_election_config = ElectionConfig.configure_election(self.election_data_dir) + the_election_config = ElectionConfig.configure_election( + self, self.election_data_dir + ) # First validate the digests error_digests = set() @@ -93,10 +91,11 @@ def run(self, contest_check: str = "") -> list: digest for digest in contest_check.split(",") if digest not in error_digests ] # show/log the digests - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): + with self.changed_cwd(the_election_config.get("git_rootdir")): output_lines = ( - Shellout.run( + self.shell_out( ["git", "show", "-s"] + valid_digests, + incoming_printlevel=5, text=True, check=True, capture_output=True, @@ -114,15 +113,18 @@ def run(self, contest_check: str = "") -> list: # this is a loop of shell commands # for digest in contest_check.split(','): # if digest not in error_digests: -# Shellout.run(['git', 'log', '-1', digest], check=True) +# self.shell_out( +# ['git', 'log', '-1', digest], +# incoming_printlevel=5, +# check=True) # this does not work well enough either # input_data = '\n'.join(contest_check.split(',')) + '\n' -# Shellout.run( +# self.shell_out( # ['git', 'cat-file', '--batch=%(objectname)'], +# incoming_printlevel=5, # input=input_data, # text=True, -# check=True, -# verbosity=self.verbosity) +# check=True) # EOF diff --git a/src/vtp/ops/tally_contests_operation.py b/src/vtp/ops/tally_contests_operation.py index a47fea9..3adc63b 100644 --- a/src/vtp/ops/tally_contests_operation.py +++ b/src/vtp/ops/tally_contests_operation.py @@ -23,10 +23,9 @@ # Project imports from vtp.core.ballot import Ballot -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 +from vtp.core.tally import Tally # Local imports from .operation import Operation @@ -49,42 +48,65 @@ def run( """Main function - see -h for more info""" # Create a VTP ElectionData object if one does not already exist - the_election_config = ElectionConfig.configure_election(self.election_data_dir) + the_election_config = ElectionConfig.configure_election( + self, self.election_data_dir + ) # git pull the ElectionData repo so to get the latest set of - # remote CVRs branches - a_ballot = Ballot() - with Shellout.changed_cwd(a_ballot.get_cvr_parent_dir(the_election_config)): - Shellout.run(["git", "pull"], verbosity=self.verbosity, check=True) + # remote CVRs branches. + a_ballot = Ballot(self) + with self.changed_cwd(a_ballot.get_cvr_parent_dir(the_election_config)): + self.shell_out( + ["git", "pull"], + check=True, + incoming_printlevel=5, + ) # Will process all the CVR commits on the main branch and tally # all the contests found. Note - even if a contest is specified, # as a first pass it is easier to just perform a git log across # all the contests and then filter later for the contest of # interest than to try to create a git grep query against the CVR - # payload. - contest_batches = Shellout.cvr_parse_git_log_output( - ["git", "log", "--topo-order", "--no-merges", "--pretty=format:%H%B"], + # payload. Note - --reverse is set so to go in parent to child order + # (though either order is valid, voters probably will understand + # parent to child order better) + contest_batches = self.cvr_parse_git_log_output( + [ + "git", + "log", + "--topo-order", + "--no-merges", + "--reverse", + "--pretty=format:%H%B", + ], the_election_config, + incoming_printlevel=5, ) # Note - though plurality voting can be counted within the above # loop, tallies such as rcv cannot. So far now, just count # everything in a separate loop. - for contest_batch in sorted(contest_batches): + for count, contest_batch in enumerate(sorted(contest_batches)): # Maybe skip if contest_uid != "": - if contest_batches[contest_batch][0]["CVR"]["uid"] != contest_uid: + if ( + contest_batches[contest_batch][0]["contestCVR"]["uid"] + != contest_uid + ): continue # Create a Tally object for this specific contest - the_tally = Tally(contest_batches[contest_batch][0], self.imprimir) + the_tally = Tally(contest_batches[contest_batch][0], self) + if contest_uid == "": + if count > 0: + self.imprimir_formatting("empty_line") + self.imprimir_formatting("horizontal_line") 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')}" + f"for contest ({contest_batches[contest_batch][0]['contestCVR']['contest_name']}) " + f"uid={contest_batches[contest_batch][0]['contestCVR']['uid']}, " + f"tally={contest_batches[contest_batch][0]['contestCVR']['tally']}, " + f"max_selections={the_tally.get('max_selections')}, " + f"win_by>{the_tally.get('win_by')}" ) # Tally all the contests for this contest # import pdb; pdb.set_trace() diff --git a/src/vtp/ops/verify_ballot_receipt_operation.py b/src/vtp/ops/verify_ballot_receipt_operation.py index 338fbb9..46c2038 100644 --- a/src/vtp/ops/verify_ballot_receipt_operation.py +++ b/src/vtp/ops/verify_ballot_receipt_operation.py @@ -21,12 +21,10 @@ # Standard imports import json -import logging import re # Project imports from vtp.core.ballot import Ballot -from vtp.core.common import Shellout from vtp.core.election_config import ElectionConfig # Local imports @@ -50,19 +48,19 @@ def validate_ballot_lines( input_data = "" for line in lines: input_data += "\n".join(line) + "\n" - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): + with self.changed_cwd(the_election_config.get("git_rootdir")): results = ( - Shellout.run( + self.shell_out( [ "git", "cat-file", "--buffer", "--batch-check=%(objectname) %(objecttype)", ], + incoming_printlevel=5, input=input_data, text=True, check=True, - verbosity=self.verbosity, capture_output=True, ) .stdout.strip() @@ -77,14 +75,16 @@ def validate_ballot_lines( digest, commit_type = line.split() if commit_type == "missing": self.imprimir( - f"[ERROR]: missing digest: row {row} column {column} " - f"contest={headers[column - 1]} digest={digest}" + f"missing digest: row {row} column {column} " + f"contest={headers[column - 1]} digest={digest}", + 1, ) error_digests.add(digest) elif commit_type != "commit": self.imprimir( - f"[ERROR]: invalid digest type: row {row} column {column} " - f"contest={headers[column - 1]} digest={digest} type={commit_type}" + f"invalid digest type: row {row} column {column} " + f"contest={headers[column - 1]} digest={digest} type={commit_type}", + 1, ) error_digests.add(digest) column += 1 @@ -116,19 +116,19 @@ def vet_rows( legit_row = [dig for dig in row if dig not in error_digests] if len(legit_row) == len(row): # all the digests are legit - cvrs = Shellout.cvr_parse_git_log_output( + cvrs = self.cvr_parse_git_log_output( ["git", "log", "--no-walk", "--pretty=format:%H%B"] + row, the_election_config, grouped_by_uid=False, - verbosity=self.verbosity - 1, + incoming_printlevel=5, ) elif len(legit_row) > 0: # Only some are legitimate - cvrs = Shellout.cvr_parse_git_log_output( + cvrs = self.cvr_parse_git_log_output( ["git", "log", "--no-walk", "--pretty=format:%H%B"] + legit_row, the_election_config, grouped_by_uid=False, - verbosity=self.verbosity - 1, + incoming_printlevel=5, ) else: # skip the row - it has no legitimate digests @@ -146,15 +146,17 @@ def vet_rows( continue if digest not in cvrs: self.imprimir( - f"[ERROR]: missing digest in main branch: row {index} " - f"contest={headers[column]} digest={digest}" + f"missing digest in main branch: row {index} " + f"contest={headers[column]} digest={digest}", + 1, ) error_digests.add(digest) continue - if cvrs[digest]["CVR"]["uid"] != uids[column]: + if cvrs[digest]["contestCVR"]["uid"] != uids[column]: self.imprimir( - f"[ERROR]: bad contest uid: row {row} column {column} contest " - f"{headers[column]} != {cvrs[digest]['CVR']['uid']} digest={digest}" + f"bad contest uid: row {row} column {column} contest " + f"{headers[column]} != {cvrs[digest]['contestCVR']['uid']} digest={digest}", + 1, ) error_digests.add(digest) continue @@ -185,7 +187,7 @@ def verify_ballot_receipt( # import pdb; pdb.set_trace() # Create a ballot to read the receipt file if receipt_file: - a_ballot = Ballot() + a_ballot = Ballot(self) lines = a_ballot.read_receipt_csv( the_election_config, receipt_file=receipt_file ) @@ -228,10 +230,10 @@ def vet_a_row(): as well do that for all contests (unless one cat create the git grep query syntax to just pull the uids of interest). """ - contest_batches = Shellout.cvr_parse_git_log_output( + contest_batches = self.cvr_parse_git_log_output( ["git", "log", "--topo-order", "--no-merges", "--pretty=format:%H%B"], the_election_config, - verbosity=self.verbosity - 1, + incoming_printlevel=5, ) unmerged_uids = {} for u_count, uid in enumerate(uids): @@ -243,18 +245,20 @@ def vet_a_row(): for c_count, contest in enumerate(contest_batches[uid]): if contest["digest"] in requested_row: self.imprimir( - f"Contest '{contest['CVR']['uid']} - {contest['CVR']['name']}' " + f"Contest '{contest['contestCVR']['uid']} - " + f"{contest['contestCVR']['contest_name']}' " f"({contest['digest']}) is vote {contest_votes - c_count} out " - f"of {contest_votes} votes" + f"of {contest_votes} votes", + 0, ) found = True break if found is False: unmerged_uids[uid] = u_count if unmerged_uids: - self.imprimir("The following contests are not merged to main yet:") + self.imprimir("The following contests are not merged to main yet:", 0) for uid, offset in unmerged_uids.items(): - self.imprimir(f"{headers[offset]} ({requested_digests[offset]})") + self.imprimir(f"{headers[offset]} ({requested_digests[offset]})", 0) # If a row is specified, will print the context index in the # actual contest tally - which basically tells the voter 'your @@ -264,31 +268,40 @@ def vet_a_row(): for digest in lines[int(row_index) - 1]: if digest in error_digests: self.imprimir( - "[ERROR]: cannot print CVR for {digest} (row {row_index}) - it is invalid" + "cannot print CVR for {digest} (row {row_index}) - it is invalid", + 1, ) continue valid_digests.append(digest) - logging.debug( - "%s", json.dumps(requested_row[digest], indent=5, sort_keys=True) + self.imprimir( + f"{json.dumps(requested_row[digest], indent=5, sort_keys=True)}", + 5, ) if show_cvr: # Show the CVRs of the row - with Shellout.changed_cwd(the_election_config.get("git_rootdir")): - Shellout.run(["git", "show", "-s"] + valid_digests, check=True) + with self.changed_cwd(the_election_config.get("git_rootdir")): + self.shell_out( + ["git", "show", "-s"] + valid_digests, + incoming_printlevel=0, + check=True, + ) else: # Just show the summary validation of the row vet_a_row() # Summerize - self.imprimir("############") if error_digests: + self.imprimir_formatting("begin_error_box") self.imprimir( - "[ERROR]: ballot receipt INVALID - the supplied ballot receipt has " - "{len(error_digests)} errors." + "ballot receipt INVALID - the supplied ballot receipt has " + "{len(error_digests)} errors.", + 1, ) + self.imprimir_formatting("end_error_box") else: - self.imprimir("[GOOD]: ballot receipt VALID - no digest errors found") - self.imprimir("############") + self.imprimir_formatting("begin_good_box") + self.imprimir("[GOOD]: ballot receipt VALID - no digest errors found", 0) + self.imprimir_formatting("end_good_box") # pylint: disable=duplicate-code def run( @@ -301,13 +314,20 @@ def run( """Main function - see -h for more info""" # Create a VTP ElectionData object if one does not already exist - the_election_config = ElectionConfig.configure_election(self.election_data_dir) + the_election_config = ElectionConfig.configure_election( + self, + self.election_data_dir, + ) # git pull the ElectionData repo so to get the latest set of # remote CVRs branches - a_ballot = Ballot() - with Shellout.changed_cwd(a_ballot.get_cvr_parent_dir(the_election_config)): - Shellout.run(["git", "pull"], verbosity=self.verbosity, check=True) + a_ballot = Ballot(self) + with self.changed_cwd(a_ballot.get_cvr_parent_dir(the_election_config)): + self.shell_out( + ["git", "pull"], + check=True, + incoming_printlevel=5, + ) # import pdb; pdb.set_trace() # Can read the receipt file directly without any Ballot info diff --git a/src/vtp/ops/vote_operation.py b/src/vtp/ops/vote_operation.py index 8909e57..94dbec8 100644 --- a/src/vtp/ops/vote_operation.py +++ b/src/vtp/ops/vote_operation.py @@ -20,12 +20,10 @@ """Logic of operation for voting.""" # Standard imports -import logging # Project imports from vtp.core.address import Address from vtp.core.ballot import Ballot -from vtp.core.common import Shellout from vtp.core.election_config import ElectionConfig from vtp.ops.accept_ballot_operation import AcceptBallotOperation from vtp.ops.cast_ballot_operation import CastBallotOperation @@ -54,17 +52,19 @@ def run( """Main function - see -h for more info""" # Create a VTP ElectionData object if one does not already exist - the_election_config = ElectionConfig.configure_election(self.election_data_dir) + the_election_config = ElectionConfig.configure_election( + self, + self.election_data_dir, + ) # git pull the ElectionData repo so to get the latest set of # remote CVRs branches - a_ballot = Ballot() - with Shellout.changed_cwd(a_ballot.get_cvr_parent_dir(the_election_config)): - Shellout.run( + a_ballot = Ballot(self) + with self.changed_cwd(a_ballot.get_cvr_parent_dir(the_election_config)): + self.shell_out( ["git", "pull"], - printonly=self.printonly, - verbosity=self.verbosity, check=True, + incoming_printlevel=4, ) # Basically only do as little as necessary to call cast_ballot.py @@ -75,7 +75,7 @@ def run( verbosity=self.verbosity, printonly=self.printonly, ) - logging.debug("Calling CastBallotOperation.run") + self.imprimir("Calling CastBallotOperation.run", 4) a_cast_ballot_operation.run( an_address=an_address, blank_ballot=blank_ballot, @@ -87,7 +87,7 @@ def run( printonly=self.printonly, ) # return what accept_ballot returns - logging.debug("Calling AcceptBallotOperation.run") + self.imprimir("Calling AcceptBallotOperation.run", 4) a_accept_ballot_operation.run( an_address=an_address, cast_ballot=blank_ballot,