civics-cdf-validator 1.60.dev1__tar.gz → 1.60.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {civics_cdf_validator-1.60.dev1/civics_cdf_validator.egg-info → civics_cdf_validator-1.60.dev3}/PKG-INFO +1 -1
  2. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3/civics_cdf_validator.egg-info}/PKG-INFO +1 -1
  3. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/rules.py +125 -5
  4. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/version.py +1 -1
  5. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/CONTRIBUTING.md +0 -0
  6. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/LICENSE-2.0.txt +0 -0
  7. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/MANIFEST.in +0 -0
  8. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/README.md +0 -0
  9. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/__init__.py +0 -0
  10. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/base.py +0 -0
  11. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/civics_cdf_validator.egg-info/SOURCES.txt +0 -0
  12. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/civics_cdf_validator.egg-info/dependency_links.txt +0 -0
  13. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/civics_cdf_validator.egg-info/entry_points.txt +0 -0
  14. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/civics_cdf_validator.egg-info/requires.txt +0 -0
  15. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/civics_cdf_validator.egg-info/top_level.txt +0 -0
  16. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/gpunit_rules.py +0 -0
  17. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/loggers.py +0 -0
  18. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/office_utils.py +0 -0
  19. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/setup.cfg +0 -0
  20. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/setup.py +0 -0
  21. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/stats.py +0 -0
  22. {civics_cdf_validator-1.60.dev1 → civics_cdf_validator-1.60.dev3}/validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: civics_cdf_validator
3
- Version: 1.60.dev1
3
+ Version: 1.60.dev3
4
4
  Summary: Checks if an election feed follows best practices
5
5
  Home-page: https://github.com/google/civics_cdf_validator
6
6
  Author: Google Civics
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: civics_cdf_validator
3
- Version: 1.60.dev1
3
+ Version: 1.60.dev3
4
4
  Summary: Checks if an election feed follows best practices
5
5
  Home-page: https://github.com/google/civics_cdf_validator
6
6
  Author: Google Civics
@@ -132,6 +132,11 @@ _UNTYPED_URI_PLATFORMS = frozenset([
132
132
  ])
133
133
  _ALL_URI_PLATFORMS = _TYPED_URI_PLATFORMS | _UNTYPED_URI_PLATFORMS
134
134
 
135
+ _WINNER_POST_ELECTION_STATUSES = frozenset([
136
+ "winner",
137
+ "projected-winner",
138
+ ])
139
+
135
140
 
136
141
  def _get_office_roles(element, is_post_office_split_feed=False):
137
142
  if is_post_office_split_feed:
@@ -1714,6 +1719,65 @@ class PersonsMissingPartyData(base.BaseRule):
1714
1719
  )
1715
1720
 
1716
1721
 
1722
+ class OnlyOneCandidateImagePerPerson(base.BaseRule):
1723
+ """Ensure only one candidate-image is provided per Person."""
1724
+
1725
+ def elements(self):
1726
+ return ["Person"]
1727
+
1728
+ def check(self, element):
1729
+ image_uris = element.findall("ImageUri")
1730
+ candidate_images = []
1731
+ for image_uri in image_uris:
1732
+ annotation = image_uri.get("Annotation", "").strip()
1733
+ if annotation == "candidate-image":
1734
+ candidate_images.append(image_uri)
1735
+
1736
+ if len(candidate_images) > 1:
1737
+ raise loggers.ElectionError.from_message(
1738
+ "Person has {} ImageUri fields annotated as 'candidate-image'."
1739
+ " Must have at most one.".format(len(candidate_images)),
1740
+ candidate_images,
1741
+ )
1742
+
1743
+
1744
+ class UniqueCandidateImageUris(base.TreeRule):
1745
+ """Check that candidate-image URIs are unique to a person."""
1746
+
1747
+ def check(self):
1748
+ root = self.election_tree.getroot()
1749
+ if root is None:
1750
+ return
1751
+
1752
+ persons_by_candidate_image_uri = collections.defaultdict(list)
1753
+ person_collection = root.find("PersonCollection")
1754
+ if person_collection is None:
1755
+ return
1756
+ for person in person_collection.findall("Person"):
1757
+ for image_uri_element in person.findall("ImageUri"):
1758
+ if not element_has_text(image_uri_element):
1759
+ continue
1760
+ annotation = image_uri_element.get("Annotation", "").strip()
1761
+ if annotation == "candidate-image":
1762
+ image_uri = image_uri_element.text.strip()
1763
+ if image_uri:
1764
+ persons_by_candidate_image_uri[image_uri].append(person)
1765
+
1766
+ error_log = []
1767
+ for image_uri, persons in persons_by_candidate_image_uri.items():
1768
+ if len(persons) > 1:
1769
+ person_ids = sorted(person.get("objectId") for person in persons)
1770
+ error_log.append(
1771
+ loggers.LogEntry(
1772
+ f"Candidate image URI '{image_uri}' is shared by multiple"
1773
+ f" people: [{', '.join(person_ids)}].",
1774
+ persons,
1775
+ )
1776
+ )
1777
+ if error_log:
1778
+ raise loggers.ElectionError(error_log)
1779
+
1780
+
1717
1781
  class AllCaps(base.BaseRule):
1718
1782
  """Name elements should not be in all uppercase.
1719
1783
 
@@ -2364,11 +2428,8 @@ class ValidURIAnnotation(base.BaseRule):
2364
2428
  "URI {} is missing annotation.".format(ascii_url), [uri]
2365
2429
  )
2366
2430
 
2367
- # Skip platform checks for image or office contact form annotations.
2368
- if (
2369
- re.search(r"candidate-image", annotation)
2370
- or annotation == "office-contact_form"
2371
- ):
2431
+ # Skip platform checks for office contact form annotations.
2432
+ if annotation == "office-contact_form":
2372
2433
  continue
2373
2434
 
2374
2435
  ann_elements = annotation.split("-")
@@ -3491,6 +3552,62 @@ class ImproperCandidateContest(base.TreeRule):
3491
3552
  raise loggers.ElectionWarning(warning_log)
3492
3553
 
3493
3554
 
3555
+ class WinnerCountLimit(base.BaseRule):
3556
+ """Number of winners must be less than or equal to NumberElected."""
3557
+
3558
+ def setup(self):
3559
+ self.candidate_post_election_status_by_object_id = (
3560
+ self._get_candidate_post_election_status_by_object_id()
3561
+ )
3562
+
3563
+ def _get_candidate_post_election_status_by_object_id(self):
3564
+ candidate_post_election_status_by_object_id = {}
3565
+ candidates = self.get_elements_by_class(
3566
+ self.election_tree, "CandidateCollection//Candidate"
3567
+ )
3568
+ for candidate in candidates:
3569
+ object_id = candidate.get("objectId")
3570
+ post_election_status = candidate.find("PostElectionStatus")
3571
+ if object_id and element_has_text(post_election_status):
3572
+ candidate_post_election_status_by_object_id[object_id] = (
3573
+ post_election_status.text.strip()
3574
+ )
3575
+ return candidate_post_election_status_by_object_id
3576
+
3577
+ def elements(self):
3578
+ return ["CandidateContest"]
3579
+
3580
+ def check(self, element):
3581
+ number_elected_element = element.find("NumberElected")
3582
+ number_elected = (
3583
+ int(number_elected_element.text)
3584
+ if element_has_text(number_elected_element)
3585
+ else 1
3586
+ )
3587
+
3588
+ candidate_ids_elements = element.findall("BallotSelection//CandidateIds")
3589
+ candidate_ids = set()
3590
+ for candidate_ids_element in candidate_ids_elements:
3591
+ if element_has_text(candidate_ids_element):
3592
+ candidate_ids.update(candidate_ids_element.text.split())
3593
+
3594
+ winner_count = 0
3595
+ for candidate_id in candidate_ids:
3596
+ status = self.candidate_post_election_status_by_object_id.get(
3597
+ candidate_id
3598
+ )
3599
+ if status in _WINNER_POST_ELECTION_STATUSES:
3600
+ winner_count += 1
3601
+
3602
+ if winner_count > number_elected:
3603
+ raise loggers.ElectionError.from_message(
3604
+ f"Contest {element.get('objectId')} has {winner_count} candidates"
3605
+ " with PostElectionStatus of 'winner' or 'projected-winner',"
3606
+ f" which exceeds NumberElected: {number_elected}.",
3607
+ [element],
3608
+ )
3609
+
3610
+
3494
3611
  class MissingFieldsError(base.MissingFieldRule):
3495
3612
  """Check for missing fields for given entity types and field names.
3496
3613
 
@@ -4811,6 +4928,7 @@ COMMON_RULES = (
4811
4928
  OfficesHaveJurisdictionID,
4812
4929
  OfficesHaveValidOfficeLevel,
4813
4930
  OfficesHaveValidOfficeRole,
4931
+ OnlyOneCandidateImagePerPerson,
4814
4932
  OptionalAndEmpty,
4815
4933
  OtherType,
4816
4934
  PartyLeadershipMustExist,
@@ -4820,6 +4938,7 @@ COMMON_RULES = (
4820
4938
  PersonsMissingPartyData,
4821
4939
  Schema,
4822
4940
  URIValidator,
4941
+ UniqueCandidateImageUris,
4823
4942
  UniqueLabel,
4824
4943
  UniqueStableID,
4825
4944
  UniqueURIPerAnnotationCategory,
@@ -4882,6 +5001,7 @@ ELECTION_RULES = COMMON_RULES + (
4882
5001
  ValidateInfoUriAnnotation,
4883
5002
  VoteCountTypesCoherency,
4884
5003
  VoteCountValidSeatsDeltaTypes,
5004
+ WinnerCountLimit,
4885
5005
  # go/keep-sorted end
4886
5006
  )
4887
5007
 
@@ -5,4 +5,4 @@ No dependencies should be added to this module.
5
5
  See https://packaging.python.org/guides/single-sourcing-package-version/
6
6
  """
7
7
 
8
- __version__ = '1.60.dev1'
8
+ __version__ = '1.60.dev3'