diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 7bc9cde..982d09c 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -37,4 +37,4 @@ jobs: python -m pip install pylint - name: Testing with pylint run: | - pylint pysteve -d W0311 # enable later + pylint pysteve diff --git a/pysteve/lib/plugins/cop.py b/pysteve/lib/plugins/cop.py index 3530a2e..5d7ccd7 100644 --- a/pysteve/lib/plugins/cop.py +++ b/pysteve/lib/plugins/cop.py @@ -38,7 +38,7 @@ def validateCOP(vote, issue): except: pass # This is a fast way to determine vote type, passing here is FINE! if not vote in letters and (ivote < 0 or ivote > len(issue['candidates'])): - return "Invalid characters in vote. Accepted are: %s" % ", ".join(letters,range(1,len(issue['candidates'])+1)) + return "Invalid characters in vote. Accepted are: %s" % ", ".join(letters,range(1,len(issue['candidates'])+1)) return None def parseCandidatesCOP(data): diff --git a/pysteve/lib/plugins/stv.py b/pysteve/lib/plugins/stv.py index 941a72a..9b1fdce 100644 --- a/pysteve/lib/plugins/stv.py +++ b/pysteve/lib/plugins/stv.py @@ -59,273 +59,273 @@ def validateSTV(vote, issue): def run_vote(names, votes, num_seats): - candidates = CandidateList(names, votes) - - # name -> Candidate - remap = dict((c.name, c) for c in candidates.l) - - # Turn VOTES into a list of ordered-lists of Candidate objects - letter = [] - votes = [[remap[n] for n in choices] for choices in votes.values()] - - if candidates.count(ELECTED + HOPEFUL) <= num_seats: - debug.append('All candidates elected') - candidates.change_state(HOPEFUL, ELECTED) - return candidates.retval() - if num_seats <= 0: + candidates = CandidateList(names, votes) + + # name -> Candidate + remap = dict((c.name, c) for c in candidates.l) + + # Turn VOTES into a list of ordered-lists of Candidate objects + letter = [] + votes = [[remap[n] for n in choices] for choices in votes.values()] + + if candidates.count(ELECTED + HOPEFUL) <= num_seats: + debug.append('All candidates elected') + candidates.change_state(HOPEFUL, ELECTED) + return candidates.retval() + if num_seats <= 0: + candidates.change_state(HOPEFUL, ELIMINATED) + return candidates.retval() + + quota = None # not used on first pass + iteration = 1 + while candidates.count(ELECTED) < num_seats: + debug.append('Iteration %d' % iteration) + iteration += 1 + quota = iterate_one(quota, votes, candidates, num_seats) + candidates.reverse_random() + + debug.append('All seats full') candidates.change_state(HOPEFUL, ELIMINATED) - return candidates.retval() - - quota = None # not used on first pass - iteration = 1 - while candidates.count(ELECTED) < num_seats: - debug.append('Iteration %d' % iteration) - iteration += 1 - quota = iterate_one(quota, votes, candidates, num_seats) - candidates.reverse_random() - debug.append('All seats full') - candidates.change_state(HOPEFUL, ELIMINATED) - - return candidates.retval() + return candidates.retval() class CandidateList(object): - def __init__(self, names, votes): - num_cand = len(names) - randset = generate_random(num_cand, votes) - - self.l = [ ] - for n, r in zip(names, randset): - c = Candidate(n, r, num_cand-1) - self.l.append(c) - - def count(self, state): - count = 0 - for c in self.l: - if (c.status & state) != 0: - count += 1 - return count - def retval(self): - winners = [] - for c in self.l: - if c.status == ELECTED: - winners.append(c.name) - return winners - - def change_state(self, from_state, to_state): - any_changed = False - for c in self.l: - if (c.status & from_state) != 0: - c.status = to_state - if to_state == ELECTED: - c.elect() - elif to_state == ELIMINATED: - c.eliminate() - any_changed = True - return any_changed - - def reverse_random(self): - # Flip the values to create a different ordering among candidates. Note - # that this will alternate the domain between [0.0, 1.0) and (0.0, 1.0] - for c in self.l: - c.rand = 1.0 - c.rand - - def adjust_weights(self, quota): - for c in self.l: - if c.status == ELECTED: - c.adjust_weight(quota) - - def print_results(self): - for c in self.l: - print ('%-40s%selected' % (c.name, c.status == ELECTED and ' ' or ' not ')) - - def dbg_display_tables(self, excess): - total = excess - for c in self.l: - debug.append('%-20s %15.9f %15.9f' % (c.name, c.weight, c.vote)) - total += c.vote - debug.append('%-20s %15s %15.9f' %( 'Non-transferable', ' ', excess)) - debug.append('%-20s %15s %15.9f' % ( 'Total', ' ', total)) + def __init__(self, names, votes): + num_cand = len(names) + randset = generate_random(num_cand, votes) + + self.l = [ ] + for n, r in zip(names, randset): + c = Candidate(n, r, num_cand-1) + self.l.append(c) + + def count(self, state): + count = 0 + for c in self.l: + if (c.status & state) != 0: + count += 1 + return count + def retval(self): + winners = [] + for c in self.l: + if c.status == ELECTED: + winners.append(c.name) + return winners + + def change_state(self, from_state, to_state): + any_changed = False + for c in self.l: + if (c.status & from_state) != 0: + c.status = to_state + if to_state == ELECTED: + c.elect() + elif to_state == ELIMINATED: + c.eliminate() + any_changed = True + return any_changed + + def reverse_random(self): + # Flip the values to create a different ordering among candidates. Note + # that this will alternate the domain between [0.0, 1.0) and (0.0, 1.0] + for c in self.l: + c.rand = 1.0 - c.rand + + def adjust_weights(self, quota): + for c in self.l: + if c.status == ELECTED: + c.adjust_weight(quota) + + def print_results(self): + for c in self.l: + print ('%-40s%selected' % (c.name, c.status == ELECTED and ' ' or ' not ')) + + def dbg_display_tables(self, excess): + total = excess + for c in self.l: + debug.append('%-20s %15.9f %15.9f' % (c.name, c.weight, c.vote)) + total += c.vote + debug.append('%-20s %15s %15.9f' %( 'Non-transferable', ' ', excess)) + debug.append('%-20s %15s %15.9f' % ( 'Total', ' ', total)) class Candidate(object): - def __init__(self, name, rand, ahead): - self.name = name - self.rand = rand - self.ahead = ahead - self.status = HOPEFUL - self.weight = 1.0 - self.vote = None # calculated later - - def elect(self): - self.status = ELECTED - debug.append('Elected: %s' % self.name) - - def eliminate(self): - self.status = ELIMINATED - self.weight = 0.0 - debug.append('Eliminated: %s' % self.name) - - def adjust_weight(self, quota): - assert quota is not None - self.weight = (self.weight * quota) / self.vote - - @staticmethod - def cmp(a,b): - if a.ahead < b.ahead: - return -1 - if a.ahead == b.ahead: - return (a.vote > b.vote) - (a.vote < b.vote) - return 1 + def __init__(self, name, rand, ahead): + self.name = name + self.rand = rand + self.ahead = ahead + self.status = HOPEFUL + self.weight = 1.0 + self.vote = None # calculated later + + def elect(self): + self.status = ELECTED + debug.append('Elected: %s' % self.name) + + def eliminate(self): + self.status = ELIMINATED + self.weight = 0.0 + debug.append('Eliminated: %s' % self.name) + + def adjust_weight(self, quota): + assert quota is not None + self.weight = (self.weight * quota) / self.vote + + @staticmethod + def cmp(a,b): + if a.ahead < b.ahead: + return -1 + if a.ahead == b.ahead: + return (a.vote > b.vote) - (a.vote < b.vote) + return 1 def iterate_one(quota, votes, candidates, num_seats): - # assume that: count(ELECTED) < num_seats - if candidates.count(ELECTED + HOPEFUL) <= num_seats: - debug.append('All remaining candidates elected') - candidates.change_state(HOPEFUL, ELECTED) - return None + # assume that: count(ELECTED) < num_seats + if candidates.count(ELECTED + HOPEFUL) <= num_seats: + debug.append('All remaining candidates elected') + candidates.change_state(HOPEFUL, ELECTED) + return None - candidates.adjust_weights(quota) + candidates.adjust_weights(quota) - changed, new_quota, surplus = recalc(votes, candidates, num_seats) - if not changed and surplus < ERROR_MARGIN: - debug.append('Remove Lowest (forced)') - exclude_lowest(candidates.l) - return new_quota + changed, new_quota, surplus = recalc(votes, candidates, num_seats) + if not changed and surplus < ERROR_MARGIN: + debug.append('Remove Lowest (forced)') + exclude_lowest(candidates.l) + return new_quota def recalc(votes, candidates, num_seats): - excess = calc_totals(votes, candidates) - calc_aheads(candidates) - candidates.dbg_display_tables(excess) - quota = calc_quota(len(votes), excess, num_seats) - any_changed = elect(quota, candidates, num_seats) - surplus = calc_surplus(quota, candidates) - any_changed |= try_remove_lowest(surplus, candidates) - return any_changed, quota, surplus + excess = calc_totals(votes, candidates) + calc_aheads(candidates) + candidates.dbg_display_tables(excess) + quota = calc_quota(len(votes), excess, num_seats) + any_changed = elect(quota, candidates, num_seats) + surplus = calc_surplus(quota, candidates) + any_changed |= try_remove_lowest(surplus, candidates) + return any_changed, quota, surplus def calc_totals(votes, candidates): - for c in candidates.l: - c.vote = 0.0 - excess = 0.0 - for choices in votes: - vote = 1.0 - for c in choices: - if c.status == HOPEFUL: - c.vote += vote - vote = 0.0 - break - if c.status != ELIMINATED: - wv = c.weight * vote # weighted vote - c.vote += wv - vote -= wv - if vote == 0.0: # nothing left to distribute - break - excess += vote - return excess + for c in candidates.l: + c.vote = 0.0 + excess = 0.0 + for choices in votes: + vote = 1.0 + for c in choices: + if c.status == HOPEFUL: + c.vote += vote + vote = 0.0 + break + if c.status != ELIMINATED: + wv = c.weight * vote # weighted vote + c.vote += wv + vote -= wv + if vote == 0.0: # nothing left to distribute + break + excess += vote + return excess def calc_aheads(candidates): - c_sorted = sorted(candidates.l, key=functools.cmp_to_key(Candidate.cmp)) - last = 0 - for i in range(1, len(c_sorted)+1): - if i == len(c_sorted) or c_sorted[last] != c_sorted[i]: - for c in c_sorted[last:i]: - c.ahead = (i - 1) + last - last = i + c_sorted = sorted(candidates.l, key=functools.cmp_to_key(Candidate.cmp)) + last = 0 + for i in range(1, len(c_sorted)+1): + if i == len(c_sorted) or c_sorted[last] != c_sorted[i]: + for c in c_sorted[last:i]: + c.ahead = (i - 1) + last + last = i def calc_quota(num_voters, excess, num_seats): - if num_seats > 2: - quota = (float(num_voters) - excess) / (float(num_seats + 1) + BILLIONTH) - else: - quota = (float(num_voters) - excess) / float(num_seats + 1) - if quota < ERROR_MARGIN: - raise Exception('Internal Error - very low quota') - debug.append('Quota = %.9f' % quota) - return quota + if num_seats > 2: + quota = (float(num_voters) - excess) / (float(num_seats + 1) + BILLIONTH) + else: + quota = (float(num_voters) - excess) / float(num_seats + 1) + if quota < ERROR_MARGIN: + raise Exception('Internal Error - very low quota') + debug.append('Quota = %.9f' % quota) + return quota def elect(quota, candidates, num_seats): - for c in candidates.l: - if c.status == HOPEFUL and c.vote >= quota: - c.status = ALMOST + for c in candidates.l: + if c.status == HOPEFUL and c.vote >= quota: + c.status = ALMOST - any_changed = False + any_changed = False - while candidates.count(ELECTED + ALMOST) > num_seats: - debug.append('Vote tiebreaker! voters: %d seats: %d' %( candidates.count(ELECTED + ALMOST), num_seats)) - candidates.change_state(HOPEFUL, ELIMINATED) - exclude_lowest(candidates.l) - any_changed = True # we changed the candidates via exclude_lowest() + while candidates.count(ELECTED + ALMOST) > num_seats: + debug.append('Vote tiebreaker! voters: %d seats: %d' %( candidates.count(ELECTED + ALMOST), num_seats)) + candidates.change_state(HOPEFUL, ELIMINATED) + exclude_lowest(candidates.l) + any_changed = True # we changed the candidates via exclude_lowest() - any_changed |= candidates.change_state(ALMOST, ELECTED) - return any_changed + any_changed |= candidates.change_state(ALMOST, ELECTED) + return any_changed def calc_surplus(quota, candidates): - surplus = 0.0 - for c in candidates.l: - if c.status == ELECTED: - surplus += c.vote - quota - debug.append('Total Surplus = %.9f' % surplus) - return surplus + surplus = 0.0 + for c in candidates.l: + if c.status == ELECTED: + surplus += c.vote - quota + debug.append('Total Surplus = %.9f' % surplus) + return surplus def try_remove_lowest(surplus, candidates): - lowest1 = 1e18 - lowest2 = 1e18 - which = None - for c in candidates.l: - if c.status == HOPEFUL and c.vote < lowest1: - lowest1 = c.vote - which = c - if not which: - debug.append("Could not find a subject to eliminate") + lowest1 = 1e18 + lowest2 = 1e18 + which = None + for c in candidates.l: + if c.status == HOPEFUL and c.vote < lowest1: + lowest1 = c.vote + which = c + if not which: + debug.append("Could not find a subject to eliminate") + return False + for c in candidates.l: + if c != which and c.status != ELIMINATED and c.vote < lowest2: + lowest2 = c.vote + + diff = lowest2 - lowest1 + if diff >= 0.0: + debug.append('Lowest Difference = %.9f - %.9f = %.9f' % ( lowest2, lowest1, diff)) + if diff > surplus: + debug.append('Remove Lowest (unforced)') + which.eliminate() + return True return False - for c in candidates.l: - if c != which and c.status != ELIMINATED and c.vote < lowest2: - lowest2 = c.vote - - diff = lowest2 - lowest1 - if diff >= 0.0: - debug.append('Lowest Difference = %.9f - %.9f = %.9f' % ( lowest2, lowest1, diff)) - if diff > surplus: - debug.append('Remove Lowest (unforced)') - which.eliminate() - return True - return False def exclude_lowest(candidates): - ### use: ahead = len(candidates) ?? - ahead = 1000000000. # greater than any possible candidate.ahead - rand = 1.1 # greater than any possible candidate.rand - which = None - used_rand = False - - random.shuffle(candidates) - - for c in candidates: - if c.status == HOPEFUL or c.status == ALMOST: - if c.ahead < ahead: - ahead = c.ahead - rand = c.rand - which = c - use_rand = False - elif c.ahead == ahead: - use_rand = True - if c.rand < rand: - rand = c.rand - which = c - - if use_rand: - debug.append('Random choice used!') - - assert which - which.eliminate() + ### use: ahead = len(candidates) ?? + ahead = 1000000000. # greater than any possible candidate.ahead + rand = 1.1 # greater than any possible candidate.rand + which = None + used_rand = False + + random.shuffle(candidates) + + for c in candidates: + if c.status == HOPEFUL or c.status == ALMOST: + if c.ahead < ahead: + ahead = c.ahead + rand = c.rand + which = c + use_rand = False + elif c.ahead == ahead: + use_rand = True + if c.rand < rand: + rand = c.rand + which = c + + if use_rand: + debug.append('Random choice used!') + + assert which + which.eliminate() def generate_random(count, votes): @@ -335,11 +335,11 @@ def generate_random(count, votes): # Ensure there are no duplicates for value in values: - if values.count(value) > 1: - break + if values.count(value) > 1: + break else: - # The loop finished without breaking out - return values + # The loop finished without breaking out + return values # STV Tally: # Version 1 == old style abcdefg... @@ -358,7 +358,7 @@ def tallySTV(votes, issue, version = 2): m = re.match(r"stv(\d+)", issue['type']) if not m: raise Exception("Not an STV vote!") - + numseats = int(m.group(1)) candidates = {} z = 0 @@ -376,9 +376,9 @@ def tallySTV(votes, issue, version = 2): return tallySTV(ovotes, issue, 1) else: winners = run_vote(candidates, votes, numseats) - + winnernames = [] - + for c in winners: winnernames.append(candidates[c]) diff --git a/pysteve/lib/voter.py b/pysteve/lib/voter.py index fe0c941..71d8ec2 100644 --- a/pysteve/lib/voter.py +++ b/pysteve/lib/voter.py @@ -98,8 +98,8 @@ def email(rcpt, subject, message): """ % (sender, rcpt, subject, message, signature) msg = msg.encode('utf-8', errors='replace') try: - smtpObj = smtplib.SMTP(config.get("email", "mta")) - smtpObj.sendmail(sender, receivers, msg) + smtpObj = smtplib.SMTP(config.get("email", "mta")) + smtpObj.sendmail(sender, receivers, msg) except SMTPException: - raise Exception("Could not send email - SMTP server down?") + raise Exception("Could not send email - SMTP server down?") diff --git a/pysteve/www/cgi-bin/rest_admin.py b/pysteve/www/cgi-bin/rest_admin.py index bde2d97..c01c43b 100755 --- a/pysteve/www/cgi-bin/rest_admin.py +++ b/pysteve/www/cgi-bin/rest_admin.py @@ -304,7 +304,7 @@ else: response.respond(404, {'message': 'No such election: %s' % electionID}) else: - response.respond(404, {'message': 'Invalid election ID'}) + response.respond(404, {'message': 'Invalid election ID'}) # Delete an issue elif action == "delete" and electionID and issue: if electionID and issue: @@ -319,7 +319,7 @@ else: response.respond(403, {'message': "You do not have karma to delete this issue"}) else: - response.respond(404, {'message': 'No such election or issue'}) + response.respond(404, {'message': 'No such election or issue'}) # Send issue hash to monitors elif action == "debug" and electionID: @@ -333,19 +333,19 @@ else: response.respond(403, {'message': "You do not have karma to do this"}) else: - response.respond(404, {'message': 'No such election'}) + response.respond(404, {'message': 'No such election'}) # Get a temp voter ID for peeking elif action == "temp" and electionID: if electionID and election.exists(electionID): basedata = election.getBasedata(electionID) if karma >= 4 or ('owner' in basedata and basedata['owner'] == whoami): - voterid, xhash = voter.add(electionID, basedata, whoami + "@stv") - response.respond(200, {'id': voterid}) + voterid, xhash = voter.add(electionID, basedata, whoami + "@stv") + response.respond(200, {'id': voterid}) else: response.respond(403, {'message': "You do not have karma to peek at this election"}) else: - response.respond(404, {'message': 'No such election'}) + response.respond(404, {'message': 'No such election'}) # Invite folks to the election elif action == "invite" and karma >= 3: @@ -396,7 +396,7 @@ else: response.respond(404, {'message': 'No such election'}) else: - response.respond(404, {'message': 'No such election'}) + response.respond(404, {'message': 'No such election'}) # Tally an issue elif action == "tally" and electionID: if electionID and issue: @@ -419,7 +419,7 @@ else: response.respond(403, {'message': "You do not have karma to tally the votes here"}) else: - response.respond(404, {'message': 'No such election or issue'}) + response.respond(404, {'message': 'No such election or issue'}) # Close an election elif action == "close" and electionID: ro = form.getvalue('reopen') @@ -447,7 +447,7 @@ else: response.respond(403, {'message': "You do not have karma to tally the votes here"}) else: - response.respond(404, {'message': 'No such election or issue'}) + response.respond(404, {'message': 'No such election or issue'}) # Get registered vote stpye elif action == "types": types = {} @@ -486,7 +486,7 @@ else: response.respond(403, {'message': "You do not have karma to tally the votes here"}) else: - response.respond(404, {'message': 'No such election or issue'}) + response.respond(404, {'message': 'No such election or issue'}) # Vote backlog, including all recasts elif action == "backlog" and electionID: if electionID and issue: @@ -519,7 +519,7 @@ else: response.respond(403, {'message': "You do not have karma to tally the votes here"}) else: - response.respond(404, {'message': 'No such election or issue'}) + response.respond(404, {'message': 'No such election or issue'}) else: response.respond(400, {'message': "No (or invalid) action supplied"}) diff --git a/pysteve/www/cgi-bin/rest_voter.py b/pysteve/www/cgi-bin/rest_voter.py index 86dacb8..b2b6338 100755 --- a/pysteve/www/cgi-bin/rest_voter.py +++ b/pysteve/www/cgi-bin/rest_voter.py @@ -134,7 +134,7 @@ def voter_action(): issuedata = election.getIssue(electionID, issueID) if basedata and issuedata: if 'closed' in basedata and basedata['closed'] == True: - raise Exception("This election has closed") + raise Exception("This election has closed") email = voter.get(electionID, basedata, voterID) if not email: response.respond(403, {'message': 'Could not save vote: Invalid voter ID presented'}) @@ -188,7 +188,7 @@ def voter_action(): basedata = election.getBasedata(electionID, hideHash=True) if basedata: if 'closed' in basedata and basedata['closed'] == True: - raise Exception("This election has closed") + raise Exception("This election has closed") if 'open' in basedata and basedata['open'] == "true": response.respond(200, { 'base_data': basedata } ) else: diff --git a/pysteve/www/wsgi/rest_admin.py b/pysteve/www/wsgi/rest_admin.py index 447a931..e238890 100644 --- a/pysteve/www/wsgi/rest_admin.py +++ b/pysteve/www/wsgi/rest_admin.py @@ -127,7 +127,7 @@ def application (environ, start_response): issuedata = election.getIssue(electionID, issueID) if basedata and issuedata: if 'closed' in basedata and basedata['closed'] == True: - raise Exception("This election has closed") + raise Exception("This election has closed") email = voter.get(electionID, basedata, voterID) if not email: return response.wsgirespond(start_response, 403, {'message': 'Could not save vote: Invalid voter ID presented'}) @@ -181,7 +181,7 @@ def application (environ, start_response): basedata = election.getBasedata(electionID, hideHash=True) if basedata: if 'closed' in basedata and basedata['closed'] == True: - raise Exception("This election has closed") + raise Exception("This election has closed") if 'open' in basedata and basedata['open'] == "true": return response.wsgirespond(start_response, 200, { 'base_data': basedata } ) else: diff --git a/pysteve/www/wsgi/rest_voter.py b/pysteve/www/wsgi/rest_voter.py index 4acf7a6..1f7f40f 100644 --- a/pysteve/www/wsgi/rest_voter.py +++ b/pysteve/www/wsgi/rest_voter.py @@ -128,7 +128,7 @@ def application (environ, start_response): issuedata = election.getIssue(electionID, issueID) if basedata and issuedata: if 'closed' in basedata and basedata['closed'] == True: - raise Exception("This election has closed") + raise Exception("This election has closed") email = voter.get(electionID, basedata, voterID) if not email: return response.wsgirespond(start_response, 403, {'message': 'Could not save vote: Invalid voter ID presented'}) @@ -182,7 +182,7 @@ def application (environ, start_response): basedata = election.getBasedata(electionID, hideHash=True) if basedata: if 'closed' in basedata and basedata['closed'] == True: - raise Exception("This election has closed") + raise Exception("This election has closed") if 'open' in basedata and basedata['open'] == "true": return response.wsgirespond(start_response, 200, { 'base_data': basedata } ) else: