move cli script to reduce core package dependencies

This commit is contained in:
Thomas (Tom) C. Gorordo 2026-05-25 00:15:49 -07:00
parent 2138a0ea6b
commit 8b39991bcd
Signed by: tgorordo
GPG key ID: 0CBED22BB0D94490
11 changed files with 251 additions and 141 deletions

148
README.md
View file

@ -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).

View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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,
)

View file

@ -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)

View file

@ -1,4 +0,0 @@
from smithy import cli
if __name__ == "__main__":
cli()

View file

@ -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)

View file

@ -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
View 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
View file

@ -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 = [