github actions; black formatting

master
Martin Lackner 2022-02-16 17:10:21 +01:00
rodzic 03673bf679
commit 4d1133ffb4
14 zmienionych plików z 333 dodań i 94 usunięć

42
.github/workflows/build.yml vendored 100644
Wyświetl plik

@ -0,0 +1,42 @@
name: Build
on:
# TODO Can we make this workflow depend on unittests to avoid building broken packages?
# As it seems: no, we cannot. At least not really. Jobs can depend on other jobs, but workflows
# cannot depend on other workflows. But jobs cannot be triggered by events, only workflows are
# triggered by events. That means we would need to run the build/upload job for every commit
# and every Python version and then check if it is the right Python version and if there is a
# new tag and only then really upload a package.
# The only additional advantage of this approach might be that we could test the package
# building process for every commit and every Python version comfortably.
#
# Note: building the package is also tested in unittests.yml.
#
# https://github.community/t/how-do-i-specify-job-dependency-running-in-another-workflow/16482/2
# https://docs.github.com/en/actions/learn-github-actions/managing-complex-workflows#creating-dependent-jobs
push:
tags:
- v*
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: 3.8
- name: Upgrade pip
run: python -m pip install --upgrade pip
- name: Build a binary wheel and a source tarball
run: python setup.py bdist_wheel
- name: Publish distribution package to PyPI
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.PYPI_API_TOKEN }}

72
.github/workflows/unittests.yml vendored 100644
Wyświetl plik

@ -0,0 +1,72 @@
# This workflow will install Python dependencies, run tests and lint with a variety of Python versions
# For more information see:
# https://help.github.com/actions/language-and-framework-guides/using-python-with-github-actions
name: Unittests
on: [push, pull_request]
jobs:
unittests:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: [3.7, 3.8, 3.9, "3.10"]
steps:
- uses: actions/checkout@v2
with:
# fetch all tags to be able to generate a version number for test packages
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .[dev]
- name: Lint with flake8
run: |
pip install flake8
# stop the build if there are Python syntax errors or undefined names
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
- name: Check for correct black formatting
run: black --check .
- name: Test with pytest
run: |
# note that some tests are skipped because Gurobi is not available on Github
coverage run --source=. -m pytest -v --tb=long -m unittests.py
- name: Generate coverage report
run: coverage xml
- name: Upload coverage metrics to codecov
uses: codecov/codecov-action@v1
# tests the package, the release is done in build.yml
- name: Build a binary wheel and a source tarball
run: python setup.py bdist_wheel
env:
# does not increment on re-run... :(
# this means this will fail in the last step, when uploading the same package again to PyPI
# because PyPI requires unique file names for uploaded packages
BUILD_NUMBER: ${{ github.run_number }}
#
# # tests uploading the package to the test repo, but only if API_KEY is defined
# - name: Publish distribution package to Test PyPI
# env:
# TEST_PYPI_API_TOKEN: ${{ secrets.TEST_PYPI_API_TOKEN }}
# if: (env.TEST_PYPI_API_TOKEN != null) && (matrix.python-version == '3.8') && (github.ref == 'refs/heads/master')
# uses: pypa/gh-action-pypi-publish@master
# with:
# password: ${{ secrets.TEST_PYPI_API_TOKEN }}
# repository_url: https://test.pypi.org/legacy/

Wyświetl plik

@ -31,15 +31,13 @@ def compute(
parties=string.ascii_letters,
threshold=None,
tiesallowed=True,
verbose=True,
verbose=False,
):
filtered_votes = apply_threshold(votes, threshold)
if method == "quota":
return quota(filtered_votes, seats, fractions, parties, tiesallowed, verbose)
elif method in ["lrm", "hamilton", "largest_remainder"]:
return largest_remainder(
filtered_votes, seats, fractions, parties, tiesallowed, verbose
)
return largest_remainder(filtered_votes, seats, fractions, parties, tiesallowed, verbose)
elif method in [
"dhondt",
"jefferson",
@ -56,9 +54,7 @@ def compute(
"majorfractions",
"greatestdivisors",
]:
return divisor(
filtered_votes, seats, method, fractions, parties, tiesallowed, verbose
)
return divisor(filtered_votes, seats, method, fractions, parties, tiesallowed, verbose)
else:
raise NotImplementedError("apportionment method " + method + " not known")
@ -87,7 +83,7 @@ def __print_results(representatives, parties):
# verifies whether a given assignment of representatives
# is within quota
def within_quota(votes, representatives, parties=string.ascii_letters, verbose=True):
def within_quota(votes, representatives, parties=string.ascii_letters, verbose=False):
n = sum(votes)
seats = sum(representatives)
within = True
@ -128,7 +124,7 @@ def largest_remainder(
fractions=False,
parties=string.ascii_letters,
tiesallowed=True,
verbose=True,
verbose=False,
):
# votes = np.array(votes)
if verbose:
@ -136,9 +132,7 @@ def largest_remainder(
if fractions:
q = Fraction(int(sum(votes)), seats)
quotas = [Fraction(int(p), q) for p in votes]
representatives = np.array(
[int(qu.numerator // qu.denominator) for qu in quotas]
)
representatives = np.array([int(qu.numerator // qu.denominator) for qu in quotas])
else:
votes = np.array(votes)
quotas = (votes * seats) / np.sum(votes)
@ -186,7 +180,7 @@ def divisor(
fractions=False,
parties=string.ascii_letters,
tiesallowed=True,
verbose=True,
verbose=False,
):
votes = np.array(votes)
representatives = np.zeros(len(votes), dtype=int)
@ -234,24 +228,17 @@ def divisor(
representatives = np.array([1 if p > 0 else 0 for p in votes])
if fractions:
divisors = np.array(
[
Fraction(2 * (i + 1) * (i + 2), 2 * (i + 1) + 1)
for i in range(seats)
]
[Fraction(2 * (i + 1) * (i + 2), 2 * (i + 1) + 1) for i in range(seats)]
)
else:
divisors = np.arange(seats)
divisors = (2 * (divisors + 1) * (divisors + 2)) / (
2 * (divisors + 1) + 1
)
divisors = (2 * (divisors + 1) * (divisors + 2)) / (2 * (divisors + 1) + 1)
else:
raise NotImplementedError("divisor method " + method + " not known")
# assigning representatives
if seats > np.sum(representatives):
if fractions and method not in ["huntington", "hill", "modified_saintelague"]:
weights = np.array(
[[Fraction(int(p), d) for d in divisors.tolist()] for p in votes]
)
weights = np.array([[Fraction(int(p), d) for d in divisors.tolist()] for p in votes])
flatweights = sorted([w for l in weights for w in l])
else:
weights = np.array([p / divisors for p in votes])
@ -296,11 +283,7 @@ def divisor(
def __divzero_fewerseatsthanparties(votes, seats, parties, tiesallowed, verbose):
representatives = np.zeros(len(votes), dtype=int)
if verbose:
print(
" fewer seats than parties; "
+ str(seats)
+ " strongest parties receive one seat"
)
print(" fewer seats than parties; " + str(seats) + " strongest parties receive one seat")
tiebreaking_message = " ties broken in favor of: "
ties = False
mincount = np.sort(votes)[-seats]
@ -328,7 +311,7 @@ def quota(
fractions=False,
parties=string.ascii_letters,
tiesallowed=True,
verbose=True,
verbose=False,
):
"""The quota method
see Balinski, M. L., & Young, H. P. (1975).
@ -338,9 +321,7 @@ def quota(
Warning: tiesallowed is not supported here (difficult to implement)
"""
if not tiesallowed:
raise NotImplementedError(
"parameter tiesallowed not supported for Quota method"
)
raise NotImplementedError("parameter tiesallowed not supported for Quota method")
if verbose:
print("\nQuota method")
@ -350,8 +331,7 @@ def quota(
while np.sum(representatives) < seats:
if fractions:
quotas = [
Fraction(int(votes[i]), int(representatives[i]) + 1)
for i in range(len(votes))
Fraction(int(votes[i]), int(representatives[i]) + 1) for i in range(len(votes))
]
else:
quotas = votes / (representatives + 1)

Wyświetl plik

@ -30,52 +30,41 @@ import apportionment.methods as app
],
)
@pytest.mark.parametrize("fractions", [True, False])
def test_all_implemented(method, fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_all_implemented(method, fractions, verbose):
votes = [1]
seats = 1
assert app.compute(method, votes, seats, fractions=fractions, verbose=False) == [1]
assert app.compute(method, votes, seats, fractions=fractions, verbose=verbose) == [1]
@pytest.mark.parametrize("method", app.METHODS)
@pytest.mark.parametrize("fractions", [True, False])
def test_weak_proportionality(method, fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_weak_proportionality(method, fractions, verbose):
votes = [14, 28, 7, 35]
seats = 12
assert app.compute(method, votes, seats, fractions=fractions, verbose=False) == [
2,
4,
1,
5,
]
result = app.compute(method, votes, seats, fractions=fractions, verbose=verbose)
assert result == [2, 4, 1, 5]
@pytest.mark.parametrize("method", app.METHODS)
@pytest.mark.parametrize("fractions", [True, False])
def test_zero_parties(method, fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_zero_parties(method, fractions, verbose):
votes = [0, 14, 28, 0, 0]
seats = 6
assert app.compute(method, votes, seats, fractions=fractions, verbose=False) == [
0,
2,
4,
0,
0,
]
result = app.compute(method, votes, seats, fractions=fractions, verbose=verbose)
assert result == [0, 2, 4, 0, 0]
@pytest.mark.parametrize("method", app.METHODS)
@pytest.mark.parametrize("fractions", [True, False])
def test_fewerseatsthanparties(method, fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_fewerseatsthanparties(method, fractions, verbose):
votes = [10, 9, 8, 8, 11, 12]
seats = 3
assert app.compute(method, votes, seats, fractions=fractions, verbose=False) == [
1,
0,
0,
0,
1,
1,
]
result = app.compute(method, votes, seats, fractions=fractions, verbose=verbose)
assert result == [1, 0, 0, 0, 1, 1]
# examples taken from
@ -96,13 +85,11 @@ def test_fewerseatsthanparties(method, fractions):
],
)
@pytest.mark.parametrize("fractions", [True, False])
def test_balinski_young_example1(method, expected, fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_balinski_young_example1(method, expected, fractions, verbose):
votes = [5117, 4400, 162, 161, 160]
seats = 100
assert (
app.compute(method, votes, seats, fractions=fractions, verbose=False)
== expected
)
assert app.compute(method, votes, seats, fractions=fractions, verbose=verbose) == expected
@pytest.mark.parametrize(
@ -119,21 +106,20 @@ def test_balinski_young_example1(method, expected, fractions):
],
)
@pytest.mark.parametrize("fractions", [True, False])
def test_balinski_young_example2(method, expected, fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_balinski_young_example2(method, expected, fractions, verbose):
votes = [9061, 7179, 5259, 3319, 1182]
seats = 26
assert (
app.compute(method, votes, seats, fractions=fractions, verbose=False)
== expected
)
assert app.compute(method, votes, seats, fractions=fractions, verbose=verbose) == expected
@pytest.mark.parametrize("method", app.METHODS)
@pytest.mark.parametrize("fractions", [True, False])
def test_tiebreaking(method, fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_tiebreaking(method, fractions, verbose):
votes = [2, 1, 1, 2, 2]
seats = 2
assert app.compute(method, votes, seats, fractions=fractions, verbose=False) == [
assert app.compute(method, votes, seats, fractions=fractions, verbose=verbose) == [
1,
0,
0,
@ -142,18 +128,20 @@ def test_tiebreaking(method, fractions):
]
def test_within_quota():
@pytest.mark.parametrize("verbose", [True, False])
def test_within_quota(verbose):
votes = [5117, 4400, 162, 161, 160]
representatives = [51, 44, 2, 2, 1]
assert app.within_quota(votes, representatives, verbose=False)
assert app.within_quota(votes, representatives, verbose=verbose)
representatives = [52, 45, 1, 1, 1]
assert not app.within_quota(votes, representatives, verbose=False)
assert not app.within_quota(votes, representatives, verbose=verbose)
representatives = [52, 43, 2, 1, 2]
assert not app.within_quota(votes, representatives, verbose=False)
assert not app.within_quota(votes, representatives, verbose=verbose)
@pytest.mark.parametrize("fractions", [True, False])
def test_threshold(fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_threshold(fractions, verbose):
votes = [41, 56, 3]
seats = 60
threshold = 0.03
@ -166,50 +154,49 @@ def test_threshold(fractions):
method = "dhondt"
threshold = 0
unfiltered_result = app.compute(
method, votes, seats, fractions=fractions, threshold=threshold, verbose=False
method, votes, seats, fractions=fractions, threshold=threshold, verbose=verbose
)
threshold = 0.04
filtered_result = app.compute(
method, votes, seats, fractions=fractions, threshold=threshold, verbose=False
method, votes, seats, fractions=fractions, threshold=threshold, verbose=verbose
)
assert unfiltered_result != filtered_result
@pytest.mark.parametrize("fractions", [True, False])
def test_saintelague_difference(fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_saintelague_difference(fractions, verbose):
votes = [6, 1]
seats = 4
r1 = app.compute(
"saintelague", votes, seats, fractions=fractions, verbose=False
) # [3, 1]
r1 = app.compute("saintelague", votes, seats, fractions=fractions, verbose=verbose) # [3, 1]
r2 = app.compute(
"modified_saintelague", votes, seats, fractions=fractions, verbose=False
"modified_saintelague", votes, seats, fractions=fractions, verbose=verbose
) # [4, 0]
assert r1 != r2
@pytest.mark.parametrize("method", app.METHODS)
@pytest.mark.parametrize("fractions", [True, False])
def test_no_ties_allowed(method, fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_no_ties_allowed(method, fractions, verbose):
votes = [11, 11, 11]
seats = 4
if method == "quota":
return
with pytest.raises(app.TiesException):
app.compute(
method, votes, seats, fractions=fractions, tiesallowed=False, verbose=False
)
app.compute(method, votes, seats, fractions=fractions, tiesallowed=False, verbose=verbose)
@pytest.mark.parametrize("method", app.METHODS)
@pytest.mark.parametrize("fractions", [True, False])
def test_no_ties_allowed2(method, fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_no_ties_allowed2(method, fractions, verbose):
votes = [12, 12, 11, 12]
seats = 3
if method == "quota":
return
assert app.compute(
method, votes, seats, fractions=fractions, tiesallowed=False, verbose=False
method, votes, seats, fractions=fractions, tiesallowed=False, verbose=verbose
) == [1, 1, 0, 1]
@ -451,7 +438,8 @@ def test_no_ties_allowed2(method, fractions):
],
)
@pytest.mark.parametrize("fractions", [True, False])
def test_austrian_elections(year, partynames, votes, officialresult, fractions):
@pytest.mark.parametrize("verbose", [True, False])
def test_austrian_elections(year, partynames, votes, officialresult, fractions, verbose):
result = app.compute(
"dhondt",
votes,
@ -459,7 +447,7 @@ def test_austrian_elections(year, partynames, votes, officialresult, fractions):
fractions=fractions,
parties=partynames,
threshold=0.04,
verbose=True,
verbose=verbose,
)
assert str(tuple(result) == tuple(officialresult))
@ -556,8 +544,9 @@ def test_austrian_elections(year, partynames, votes, officialresult, fractions):
],
)
@pytest.mark.parametrize("fractions", [True, False])
@pytest.mark.parametrize("verbose", [True, False])
def test_israeli_elections(
knesset_nr, partynames, votes, officialresult, threshold, fractions
knesset_nr, partynames, votes, officialresult, threshold, fractions, verbose
):
print("Knesset #" + str(knesset_nr) + ":")
result = app.compute(
@ -567,6 +556,6 @@ def test_israeli_elections(
fractions=fractions,
parties=partynames,
threshold=threshold,
verbose=True,
verbose=verbose,
)
assert str(tuple(result) == tuple(officialresult))

53
pyproject.toml 100644
Wyświetl plik

@ -0,0 +1,53 @@
[build-system]
requires = [
"setuptools>=42",
"wheel"
]
build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
# https://docs.pytest.org/en/stable/customize.html
addopts = '''
-ra
--tb=line
-W error
--doctest-modules
--strict-markers
'''
# -ra: show extra test summary [...] (a)ll except passed [...]
# --tb=style: traceback print mode (auto/long/short/line/native/no)
# -W: set which warnings to report, "error" turns matching warnings into exceptions
testpaths = [
"tests",
]
norecursedirs = [
".git",
".github",
".pytest_cache",
"__pycache__",
]
[tool.coverage.run]
# https://coverage.readthedocs.io/en/latest/config.html
branch = true
[tool.coverage.report]
show_missing = true
[tool.black]
# https://black.readthedocs.io/en/stable/pyproject_toml.html
line-length = 99
target-version = ['py38']
exclude = '''
(
/(
\.git
| \.github
| \.pytest_cache
| env
| venv
)/
)
'''

103
setup.py 100644
Wyświetl plik

@ -0,0 +1,103 @@
import os
import setuptools
import subprocess
def read_readme():
with open("README.md", "r", encoding="utf-8") as fh:
readme = fh.read()
return readme
def read_version():
"""This is not guaranteed to be a valid version string, but should work well enough.
Tested with version strings as tag names of the following formats:
2.0.0
2.0.0-beta
v2.0.0
v2.0.0-beta
Version strings need to comply with PEP 440. Git tags are used, but for development versions
the build number is appended. To comply with PEP 440 everything after the first dash is removed
before appending the build number.
"""
git_describe = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"], stdout=subprocess.PIPE
)
if git_describe.returncode == 0:
git_version = git_describe.stdout.strip().decode("utf-8")
else:
# per default no old commit are fetched in Github actions, that means, that git describe
# fails if the latest commit is not a tag, because old tags can't be found.
# to fetch tags in Github actions use "fetch-depth: 0"
raise RuntimeError("Git describe failed: did you fetch at least one tag?")
# git_version contains the latest tag, if it is not identical with HEAD need to postifx
head_is_tag = (
subprocess.run(
["git", "describe", "--tags", "--exact-match", "HEAD"], stderr=subprocess.PIPE
).returncode
== 0
)
if not head_is_tag:
try:
# set by Github actions, necessary for unique file names for PyPI
build_nr = os.environ["BUILD_NUMBER"]
except KeyError:
build_nr = 0
# +something is disallowed on PyPI even if allowed in PEP 440... :-/
# https://github.com/pypa/pypi-legacy/issues/731#issuecomment-345461596
next_stable = git_version.split("-")[0]
git_version = f"{next_stable}.dev{build_nr}"
if git_version[0] == "v":
git_version = git_version[1:]
return git_version
setuptools.setup(
name="abcvoting",
version=read_version(),
author="Martin Lackner",
author_email="unexpected@sent.at",
description="A Python implementation of common apportionment methods",
long_description=read_readme(),
long_description_content_type="text/markdown",
url="https://github.com/martinlackner/apportionment/",
project_urls={"Bug Tracker": "https://github.com/martinlackner/apportionment/issues"},
license="MIT License",
classifiers=[
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Intended Audience :: Science/Research",
],
packages=["apportionment"],
python_requires=">=3.7",
setup_requires=[
"wheel",
],
install_requires=[
"numpy>=2.2",
],
extras_require={
"dev": [
"pytest>=6",
"coverage[toml]>=5.3",
"black==22.1.0",
]
},
)