Source code for votekit.ballot

from typing import Optional, Union, TypeAlias, Iterable, Sequence
from numbers import Real


Ranking: TypeAlias = Optional[tuple[frozenset[str], ...]]
RankingLike: TypeAlias = Optional[Sequence[Iterable[str]]]


class Ballot:
    """
    Ballot parent class, contains voter set and assigned weight.

    Args:
        ranking (Optional[Sequence[Iterable[str]]]): Candidate ranking. Entry i of the
            sequence is an iterable of candidates ranked in position i. Defaults to None.
            Will be coerced to tuple[frozenset[str], ...].
        weight (Union[float, int]): Weight assigned to a given ballot. Defaults to 1.0
            Can be input as int or float, and will be coerced to float.
        voter_set (Union[set[str], frozenset[str]]): Set of voters who cast the ballot.
            Defaults to frozenset(). Will be coerced to frozenset.
        scores (Optional[dict[str, Union[int, float]]): Scores for individual candidates.
            Defaults to None. Values can be input as int or float but will be coerced to float.
            Only retains non-zero scores.

    Attributes:
        ranking (Optional[tuple[frozenset[str], ...]]): Tuple of candidate ranking.
            Entry i of the tuple is a
            frozenset of candidates ranked in position i.
        weight (float): Weight assigned to a given ballot.
        voter_set (frozenset[str]): Set of voters who cast the ballot.
        scores (Optional[dict[str, float]]): Scores for individual candidates.

    Raises:
        TypeError: Only one of ranking or scores can be provided.
        ValueError: Ballot weight cannot be negative.
    """

    # Memory trick since this is a basic type
    __slots__ = [
        "ranking",
        "weight",
        "voter_set",
        "scores",
        "_frozen",
    ]

    def __new__(
        cls,
        *,
        ranking: Optional[Sequence[Iterable[str]]] = None,
        scores: Optional[dict[str, Union[int, float]]] = None,
        weight: Union[float, int] = 1.0,
        voter_set: Union[set[str], frozenset[str]] = frozenset(),
    ):
        if ranking is not None and scores is not None:
            raise TypeError("Only one of ranking or scores can be provided.")
        elif ranking is not None:
            return super().__new__(RankBallot)
        elif scores is not None:
            return super().__new__(ScoreBallot)

        return super().__new__(cls)

    def __init__(
        self,
        *,
        ranking: Optional[Sequence[Iterable[str]]] = None,
        scores: Optional[dict[str, Union[int, float]]] = None,
        weight: Union[float, int] = 1.0,
        voter_set: Union[set[str], frozenset[str]] = frozenset(),
    ):

        self.voter_set = (
            frozenset(voter_set) if not isinstance(voter_set, frozenset) else voter_set
        )

        if weight < 0:
            raise ValueError("Ballot weight cannot be negative.")

        # Silently promote weight to float
        self.weight = float(weight)
        self._frozen = True

    def __eq__(self, other):
        # Check type
        if not isinstance(other, Ballot):
            return False

        # Check weight
        if self.weight != other.weight:
            return False

        # Check voters
        if self.voter_set != other.voter_set:
            return False

        return True

    def __hash__(self):
        return hash(self.weight) + hash(self.voter_set)

    def __str__(self):
        repr_str = f"Ballot\nWeight: {self.weight}"
        if self.voter_set != frozenset():
            repr_str += f"\nVoter set: {set(self.voter_set)}"
        return repr_str

    __repr__ = __str__

    def __setattr__(self, name, value):
        if getattr(self, "_frozen", False):
            raise AttributeError(f"{type(self).__name__} is frozen")
        object.__setattr__(self, name, value)

    def __delattr__(self, name):
        if getattr(self, "_frozen", False):
            raise AttributeError(f"{type(self).__name__} is frozen")
        object.__delattr__(self, name)


[docs] class RankBallot(Ballot): """ Class to handle ballots with rankings. Strips whitespace from candidate names. Args: ranking (RankingLike): Ranking of candidates, defaults to None. weight (Union[int, float]): Weight of the ballot, defaults to 1.0. voter_set (Union[set[str], frozenset[str]]): Voter set of the ballot, defaults to frozenset(). Attributes: ranking (RankingLike): Ranking of candidates. weight (float): Weight of the ballot. voter_set (frozenset[str]): Voter set of the ballot. Raises: ValueError: Candidate '~' found in ballot ranking. ValueError: Ballot weight cannot be negative. """ def __init__( self, *, ranking: RankingLike = None, weight: Union[int, float] = 1.0, voter_set: Union[set[str], frozenset[str]] = frozenset(), ): self._validate_ranking_candidates(ranking) self.ranking = self._strip_whitespace_ranking_candidates(ranking) super().__init__(weight=weight, voter_set=voter_set) def _validate_ranking_candidates(self, ranking: RankingLike): if ranking is None: return if any(c == "~" for cand_set in ranking for c in cand_set): raise ValueError( f"Candidate '~' found in ballot ranking {ranking}." " '~' is a reserved character and cannot be used for" " candidate names." ) def _strip_whitespace_ranking_candidates(self, ranking: RankingLike) -> Ranking: if ranking is None: return None return tuple([frozenset(c.strip() for c in cand_set) for cand_set in ranking]) def __eq__(self, other): if not isinstance(other, RankBallot): return False if self.ranking != other.ranking: return False return super().__eq__(other) def __hash__(self): return hash(self.ranking) + super().__hash__() def __str__(self): ranking_str = "RankBallot\n" if self.ranking: for i, s in enumerate(self.ranking): ranking_str += f"{i+1}.) " for c in s: ranking_str += f"{c}, " if len(s) > 1: ranking_str += "(tie)" ranking_str += "\n" ranking_str += f"Weight: {self.weight}" if self.voter_set != frozenset(): ranking_str += f"\nVoter set: {set(self.voter_set)}" return ranking_str
[docs] class ScoreBallot(Ballot): """ Class to handle ballots with scores. Strips whitespace from candidate names. Args: scores (Optional[dict[str, Union[int, float]]]): Scores of candidates, defaults to None. weight (Union[int, float]): Weight of the ballot, defaults to 1.0. voter_set (Union[set[str], frozenset[str]]): Voter set of the ballot, defaults to frozenset(). Attributes: scores (Optional[dict[str, float]]): Scores of candidates. weight (float): Weight of the ballot. voter_set (frozenset[str]): Voter set of the ballot. Raises: ValueError: Candidate '~' found in ballot scores. ValueError: Ballot weight cannot be negative. TypeError: Score values must be numeric. """ def __init__( self, *, scores: Optional[dict[str, Union[int, float]]] = None, weight: Union[int, float] = 1.0, voter_set: Union[set[str], frozenset[str]] = frozenset(), ): self._validate_scores_candidates(scores) self.scores = self._convert_scores_to_float_strip_whitespace(scores) super().__init__(weight=weight, voter_set=voter_set) def _validate_scores_candidates( self, scores: Optional[dict[str, Union[int, float]]] ): if scores is not None: if "~" in scores: raise ValueError( f"Candidate '~' found in ballot scores {list(scores.keys())}." " '~' is a reserved character and cannot be used for" " candidate names." ) def _convert_scores_to_float_strip_whitespace( self, scores: Optional[dict[str, float]] ) -> Optional[dict[str, float]]: if scores is None: return None if any(not isinstance(s, Real) for s in scores.values()): raise TypeError("Score values must be numeric.") return {c.strip(): float(s) for c, s in scores.items() if s != 0} def __eq__(self, other): if not isinstance(other, ScoreBallot): return False if self.scores != other.scores: return False return super().__eq__(other) def __hash__(self): return ( hash( tuple(sorted((c, s) for c, s in self.scores.items())) if self.scores is not None else self.scores ) + super().__hash__() ) def __str__(self): score_str = "ScoreBallot\n" if self.scores: for c, score in self.scores.items(): score_str += f"{c}: {score:.2f}\n" score_str += f"Weight: {self.weight}" if self.voter_set != frozenset(): score_str += f"\nVoter set: {set(self.voter_set)}" return score_str __repr__ = __str__