diff --git a/justfile b/justfile index 30fe17b..962dde2 100644 --- a/justfile +++ b/justfile @@ -13,8 +13,12 @@ test: format: uv run ruff format src test +example: + uv run python src/main.py test/test_ballot.csv + compile: - uv run pyinstaller src/main.py + uv run pyinstaller --clean -F src/main.py --name smithy + clean: uv run pyclean src test diff --git a/pyproject.toml b/pyproject.toml index d8a6f28..1af11f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ dependencies = [ ] [project.scripts] -smithy = "smithy:main" +smithy = "smithy:cli" [build-system] requires = ["uv_build>=0.11.7,<0.12.0"] diff --git a/requirements.txt b/requirements.txt index f20edfe..40ae829 100644 --- a/requirements.txt +++ b/requirements.txt @@ -118,7 +118,9 @@ platformdirs==4.9.6 pluggy==1.6.0 # via pytest polars==1.40.1 - # via marimo + # via + # smithy (pyproject.toml) + # marimo polars-runtime-32==1.40.1 # via polars psutil==7.2.2 diff --git a/smithy.spec b/smithy.spec new file mode 100644 index 0000000..95e0479 --- /dev/null +++ b/smithy.spec @@ -0,0 +1,38 @@ +# -*- mode: python ; coding: utf-8 -*- + + +a = Analysis( + ['src/main.py'], + pathex=[], + binaries=[], + datas=[], + hiddenimports=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='smithy', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) diff --git a/src/main.py b/src/main.py index e69de29..e86230c 100644 --- a/src/main.py +++ b/src/main.py @@ -0,0 +1,4 @@ +from smithy import cli + +if __name__ == "__main__": + cli() diff --git a/src/smithy/__init__.py b/src/smithy/__init__.py index df9153e..701081d 100644 --- a/src/smithy/__init__.py +++ b/src/smithy/__init__.py @@ -7,23 +7,20 @@ from rich.panel import Panel from .rcv import smith_set -console = Console() @click.command() -@click.argument( - "spreadsheet", - type=click.Path(exists=True, dir_okay=False) -) -def main(spreadsheet: str) -> None: +@click.argument("spreadsheet", type=click.Path(exists=True, dir_okay=False)) +def cli(spreadsheet: str) -> None: """ Compute the Smith set from a ranked-choice ballot spreadsheet. - + The Smith set is the minimal set of candidates which can beat all others pairwise - if there is a single winner in the set they are guaranteed the Condorcet i.e. Majority winner. """ - try: + console = Console() + try: # Load spreadsheet if spreadsheet.endswith(".csv"): df = pl.read_csv(spreadsheet) @@ -33,17 +30,21 @@ def main(spreadsheet: str) -> None: else: console.print( - "[bold red]Unsupported file type.[/bold red]\n" - "Use CSV or Excel." + "[bold red]Unsupported file type.[/bold red]\nUse CSV or Excel." ) raise SystemExit(1) # Normalize numerical dataframe entries - df = df.with_columns([ pl.col(c) - .cast(pl.Utf8) - .str.strip_chars() - .cast(pl.Int64, strict=False).fill_null(0) - for c in df.columns ]) + df = df.with_columns( + [ + pl.col(c) + .cast(pl.Utf8) + .str.strip_chars() + .cast(pl.Int64, strict=False) + .fill_null(0) + for c in df.columns + ] + ) # Compute Smith set smiths = smith_set(df) @@ -66,14 +67,11 @@ def main(spreadsheet: str) -> None: Panel.fit( "\n".join(f"• {c}" for c in smiths), title="Resulting Smith Set", - border_style="green" + border_style="green", ) ) except Exception as e: - - console.print( - f"[bold red]Error:[/bold red] {e}" - ) + console.print(f"[bold red]Error:[/bold red] {e}") raise SystemExit(1) diff --git a/src/smithy/rcv.py b/src/smithy/rcv.py index f022263..482b4d8 100644 --- a/src/smithy/rcv.py +++ b/src/smithy/rcv.py @@ -1,13 +1,14 @@ import polars as pl from itertools import combinations + def smith_set(df: pl.DataFrame) -> list: """ Compute the Smith set from a Ranked-Choice ballot. - + The Smith set is the minimal set of candidates which can beat all others pairwise - if there is a single winner in the set they are guaranteed the Condorcet i.e. Majority winner. - + parameters --- df : pl.DataFrame @@ -27,7 +28,7 @@ def smith_set(df: pl.DataFrame) -> list: candidates = df.columns # Build pairwise majority graph - graph: dict[str, set[str]] = { c: set() for c in candidates } + graph: dict[str, set[str]] = {c: set() for c in candidates} for a, b in combinations(candidates, 2): result = df.select( @@ -46,17 +47,13 @@ def smith_set(df: pl.DataFrame) -> list: # Find Smith set for size in range(1, len(candidates) + 1): - for sub in combinations(candidates, size): - subset = set(sub) out = set(candidates) - subset dom = True for member in subset: - - # DIRECT dominance only if not out.issubset(graph[member]): dom = False break diff --git a/test/test_nb.py b/test/test_nb.py index a3f3b6a..6d17d8a 100644 --- a/test/test_nb.py +++ b/test/test_nb.py @@ -23,18 +23,23 @@ def _(): @app.cell def _(mo, pl): df = pl.read_csv(mo.notebook_dir() / "test_ballot.csv") - df = df.with_columns([ pl.col(c) # make safe, clean up - .cast(pl.Utf8) - .str.strip_chars() - .cast(pl.Int64, strict=False).fill_null(df.width + 1) - for c in df.columns ]) + df = df.with_columns( + [ + pl.col(c) # make safe, clean up + .cast(pl.Utf8) + .str.strip_chars() + .cast(pl.Int64, strict=False) + .fill_null(df.width + 1) + for c in df.columns + ] + ) df return (df,) @app.cell def _(df, smith_set): - smith_set(df) # find the smith set (should be "Alice" and "Bob" as a pair) + smith_set(df) # find the smith set (should be "Alice" and "Bob" as a pair) return diff --git a/test/test_rcvs.py b/test/test_rcvs.py index 425af01..44816b2 100644 --- a/test/test_rcvs.py +++ b/test/test_rcvs.py @@ -1,35 +1,41 @@ import polars as pl from smithy import smith_set + def test_condorcet(): - df = pl.DataFrame({ - 'A': [1, 1, 2, 1], - 'B': [2, 2, 1, 2], - 'C': [3, 3, 3, 3], - }) - assert smith_set(df) == ['A'] + df = pl.DataFrame( + { + "A": [1, 1, 2, 1], + "B": [2, 2, 1, 2], + "C": [3, 3, 3, 3], + } + ) + assert smith_set(df) == ["A"] + def test_rockpprscrcycle(): - df = pl.DataFrame({ - 'A': [1, 2, 3], - 'B': [2, 3, 1], - 'C': [3, 1, 2], - }) - assert smith_set(df) == ['A', 'B', 'C'] + df = pl.DataFrame( + { + "A": [1, 2, 3], + "B": [2, 3, 1], + "C": [3, 1, 2], + } + ) + assert smith_set(df) == ["A", "B", "C"] + def test_abpair(): - df = pl.DataFrame({ - "A": [1, 2, 1, 3], - "B": [2, 1, 3, 1], - "C": [3, 3, 2, 2] - }) - assert smith_set(df) == ['A', 'B'] + df = pl.DataFrame({"A": [1, 2, 1, 3], "B": [2, 1, 3, 1], "C": [3, 3, 2, 2]}) + assert smith_set(df) == ["A", "B"] + def test_fourcycle(): - df = pl.DataFrame({ - "A": [1,2,3,4], - "B": [2,3,4,1], - "C": [3,4,1,2], - "D": [4,1,2,3], - }) - assert smith_set(df) == ['A', 'B', 'C', 'D'] + df = pl.DataFrame( + { + "A": [1, 2, 3, 4], + "B": [2, 3, 4, 1], + "C": [3, 4, 1, 2], + "D": [4, 1, 2, 3], + } + ) + assert smith_set(df) == ["A", "B", "C", "D"]