diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..61f9813 --- /dev/null +++ b/.github/workflows/build.yml @@ -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 }} diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml new file mode 100644 index 0000000..95ca98a --- /dev/null +++ b/.github/workflows/unittests.yml @@ -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/ diff --git a/apportionment/methods.py b/apportionment/methods.py index 768cc4e..3fc5767 100644 --- a/apportionment/methods.py +++ b/apportionment/methods.py @@ -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) diff --git a/apportionment/unittests.py b/apportionment/unittests.py index e8f0f33..15478f0 100644 --- a/apportionment/unittests.py +++ b/apportionment/unittests.py @@ -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)) diff --git a/apportionment/examples/__init__.py b/examples/__init__.py similarity index 100% rename from apportionment/examples/__init__.py rename to examples/__init__.py diff --git a/apportionment/examples/alldifferent.py b/examples/alldifferent.py similarity index 100% rename from apportionment/examples/alldifferent.py rename to examples/alldifferent.py diff --git a/apportionment/examples/austria.py b/examples/austria.py similarity index 100% rename from apportionment/examples/austria.py rename to examples/austria.py diff --git a/apportionment/examples/israel.py b/examples/israel.py similarity index 100% rename from apportionment/examples/israel.py rename to examples/israel.py diff --git a/apportionment/examples/knesset.txt b/examples/knesset.txt similarity index 100% rename from apportionment/examples/knesset.txt rename to examples/knesset.txt diff --git a/apportionment/examples/nr_wahlen.txt b/examples/nr_wahlen.txt similarity index 100% rename from apportionment/examples/nr_wahlen.txt rename to examples/nr_wahlen.txt diff --git a/apportionment/examples/quota-ties.py b/examples/quota-ties.py similarity index 100% rename from apportionment/examples/quota-ties.py rename to examples/quota-ties.py diff --git a/apportionment/examples/simple.py b/examples/simple.py similarity index 100% rename from apportionment/examples/simple.py rename to examples/simple.py diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6f5aa4e --- /dev/null +++ b/pyproject.toml @@ -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 + )/ +) +''' diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..f069b08 --- /dev/null +++ b/setup.py @@ -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", + ] + }, +) +