mirror of
https://github.com/tgorordo/smithy.git
synced 2026-06-05 16:22:15 -07:00
move cli script to reduce core package dependencies
This commit is contained in:
parent
2138a0ea6b
commit
8b39991bcd
11 changed files with 251 additions and 141 deletions
148
README.md
148
README.md
|
|
@ -1,5 +1,147 @@
|
|||
# Smithy
|
||||
*A simple smith set solver for ranked-choice ballots.*
|
||||
*A simple Smith set solver for ranked-choice ballots.*
|
||||
|
||||
The Smith set is the minimal set of election candidates which can beat all others pairwise
|
||||
(by simple majority ranking preference) - if there is a single winner in the set they are
|
||||
guaranteed the standard Condorcet i.e. Majority winner (they beat all others pairwise).
|
||||
|
||||
`smithy` currently identifies the set by brute-force search which is combinatoric complexity
|
||||
in the worst case (TODO: better [algorithm](https://en.wikipedia.org/wiki/Floyd%E2%80%93Warshall_algorithm))
|
||||
but appears approximately $O(n^2)$ on-average in the number of candidates for typical/random ballots,
|
||||
and linear in the number of ballots.
|
||||
|
||||
|
||||
## Usage
|
||||
`smithy` can be used a few different ways: as a CLI command, via a web form upload,
|
||||
or imported as a python package (TODO: a small [GUI](https://doc.qt.io/qtforpython-6/)).
|
||||
|
||||
|
||||
### CLI Command
|
||||
`smithy` provides a [`click`](https://click.palletsprojects.com/en/stable/)
|
||||
CLI command defined in `src/cmd.py` which can be invoked a couple of ways:
|
||||
|
||||
- The [GitHub Releases Page](https://github.com/tgorordo/smithy/releases) provides standalone
|
||||
CLI executables bundled using [PyInstaller](https://pyinstaller.org/en/stable/index.html)
|
||||
for linux and windows on `x86_64` and linux on `aarch64`. Downloading the appropriate
|
||||
executable should give you access to the `smithycmd` CLI command.
|
||||
|
||||
- Alternatively, if you are using `uv` (see [Development](#Development)), you can invoke
|
||||
this in your shell as `uv run src/smithycmd.py [...]`.
|
||||
|
||||
In either case, the command expects the same argument structure:
|
||||
TODO
|
||||
|
||||
While plain output is the default, so that the command can easily be used in a unix-pipe
|
||||
or `stdio` workflows, it can also pretty-print its output for your reading pleasure
|
||||
using [`rich`](https://rich.readthedocs.io/en/stable/introduction.html) if you pass it the
|
||||
`--pretty` (or `-p`) flag.
|
||||
|
||||
|
||||
### UOregon CGI Application
|
||||
[**This** `pages.uoregon.edu` page](https://pages.uoregon.edu/tgorordo/files/smithy/src/cgi/form.html)
|
||||
hosts a [CGI form](https://service.uoregon.edu/TDClient/2030/Portal/KB/ArticleDet?ID=43069)
|
||||
which accepts a `.csv` or `.xls`(`x`) upload of a ballot-box and responds with the resulting
|
||||
Smith set. The application can typically handle ~100s of candidates and ~10ks of votes
|
||||
before running into hosting resource limitations and timing out (pathological cases, involving
|
||||
many or large cycles, or especially a large cyclic tie, may perform worse and are not extensively tested).
|
||||
|
||||
|
||||
### Python Package
|
||||
The `smithy` python package primarily provides the function `smith_set(ballot_box_df)`
|
||||
(defined in `src/smithy/rcv.py`), which returns the smith set given a
|
||||
[polars](https://docs.pola.rs/api/python/stable/reference/index.html)
|
||||
dataframe `ballot_box_df` whose columns are candidates and whose rows are RCV ballots.
|
||||
|
||||
e.g. `smithy` can be imported into a [marimo](https://docs.marimo.io/#highlights) notebook
|
||||
as seen in `test/test_nb.py` - if you're using `uv` you can launch marimo via
|
||||
`uv run marimo --edit` and navigate to that example notebook
|
||||
(`just marimo` is an available shorthand if you are using the `just` taskrunner --
|
||||
see [Development](#Development)). Cloning `smithy` then working in scratch notebooks within the repo
|
||||
(e.g. makeing your own `nbs/` directory)
|
||||
is probably the easiest way to use it if you need to do some of cleanup of the tabular
|
||||
data before invocation - just do your cleanup in a notebook using
|
||||
[polars](https://docs.pola.rs/py-polars/html/reference/) then pass a tidy dataframe to `smith_set(df)`.
|
||||
|
||||
Of course, you can also import into any python script or package for a more involved project
|
||||
as well:
|
||||
- If you're using `uv` adding `smithy` should be as simple as
|
||||
```bash
|
||||
uv add git+https://github.com/tgorordo/smithy.git
|
||||
```
|
||||
- Alternatively, if you're using the more default `pip` you can make it available by
|
||||
```bash
|
||||
git clone https://github.com/tgorordo/smithy.git # or submodule add somewhere appropriate in your project
|
||||
cd smithy
|
||||
pip install .
|
||||
```
|
||||
(if you're new to pip-venvs,
|
||||
[this brief guide might be helpful](https://pages.uoregon.edu/tgorordo/courses/uoph410-510a_Image-Analysis/setup.html)).
|
||||
|
||||
|
||||
## Development
|
||||
Current development tooling is based on the [`uv`](https://docs.astral.sh/uv/) Python package and
|
||||
project manager. A [`justfile`](https://github.com/casey/just) lists some common useful
|
||||
task commands. A simple [`shell.nix`](https://nix.dev/tutorials/first-steps/declarative-shell.html)
|
||||
can be used to load these tools into a local environment, and you might find
|
||||
[`direnv`](https://github.com/direnv/direnv) a convenient pairing to `nix` - but just having a
|
||||
system-provided `uv` available is more than enough to use and develop `smithy`.
|
||||
|
||||
|
||||
|
||||
### Formatting, Checking, Testing and Locking
|
||||
Source can be formatted using [`ruff`](https://docs.astral.sh/ruff/) by running
|
||||
```bash
|
||||
uv run ruff format src test
|
||||
```
|
||||
(or `just format`)
|
||||
which should be done before any `git` commit.
|
||||
|
||||
This is not really taken full advantage of at the moment, but type hints can be checked
|
||||
by running
|
||||
```bash
|
||||
uv run pyright src
|
||||
```
|
||||
(or `just check`).
|
||||
|
||||
A test suite can be run as
|
||||
```bash
|
||||
uv run pytest -vvv --tb=short --log-cli-level=INFO
|
||||
```
|
||||
(or `just test`).
|
||||
|
||||
Dependencies can be locked by running
|
||||
```bash
|
||||
uv lock
|
||||
uv pip compile pyproject.toml -o requirements.txt --group dev
|
||||
```
|
||||
(or `just lock`).
|
||||
|
||||
|
||||
### Bundling, Releases, Deployment and Cleanup
|
||||
Bundling with [PyInstaller](https://pyinstaller.org/en/stable/index.html) can be done by
|
||||
```bash
|
||||
uv run pyinstaller --clean -F src/cmd.py --name smithycmd
|
||||
```
|
||||
which will output an executable `smithycmd` for the CLI in `dist/`, which can be uploaded
|
||||
to the latest [GitHub Releases Page](https://github.com/tgorordo/smithy/releases).
|
||||
|
||||
Deploying the main CGI page at
|
||||
[`pages.uoregon.edu/tgorordo/files/smithy/src/cgi`](https://pages.uoregon.edu/tgorordo/files/smithy/src/cgi)
|
||||
requires shell or SFTP access to [my `pages.uoregon.edu`](https://pages.uoregon.edu/tgorordo),
|
||||
which you won't get unless you're [me](https://github.com/tgorordo).
|
||||
|
||||
If you want to deploy it to your own `pages.uoregon.edu` page, then it should be enough to
|
||||
ensure you have [CGI set up](https://service.uoregon.edu/TDClient/2030/Portal/KB/ArticleDet?ID=43069)
|
||||
then SFTP into `sftp.uoregon.edu` and `put -r smithy` (or git clone when `ssh`ed into
|
||||
`shell.uoregon.edu`) wherever you'd like in your `public_html`, ensure `smithy.cgi` has
|
||||
`chmod 755` permissions,
|
||||
[standalone install `uv` for your user](https://docs.astral.sh/uv/getting-started/installation/)
|
||||
then run `uv sync` from somewhere inside the `smithy` directory. Navigating to the `smithy.cgi`
|
||||
file in `pages.uoregon.edu/YOUR_USER/.../cgi/smithy.cgi` should give you a working form.
|
||||
|
||||
If you're using `just` you can cleanup working files by running `just clean` and can fully
|
||||
wipe your `uv` generated `.venv` with `just wipe` (if you want manual versions of those,
|
||||
check the `justfile` definition). It's probably best to do this before any commit as well,
|
||||
though probably not required if you're careful or have `.gitignore` updated appropriately
|
||||
for any changes you made.
|
||||
|
||||
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 standard Condorcet i.e. Majority winner (they beat all others pairwise).
|
||||
|
|
|
|||
6
justfile
6
justfile
|
|
@ -1,8 +1,8 @@
|
|||
list:
|
||||
just --list --unsorted
|
||||
|
||||
run spreadsheet:
|
||||
uv run smithy {{spreadsheet}}
|
||||
run *args:
|
||||
uv run src/smithycmd.py {{args}}
|
||||
|
||||
marimo:
|
||||
uv run marimo --edit
|
||||
|
|
@ -25,7 +25,7 @@ compile:
|
|||
clean:
|
||||
uv run pyclean src test
|
||||
uv run ruff clean
|
||||
rm -rf main.spec cli.spec build dist .pytest_cache .hypothesis .benchmarks __marimo__
|
||||
rm -rf smithy.spec build dist .pytest_cache .hypothesis .benchmarks __marimo__
|
||||
|
||||
wipe:
|
||||
just clean
|
||||
|
|
|
|||
|
|
@ -8,13 +8,17 @@ authors = [
|
|||
]
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"click>=8.4.0",
|
||||
"polars>=1.40.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
cli = [
|
||||
"click>=8.4.0",
|
||||
"rich>=15.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
smithy = "smithy:cli"
|
||||
#[project.scripts]
|
||||
#smithy = "smithycmd:cli"
|
||||
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.11.7,<0.12.0"]
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ charset-normalizer==3.4.7
|
|||
# via requests
|
||||
click==8.4.0
|
||||
# via
|
||||
# smithy (pyproject.toml)
|
||||
# marimo
|
||||
# uvicorn
|
||||
distro==1.9.0
|
||||
|
|
@ -84,12 +83,8 @@ markdown==3.10.2
|
|||
# via
|
||||
# marimo
|
||||
# pymdown-extensions
|
||||
markdown-it-py==4.2.0
|
||||
# via rich
|
||||
markupsafe==3.0.3
|
||||
# via jinja2
|
||||
mdurl==0.1.2
|
||||
# via markdown-it-py
|
||||
msgspec==0.21.1
|
||||
# via marimo
|
||||
narwhals==2.21.2
|
||||
|
|
@ -151,7 +146,6 @@ pygments==2.20.0
|
|||
# via
|
||||
# marimo
|
||||
# pytest
|
||||
# rich
|
||||
pyinstaller==6.20.0
|
||||
# via smithy (pyproject.toml:dev)
|
||||
pyinstaller-hooks-contrib==2026.5
|
||||
|
|
@ -184,8 +178,6 @@ regex==2026.5.9
|
|||
# via tiktoken
|
||||
requests==2.34.2
|
||||
# via tiktoken
|
||||
rich==15.0.0
|
||||
# via smithy (pyproject.toml)
|
||||
rpds-py==0.30.0
|
||||
# via
|
||||
# jsonschema
|
||||
|
|
|
|||
38
smithy.spec
38
smithy.spec
|
|
@ -1,38 +0,0 @@
|
|||
# -*- mode: python ; coding: utf-8 -*-
|
||||
|
||||
|
||||
a = Analysis(
|
||||
['src/cmd.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,
|
||||
)
|
||||
|
|
@ -43,10 +43,10 @@
|
|||
|
||||
<h2>About</h2>
|
||||
<p>This is an upload form for the <a href="https://github.com/tgorordo/smithy">smithy</a> RCV Ballot counter for identifying the Smith set (majority winners) in small elections,
|
||||
mainly to help with UO Physics Grad student elections of various flavors. This form uses the <a href="https://service.uoregon.edu/TDClient/2030/Portal/KB/ArticleDet?ID=43069">UO pages.uoregon.edu CGI Capability</a>,
|
||||
mainly to help with <a href="https://blogs.uoregon.edu/physicsgsg/">UO Physics Grad student elections</a> of various flavors. This form uses the <a href="https://service.uoregon.edu/TDClient/2030/Portal/KB/ArticleDet?ID=43069">UO pages.uoregon.edu CGI Capability</a>,
|
||||
so the implementation of smithy being invoked can be <a href="https://pages.uoregon.edu/tgorordo/files/smithy/"> inspected here</a>
|
||||
and you may also inspect the source of this page to verify that <a href="https://pages.uoregon.edu/tgorordo/files/smithy/src/cgi/smithy.cgi?source">this script<a> is called to invoke it - though
|
||||
you have to trust it to be responding with its own contents faithfully.
|
||||
you have to trust it to <a href="https://en.wikipedia.org/wiki/Quine_(computing)">quine</a> itself faithfully.
|
||||
</p>
|
||||
|
||||
<p> The <a href="https://en.wikipedia.org/wiki/Smith_set">Smith set</a> is the minimal set of candidates which can beat all others pairwise (by majority ranking)
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
from smithy import cli
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
|
|
@ -1,77 +1 @@
|
|||
import click
|
||||
import polars as pl
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
|
||||
from .rcv import smith_set
|
||||
|
||||
|
||||
@click.command()
|
||||
@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.
|
||||
"""
|
||||
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
# Load spreadsheet
|
||||
if spreadsheet.endswith(".csv"):
|
||||
df = pl.read_csv(spreadsheet)
|
||||
|
||||
elif spreadsheet.endswith((".xlsx", ".xls")):
|
||||
df = pl.read_excel(spreadsheet)
|
||||
|
||||
else:
|
||||
console.print(
|
||||
"[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
|
||||
]
|
||||
)
|
||||
|
||||
# Compute Smith set
|
||||
smiths = smith_set(df)
|
||||
|
||||
# Preview table
|
||||
preview = Table(title="Ballot Box")
|
||||
|
||||
for col in df.columns:
|
||||
preview.add_column(col)
|
||||
|
||||
for row in df.head(5).iter_rows():
|
||||
preview.add_row(*map(str, row))
|
||||
|
||||
console.print(preview)
|
||||
|
||||
# Results
|
||||
console.print()
|
||||
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"\n".join(f"• {c}" for c in smiths),
|
||||
title="Resulting Smith Set",
|
||||
border_style="green",
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[bold red]Error:[/bold red] {e}")
|
||||
|
||||
raise SystemExit(1)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import polars as pl
|
||||
from itertools import combinations
|
||||
|
||||
|
||||
def smith_set(df: pl.DataFrame) -> list:
|
||||
"""
|
||||
Compute the Smith set from a Ranked-Choice ballot.
|
||||
|
|
|
|||
86
src/smithycmd.py
Normal file
86
src/smithycmd.py
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# /// script
|
||||
# requires-python = ">=3.13"
|
||||
# dependencies = [
|
||||
# "click>=8.4.1",
|
||||
# "rich>=15.0.0",
|
||||
# "polars>=1.40.1"
|
||||
# ]
|
||||
# ///
|
||||
import click
|
||||
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
|
||||
from smithy import smith_set
|
||||
|
||||
@click.command()
|
||||
@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.
|
||||
"""
|
||||
|
||||
console = Console()
|
||||
|
||||
try:
|
||||
# Load spreadsheet
|
||||
if spreadsheet.endswith(".csv"):
|
||||
df = pl.read_csv(spreadsheet)
|
||||
|
||||
elif spreadsheet.endswith((".xlsx", ".xls")):
|
||||
df = pl.read_excel(spreadsheet)
|
||||
|
||||
else:
|
||||
console.print(
|
||||
"[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
|
||||
]
|
||||
)
|
||||
|
||||
# Compute Smith set
|
||||
smiths = smith_set(df)
|
||||
|
||||
# Preview table
|
||||
preview = Table(title="Ballot Box")
|
||||
|
||||
for col in df.columns:
|
||||
preview.add_column(col)
|
||||
|
||||
for row in df.head(5).iter_rows():
|
||||
preview.add_row(*map(str, row))
|
||||
|
||||
console.print(preview)
|
||||
|
||||
# Results
|
||||
console.print()
|
||||
|
||||
console.print(
|
||||
Panel.fit(
|
||||
"\n".join(f"• {c}" for c in smiths),
|
||||
title="Resulting Smith Set",
|
||||
border_style="green",
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[bold red]Error:[/bold red] {e}")
|
||||
|
||||
raise SystemExit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
11
uv.lock
generated
11
uv.lock
generated
|
|
@ -1484,8 +1484,12 @@ name = "smithy"
|
|||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "polars" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
cli = [
|
||||
{ name = "click" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
|
|
@ -1505,10 +1509,11 @@ dev = [
|
|||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "click", specifier = ">=8.4.0" },
|
||||
{ name = "click", marker = "extra == 'cli'", specifier = ">=8.4.0" },
|
||||
{ name = "polars", specifier = ">=1.40.1" },
|
||||
{ name = "rich", specifier = ">=15.0.0" },
|
||||
{ name = "rich", marker = "extra == 'cli'", specifier = ">=15.0.0" },
|
||||
]
|
||||
provides-extras = ["cli"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue