civics-cdf-validator 1.60.dev2__tar.gz → 1.60.dev4__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.dev2/civics_cdf_validator.egg-info → civics_cdf_validator-1.60.dev4}/PKG-INFO +1 -1
  2. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4/civics_cdf_validator.egg-info}/PKG-INFO +1 -1
  3. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/rules.py +111 -21
  4. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/version.py +1 -1
  5. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/CONTRIBUTING.md +0 -0
  6. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/LICENSE-2.0.txt +0 -0
  7. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/MANIFEST.in +0 -0
  8. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/README.md +0 -0
  9. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/__init__.py +0 -0
  10. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/base.py +0 -0
  11. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/civics_cdf_validator.egg-info/SOURCES.txt +0 -0
  12. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/civics_cdf_validator.egg-info/dependency_links.txt +0 -0
  13. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/civics_cdf_validator.egg-info/entry_points.txt +0 -0
  14. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/civics_cdf_validator.egg-info/requires.txt +0 -0
  15. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/civics_cdf_validator.egg-info/top_level.txt +0 -0
  16. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/gpunit_rules.py +0 -0
  17. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/loggers.py +0 -0
  18. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/office_utils.py +0 -0
  19. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/setup.cfg +0 -0
  20. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/setup.py +0 -0
  21. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/stats.py +0 -0
  22. {civics_cdf_validator-1.60.dev2 → civics_cdf_validator-1.60.dev4}/validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: civics_cdf_validator
3
- Version: 1.60.dev2
3
+ Version: 1.60.dev4
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.dev2
3
+ Version: 1.60.dev4
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:
@@ -1360,42 +1365,70 @@ class ValidateDuplicateColors(base.TreeRule):
1360
1365
  """
1361
1366
 
1362
1367
  def check(self):
1363
- party_color_mapping = {}
1368
+ party_colors_by_id = {}
1369
+ party_objects = {}
1364
1370
  for party in self.get_elements_by_class(self.election_tree, "Party"):
1371
+ party_id = party.get("objectId")
1372
+ party_objects[party_id] = party
1373
+ party_colors_by_id[party_id] = {}
1365
1374
  color_element = party.find("Color")
1366
- if color_element is None or not color_element.text:
1367
- continue
1368
- party_color_mapping[party.get("objectId")] = (color_element.text, party)
1375
+ if element_has_text(color_element):
1376
+ party_colors_by_id[party_id]["Color"] = color_element.text.lower()
1377
+ colors_element = party.find("Colors")
1378
+ if colors_element is not None:
1379
+ for color_field in ("DarkThemeColor", "LightThemeColor"):
1380
+ sub_color_element = colors_element.find(color_field)
1381
+ if element_has_text(sub_color_element):
1382
+ party_colors_by_id[party_id][
1383
+ color_field
1384
+ ] = sub_color_element.text.lower()
1369
1385
 
1370
1386
  warning_log = []
1371
1387
  for party_contest in self.get_elements_by_class(
1372
1388
  element=self.election_tree, element_name="PartyContest"
1373
1389
  ):
1374
- contest_colors = {}
1390
+ contest_colors = {
1391
+ "Color": collections.defaultdict(list),
1392
+ "DarkThemeColor": collections.defaultdict(list),
1393
+ "LightThemeColor": collections.defaultdict(list),
1394
+ }
1375
1395
  for party_ids_element in self.get_elements_by_class(
1376
1396
  element=party_contest, element_name="PartyIds"
1377
1397
  ):
1378
1398
  for party_id in party_ids_element.text.split():
1379
- if party_id not in party_color_mapping:
1399
+ if party_id not in party_colors_by_id:
1400
+ continue
1401
+ party_colors = party_colors_by_id[party_id]
1402
+ party_element = party_objects[party_id]
1403
+
1404
+ has_party_colors = "Color" in party_colors or (
1405
+ "DarkThemeColor" in party_colors
1406
+ and "LightThemeColor" in party_colors
1407
+ )
1408
+ if not has_party_colors:
1380
1409
  warning_log.append(
1381
1410
  loggers.LogEntry(
1382
- "Party (%s) in PartyContest should have an assigned color."
1383
- % party_id
1411
+ f"Party ({party_id}) in PartyContest should have either"
1412
+ " Color or Colors.DarkThemeColor and Colors.LightThemeColor"
1413
+ " set.",
1414
+ [party_element],
1415
+ )
1416
+ )
1417
+
1418
+ for color_field in ("Color", "DarkThemeColor", "LightThemeColor"):
1419
+ if color_field in party_colors:
1420
+ color_val = party_colors[color_field]
1421
+ contest_colors[color_field][color_val].append(party_element)
1422
+
1423
+ for color_field in ("Color", "DarkThemeColor", "LightThemeColor"):
1424
+ for color, parties in contest_colors[color_field].items():
1425
+ if len(parties) > 1:
1426
+ warning_log.append(
1427
+ loggers.LogEntry(
1428
+ f"Parties have the same {color_field} {color}.",
1429
+ parties,
1384
1430
  )
1385
1431
  )
1386
- continue
1387
- party_color = party_color_mapping[party_id][0]
1388
- if party_color in contest_colors:
1389
- contest_colors[party_color].append(party_color_mapping[party_id][1])
1390
- else:
1391
- contest_colors[party_color] = [party_color_mapping[party_id][1]]
1392
- for color, parties in contest_colors.items():
1393
- if len(parties) > 1:
1394
- warning_log.append(
1395
- loggers.LogEntry(
1396
- "Parties have the same color %s." % color, parties
1397
- )
1398
- )
1399
1432
  if warning_log:
1400
1433
  raise loggers.ElectionWarning(warning_log)
1401
1434
 
@@ -3547,6 +3580,62 @@ class ImproperCandidateContest(base.TreeRule):
3547
3580
  raise loggers.ElectionWarning(warning_log)
3548
3581
 
3549
3582
 
3583
+ class WinnerCountLimit(base.BaseRule):
3584
+ """Number of winners must be less than or equal to NumberElected."""
3585
+
3586
+ def setup(self):
3587
+ self.candidate_post_election_status_by_object_id = (
3588
+ self._get_candidate_post_election_status_by_object_id()
3589
+ )
3590
+
3591
+ def _get_candidate_post_election_status_by_object_id(self):
3592
+ candidate_post_election_status_by_object_id = {}
3593
+ candidates = self.get_elements_by_class(
3594
+ self.election_tree, "CandidateCollection//Candidate"
3595
+ )
3596
+ for candidate in candidates:
3597
+ object_id = candidate.get("objectId")
3598
+ post_election_status = candidate.find("PostElectionStatus")
3599
+ if object_id and element_has_text(post_election_status):
3600
+ candidate_post_election_status_by_object_id[object_id] = (
3601
+ post_election_status.text.strip()
3602
+ )
3603
+ return candidate_post_election_status_by_object_id
3604
+
3605
+ def elements(self):
3606
+ return ["CandidateContest"]
3607
+
3608
+ def check(self, element):
3609
+ number_elected_element = element.find("NumberElected")
3610
+ number_elected = (
3611
+ int(number_elected_element.text)
3612
+ if element_has_text(number_elected_element)
3613
+ else 1
3614
+ )
3615
+
3616
+ candidate_ids_elements = element.findall("BallotSelection//CandidateIds")
3617
+ candidate_ids = set()
3618
+ for candidate_ids_element in candidate_ids_elements:
3619
+ if element_has_text(candidate_ids_element):
3620
+ candidate_ids.update(candidate_ids_element.text.split())
3621
+
3622
+ winner_count = 0
3623
+ for candidate_id in candidate_ids:
3624
+ status = self.candidate_post_election_status_by_object_id.get(
3625
+ candidate_id
3626
+ )
3627
+ if status in _WINNER_POST_ELECTION_STATUSES:
3628
+ winner_count += 1
3629
+
3630
+ if winner_count > number_elected:
3631
+ raise loggers.ElectionError.from_message(
3632
+ f"Contest {element.get('objectId')} has {winner_count} candidates"
3633
+ " with PostElectionStatus of 'winner' or 'projected-winner',"
3634
+ f" which exceeds NumberElected: {number_elected}.",
3635
+ [element],
3636
+ )
3637
+
3638
+
3550
3639
  class MissingFieldsError(base.MissingFieldRule):
3551
3640
  """Check for missing fields for given entity types and field names.
3552
3641
 
@@ -4940,6 +5029,7 @@ ELECTION_RULES = COMMON_RULES + (
4940
5029
  ValidateInfoUriAnnotation,
4941
5030
  VoteCountTypesCoherency,
4942
5031
  VoteCountValidSeatsDeltaTypes,
5032
+ WinnerCountLimit,
4943
5033
  # go/keep-sorted end
4944
5034
  )
4945
5035
 
@@ -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.dev2'
8
+ __version__ = '1.60.dev4'