diff --git a/.github/workflows/build-executables.yml b/.github/workflows/build-executables.yml index a0e83e4..f185351 100644 --- a/.github/workflows/build-executables.yml +++ b/.github/workflows/build-executables.yml @@ -13,7 +13,7 @@ jobs: # 1. Build Windows Executable # build-windows-exe: - name: "script.exe creation using python 3.11 on Windows" + name: "strat-select.exe creation using python 3.11 on Windows" runs-on: "windows-latest" steps: - uses: actions/checkout@v6 @@ -31,9 +31,13 @@ jobs: uv sync - name: "Build windows package" run: | - uv run python -m eel script.py web -n strat-select-win.exe --additional-hooks-dir=./pyinstallerhooks/ --onefile + uv run pyinstaller strat-select.spec mkdir -p target/release mv dist/strat-select-win.exe target/release + # a packaged build fails in ways a development one never does - most often by + # not finding the Qt platform plugin. So run the thing we just built. + - name: "Smoke test the executable" + run: ./target/release/strat-select-win.exe --self-test # copy script exe to build artifacts so we can test it - name: Upload executable to artifacts uses: actions/upload-artifact@v7 @@ -45,12 +49,13 @@ jobs: # 2. Build MacOS Executable # - # Note that this produces an ARM based build, so won't work on older - # intel based Macs. + # Note that this produces an x86_64 build, so it needs Rosetta on ARM based Macs. build-macos-executable: - name: "script.app creation using python 3.11 on MacOS" - # we use 13 to build x86_64 apps - runs-on: "macos-13" + name: "strat-select.app creation using python 3.11 on MacOS" + # The `-intel` images are the x86_64 runners. macos-26-intel is the other + # option - we stay on 15 so the bundle links against the older SDK and keeps + # working on the older macOS versions that Intel Macs tend to be stuck on. + runs-on: "macos-15-intel" steps: - uses: actions/checkout@v6 - name: Install uv @@ -66,17 +71,19 @@ jobs: uv run python -m site uv sync # notes: - # --onedir rather than --onefile is required for app bundle - # --target-arch=x86_64 is required for python-mip 1.15.0 - revisit once 1.16.0 is properly released - # See https://groups.google.com/g/python-mip/c/0R2NC_URD6M + # the spec builds onedir on macOS, which an app bundle requires, and sets + # target_arch=x86_64 to match this runner. See strat-select.spec for what + # moving to an arm64 build would take. - name: "Build MacOS package" run: | - uv run python -m eel script.py web -n strat-select-macos --additional-hooks-dir=./pyinstallerhooks/ --onedir --windowed --target-arch=x86_64 + uv run pyinstaller strat-select.spec mkdir -p target/release ls -l dist/strat-select-macos.app mv dist/strat-select-macos target/release - cd dist && zip -r ../target/release/strat-select-macos.app.zip strat-select-macos.app + cd dist && zip -r ../target/release/strat-select-macos.app.zip strat-select-macos.app ls -l ../target/release/ + - name: "Smoke test the executable" + run: ./target/release/strat-select-macos/strat-select-macos --self-test # copy script app to build artifacts so we can test it - name: Upload executable to artifacts uses: actions/upload-artifact@v7 @@ -93,6 +100,12 @@ jobs: runs-on: "ubuntu-24.04" steps: - uses: actions/checkout@v6 + - name: "Install Qt system libraries" + run: | + sudo apt-get update + sudo apt-get install -y xvfb libegl1 libgl1 libdbus-1-3 \ + libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 \ + libxcb-keysyms1 libxcb-shape0 - name: Install uv uses: astral-sh/setup-uv@v7 with: @@ -107,9 +120,13 @@ jobs: uv sync - name: "Build Linux package" run: | - uv run python -m eel script.py web -n strat-select-linux --additional-hooks-dir=./pyinstallerhooks/ --onefile + uv run pyinstaller strat-select.spec mkdir -p target/release mv dist/strat-select-linux target/release + # under xvfb rather than the offscreen platform, so this exercises the xcb + # plugin - the one that actually goes missing from a packaged build + - name: "Smoke test the executable" + run: xvfb-run --auto-servernum ./target/release/strat-select-linux --self-test # copy script app to build artifacts so we can test it - name: Upload executable to artifacts uses: actions/upload-artifact@v7 @@ -122,10 +139,11 @@ jobs: # 4. Release Windows Executable (tag pushes only) # release-windows-exe: - name: "script.exe release for Windows" + name: "strat-select.exe release for Windows" needs: build-windows-exe if: startsWith(github.ref, 'refs/tags/') - runs-on: "windows-latest" + # this job only moves a file, so it runs on the cheapest runner + runs-on: "ubuntu-24.04" permissions: contents: write steps: @@ -133,7 +151,6 @@ jobs: uses: actions/download-artifact@v8 with: name: strat-select-win.exe - # copy nicks-script.exe to the release # https://github.com/marketplace/actions/upload-files-to-a-github-release - name: upload executable to release uses: svenstaro/upload-release-action@v2 @@ -147,10 +164,11 @@ jobs: # 5. Release MacOS Executable (tag pushes only) # release-macos-executable: - name: "script.app release for MacOS" + name: "strat-select.app release for MacOS" needs: build-macos-executable if: startsWith(github.ref, 'refs/tags/') - runs-on: "macos-latest" + # this job only moves a file, so it runs on the cheapest runner + runs-on: "ubuntu-24.04" permissions: contents: write steps: @@ -158,13 +176,14 @@ jobs: uses: actions/download-artifact@v8 with: name: strat-select-macos - # copy script.app to the release + # The artifact holds the unpacked onedir build alongside the zip. A release + # asset has to be a single file, so we attach the zipped app bundle. # https://github.com/marketplace/actions/upload-files-to-a-github-release - name: upload executable to release uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} - file: strat-select-macos + file: strat-select-macos.app.zip tag: ${{ github.ref }} overwrite: true body: "script release text" @@ -175,6 +194,7 @@ jobs: name: "executable release for Linux" needs: build-linux-executable if: startsWith(github.ref, 'refs/tags/') + # this job only moves a file, so it runs on the cheapest runner runs-on: "ubuntu-24.04" permissions: contents: write @@ -183,8 +203,6 @@ jobs: uses: actions/download-artifact@v8 with: name: strat-select-linux - # copy script.app to the release - # # https://github.com/marketplace/actions/upload-files-to-a-github-release - name: upload executable to release uses: svenstaro/upload-release-action@v2 diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..efbaf0e --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,63 @@ +name: Run Tests + +permissions: + contents: read + +on: + push: + pull_request: + +jobs: + test: + name: "pytest on python ${{ matrix.python-version }} / ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: ["ubuntu-24.04", "macos-latest", "windows-latest"] + python-version: ["3.11", "3.12"] + env: + # the tests never open a window - see tests/conftest.py + QT_QPA_PLATFORM: offscreen + steps: + - uses: actions/checkout@v6 + # PySide6 needs these even for the offscreen platform plugin + - name: "Install Qt system libraries" + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libgl1 libdbus-1-3 libxkbcommon0 + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + python-version: "${{ matrix.python-version }}" + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: "Install dependencies" + run: | + uv run python -VV + uv sync --locked + - name: "Run the tests" + run: uv run pytest -v + + check: + name: "lint and type check" + runs-on: "ubuntu-24.04" + steps: + - uses: actions/checkout@v6 + - name: Install uv + uses: astral-sh/setup-uv@v7 + with: + version: "latest" + python-version: "3.12" + enable-cache: true + cache-dependency-glob: "uv.lock" + - name: "Install dependencies" + run: uv sync --locked + - name: "Check formatting and lint" + run: | + uv run ruff format --check . + uv run ruff check . + - name: "Type check" + run: uv run mypy . diff --git a/.gitignore b/.gitignore index af6d502..55c5589 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Stuff I want to look at but not commit +ignore/ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] @@ -30,8 +33,10 @@ MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. +# Ours is checked in, since it is the build recipe. *.manifest *.spec +!strat-select.spec # Installer logs pip-log.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 997428a..9310300 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,8 +21,10 @@ repos: - id: detect-private-key # Run the Ruff linter. + # keep this in step with the ruff dev dependency, or `just check` and this hook + # disagree about the same file - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.1 + rev: v0.15.12 hooks: # Linter - id: ruff diff --git a/README.md b/README.md index 8abbd7c..1c80835 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ Strat App ========= -[![Test Status](https://github.com/sortitionfoundation/stratification-app/workflows/tests/badge.svg?branch=master)](https://github.com/sortitionfoundation/stratification-app/actions?workflow=tests) +[![Test Status](https://github.com/sortitionfoundation/stratification-app/actions/workflows/run-tests.yml/badge.svg?branch=main)](https://github.com/sortitionfoundation/stratification-app/actions/workflows/run-tests.yml) A simple GUI for stratification for sortition/citizens' assemblies. @@ -22,7 +22,8 @@ Other relevant papers: Development ----------- -The app is built using [eel](https://github.com/ChrisKnott/Eel) - a framework that allows the GUI to be defined in HTML and CSS, but then some basic JavaScript can call Python and I can do the heavy lifting in Python. +The GUI is built with [PySide6](https://doc.qt.io/qtforpython-6/) - native Qt widgets, in +Python. There is no HTML, no JavaScript and no embedded browser. ### Install for development @@ -30,32 +31,48 @@ First you need to have the following installed: - git - python 3.11 or 3.12 -- a recent version of Chrome or Chromium - `uv` - see +- `just` - see (optional, but the recipes below assume it) ### Running in development When you first set up a development version, you need to clone this repo, open a terminal in the root of the repo and run: ``` sh -uv run python script.py +just run ``` -At this point you should have a window pop up and be able to interact with it, either via uploading .csv files or else by reading directly from a google sheet. +At this point you should have a window pop up and be able to interact with it, either by +choosing .csv files or else by reading directly from a google sheet. As you update the repo or want to re-run, the above command is all you need. -### Key files +### Tests and checks -The python command *python script.py* requires only: +``` sh +just test # unit, integration and end to end +just check # ruff format, ruff lint, mypy +``` + +The tests run headless - `tests/conftest.py` sets Qt's offscreen platform - so they need no +display, and none of them touch the network or a real Google Spreadsheet. -- script.py -- stratification.py -- all of the files in the folder *web* +### Layout + +``` +strat_app/ + __main__.py # QApplication and the window + settings_holder.py # reads sf_stratification_settings.toml + sessions/ # all the logic, importing neither Qt nor any GUI library + qt/ # the widgets, which are the only thing that imports PySide6 +main.py # the entry point PyInstaller builds from +``` -To use `uv` (as we suggest above) to control dependencies, you need *pyproject.toml* +The split is the point: `sessions/` talks to the GUI through the protocols in +`sessions/view.py`, so all of it can be tested without a window. `qt/` implements those +protocols and does nothing else. -To create exectuables using pyinstaller (as we describe below), you need `hook-mip.py` to deal with [this error](https://github.com/coin-or/python-mip/issues/198). +To create executables using pyinstaller (as we describe below), you need `hook-mip.py` to deal with [this error](https://github.com/coin-or/python-mip/issues/198). Executables ----------- @@ -68,7 +85,7 @@ If you want to make an executable yourself, use [PyInstaller](https://pyinstalle ``` sh git pull -uv run python -m eel script.py web --additional-hooks-dir=. --onefile --noconsole +uv run pyinstaller strat-select.spec ``` The resulting executable will work on any computer running the same operating system as yours, i.e. Windows, Mac OS or Linux. So if you run the above command on Linux, you can give the file to someone else running Linux. If the person who wants the app is running Windows, you need to run the above command on Windows. diff --git a/docs/index.md b/docs/index.md index 97c752e..9f4e071 100644 --- a/docs/index.md +++ b/docs/index.md @@ -83,7 +83,9 @@ The app will let you search for the Spreadsheet by name - it should have a uniqu You can select what the names of the input tabs are - the defaults are "Categories" and "Respondents". -It will create new tabs, with the default names (if they don't already exist) of "X" and "Y". **TODO:** fix this line. +It will create new tabs for the output, named "Original Selected - output - N" and +"Remaining - output - N". `N` is the lowest number for which neither tab already exists, so +running a selection twice does not overwrite the first run's output. ## Config Files diff --git a/fixtures/categories_no_flex.csv b/fixtures/categories_no_flex.csv new file mode 100644 index 0000000..ce9a0e3 --- /dev/null +++ b/fixtures/categories_no_flex.csv @@ -0,0 +1,21 @@ +category,name,min,max +gender,Female,11,12 +gender,Male,10,11 +gender,Non-binary or other,0,1 +age_bracket,0-15,0,0 +age_bracket,16-29,5,7 +age_bracket,30-44,5,7 +age_bracket,45-59,5,6 +age_bracket,60+,5,6 +edu_level,No qualifications,5,7 +edu_level,Level 1,5,7 +edu_level,Level 2 or 3,5,6 +edu_level,Level 4 and above,5,6 +geo_bucket,Central Scotland,3,5 +geo_bucket,Glasgow,3,5 +geo_bucket,Highlands and Islands,2,5 +geo_bucket,Lothian,3,5 +geo_bucket,Mid Scotland and Fife,3,5 +geo_bucket,North East Scotland,2,5 +geo_bucket,South Scotland,3,5 +geo_bucket,West Scotland,3,5 diff --git a/justfile b/justfile new file mode 100644 index 0000000..4551ea7 --- /dev/null +++ b/justfile @@ -0,0 +1,38 @@ +# ABOUTME: Task runner recipes for the stratification app - see https://just.systems +# ABOUTME: `just test` runs all the tests, `just check` runs linting and type checking + +# list the available recipes +default: + @just --list + +# run all the tests +test *args: + uv run pytest {{ args }} + +# run the unit tests only - no Qt, no network +test-unit *args: + uv run pytest tests/unit {{ args }} + +# run the tests with a coverage report +test-cov *args: + uv run pytest --cov=strat_app --cov-report=term-missing {{ args }} + +# run the app +run: + uv run python -m strat_app + +# build the window, pump the event loop and exit - the packaged build smoke test +self-test: + uv run python -m strat_app --self-test + +# run the linter and the type checker +check: + uv run ruff format --check . + uv run ruff check . + uv run mypy . + +# fix the lint problems that can be fixed automatically +# the `-` lets the formatter still run when some lint errors need a human +fix: + -uv run ruff check --fix . + uv run ruff format . diff --git a/llm-working/eel-to-qt.md b/llm-working/eel-to-qt.md new file mode 100644 index 0000000..c0e6d97 --- /dev/null +++ b/llm-working/eel-to-qt.md @@ -0,0 +1,587 @@ +# Migrating the Strat App from Eel to Qt + +Status: **agreed plan for a spike**. All open questions answered by Doctor Chewie; the +answers are recorded verbatim in [Decisions](#decisions) at the bottom and folded into the +body above. Nothing implemented yet. + +Two answers changed the plan materially: + +- **No `sortition-algorithms` bump for this work** (D5) — so no structured progress bar. + The live detailed log survives anyway; see §5.4. +- **Both tabs in scope**, not just CSV (D10) — §10 rescoped accordingly. + +## 1. Recommendation in one paragraph + +Rewrite the GUI as **native PySide6 widgets** (no HTML, no embedded browser), and use the +port as the excuse to split `script.py` into a UI-agnostic session layer plus a thin Qt +view layer. Keep PyInstaller for packaging. The interesting work is not "Qt instead of +Chrome" — it is that today every piece of business logic reaches out and calls +`eel.some_js_function(...)` directly, which is why there are zero tests. Introduce a view +protocol at that boundary and the whole app becomes testable without a GUI; the Qt widgets +then become a thin, separately-testable adapter. + +## 2. What we have today + +Findings from reading the repo: + +| Thing | Detail | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `script.py` | 610 lines. Three concerns tangled together: session state (`CSVHandler`, `GSheetHandler`, `SettingsHolder`), view updates (~25 direct `eel.*` calls), and 20 `@eel.expose` entry points. | +| `web/main.html` | 253 lines. Bootstrap 5 + jQuery **loaded from CDN** — so the CSV workflow silently needs an internet connection today. | +| `web/js/main.js` | 286 lines, almost all DOM plumbing: `getElementById`, `innerHTML = ...`, `disabled = false`. Nothing worth preserving. | +| Tests | **None.** `test_stratification.py` and `test_end_to_end.py` were deleted in `1b31e13` ("Move to sortition-algorithms library"), along with `.github/workflows/run-tests.yml`. The test badge in `README.md` is stale. | +| Fixtures | `fixtures/categories_no_flex.csv` exists but is **untracked**. No people fixture. The sibling repo has `tests/fixtures/{features,candidates,candidates_lower}.csv`. | +| Build | PyInstaller, driven via `python -m eel script.py web ...` in `.github/workflows/build-executables.yml`. `--onefile` on Linux/Windows, `--onedir --windowed --target-arch=x86_64` on macOS (macos-13 runner, x86_64 forced because of python-mip). | +| Current artefact size | `strat-select-linux` 126 MB, `strat-select-win.exe` 111 MB (v1.1.0). The bulk is numpy/cvxpy/mip/highspy, not eel. | +| Library pin | `sortition-algorithms==0.11.5`. Latest tag upstream is **0.12.10**. | + +### Eel's own README says it plainly + +> "This project is effectively unmaintained. It has not received regular update in a number +> of years, and there are no plans by active maintainers for this to change." + +Architecturally eel runs a Bottle/gevent webserver on localhost, launches Chrome in app +mode, and serves an `/eel.js` shim that proxies function calls over a websocket. So the app +currently depends on: an unmaintained library, a gevent-patched event loop, a locally bound +TCP port, a separately-installed Chrome/Edge, and a CDN. That is a lot of moving parts for +two tabs of form controls. + +### The maintainability problem is deeper than eel + +`CSVHandler._set_panel_size` has this: + +```python +# finally update the display - unless we've been called from the JS +# in which case skip this to avoid infinite loops. +if update_eel: + eel.set_csv_panel_size(self.panel_size_str) +``` + +That `update_eel` flag exists purely because the panel size is a free-text `` whose +value is round-tripped through Python. A `QSpinBox` with `setRange(min, max)` deletes the +flag, the string parsing, the `panel_size_str` / `panel_size_num` pair and the loop-guard — +about 25 lines per handler class, twice. That kind of deletion is the real payoff. + +## 3. Options considered + +### Option A — QWebEngineView + QWebChannel (keep the HTML, swap the bridge) + +Keep `web/main.html` verbatim, replace `eel.js` with `qwebchannel.js`, expose a `QObject` +with slots instead of `@eel.expose`. + +- Cheapest in raw diff size, and a well-trodden Qt path. +- But: still two languages, still a message-passing bridge, still CDN assets, and it adds + a ~130 MB Chromium (`QtWebEngineCore`) to a binary that is already 126 MB. +- Fixes the "unmaintained upstream" problem and nothing else. + +**Rejected** — and you've since confirmed you want no HTML left, which settles it. + +### Option B — Native Qt Widgets (PySide6) — **recommended** + +Rewrite the UI as `QMainWindow` / `QTabWidget` / ordinary controls. + +- One language. Standard, boring, extremely well documented technology. +- Testable headlessly with `pytest-qt` + `QT_QPA_PLATFORM=offscreen`. +- No CDN, no localhost port, no browser dependency, no gevent. +- Native `QFileDialog` for input _and_ output, replacing the `data:` URI download hack. +- Opens the door to a real progress bar (see §5.4), which the current UI cannot do. +- Cost: the UI is genuinely rewritten. But it is ~250 lines of HTML implementing two forms. + +### Option C — Qt Quick / QML + +Declarative UI in yet another language, with worse packaging ergonomics and no benefit for +a form-heavy app. **Rejected.** + +### PySide6 or PyQt6? + +**PySide6.** It is the Qt Company's official binding, LGPL-3 (so no licence question ever +arises for downstream users), and tracks Qt releases. The repo is GPL-3 so PyQt6's GPL +would also be legally fine here, but there is no upside. Depend on **`PySide6-Essentials`** +rather than the `PySide6` meta-package — that alone keeps QtWebEngine and the addons out of +the wheel set and therefore out of the bundle. **Agreed (D1).** + +## 4. Mapping the existing UI to widgets + +| Today (HTML) | Proposed widget | +| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| `` | `QPushButton` "Choose categories CSV…" → `QFileDialog.getOpenFileName`, plus a `QLabel` showing the chosen path | +| `` (disabled until features load) | same, `setEnabled(False)` until features load | +| `#csv-output-area-features-target-p` (`innerHTML`) | `QTextBrowser` (read-only; rendering settled in §5.5) | +| `#csv-panel-size` text input + Python-side validation | `QSpinBox` with `setRange(min_selection, max_selection)` | +| `#csv-run-btn` / `#csv-run-test-btn` | `QPushButton` ×2 | +| `#csv-download-selected-btn` (`data:` URI) | `QPushButton` "Save selected…" → `QFileDialog.getSaveFileName` | +| `#g-sheet-name`, `#g-sheet-respondents-tab`, `#g-sheet-features-tab` | `QLineEdit` | +| `#g-sheet-gen-rem-tab` checkbox | `QCheckBox` | +| `#g-sheet-number-selections` | `QSpinBox` (min 1) | +| Bootstrap collapse "advanced settings" | checkable `QGroupBox` | +| Bootstrap collapse "detailed log" | checkable `QGroupBox` containing a `QTextBrowser` | +| `#user-alerts` div | `QStatusBar` message, or `QMessageBox` for errors | +| `logo_sortition-foundation_alt.svg` | `QSvgWidget` (needs `PySide6.QtSvg`, which is in Essentials) | +| tabs | `QTabWidget` | + +Two deliberate behaviour changes fall out of this, both **agreed (D4)**: + +1. **Output is saved via a Save dialog** rather than "downloaded". This also means the CSV + path can switch from `CSVStringDataSource` to `CSVFileDataSource` and stop passing whole + files around as strings. +2. **Panel size becomes a spin box** clamped to the feasible range, so it is impossible to + type a value outside `[min_selection, max_selection]`. + +Beyond those two, **the layout, wording and flow stay as they are (D11)**. The widget tree +mirrors the current page: same two tabs in the same order, same step numbering, same labels, +same collapsible advanced-settings and detailed-log sections. This is a port, not a +redesign. It also keeps the port reviewable — if a screen looks different, that's a bug. + +## 5. Target architecture + +### 5.1 Layout + +``` +strat_app/ + __init__.py + __main__.py # QApplication + MainWindow; `uv run python -m strat_app` + settings_holder.py # SettingsHolder, unchanged logic + sessions/ + view.py # CsvView / GSheetView / LogView protocols — no Qt import + csv_session.py # CSVHandler, de-eel-ed — no Qt import + gsheet_session.py # GSheetHandler, de-eel-ed — no Qt import + log.py # GuiLog + qt/ + main_window.py + csv_tab.py # implements CsvView + gsheet_tab.py # implements GSheetView + log_panel.py # implements LogView + report_view.py # RunReport -> widget + workers.py # QThread worker, progress reporter, log bridge +tests/ + unit/ # no Qt, no network + integration/ # pytest-qt, offscreen + e2e/ # whole app, offscreen + fixtures/ +``` + +`script.py` goes away. The PyInstaller entry point becomes a two-line `main.py` (PyInstaller +is happier with a plain script than with `-m`). **Package restructure is in scope (D2).** + +### 5.2 The view protocol — the crux of the whole plan + +Every `eel.*` call in `script.py` becomes a method on a protocol. This is a near-mechanical +transformation; the current calls _are_ the protocol: + +```python +class CsvView(Protocol): + def show_features_report(self, report: RunReport) -> None: ... + def show_people_report(self, report: RunReport) -> None: ... + def set_panel_size_range(self, minimum: int, maximum: int) -> None: ... + def set_panel_size(self, size: int) -> None: ... + def set_people_input_enabled(self, enabled: bool) -> None: ... # enable_csv_selection_content + def set_run_enabled(self, enabled: bool) -> None: ... # enable/disable_csv_run_button + def offer_selected_download(self, contents: str, filename: str) -> None: ... + def offer_remaining_download(self, contents: str, filename: str) -> None: ... +``` + +The sessions never import Qt. Tests drive them with a `RecordingView` that appends +`(method, args)` tuples — **agreed as an acceptable test double (D7)**. It is a spy on a +boundary _we_ own, not a fake implementation of an external service, and no "mock mode" +exists in the shipped app. + +`GSheetHandler` additionally gains an injected data source (default +`adapters.GSheetDataSource`) so tests can substitute a fixture-backed +`AbstractDataSource` — that's the library's own documented extension point. + +### 5.3 Threading — mandatory, not optional + +Today `run_selection` blocks; with eel that only wedges the gevent loop and the browser +carries on rendering. In Qt, blocking the main thread **freezes the window**, and a +10-minute leximin run would look like a crash. So: + +- `SelectionWorker(QObject)` moved onto a `QThread`. Signals: `progress(...)`, + `log_line(str)`, `finished(success, report, selected_rows, remaining_rows)`, + `failed(str)`. +- Google Sheets load/write also goes on the worker thread — `gspread` is blocking network + I/O and the existing code comments mention multi-second API stalls. +- Rule to hold the line on: the worker touches **no** widget, only emits signals. + +### 5.4 Feedback during a run, staying on `sortition-algorithms==0.11.5` + +**No library bump in this work (D5)** — we port on the current pin so behaviour is directly +comparable, and revisit 0.12.x separately. That splits what we can offer during a long run +into two halves, and the split is better than I first assumed: + +**Available now, at 0.11.5** — the live detailed log. I checked the installed package: +`utils.override_logging_handlers()` is present, and there are 16 `add_line_and_log()` / +`add_message_and_log()` call sites in `core.py` and `committee_generation/`, including the +per-trial `"trial_number"` message. So: + +- a `logging.Handler` subclass that emits a Qt signal per record, installed over the + `sortition_algorithms_user` logger for the duration of the run, gives a detailed log that + **updates while the selection runs** instead of only at the end. That alone is better + feedback than the current app gives, and it costs no version bump. + +**Deferred to the bump** — the structured progress bar. `ProgressReporter` (`start_phase` / +`update` / `end_phase`, documented in the library's `docs/progress.md`) landed *after* +0.11.5, so there's no phase/percentage data to drive a determinate `QProgressBar`. For now: +an **indeterminate** `QProgressBar` (busy indicator) plus the existing +`"Selecting... please wait..."` line, shown while the worker runs and hidden when it +finishes. Same information the user gets today, but on a window that stays responsive. + +Design the seam for it now, though: the worker takes an optional `progress_reporter` +argument it currently never populates, so adding `QtProgressReporter` after the bump is a +one-file change rather than a re-plumb. + +**Cancellation is out of reach either way.** The library deliberately swallows exceptions +raised by a reporter, so Cancel cannot be implemented by raising from one, and killing a +`QThread` mid-solve is not acceptable. Proper cancellation needs upstream support — a +`should_cancel()` on the protocol, or a cancellation token. Worth an issue on +`sortition-algorithms`; you own it, so that's cheap. Out of scope here. + +When we do take the bump, note it is not free: 0.12 also brings `GSheetTabNamer` and +`load_already_selected`, and the current code assigns `data_source.selected_tab_name` +directly, which may no longer be the right API. That's its own PR with its own tests — +outlined separately in [`llm-plans/library-bump.md`](../llm-plans/library-bump.md). + +### 5.5 Rendering reports without HTML + +`RunReport.as_html()` produces a small subset: ``, ``, `
`, +and `tabulate(..., tablefmt="html")` tables. `QTextBrowser` renders all of that natively — +Qt's rich text engine, not a browser. Three choices were on the table: + +- **(a)** Feed `report.as_html()` to `QTextBrowser.setHtml()`. Simplest, keeps bold/red/tables. + Technically "HTML" but it is library-generated markup into a text widget, with no web + engine, no DOM, no JS. +- **(b)** Feed `report.as_text()` to a `QPlainTextEdit`. Zero HTML, but loses the CRITICAL + red highlighting that flags infeasible targets. +- **(c)** Walk the structured report (`RunReport.serialize()`, or a small public accessor + added upstream) and render lines with `QTextCharFormat` and tables as `QTableWidget`. + Purest, best-looking, most work, probably wants a tiny upstream API addition. + +**(a) for now (D3)**, with (c) as a possible follow-up. Worth being precise about what this +means for the "no HTML left" aim: no HTML *page*, no hand-written markup, no browser engine +and no JS survive the port. The only remaining HTML is a string the library hands us, going +straight into a Qt rich-text widget. Isolating it in `report_view.py` means swapping to (c) +later touches one module. + +## 6. Testing strategy + +Nothing in this repo is tested today, so the port has to bring its own safety net — and the +net has to exist _before_ the logic moves, or we have no way to know the port preserved +behaviour. + +Tooling: `pytest`, `pytest-qt`, `pytest-cov`, `QT_QPA_PLATFORM=offscreen` in CI (and +locally). **Add a `justfile`** with `just test` / `just check` mirroring the +sortition-algorithms repo, and **restore `.github/workflows/run-tests.yml`** — both +**agreed (D8)**. The README's test badge starts working again as a side effect. + +All three test types from your no-exceptions policy are present: unit (§6.1), integration +(§6.2) and end-to-end (§6.3). The one gap is deliberate and scoped: the Google Sheets path +has no *automated* end-to-end coverage, because there are no CI credentials for it (D6). +Its session logic is still unit-tested against a fixture-backed data source; only the real +API round-trip is manual. See §6.3 for the manual checklist that stands in for it. + +### 6.1 Unit tests — sessions with a `RecordingView`, no Qt, no network + +`tests/unit/test_csv_session.py`: + +- loading a valid categories CSV → view told to show a report, people input enabled +- empty file contents → "was the file empty?" reported, people input **not** enabled +- missing/invalid `sf_stratification_settings.toml` → error surfaced, load aborted +- features where `min_selection == max_selection` → panel size auto-set to that value +- features loaded → `set_panel_size_range(min, max)` called with values from + `features.minimum_selection` / `maximum_selection` +- reloading categories after people are loaded → people are re-parsed against new features +- run button enabled **only** when features **and** people **and** size > 0 (table-driven + over the eight combinations) +- successful run → report shown, both downloads offered, contents parse as CSV with the + expected header and row count +- infeasible targets → "No panels written…" and no downloads offered +- exception mid-selection → error reported, session still usable afterwards + +`tests/unit/test_gsheet_session.py` (fixture-backed `AbstractDataSource`, no network): + +- blank sheet name → "Please enter a spreadsheet name…", load button stays disabled +- entering a name → load button enabled, previous state cleared +- changing the categories tab name resets features **and** people; changing the respondents + tab name resets people only (this asymmetry is real in the current code and easy to break) +- `number_selections > 1` → warning emitted and `gen_rem_tab` forced false + (`_safe_gen_rem_tab`) +- a `gspread.exceptions.APIError` during load → the specific "API error causing delay" + message, and `KnownFailureError` handling does not double-report + +`tests/unit/test_settings_holder.py`, `tests/unit/test_gui_log.py`: + +- settings loaded once and cached; failure reported once per section +- log accumulation, `reset`, blank-line filtering, per-section isolation + +### 6.2 Integration tests — widgets with `qtbot` + +`tests/integration/`: + +- each tab satisfies its view protocol: call `set_run_enabled(False)` → both buttons + actually disabled; `set_panel_size_range(20, 24)` → spin box `minimum()`/`maximum()` match +- user actions raise the right session calls: `qtbot.mouseClick(run_btn, LeftButton)` with a + recording session → `run_selection(test_selection=False)` recorded +- `QSpinBox` change → session's panel size updated → run button state recomputed +- report rendering: a `RunReport` containing a table and a CRITICAL line → widget's + `toPlainText()` contains the table cells; the critical text is present +- worker thread: `qtbot.waitSignal(worker.finished, timeout=...)` around a real (small) + selection using fixtures; assert the busy indicator is shown on start and hidden on + finish, and that the worker touched no widget off-thread +- log bridge: with the handler installed over `sortition_algorithms_user`, a record emitted + from the worker thread reaches the log panel — and the handler is removed again afterwards + so a second run doesn't double-log +- tab wiring: `MainWindow` has both tabs with the expected titles; log panel collapses + +### 6.3 End-to-end tests + +`tests/e2e/test_csv_flow.py` — real `MainWindow`, real `sortition_algorithms`, offscreen: + +1. drive "choose categories file" with a fixture path (via the seam described in §7), +2. then the people file, +3. assert the reports and that the spin box is now bounded and pre-filled, +4. click Run, `qtbot.waitSignal(finished)`, +5. save both outputs to `tmp_path`, +6. assert the selected CSV has exactly `panel_size` data rows, that every ID appears in the + input pool, and that selected + remaining partition the pool. + +`tests/e2e/test_gsheet_tab.py` — the same *widget* flow, but against a fixture-backed +`AbstractDataSource` injected into the session, so it runs everywhere with no credentials. +It proves the tab, the worker and the wiring; it does not prove gspread. + +**No automated test against the real Google API (D6).** The gspread round-trip is verified +by hand before the branch merges, against a scratch spreadsheet, working through this +checklist: + +1. wrong sheet name → sensible error, load button still usable +2. wrong tab name → sensible error naming the tab +3. good sheet → categories and respondents reports match the CSV path for the same data +4. run with "generate remaining tab" on → both output tabs written, dupes highlighted orange +5. `number_selections = 2` → warning shown, no remaining tab, test-panel button refused +6. run twice in a row → output tabs are not clobbered or duplicated + +If we later want this in CI, it needs a service-account JSON in GitHub secrets and a +throwaway sheet — parked, not forgotten. It is the largest known hole in the safety net, so +step 2's characterisation tests matter more for the gsheet path than for the CSV one. + +### 6.4 Packaged-binary smoke test — new, and overdue + +CI builds three binaries today and never runs any of them. The classic PySide6 packaging +failure ("could not load the Qt platform plugin") only ever shows up in the packaged +artefact. So: add a `--self-test` flag that constructs the `QApplication` and `MainWindow`, +pumps the event loop briefly, and exits 0. Then in each build job run the freshly built +artefact with `--self-test` (under `xvfb-run` on Linux). Cheap, and it would catch the one +class of bug that currently reaches users first. + +## 7. How I'd do it: red/green, step by step + +Being straight about the terminology: some of this is genuine red-first TDD (all the new +code), and some is characterisation testing (pinning down behaviour that already exists). +Both matter here; I'll say which is which rather than pretending it's all one thing. + +There is one seam I need for testability up front: **file choosing must be separable from +file dialogs.** So `CsvTab.load_features_file(path: Path)` does the work and the button's +slot is only `path = QFileDialog.getOpenFileName(...); self.load_features_file(path)`. +Tests call the method; nobody tries to automate a native dialog. + +### Step 0 — test harness (red → green, deliberately) + +Add `pytest`, `pytest-qt`, `justfile`, CI `run-tests.yml` (which was deleted and never +replaced). Prove the harness works by committing a test that asserts something false, watch +CI go **red**, fix it to **green**, delete it. Also commit the fixtures: `fixtures/` is +untracked, and we need a people CSV to go with `categories_no_flex.csv` — the sibling +repo's `tests/fixtures/candidates.csv` is the obvious donor. + +### Step 1 — characterisation tests against the _current_ eel code (red → green) + +Before changing a line of logic, `monkeypatch.setattr(script, "eel", Recorder())` and +assert the exact sequence of view calls for each user action, writing the expectation from +reading the code _first_. + +This is the step people skip, and it's the one that pays. Every test written this way is +either green immediately (the code does what I read it to do) or red (it doesn't — and I've +found a real bug, or a real misunderstanding, before porting it). Either outcome is useful; +a green-on-first-run test here is not a wasted test, it is a locked-in behaviour. + +The resulting recorded call sequences become the literal specification for the view +protocol in step 2. + +### Step 2 — extract the session layer (refactor under green tests) + +Create `strat_app/sessions/`, move the handler classes across, replace every `eel.*` call +with a view-protocol call. Port the step-1 tests to the `RecordingView` — same assertions, +new double. + +To keep the app runnable and comparable during this step, write a ~40-line `EelCsvView` / +`EelGSheetView` adapter implementing the protocols with the current `eel.*` calls. The eel +app keeps working, so we can run old and new side by side and eyeball them. Both adapters +get **deleted** in step 6. + +Red/green here is "tests stay green while the code moves" — if they go red, the extraction +was wrong. + +### Step 3 — Qt widgets, one at a time (genuine red-first) + +Each of these is: write the failing test, watch it fail for the right reason, implement. + +1. `MainWindow` exists with two tabs of the expected titles. +2. `CsvTab` has the expected widgets and correct initial enabled/disabled states. +3. `CsvTab` satisfies `CsvView` — one test per protocol method, asserting widget state. +4. `CsvTab` emits the right session calls on user interaction (recording session double). +5. `GSheetTab` likewise (including the advanced-settings group box). +6. `LogPanel` likewise. +7. `report_view` rendering. + +### Step 4 — threading (red-first, `waitSignal`) + +Worker + signals, then the busy indicator and the `user_logger` → log-panel bridge (§5.4 — +both work at the current library pin). Tests use `qtbot.waitSignal` and assert on signal +ordering and payloads. Leave the unused `progress_reporter` seam in the worker for the +later bump. + +### Step 5 — wire it together and write the e2e tests + +`__main__.py`, then the end-to-end tests from §6.3. Realistically some of these are +written red _before_ the wiring, because they define what "wired" means. + +### Step 6 — delete eel + +Remove `script.py`, `web/`, the eel adapters, the `Eel` dependency, the eel mypy override +in `pyproject.toml`. Update `README.md` and `docs/index.md`. This is the commit where the +"no HTML left" aim is verifiable: `rg -i ' 1`**. The gsheet tab +warns about this (`_multiple_selections_warning`) but does not disable the button, so a +user can do it. Today they get a tidy report and "No panels written to spreadsheet, +process ended."; after the bump they get `_selection_failed`'s generic "Unexpected error +during selection: …". Not a crash, but a downgrade. + +**Decided: do both.** In the app, disable `run_test_button` when +`number_selections > 1`, which is what the warning already promises — _"You cannot use the +'Produce a Test Panel' button"_ — and currently doesn't enforce. That belongs in +`GSheetSession.set_number_selections`, pushed to the view through the existing +`set_run_enabled`-style path rather than by reaching into the widget, plus a unit test that +the test button goes disabled at 2 and re-enabled at 1. Separately, the upstream +inconsistency (`ConfigurationError` escaping where `ValueError` used to be caught) is +issue 1 in `llm-working/upstream.md`. + +Other `ConfigurationError` sites (invalid `selection_algorithm`, invalid `solver_backend`, +`check_same_address` with no columns) are raised from `Settings` construction, which +`SettingsHolder.init_settings` already catches as bare `Exception`. No change needed. + +### 2.5 New setting: `solver_backend` + +`SOLVER_BACKENDS = ("highspy", "mip", "mip-cbc", "mip-highs", "mip-gurobi")`, +`DEFAULT_BACKEND = "highspy"`. The default `Settings` field means an existing +`~/sf_stratification_settings.toml` without the key keeps working. But the newly written +default settings file gains a `solver_backend = "highspy"` line, and — critically for §4 — +**the default solve now goes through `highspy`, not python-mip/CBC.** + +Also worth knowing: the default-settings comment in the library is out of step with the +code — it says `# solver_backend can be "highspy" "mip" (default), ...` while +`DEFAULT_BACKEND = "highspy"`. Cosmetic upstream nit; issue 3 in +`llm-working/upstream.md`. + +### 2.6 Behaviour and message changes users may notice + +- `InfeasibleQuotasError`'s first line changed from _"The quotas are infeasible:"_ to + _"It is not possible to hit all the targets with the current set of people. I suggest the + following steps:"_. Friendlier; no test depends on it. +- Duplicate-ID reporting rewritten: now names the row numbers + (`id_2 (rows 3 and 5)`) instead of dumping every column of every duplicate row. Strictly + better for our users, who are staring at a spreadsheet. +- `finished_writing_selected_only` now explains _why_ only selected was written. +- `normalise_dict` now strips whitespace from **keys** as well as values, and headers go + through the new `normalise_iter` — so a Google Sheet with `" nationbuilder_id"` in the + header row now works where it previously produced a confusing parse error. Real fix for a + real support burden. +- Blank-ID rows are skipped before the duplicate check (`0f64642`) — previously a run of + empty rows under the data could trip the duplicate detector. +- New `NotNativeGoogleSheetError`, raised _before_ opening a Drive file that isn't a native + Sheet (the classic "user uploaded an .xlsx"). It's a `SelectionError`, so our + `_read_features` catch-all already reports it. **Decided: show the library's message + and nothing more** — no special-casing in `gsheet_session`. If the wording turns out to be + thin when we hit it in step 5's manual check, the fix belongs upstream in + `error_messages.py` where every caller benefits, not in this app. +- `GSheetDataSource` now takes `request_timeout` (default 60s) and calls + `client.set_timeout()`. **Decided: leave it at the default**, and don't expose it. + It is a behaviour change for anyone on a slow connection with a very large respondents tab + — previously the request would block indefinitely, now it fails after a minute — but a + hung app is worse than a clear failure, and we can revisit if anyone hits it. +- One extra Drive API call per spreadsheet open (the mimetype check). Negligible. + +### 2.7 Messages the app's tests assert on — all still present + +Checked individually against `report_messages.py` / `settings.py` at 0.12.10: +`features_found` ("Number of features found: %(count)s") ✓, `wrote_default_settings` +("Wrote default settings to…") ✓, `tab_not_found` ("no tab called '…'") ✓. The +`RecordingLogView.text()` / `as_text()` path is unchanged. So beyond the three +`"Trial number: 1"` assertions in §2.3, no other test string is at risk — but see step 1 in +§5, we find out by running rather than by reading. + +--- + +## 3. Progress reporting: what the library actually gives us + +Phases emitted (from `docs/progress.md`, which the library treats the `name` values as +public API): + +| `name` | `total` | when | +| ------------------------ | ----------------------------------------------- | --------------------------------------------------- | +| `legacy_attempt` | `max_attempts` | legacy only | +| `multiplicative_weights` | `multiplicative_weights_rounds` (typically 200) | initial committee search — **maximin/leximin/nash** | +| `maximin_optimization` | `None` | maximin convergence loop | +| `nash_optimization` | `None` | nash convergence loop | +| `leximin_outer` | `people.count` | leximin outer loop | +| `diversimax` | `None` | one event, no updates | + +For our default (`maximin`) a run is therefore: `multiplicative_weights` (determinate, +~200 rounds — this is the long bit) → `maximin_optimization` (indeterminate). That maps +cleanly onto `QProgressBar`: `setRange(0, total)` when `total` is not `None`, +`setRange(0, 0)` when it is. `message` is always supplied by the library and is +human-readable ("Round 47/200: 31 committees found"), so it can go straight into a label +next to the bar. + +Contract details that matter for the implementation: + +- **Phases are flat.** Each `start_phase` implicitly ends the previous one. No nesting to + model. +- **The library never throttles.** `update()` is called every iteration of the inner loop — + "hundreds of times per second on a fast solver". Throttling is explicitly the caller's + job. Emitting a Qt signal per call would flood the GUI thread's event queue. +- **Exceptions from the reporter are swallowed and logged** by `ErrorSwallowingReporter` + (which `coerce_reporter` wraps ours in). So a bug in our reporter cannot kill a run — and + equally, **Cancel cannot be implemented by raising from the reporter**. See §7. +- **Single-threaded from the library's side**, called from whichever thread we run the + work on (our `QThread`). Signal emission across threads with a queued connection is the + right bridge and needs no locking of our own. + +Two reference implementations to crib from: `progress_rich.py` in the library (rich, no +throttling, single task) and OpenDLP's `DatabaseProgressReporter` +(`backend/src/opendlp/adapters/sortition_progress.py`) which is the throttled shape we +want — `time.monotonic()`, `min_interval_seconds`, `force=True` on phase transitions. + +--- + +## 4. Packaging — the thing that isn't in the old plan and matters most + +### 4.1 The resolution + +Resolved with the app's real constraint set (`requires-python >=3.11,<3.13`, +`exclude-newer = "1 week"`) in a scratch project: + +| | 0.11.5 (current lock) | 0.12.10 | +| -------------- | ----------------------------------- | ------------------------------------------------- | +| `pandas` | 3.0.3 (27 MB installed) | **gone** | +| `scikit-learn` | 1.8.0 (18 MB) + `joblib` | **gone** (`joblib` remains, via cvxpy) | +| `mip` | 1.15.0 (23 MB, CBC bundled inside) | 1.17.6 (88 KB) | +| `cbcbox` | — | **2.929 — new, and it is 181 MB on linux-x86_64** | +| `highspy` | 1.14.0 (3.8 MB, pulled in by cvxpy) | 1.15.1, now a direct dependency | +| `cvxpy` | 1.8.2 | 1.9.2 | +| `numpy` | 2.4.4 | 2.4.6 / 2.5.1 | +| `scipy` | 1.17.1 | 1.17.1 / 1.18.0 | + +`mip` 1.17 stopped vendoring CBC and moved it into a separate `cbcbox` wheel. The wheels +are **181 MB (linux x86_64), 145 MB (linux aarch64), 135 MB (win_amd64), 88 MB +(macos x86_64), 60 MB (macos arm64)**. `mip` is still a _hard_ dependency of +`sortition-algorithms`, so `uv sync` will install it and PyInstaller will happily collect +it. The current Linux binary is **157 MB**; naively bumping would push it far past 250 MB. + +### 4.2 What to do about it + +We don't need mip. The default backend is `highspy`, and `solver.py` computes +`MIP_AVAILABLE = importlib.util.find_spec("mip") is not None` at import time, then imports +`mip` lazily inside `MipSolver.__init__`. So: + +- add `"mip"` and `"cbcbox"` to the spec's `EXCLUDES`; +- **delete `pyinstallerhooks/hook-mip.py`** and drop `hookspath=["pyinstallerhooks"]` (that + hook exists solely to work around python-mip's `.so` collection, coin-or/python-mip#198, + and there is nothing else in the directory); +- verify in the packaged build that `find_spec("mip")` returns `None` under PyInstaller's + `FrozenImporter` (it should — excluded modules are absent from the archive and from + `sys.path`), so `MIP_AVAILABLE` is `False` and nothing tries to import it. + +Net effect on the bundle: **−pandas −sklearn −mip, +highspy** ≈ a large reduction, probably +to well under 120 MB. Measure it (§5, step 8) rather than trusting that arithmetic. + +Excluding mip means a user who sets `solver_backend = "mip"` (or `mip-cbc` / `mip-highs`) in +their settings file gets a `RuntimeError` from the packaged app — and gets it ten minutes +into a run, not at startup. **Decided: validate the setting.** See §4.5 for what that +validation covers, since the same mechanism covers the diversimax decision in §4.4. + +### 4.3 Lazy imports and PyInstaller + +Commit `f85e6ff` moved `cvxpy`, `numpy`, `gurobipy`, `highspy`, `mip`, `pandas` and +`sklearn` imports _inside_ the functions that use them. PyInstaller's module graph does +follow function-level `import` statements, so this should be transparent — but it is +exactly the kind of change that produces a `ModuleNotFoundError` only in the frozen build. +The `--self-test` smoke test on all three platforms is the check, and it must run a real +selection (it does). + +Watch specifically for `highspy` needing a hook of its own — it is a compiled extension +with data files, and nothing in `_pyinstaller_hooks_contrib` covers it as far as I can +tell. If the Linux self-test fails on it, `collect_dynamic_libs("highspy")` in a small +`hook-highspy.py` is the fix (i.e. we may end up replacing the mip hook rather than +deleting the hooks directory outright). + +The existing `module_collection_mode={"cvxpy": "pyz+py"}` workaround should still be needed +— cvxpy 1.9 still lists its own package directory at import time. Leave it. + +### 4.4 diversimax + +`pandas` and `scikit-learn` are now the `diversimax` extra. +`selection_algorithm = "diversimax"` is one of the five valid values, and a user could put +it in their settings file today and have it work. After the bump, without the extra, they +get `RuntimeError("Diversimax algorithm requires the optional 'diversimax' dependencies")`. + +**Decided: this app does not need to support diversimax.** So: no `diversimax` extra, +45 MB of pandas + sklearn stays out of the binary, and `selection_algorithm = "diversimax"` +becomes an unsupported value — validated at settings-load time along with the mip backends, +per §4.5. + +### 4.5 Settings this build cannot honour, and where to say so + +Those two decisions leave the app in a position the library can't help with: two settings values are +syntactically valid to `sortition-algorithms` but unsupported by *our build*, because we +deliberately don't ship the code behind them. + +- `solver_backend` in `("mip", "mip-cbc", "mip-highs", "mip-gurobi")` — mip is excluded + from the bundle (§4.2) +- `selection_algorithm = "diversimax"` — pandas/sklearn are not installed (§4.4) +- (already true today, and worth folding in while we're here: + `selection_algorithm = "leximin"` needs Gurobi, which we have never shipped. The library + falls back to maximin with a report line, so this one is informational, not fatal.) + +`SettingsHolder` is the right home: it already loads once, caches, and reports into a log +section next to whatever the user just did. Add a check after `Settings.load_from_file` +that inspects the two fields and returns a clear message — +_"solver_backend 'mip' is not available in this build; supported values are: highspy"_ — +rather than letting the run get most of the way through and then die. Unit tests go in +`tests/unit/test_settings_holder.py`, which already has the settings-file-error shape to +copy. + +Deliberately a **refusal at load, not a silent rewrite**: quietly swapping the user's +solver for a different one would make two machines with the same settings file produce +selections by different algorithms without saying so. That is exactly the kind of thing this +app must never do. + +### 4.6 The macOS x86_64 pin — now removable, but later + +`strat-select.spec` has `target_arch="x86_64"` with the comment _"python-mip has no arm64 +macOS wheel"_, and `build-executables.yml` runs the macOS job on `macos-13` with a `TODO` +noting GitHub has been retiring those runners. Both constraints exist because of mip. + +With mip excluded, and `highspy` shipping `macosx_11_0_arm64` wheels (and `cbcbox` now +shipping `macosx_15_0_arm64` too, if we ever wanted mip back), **the macOS build can go +native arm64 on a modern runner.** That would fix the "won't work on newer ARM Macs without +Rosetta" note in the workflow, which is the majority of Macs SF staff will be using. + +**Decided: leave the macOS work until later.** Not in this PR. It is a genuinely separate +change with its own risk — a new runner, a new architecture, and no ARM Mac in CI to test +the _result_ beyond `--self-test` — and this PR is already doing enough. Two things to carry +forward so it doesn't get lost: + +- say in this PR's description that the x86_64 pin is now unblocked, and leave the + `target_arch="x86_64"` comment in the spec updated to say _why_ it is still there (habit + and caution, not mip); +- the `macos-13` runner retirement is a clock we don't control. If GitHub pulls it, this + stops being a nice-to-have and becomes the thing blocking macOS builds entirely. + +--- + +## 5. The work, in order + +Ordered so the risky parts happen under test, and so each step's failure mode is legible. + +### Step 1 — bump the pin, run the suite, change nothing else — **DONE** + +``` +uv add "sortition-algorithms==0.12.10" +just test +``` + +Expected: the three `"Trial number: 1"` assertions in §2.3 fail. **Anything else that goes +red is new information and belongs in this document before it gets fixed.** Do not fix +anything in this step; record what broke. + +Also run `just check` — `mypy` may have opinions about the newly-lazy imports or the +`progress_reporter: Any` annotation. + +**What actually happened.** Baseline before the bump: 148 passed. After: **3 failed, 145 +passed** — and the three are exactly the predicted ones, no others: + +- `tests/e2e/test_csv_flow.py::test_the_detailed_log_fills_in_as_the_run_goes` +- `tests/integration/test_threaded_selection.py::test_the_library_log_arrives_while_the_selection_runs` +- `tests/integration/test_threaded_selection.py::test_the_report_does_not_repeat_the_lines_already_logged` + +The captured log on each confirms the replacement line predicted in §2.3 is there: +`Using maximin algorithm.`, then `All agents are contained in some feasible committee.` + +`uv sync` pruned `pandas`, `scikit-learn`, `python-dateutil` and `threadpoolctl` from the +venv, and added `cbcbox` 2.929 — §4.1's resolution confirmed against the real lock file. + +`just check`: ruff clean, and **mypy had no opinions about the lazy imports or the +`progress_reporter: Any` annotation.** It did fail, but for an unrelated pre-existing reason +— the research checkout in `ignore/` gives mypy a second module called `tests`. ruff honours +`.gitignore` and skips it; mypy does not. Fixed by adding `^ignore/` to mypy's `exclude` in +`pyproject.toml`. Confirmed pre-existing by stashing the bump and re-running. + +One practical note for later steps: the three failures each burn a 120s `waitUntil` timeout, +so a red run of the full suite takes ~7 minutes against ~30s green. + +### Step 2 — fix the tests that the retry-loop move broke — **DONE** + +- `tests/integration/test_threaded_selection.py`: swap `"Trial number: 1"` for + `"Using maximin algorithm"` in both tests; in `test_the_library_log_arrives_while_the_selection_runs` + collapse the now-redundant pair of assertions into one. +- `tests/e2e/test_csv_flow.py:93`: same swap. +- Update the docstring at `test_threaded_selection.py:82` — it says "The trial and algorithm + lines", and there are no trial lines any more. +- Update the comment at `strat_app/qt/workers.py:127` ("which trial it is on, which + algorithm it picked") for the same reason. + +Green here means the bump is behaviourally clean for the CSV path. + +**How it went.** All three green. The string went into `tests/conftest.py` as +`ALGORITHM_LINE` rather than being repeated in two files, with a comment noting it tracks +`selection_algorithm` — change the algorithm in settings and the line changes with it, which +is the kind of coupling worth writing down. + +Worth recording because it nearly got lost: **both tests still prove what they were written +to prove.** `LogPanel` builds its browser with `include_logged=False`, so a line the library +logs can only reach that widget through the live `user_log_handler` — never through the +end-of-run report. So "the line is in the browser" really does mean "the live path works", +and `count(ALGORITHM_LINE) == 1` really does mean "the report didn't duplicate it". Dropping +the separate `any("algorithm" in line for line in lines)` assertion cost nothing. + +### Step 3 — packaging — **DONE** + +- `EXCLUDES += ["mip", "cbcbox"]` in `strat-select.spec`. +- Delete `pyinstallerhooks/hook-mip.py`; remove `hookspath` if the directory ends up empty + (keep it if `highspy` turns out to need a hook, §4.3). +- Update the two stale comments in the spec that reference the mip hook. +- `uv run pyinstaller strat-select.spec && ./dist/strat-select-linux --self-test`, and + record the size. Then let CI do Windows and macOS. +- If the frozen build fails to import `highspy`, add `pyinstallerhooks/hook-highspy.py`. + +Doing this _before_ the progress bar means a red self-test can only be about the bump, not +about new app code. + +**How it went.** + +- **cbcbox is worse than §4.1 said**: 181 MB as a wheel, **275 MB unpacked** into the venv. + Excluding it was not optional. +- **Linux bundle: 156.4 MiB → 141.9 MiB**, a 14.5 MiB (9%) reduction. §4.2 guessed "well + under 120 MB" and **that was too optimistic** — pandas, sklearn and the old vendored mip + compress well inside the archive, so removing 68 MB of installed weight bought about a + fifth of that on disk. PySide6 and scipy dominate what is left, and neither is going + anywhere. The win is real but modest; the real win is not *adding* 275 MB. +- **highspy needed no hook.** It is collected correctly on its own (9 entries in the PKG + TOC), so `pyinstallerhooks/` ended up empty, the directory is deleted and `hookspath` is + now `[]`. Worth doing rather than leaving an empty directory: git cannot track one, so a + fresh clone would have had `hookspath` pointing at nothing. +- **§4.3's worry is settled.** The build log says + `excluded module named mip - imported by sortition_algorithms.committee_generation.solver (delayed)` + — PyInstaller does follow function-level imports, and `(delayed)` is it saying so. No + `ModuleNotFoundError` from the lazy-import refactor. +- **`--self-test` passes on the packaged Linux binary**, running a real selection end to end + through highspy: `Using maximin algorithm.` → `self test: ok - selected 4 of 10`. + (It needs a display — `QT_QPA_PLATFORM=offscreen` locally, `xvfb-run` in CI.) +- The spec's `target_arch="x86_64"` comment blamed python-mip, which is no longer true. + Rewritten to say the pin is now caution rather than necessity, per §4.6. + +### Step 4 — refuse the settings this build cannot honour — **DONE** + +Per §4.5, and red-first: `tests/unit/test_settings_holder.py` gets cases for a settings file +naming a mip backend and one naming `diversimax`, asserting the returned message says which +value is unsupported and what is supported. Then implement the check in `SettingsHolder`. + +Keep it a **message, not an exception** — `init_settings` already returns a string that +`init_settings_log` puts in the log section next to the user's action, and that is exactly +where this belongs. Whether an unsupported value should also block the run button, or just +warn loudly, is worth thinking about while writing the test: my read is **block**, because a +warning that is followed by a ten-minute wait and then a `RuntimeError` is worse than no +warning at all. + +**How it went.** 13 new tests, red first, then green. Settled on **block**, and it needed no +new plumbing: refusing means not storing the settings, so `loaded()` stays `False`, and both +sessions already bail out early on `if not self.settings_holder.loaded()`. An unsupported +value therefore behaves exactly like a broken settings file — reported in the log section +next to whatever the user just did, with the load abandoned. + +`SUPPORTED_SOLVER_BACKENDS` and `SUPPORTED_SELECTION_ALGORITHMS` are module constants in +`settings_holder.py`, so what the build supports is stated in one place rather than inferred +from the spec file. + +Two tests worth keeping beyond this PR: + +- `test_the_settings_file_written_for_a_new_user_is_supported` — the library writes its own + defaults on a fresh machine. If a future release changes `DEFAULT_BACKEND` to something we + exclude, every new user would be locked out on first run. This test fails first instead. +- the `legacy`/`maximin`/`nash`/`leximin` parametrised case, which pins the positive side so + the refusal can't quietly widen. + +Note `init_settings()` does not return `""` on success — a valid file still reports the +address-checking warning — so the acceptance tests assert on the absence of the refusal +text rather than an empty string. First draft got that wrong and the red run caught it. + +### Step 4b — keep the test-panel button's promise — **DONE** + +Not a numbered step in the original plan, but §2.4's decision and it belongs here, next to +the other settings-shaped refusal. + +`set_run_enabled` used to drive both run buttons, so there was no way to refuse only the +test panel. Split into `set_run_enabled` / `set_test_run_enabled` on `GSheetView`, with +`update_run_button` enabling the test button only when `number_selections == 1`, and +`set_number_selections` now calling `update_run_button` so the button tracks the spinner. + +Three things the tests pinned down that were easy to get wrong: + +- `set_busy` has to remember and restore **both** buttons independently — otherwise + finishing a run hands back a test button that was deliberately disabled. + `test_a_test_panel_refused_before_a_run_stays_refused_after_it` is that test. +- `set_number_selections` calls `_clear_messages`, which resets the panel size to 0 — so the + realistic order is "choose the number of selections, then the panel size". The first draft + of the test asserted the wrong sequence and went red. +- two existing tests encoded the old "one switch drives both buttons" assumption and needed + updating. That is the assumption we were deliberately changing, so they were right to + fail. + +### Step 5 — the gsheet path, by hand + +The gsheet path has no automated end-to-end coverage (eel-to-qt plan, D6), so this is the +step that needs care. Work the §6.3 checklist from that plan against a scratch spreadsheet: + +1. wrong sheet name → sensible error, load button still usable +2. wrong tab name → sensible error naming the tab +3. good sheet → categories and respondents reports match the CSV path for the same data +4. run with "generate remaining tab" on → both output tabs written, dupes highlighted orange +5. `number_selections = 2` → warning shown, no remaining tab, test-panel button refused +6. run twice in a row → output tabs are not clobbered or duplicated + +Plus three new cases the bump introduces: + +7. a `.xlsx` uploaded to Drive (not converted) → the new `NotNativeGoogleSheetError` + message appears in the features output area, and the app stays usable +8. a respondents tab with leading/trailing whitespace in a header cell → now loads + (previously a parse error) +9. a respondents tab with a numeric-looking header cell → now loads + +Item 6 remains the one to be most careful about — it exercises `find_unused_tab_suffix`, +which is unchanged, but "unchanged code, changed surroundings" is exactly how this kind of +thing bites. + +**Status: NOT DONE — blocked, and it is the one gap in this PR.** + +This machine has no `~/secret_do_not_commit.json` and no scratch spreadsheet, so items 1–7 +cannot be run. They need a Google service account and a human driving the GUI. **Chewie, or +whoever merges this, has to work that checklist before it ships** — it is the only coverage +the gsheet write path has, and the bump changes how that path opens a spreadsheet (the new +mimetype pre-check, §2.6) and how long it waits (the new 60s timeout, §2.6). + +What was done instead, to shrink the hole rather than just note it: **items 8 and 9 turned +out not to need Google at all** and are now automated against the fixture-backed data source +in `tests/unit/test_gsheet_session.py`. Padding every header name with spaces and loading +through the whole session flow tests exactly what the manual item was going to test. + +**That found a real bug, which is why it was worth doing.** 0.12's whitespace fix went into +`SelectionData.load_people` and `load_already_selected` but **not** `load_features`. So: + +- a **respondents** tab with `" nationbuilder_id "` now loads — the fix works +- a **categories** tab with `" category "` still fails, with + `Did not find required column name 'category' in the input` — naming a column the user can + see spelled correctly in front of them, which is precisely the confusion the fix existed + to remove + +Not worked around in the app — that would be reimplementing library behaviour in the wrong +place. Instead it is `upstream.md` issue 5, and +`test_padded_category_headers_are_not_tolerated_yet` documents the current behaviour so the +asymmetry is written down rather than rediscovered. When upstream fixes it, that test goes +red and should be flipped into the positive assertion its sibling already makes. + +Item 7 (`NotNativeGoogleSheetError`) genuinely needs Drive — you cannot fake a non-native +mimetype through `CSVStringDataSource` — so it stays on the manual list. + +### Step 6 — `QtProgressReporter`, red-first — **DONE** + +New module `strat_app/qt/progress.py`: + +- a `QObject` with `phase_started = Signal(str, object, str)` (name, total-or-None, + message), `progress = Signal(int, str)`, `phase_ended = Signal()`; +- `start_phase` / `update` / `end_phase` emit them; connections are + `Qt.ConnectionType.QueuedConnection` so they land on the GUI thread; +- throttling: `time.monotonic()`, `min_interval_seconds = 0.1` (≈10 Hz), **phase + transitions always flush** (`force=True`), so the label never lags behind the bar; +- also flush the last `update` on `end_phase`, so a phase never visually stops at 187/200. + +Tests (`tests/integration/test_progress.py`): + +- `qtbot.waitSignal` on `phase_started` asserts name/total/message arrive intact, including + `total is None` for the convergence loops; +- hammer `update()` in a tight loop (say 1000 calls with no sleep) and assert the emitted + signal count is bounded — this is the test that stops a future refactor from + reintroducing the flood; +- assert a `start_phase` immediately following an `update` is _not_ throttled away; +- assert `end_phase` emits the final value. + +Then wire it: `CsvSession.progress_reporter` and `GSheetSession.progress_reporter` already +exist as `Any = None` placeholders and are already passed through to `run_stratification` +via the `**extra` dance in `_stratify`. Replace the placeholder with a real type and delete +the `extra = {...} if ... else {}` conditional — 0.12 takes `progress_reporter=None` +happily, so it can be passed unconditionally. Drop the "the library grows a +progress_reporter argument in 0.12" comments in both sessions. + +The view protocols in `strat_app/sessions/view.py` gain a method — something like +`set_progress(current: int, total: int | None, message: str)` alongside `set_busy` — so the +sessions stay Qt-free and `CallRecorder` keeps working unchanged. + +**How it went.** Split into two pieces rather than one, which was worth it: + +- `strat_app/sessions/progress.py` — `ProgressThrottle`, pure and Qt-free, tested against a + `FakeClock` in `tests/unit/`. All the deciding lives here. +- `strat_app/qt/progress.py` — `QtProgressReporter`, which is only signal plumbing. + +That split means the interesting logic is tested with no event loop and no timing +flakiness: the hammer test drives 1000 updates through a clock it controls and asserts +exactly 10 get through, which a wall-clock test could never do reliably. + +`isinstance(reporter, ProgressReporter)` is asserted directly — the library's protocol is +`@runtime_checkable` and `coerce_reporter` has to accept us, so a missing method should fail +in our suite rather than mid-run. + +One test had to be rewritten after going red for the wrong reason. The first version +connected a raising slot directly and asserted the run survived — but a direct connection is +not how this is ever wired, and Qt propagated the exception straight back. Replaced with +`test_connecting_through_the_helper_defers_the_slot`, which asserts the slot has *not* run +when `start_phase` returns. That is the property that matters and it covers both concerns at +once: the worker thread never touches a widget, and nothing a slot does can reach back into +the library's call stack. + +The `**extra` dance in both sessions is gone — `progress_reporter=self.progress_reporter` is +passed unconditionally, since 0.12 takes `None` happily. Both `Any` annotations became real +types, deferred behind `TYPE_CHECKING` to satisfy ruff. + +`MainWindow` builds **one reporter per tab**, so a run on one tab cannot drive the other's +bar. + +### Step 7 — the `QProgressBar` itself — **DONE** + +`csv_tab.py:93-97` and `gsheet_tab.py:127-131` both have a `busy_bar` with +`setRange(0, 0)` and the comment _"no phases or percentages from the library at this +version, so all we can honestly show is that something is happening"_. That comment can +finally go. + +- `total is None` → `setRange(0, 0)` (busy), else `setRange(0, total)` + `setValue(current)`; +- a `QLabel` under the bar for `message`; +- `set_busy(True)` should reset the bar to indeterminate with an empty label, so a second + run doesn't briefly show the first run's final state. + +**Decided: duplicate the code across the two tabs** rather than extracting a shared +`ProgressPanel` widget. They already duplicate `set_busy`, `set_panel_size_range`, +`set_panel_size`, `set_run_enabled` and `_row`, so the progress panel is one more symptom of +a duplication that predates this work — and the right time to fix that is in a refactor +aimed at it, not inside a version bump. Noted in §7 as its own piece of work. + +**How it went.** `tests/integration/test_progress_bar.py` is parametrised over both tabs, so +the two copies are held to the same behaviour by the same eight tests. That is the thing +that makes the duplication safe to live with until the refactor happens — if the copies +drift, the suite says so. + +The comment about "no phases or percentages from the library at this version" is gone from +both tabs, which was the point. + +**Proof it works against the real library**, taken from a scripted run over the 200-person +fixture: + +``` +PHASES: + ('multiplicative_weights', 200, bar max 200, 'Searching for diverse committees (200 rounds)') + ('maximin_optimization', None, bar max 0, 'Optimizing maximin distribution') +UPDATES: 55 delivered + first: (1, 200, 'Round 1/200: 1 committees found') + last: (1, None, 'Maximin iteration 1: 200 committees, gap 0.00%') +``` + +Exactly the shape §3 predicted: determinate for the 200-round search, busy for the +convergence loop that follows, and the throttle cutting the stream to 55 events. On this +fixture the run is quick; on a real pool the second phase is where the minutes go, and it is +correctly indeterminate rather than pretending to a percentage. + +`tests/e2e/test_csv_flow.py::test_the_progress_bar_becomes_determinate_during_a_real_run` +pins this down end to end. It records the bar's maximum as each phase starts rather than +checking at the end, because the bar deliberately returns to indeterminate afterwards — +checking at the end would always see 0 and pass for the wrong reason. + +### Step 8 — re-measure, re-smoke, and write it up — **DONE** + +- Record the before/after bundle size for all three platforms in the PR description. +- Re-run the packaged `--self-test` everywhere (CI does this already). +- Update `README.md` if it names the library version anywhere. + +**Final numbers, Linux:** + +| | bytes | | +| --- | ---: | --- | +| before (0.11.5) | 163,986,784 | 156.4 MiB | +| after (0.12.10) | 148,781,960 | 141.9 MiB | +| **saved** | **15,204,824** | **14.5 MiB (9.3%)** | + +Rebuilt with all of the progress-bar work in and `--self-test` passes on the packaged +binary, running a real selection: `self test: ok - selected 4 of 10`. + +Windows and macOS are **not measured here** — CI builds and smoke-tests them on every push, +and this machine can only build Linux. Their numbers should go in the PR description once CI +has run. Expect a similar shape: the same three packages come out, and `cbcbox` never goes +in. + +`README.md` and `docs/index.md` name no library version, so nothing to update there. + +### One loose end: a single unexplained SIGABRT + +**Worth knowing about before CI surprises anyone.** One full-suite run aborted with exit +code 134 (SIGABRT) part-way through. Every run before and after it passed. It has not +recurred in the four full-suite runs since. + +A `QThread` destroyed while still running aborts the process, which fits the symptom. The +likely culprit was mine: `test_the_events_arrive_on_the_gui_thread` built a `QtTaskRunner` +as a local variable, so it became garbage the instant the test returned — possibly while its +thread was still winding down. That is now a fixture that calls `runner.wait()` on teardown, +matching what `test_workers.py` does and what `MainWindow.closeEvent` does in the app. + +**Being straight about it: the fix is a real hazard removed, but not a proven cause.** The +abort was never reproduced on demand, so "four clean runs since" is evidence, not proof. If +it shows up again in CI, the place to look is `QtTaskRunner._reap`: + +```python +self._threads = [pair for pair in self._threads if pair[0].isRunning()] +``` + +That drops the last Python reference to a `QThread` the moment `isRunning()` goes false, +and there is a narrow window where the underlying thread has not fully terminated. It is +pre-existing, unrelated to the bump, and the app itself is protected by `closeEvent` calling +`wait()` — so it was left alone rather than fixed inside a version bump. But it is the first +thing to suspect. + +--- + +## 6. Test coverage: what this PR adds and what it can't + +Per the no-exceptions policy, all three levels are covered: + +- **Unit** — `tests/unit/`: the reporter's throttling logic is pure (`time.monotonic` + + an interval), so the throttle-bound test can live here with a fake clock, independent of + Qt. `test_settings_holder.py` gains the unsupported-value cases (step 4). + `test_gsheet_session.py` gains the test-panel-button case from §2.4. Session-level: + `progress_reporter` is passed through to `run_stratification` and is not `None`. +- **Integration** — `tests/integration/test_progress.py` (new, §5 step 6) for the + signal/thread bridge with `qtbot`; extensions to `test_csv_tab.py` / + `test_gsheet_tab.py` asserting the bar goes determinate when a `total` arrives and busy + when it doesn't. +- **End-to-end** — `tests/e2e/test_csv_flow.py` gains an assertion that the progress bar + reached a determinate range during a real `maximin` run (the `multiplicative_weights` + phase guarantees one), alongside the existing detailed-log assertion. + +What it can't cover, and we should be honest about in the PR: the real gsheet round-trip +(§5 step 5 is manual, per the eel-to-qt plan's D6) and the packaged builds beyond +`--self-test`. + +--- + +## 7. Related, but explicitly not in this PR + +- **Cancellation.** Still not possible, and 0.12 does not change that. The library wraps + our reporter in `ErrorSwallowingReporter` _by design_, so raising from `update()` to + abort a run is swallowed and logged, not propagated. Killing a `QThread` mid-solve isn't + acceptable. It needs upstream support: `should_cancel() -> bool` on the protocol, or a + cancellation token argument. You own the repo, so it's cheap to ask — worth an issue + regardless of whether we take this bump. **This is the single biggest remaining UX gap**: + after this PR the user can _see_ that a 10-minute run is progressing, but still can't stop + it. +- **`load_already_selected` / replacements.** A feature for the app, not part of the bump. + Needs its own UI (a third file input on the CSV tab, a third tab name on the gsheet tab) + and its own plan. The library side is ready: + `SelectionData.load_already_selected(settings)` and + `run_stratification(..., already_selected=people)`. +- **macOS arm64** — decided, deferred: §4.6. +- **Relaxing `requires-python`.** The library dropped its `<3.13` cap in 0.12 and now + advertises 3.13 and 3.14. This app still says `>=3.11,<3.13`. Nothing forces us to move, + and PySide6 + PyInstaller are the constraint that actually matters, so: separate change, + separate PR, only if there's a reason. +- **Shared widget code across the two tabs.** They duplicate `set_busy`, + `set_panel_size_range`, `set_panel_size`, `set_run_enabled`, `_row`, and — after step 7 — + the progress panel. Decided (§5 step 7) to duplicate rather than extract for now, so this + becomes a tidy-up of its own. Do it as a refactor aimed at the duplication, with the tab + integration tests as the safety net; not inside a bump. +- **Configurable output tab names on the gsheet tab.** Considered and dropped for this + round. Worth recording _why_, since the trail is confusing: the `selected_tab_name` / + `remaining_tab_name` assignments the Eel app made (`script.py:455-456` at `f5de19a^`) and + the port dropped were **dead code even when they were there** — they were set to the + library's own default strings, and the library never reads those attributes anyway + (`write_selected` _assigns_ them with the title of the tab it just created, + `adapters.py:843`). So nothing regressed in the port and there is nothing to restore. If + we ever do want user-chosen tab names, the knob is + `data_source.tab_namer.selected_tab_name_stub`, applied in `_write_output` — see + `llm-working/upstream.md` issues 3 and 4 for the two API sharp edges that would need + handling first. +- **Upstream issues.** Written up in `llm-working/upstream.md` for you to file: the + `ConfigurationError` escape (§2.4), `should_cancel()` on `ProgressReporter` (above), the + stale `DEFAULT_SETTINGS` comment (§2.5), and the vestigial + `GSheetDataSource.selected_tab_name` / `remaining_tab_name` write-only attributes (§2.1). + +--- + +## 8. Decisions + +Answered by Chewie, and folded into the sections above. + +| Decision | Where it lands | +| -------------------------------------------------------------------------------------------------------- | --------------- | +| This app does not need to support diversimax — no extra, 45 MB of pandas + sklearn stays out | §4.4, §4.5 | +| Validate `solver_backend` (and `diversimax`) at settings-load time rather than failing mid-run | §4.5, §5 step 4 | +| Leave the macOS arm64 work until later; note in the PR that it is now unblocked | §4.6 | +| Test-panel button with `number_selections > 1`: disable it in the app **and** raise the upstream issue | §2.4, upstream 1 | +| Keep the `user_logger` → detailed-log bridge; it says _what_, the bar says _how far_ | §2.3 | +| `NotNativeGoogleSheetError`: just show the library's message, no app-side special-casing | §2.6 | +| gspread `request_timeout`: leave it at the library's 60s default | §2.6 | +| Duplicate the progress panel code across the two tabs rather than extracting a shared widget | §5 step 7, §7 | +| No configurable output tab names this round — nothing was lost in the port, so nothing to restore | §7 | +| Upstream issues go in `llm-working/upstream.md` | `upstream.md` | + +Nothing is left open. The plan is ready to work through. + +### One thing worth knowing before anyone reopens the tab-name question + +The `selected_tab_name` trail is genuinely misleading and cost time once already. In short: +the Eel app's assignments were dead code even while they were there, so the port didn't +regress anything and there is nothing to restore. The full explanation is in §7 under +"Configurable output tab names", and the two API sharp edges that made it confusing are +written up as upstream issues 3 and 4. diff --git a/llm-working/upstream.md b/llm-working/upstream.md new file mode 100644 index 0000000..afb81f4 --- /dev/null +++ b/llm-working/upstream.md @@ -0,0 +1,288 @@ +# Upstream issues for `sortition-algorithms` + +Found while researching the 0.11.5 → 0.12.10 bump for this app +(`llm-working/library-bump.md`). All line numbers are against tag **0.12.10**. + +Written as issue drafts — title, then body — so they can be pasted straight into +. Ordered by how much +they matter to a caller. + +--- + +## 1. `ConfigurationError` escapes `run_stratification` where `ValueError` used to be caught + +**Labels:** bug, regression + +### What happens + +`run_stratification` catches four exception types around its call to `find_random_sample` +(`core.py:574`): + +```python +except (errors.SelectionError, ValueError, RuntimeError, errors.InfeasibleQuotasCantRelaxError) as error: + report.add_error(error) + return False, people_selected, report +``` + +In 0.11.5, `find_random_sample` raised plain `ValueError` for its four +invalid-argument cases, so they were caught here and turned into a +`(False, [], report)` result with the error in the report. + +In 0.12 those four sites were changed to raise the new `ConfigurationError` +(`core.py:299`, `:306`, `:312`, `:381`). But `ConfigurationError` subclasses +`SortitionBaseError`, which subclasses `Exception` — **not `ValueError`** +(`errors.py:13`, `:38`). So the except clause no longer catches them and they propagate out +of `run_stratification`. + +### Why it matters + +A caller that was correctly handling the documented `(success, committees, report)` contract +now gets an exception instead. The four affected cases are: + +- `test_selection=True` with `number_selections > 1` +- `selection_algorithm="legacy"` with `number_selections > 1` +- `selection_algorithm="diversimax"` with `number_selections > 1` +- an unknown `selection_algorithm` + +The first of those is reachable from a UI where the user can set both independently — it is +how we found this. The user goes from a tidy report explaining the problem to a generic +"unexpected error" with a traceback behind it. + +The docstring is also now inaccurate: `core.py:526` still says +`ValueError: For invalid parameters`. + +### Suggested fix + +Add `errors.ConfigurationError` to the except tuple (or, more robustly, catch +`errors.SortitionBaseError`, which covers `SelectionError`, `ConfigurationError`, +`BadDataError` and `InfeasibleQuotasCantRelaxError` in one), and update the docstring's +`Raises:` section. + +A regression test along the lines of "every documented invalid-argument case returns +`success=False` rather than raising" would stop this recurring the next time an exception +type is introduced. + +--- + +## 2. `ProgressReporter` has no way to request cancellation + +**Labels:** enhancement + +### The problem + +`ProgressReporter` gives a caller a live view of a long-running selection, which is exactly +what a desktop or web UI needs. But there is no way to ask that selection to stop. + +The obvious hack — raise from `update()` — is deliberately defeated: `coerce_reporter` wraps +every caller-supplied reporter in `ErrorSwallowingReporter`, which catches and logs +everything (`progress.py`). That is the right default for a buggy reporter, and +`docs/progress.md` documents it as intentional. But it means cancellation has no seam at +all. + +The alternatives available to a caller are all bad: + +- kill the worker thread — not safe, and not possible to do cleanly in Qt +- run the selection in a subprocess purely so it can be killed — a lot of machinery, and it + breaks the in-process reporter +- wait it out — a leximin run over a real pool can be 10+ minutes + +### What would help + +Either: + +1. **`should_cancel() -> bool` on the protocol**, called at the same points `update()` is + called. The library checks it and raises a `SelectionCancelledError` (or returns + `success=False` with a "cancelled" report line). Default implementation returns `False`, + so it is backwards compatible and `NullProgressReporter` needs one extra method. +2. **A separate cancellation token argument** to `run_stratification` / + `find_random_sample` — an object with an `is_set()`-shaped API, so + `threading.Event` works directly. + +(1) keeps everything on one object the caller already supplies. (2) separates "observing" +from "controlling", which is arguably cleaner and lets a caller cancel without also wanting +progress. + +Either way, the important part is that the check happens **inside the convergence loops**, +not just between phases — a maximin or leximin inner loop is where the time goes. + +Cancelling should leave the caller with a clear signal that the run was cancelled rather +than that it failed, so a UI can say "cancelled" instead of "something went wrong". + +--- + +## 3. `GSheetDataSource.selected_tab_name` / `remaining_tab_name` look like inputs but are outputs + +**Labels:** documentation, api + +### What they look like + +`GSheetDataSource.__init__` sets: + +```python +self.selected_tab_name = "" +self.remaining_tab_name = "" +``` + +(`adapters.py:553-554`). Sitting next to `feature_tab_name`, `people_tab_name` and +`already_selected_tab_name` — which _are_ inputs, and _are_ constructor arguments — these +read unmistakably as "set these to choose your output tab names". + +### What they actually are + +They are write-only-by-the-library result attributes. `write_selected` and `write_remaining` +take the names from `self.tab_namer` and then _assign_ these fields with whatever the created +tab ended up being called: + +```python +tab_selected = self._create_tab(self.tab_namer.selected_tab_name()) +... +self.selected_tab_name = tab_selected.title # adapters.py:843 +``` + +Nothing ever reads them. A caller who sets them gets no error, no warning, and no effect — +their value is silently overwritten the moment a selection is written. + +### How we know it bites + +The Sortition Foundation desktop app did exactly this for years: + +```python +self.data_source.selected_tab_name = self.original_selected_tab_name +self.data_source.remaining_tab_name = self.remaining_tab_name +``` + +immediately before `output_selected_remaining`. It had no effect (the values it set happened +to equal the library's own defaults, so nobody noticed). The lines were dropped in a +refactor, and when we later went looking for how to make the tab names configurable we +assumed the drop was the bug — it wasn't, but it cost real time to establish that. + +### Suggested fix + +Any of, in rough order of preference: + +1. Rename to `last_written_selected_tab_name` / `last_written_remaining_tab_name`, which + says what they are. +2. Make them read-only properties backed by private attributes, so assignment fails loudly. +3. At minimum, a docstring or comment on both saying "output only — set by + `write_selected()` / `write_remaining()`; to control the names, use `tab_namer`". + +--- + +## 4. `GSheetTabNamer` stubs are hard to use from a UI: the suffix has no separator + +**Labels:** enhancement, api + +`find_unused_tab_suffix` builds candidate names by direct concatenation +(`adapters.py:475-476`): + +```python +selected_tab_name_candidate = f"{self.selected_tab_name_stub}{number}" +``` + +That works because the built-in stubs end in `" - "`, giving +`Original Selected - output - 0`. But any caller that exposes the stub to a user gets +`Panel A0` when the user types `Panel A`, which looks like a bug to them. The stub is the +natural place for a UI to hang a "what should the output tabs be called?" field, and this +makes that harder than it needs to be. + +Options that would help: + +- a `separator` attribute on `GSheetTabNamer`, defaulting to `""` so nothing changes for + existing callers, and settable to `" - "` alongside a stub that has no trailing space; +- or a method that returns the name a given stub _would_ produce, so a UI can show a preview + without reimplementing the format string; +- or, at minimum, a note in the class docstring that the stub is concatenated directly and + should normally end in a separator. + +Related: `find_unused_tab_suffix` gives up silently after 1000 attempts, leaving +`_write_tab_suffix` empty, and the next call to `selected_tab_name()` then raises a +`SelectionError` about a "logic error". A spreadsheet with 1000 old output tabs is +implausible, but the failure mode reads as an internal bug rather than "you have too many +output tabs, delete some" — worth a clearer message if it's cheap. + +--- + +## 5. `load_features` does not strip its header names, but `load_people` does + +**Labels:** bug + +### What happens + +0.12 fixed the "stray space in a spreadsheet header" problem by normalising header names, +via the new `normalise_iter`. It went into two of the three loaders on `SelectionData` +(`adapters.py`): + +```python +def load_features(self, number_to_select: int = 0): + ... + headers = list(headers_iter) # <- not normalised + +def load_people(self, settings, features): + ... + headers = normalise_iter(headers_iter) # <- normalised + +def load_already_selected(self, settings): + ... + headers = normalise_iter(headers_iter) # <- normalised +``` + +So a respondents tab with `" nationbuilder_id "` in its header row now loads, and a +categories tab with `" category "` still fails. + +### Why it matters + +The failure is the confusing one the fix was meant to eliminate. With a categories tab whose +header cells have leading or trailing spaces: + +``` +ConfigurationError: Did not find required column name 'category' in the input +Did not find required column name 'name' in the input +Did not find required column name 'min' in the input +Did not find required column name 'max' in the input +``` + +The user is looking at a spreadsheet with `category`, `name`, `min` and `max` in the header +row, spelled exactly as the error says they should be. There is nothing on screen to +suggest the problem is whitespace. + +It is also now inconsistent in a way that is hard to explain: pad the respondents tab and +it works, pad the categories tab and it doesn't. + +### Reproduction + +```python +from sortition_algorithms import adapters + +cats = " category , name , min , max \ngender,Female,1,2\n" +adapters.SelectionData(adapters.CSVStringDataSource(cats, "")).load_features() +# ConfigurationError: Did not find required column name 'category' in the input +``` + +### Suggested fix + +`headers = normalise_iter(headers_iter)` in `load_features`, matching its two siblings. +Worth a test over all three loaders together so the next one added doesn't miss it. + +--- + +## 6. Nit: the default settings comment contradicts `DEFAULT_BACKEND` + +**Labels:** documentation, good first issue + +`settings.py:56`, inside `DEFAULT_SETTINGS`: + +``` +# solver_backend can be "highspy" "mip" (default), "mip-cbc", "mip-highs", "mip-gurobi" +``` + +`DEFAULT_BACKEND = "highspy"`, so `(default)` is on the wrong value. There is also a missing +comma between `"highspy"` and `"mip"`. + +This text is written into every new user's settings file, so it is the first thing anyone +reads about the setting. + +Suggested: + +``` +# solver_backend can be "highspy" (default), "mip", "mip-cbc", "mip-highs", "mip-gurobi" +``` diff --git a/main.py b/main.py new file mode 100644 index 0000000..65207c4 --- /dev/null +++ b/main.py @@ -0,0 +1,9 @@ +# ABOUTME: The entry point PyInstaller builds from - a plain script suits it better than -m. +# ABOUTME: Everything it does lives in strat_app.__main__. + +import sys + +from strat_app.__main__ import main + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyinstallerhooks/hook-mip.py b/pyinstallerhooks/hook-mip.py deleted file mode 100644 index 558ee02..0000000 --- a/pyinstallerhooks/hook-mip.py +++ /dev/null @@ -1,20 +0,0 @@ -# noqa: INP001 -from PyInstaller.utils.hooks import collect_all, collect_dynamic_libs - - -# this function came from this comment: -# https://github.com/coin-or/python-mip/issues/198#issuecomment-1640309437 -def hook(hook_api): - packages = ["mip"] - for package in packages: - datas, binaries, hiddenimports = collect_all(package) - binary_paths = {b[0] for b in binaries} - # find any xyz.so libraries that don't start with "lib" - binaries += [ - b - for b in collect_dynamic_libs(package, search_patterns=["*.so"]) - if b[0] not in binary_paths - ] - hook_api.add_datas(datas) - hook_api.add_binaries(binaries) - hook_api.add_imports(*hiddenimports) diff --git a/pyproject.toml b/pyproject.toml index 0ed4749..2be1071 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,44 +3,49 @@ name = "stratification-app" version = "0.1.0" description = "Application to select people from a spreadsheet according to given categories" authors = [ - "Nick Gill ", - "Brett Hennig ", - "Paul Goelz ", - "Hamish Downer ", + { name = "Nick Gill", email = "nick@sortitionfoundation.org" }, + { name = "Brett Hennig", email = "brett@sortitionfoundation.org" }, + { name = "Paul Goelz", email = "mail@paulgoelz.de" }, + { name = "Hamish Downer", email = "hamish@sortitionfoundation.org" }, ] readme = "README.md" requires-python = ">=3.11,<3.13" dependencies = [ - "Eel==0.18.2", "oauth2client<5", "pyinstaller<7", + "pyside6-essentials>=6.11.1", "setuptools<82", - "sortition-algorithms==0.11.5", + "sortition-algorithms==0.12.10", "toml", ] [dependency-groups] -dev = ["pytest>=8.3.4", "pytest-cov>=6.0.0", "ruff>=0.9.7"] +dev = [ + "mypy>=2.3.0", + "pytest>=8.3.4", + "pytest-cov>=6.0.0", + "pytest-qt>=4.5.0", + "ruff>=0.9.7", +] [tool.uv] # this provides a "cooldown" to avoid supply chain issues - requires uv>=0.9.17 # background: https://blog.yossarian.net/2025/11/21/We-should-all-be-using-dependency-cooldowns exclude-newer = "1 week" +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--strict-markers" + [tool.mypy] python_version = "3.12" +exclude = ["^build/", "^dist/", "^ignore/"] check_untyped_defs = false ignore_missing_imports = true warn_unused_ignores = true warn_redundant_casts = true warn_unused_configs = true -# eel does not have type hints/stubs -[[tool.mypy.overrides]] -module = "eel.*" -ignore_missing_imports = true -ignore_errors = true - [tool.ruff] target-version = "py312" line-length = 120 @@ -107,6 +112,7 @@ ignore = [ "B017", # assertRaises(Exception) "BLE001", # don't catch Exception "C901", # too complex + "COM812", # missing trailing comma - conflicts with the formatter, which ruff warns about "FBT001", # positional boolean arg in function defn "PLR0912", # too many branches "PLR0913", # too many args in function defn diff --git a/script.py b/script.py deleted file mode 100644 index c181572..0000000 --- a/script.py +++ /dev/null @@ -1,609 +0,0 @@ -import platform -import sys -from collections.abc import Iterable -from enum import Enum -from pathlib import Path - -import eel -import gspread -from sortition_algorithms import Settings, adapters, core, features, people - -DEFAULT_SETTINGS_PATH = Path.home() / "sf_stratification_settings.toml" -DEFAULT_AUTH_JSON_PATH = Path.home() / "secret_do_not_commit.json" - - -class KnownFailureError(Exception): - """Class to raise without extra errors""" - - -class LogType(Enum): - CSV_FEATURES = 1 - CSV_SELECTION = 2 - GSHEET_FEATURES = 3 - GSHEET_SELECTION = 4 - DETAILED_LOG = 5 - - -class GuiLog: - """Singleton class for sending messages to different divs""" - - def __init__(self) -> None: - self.lines: dict[LogType, list[str]] = {lt: [""] for lt in LogType} - - def reset(self, section: LogType, new_message: str = "") -> None: - self.lines[section] = [new_message] - self.update_area(section) - - def add_lines(self, section: LogType, lines: Iterable[str]) -> None: - self.lines[section] += lines - self.update_area(section) - - def add_line(self, section: LogType, line: str) -> None: - self.lines[section].append(line) - self.update_area(section) - - def update_area(self, section: LogType) -> None: - update_str = "
".join(line for line in self.lines[section] if line.strip()) - if section == LogType.CSV_FEATURES: - eel.update_csv_features_output_area(update_str) - elif section == LogType.CSV_SELECTION: - eel.update_csv_selection_output_area(update_str) - elif section == LogType.GSHEET_FEATURES: - eel.update_g_sheet_features_output_area(update_str) - elif section == LogType.GSHEET_SELECTION: - eel.update_g_sheet_selection_output_area(update_str) - elif section == LogType.DETAILED_LOG: - eel.update_detailed_log_messages_area(update_str) - - def update_all_areas(self) -> None: - for log_type in LogType: - self.update_area(log_type) - - -gui_log = GuiLog() - - -class SettingsHolder: - def __init__(self): - self._settings: Settings | None = None - - @property - def settings(self) -> Settings: - self.init_settings() - assert self._settings is not None - return self._settings - - def init_settings(self) -> str: - """ - Call from lots of places to report the error early - """ - if self._settings is None: - try: - self._settings, report = Settings.load_from_file( - settings_file_path=DEFAULT_SETTINGS_PATH, - ) - return report.as_html() - except Exception as error: - return f"Error reading in settings file: {error}" - return "" - - def init_settings_log(self, section: LogType) -> None: - message = self.init_settings() - if message: - gui_log.add_line(section, message) - - def loaded(self) -> bool: - return self._settings is not None - - -settings_holder = SettingsHolder() - - -class CSVHandler: - def __init__(self): - self.data_source = adapters.CSVStringDataSource("", "") - self.select_data = adapters.SelectionData(self.data_source) - self.features: features.FeatureCollection | None = None - self.people: people.People | None = None - # cache this in case we need to reload - self.people_contents: str = "" - self.panel_size_str = "0" # Number of people in each panel - - @property - def panel_size_num(self) -> int: - if self.panel_size_str == "": - return 0 - return int(self.panel_size_str) - - def _set_panel_size(self, panel_size: str | int, *, update_eel: bool = True) -> None: - self.panel_size_str = str(panel_size) - # check if we can convert to int, and that it is >= 0 - try: - panel_size_int = int(self.panel_size_str) - if panel_size_int < 0: - self.panel_size_str = "" - except ValueError: - self.panel_size_str = "" - # finally update the display - unless we've been called from the JS - # in which case skip this to avoid infinite loops. - if update_eel: - eel.set_csv_panel_size(self.panel_size_str) - - def update_panel_size(self, panel_size: str) -> None: - # this comes from the UI, so don't update_eel - otherwise we have a loop - self._set_panel_size(panel_size.strip(), update_eel=False) - self.update_run_button() - - def update_run_button(self): - if self.features and self.people and self.panel_size_num > 0: - eel.enable_csv_run_button() - else: - eel.disable_csv_run_button() - - def add_feature_content(self, file_contents: str): - gui_log.reset(LogType.CSV_FEATURES) - if not file_contents: - gui_log.add_line( - LogType.CSV_FEATURES, - "No file contents - was the file empty?", - ) - return - settings_holder.init_settings_log(LogType.CSV_FEATURES) - if not settings_holder.loaded(): - return - try: - self.data_source.features_data = file_contents - self.features, report = self.select_data.load_features() - gui_log.add_line(LogType.CSV_FEATURES, report.as_html()) - except Exception as error: - gui_log.add_line(LogType.CSV_FEATURES, f"Failed to load features: {error}") - if not self.features: - return - eel.enable_csv_selection_content() - min_size = features.minimum_selection(self.features) - max_size = features.maximum_selection(self.features) - eel.update_csv_selection_range(min_size, max_size) - # if these are the same just set the value! - if min_size == max_size and min_size > 0: - self._set_panel_size(min_size) - # reset people, might need to be reloaded with features - if self.people: - self.add_people_content(self.people_contents) - - def add_people_content(self, file_contents: str) -> None: - gui_log.reset(LogType.CSV_SELECTION) - assert self.features is not None - try: - self.data_source.people_data = file_contents - self.people, report = self.select_data.load_people(settings_holder.settings, self.features) - # now we've done a successful load, cache the results - self.people_contents = file_contents - gui_log.add_line(LogType.CSV_SELECTION, report.as_html()) - gui_log.add_line( - LogType.CSV_SELECTION, - f"Loaded {self.people.count} people.", - ) - gui_log.add_line(LogType.CSV_SELECTION, "Successfully loaded features and people.") - except Exception as error: - gui_log.add_line(LogType.CSV_SELECTION, f"Failed to load people: {error}") - self.update_run_button() - - def run_selection(self, test_selection: bool) -> None: - assert self.people is not None and self.features is not None - # they may have hit this button again, so clear the output area so it's more obvious - gui_log.reset(LogType.DETAILED_LOG, "Selecting... please wait...
") - try: - success, people_selected, report = core.run_stratification( - features=self.features, - people=self.people, - number_people_wanted=self.panel_size_num, - settings=settings_holder.settings, - test_selection=test_selection, - ) - except Exception as err: - gui_log.add_lines( - LogType.DETAILED_LOG, - [f"Unexpected error during selection: {err}", "Selection failed, process ended."], - ) - return - gui_log.add_line(LogType.DETAILED_LOG, report.as_html()) - if not success: - gui_log.add_line(LogType.DETAILED_LOG, "No panels written to CSV, process ended.") - return - try: - selected_rows, remaining_rows, _ = core.selected_remaining_tables( - full_people=self.people, - people_selected=people_selected[0], - features=self.features, - settings=settings_holder.settings, - ) - if success: - self.select_data.output_selected_remaining(selected_rows, remaining_rows, settings_holder.settings) - eel.enable_csv_selected_download( - self.data_source.selected_file.getvalue(), - "selected.csv", - ) - eel.enable_csv_remaining_download( - self.data_source.remaining_file.getvalue(), - "remaining.csv", - ) - except Exception as err: - gui_log.add_lines( - LogType.DETAILED_LOG, - [f"Unexpected error during writing selection: {err}", "Writing failed, process ended."], - ) - - -class GSheetHandler: - clear_message = "Number of features: You must (re)load sheet..." - original_selected_tab_name = "Original Selected - output - " - remaining_tab_name = "Remaining - output - " - - def __init__(self): - self.data_source = adapters.GSheetDataSource( - feature_tab_name="", - people_tab_name="", - auth_json_path=DEFAULT_AUTH_JSON_PATH, - ) - self.select_data = adapters.SelectionData(self.data_source) - self.features: features.FeatureCollection | None = None - self.people: people.People | None = None - self.g_sheet_name = "" - self.features_tab_name = "Categories" - self.people_tab_name = "Respondents" - self.gen_rem_tab = True - self.number_selections = 1 # How many panels to create - self.panel_size_str = "0" # Number of people in each panel - - @property - def panel_size_num(self) -> int: - if self.panel_size_str == "": - return 0 - return int(self.panel_size_str) - - def _set_panel_size(self, panel_size: str | int, *, update_eel: bool = True) -> None: - self.panel_size_str = str(panel_size) - # check if we can convert to int, and that it is >= 0 - try: - panel_size_int = int(self.panel_size_str) - if panel_size_int < 0: - self.panel_size_str = "" - except ValueError: - self.panel_size_str = "" - # finally update the display - unless we've been called from the JS - # in which case skip this to avoid infinite loops. - if update_eel: - eel.set_g_sheet_panel_size(self.panel_size_str) - - def _clear_messages(self, features_message: str = clear_message) -> None: - """Clear all messages (and optionally put a new message in place)""" - gui_log.reset(LogType.GSHEET_FEATURES, features_message) - gui_log.reset(LogType.GSHEET_SELECTION) - gui_log.reset(LogType.DETAILED_LOG) - self._set_panel_size("") - - def _reset_spreadsheet(self, *, reset_features: bool = True) -> None: - if reset_features: - self.features = None - self.people = None - self._set_panel_size("") - self.update_run_button() - - # called from g-sheet input - def update_g_sheet_name(self, g_sheet_name_input) -> None: - self._clear_messages() - self._reset_spreadsheet() - self.g_sheet_name = g_sheet_name_input - if self.g_sheet_name != "": - eel.enable_load_g_sheet_btn() - - def update_panel_size(self, panel_size: str) -> None: - # this comes from the UI, so don't update_eel - otherwise we have a loop - self._set_panel_size(panel_size.strip(), update_eel=False) - self.update_run_button() - - def update_run_button(self) -> None: - if self.features and self.people and self.panel_size_num > 0: - eel.enable_g_sheet_run_button() - else: - eel.disable_g_sheet_run_button() - - def update_people_tab_name(self, people_tab_name_input: str) -> None: - self._clear_messages() - self._reset_spreadsheet(reset_features=False) - self.people_tab_name = people_tab_name_input - - def update_features_tab_name(self, features_tab_name_input: str) -> None: - self._clear_messages() - self._reset_spreadsheet() - self.features_tab_name = features_tab_name_input - - def update_gen_rem_tab(self, gen_rem_tab: bool) -> None: - self.gen_rem_tab = gen_rem_tab - - def _safe_gen_rem_tab(self) -> bool: - """Get self.gen_rem_tab - but set to false if number_selections > 1""" - # never generate a remaining tab if doing a multiple selection - if self.number_selections > 1: - return False - return self.gen_rem_tab - - def update_number_selections(self, number_selections_input: str) -> None: - self._clear_messages() - self.number_selections = 1 if number_selections_input == "" else int(number_selections_input) - - # do features and people at same time... - def load_g_sheet(self) -> None: - # forget about any previously loaded spreadsheet, and disable the run button - self._reset_spreadsheet() - # this can happen if they enter something and then delete it... - if self.g_sheet_name == "": - self._clear_messages("Please enter a spreadsheet name...") - return - self._clear_messages(f"Requesting category data from spreadsheet tab {self.features_tab_name} ...") - settings_holder.init_settings_log(LogType.GSHEET_FEATURES) - if not settings_holder.loaded(): - return - try: - if self.number_selections > 1: - gui_log.add_line( - LogType.GSHEET_SELECTION, - f"WARNING: You've asked for {self.number_selections} selections. " - f"You cannot use the Produce a Test Panel button if you want more " - f"than 1 selection and no Remaining tab will be created.", - ) - self.data_source.set_g_sheet_name(self.g_sheet_name) - self.add_feature_content(self.features_tab_name) - gui_log.add_line( - LogType.GSHEET_SELECTION, - f"Requesting people data from spreadsheet tab {self.people_tab_name} ...", - ) - self.add_people_content(self.people_tab_name) - gui_log.add_line(LogType.GSHEET_SELECTION, "Successfully loaded features and people.") - self.update_run_button() - eel.enable_load_g_sheet_btn() - except KnownFailureError: - # this is for when the function called has already logged the error, so we don't need - # to report it again. - gui_log.add_lines( - LogType.GSHEET_FEATURES, - ["Loading spreadsheet failed, see above messages.", "Fix the problems and try loading again."], - ) - except Exception as error: - gui_log.add_line(LogType.GSHEET_FEATURES, f"Loading spreadsheet failed: {error}") - - def add_feature_content(self, features_tab_name: str) -> None: - try: - self.data_source.feature_tab_name = features_tab_name - self.features, report = self.select_data.load_features() - gui_log.add_line(LogType.GSHEET_FEATURES, report.as_html()) - except gspread.exceptions.APIError as error: - gui_log.add_line( - LogType.GSHEET_FEATURES, - f"API error causing delay. Please wait a couple of seconds while gsheet updates. " - f"After waiting you may need to reload sheet. " - f"For the record, the API error is {error}", - ) - raise KnownFailureError from error - except Exception as error: - gui_log.add_line(LogType.GSHEET_FEATURES, f"Failed to load features: {error}") - raise KnownFailureError from error - if not self.features: - gui_log.add_line(LogType.GSHEET_FEATURES, "Failed to load features") - raise KnownFailureError - eel.update_g_sheet_selection_range( - features.minimum_selection(self.features), - features.maximum_selection(self.features), - ) - min_size = features.minimum_selection(self.features) - max_size = features.maximum_selection(self.features) - eel.update_g_sheet_selection_range(min_size, max_size) - # if these are the same just set the value! - if min_size == max_size and min_size > 0: - self._set_panel_size(min_size) - - def add_people_content(self, people_tab_name: str) -> None: - assert self.features is not None - try: - self.data_source.people_tab_name = people_tab_name - self.people, report = self.select_data.load_people(settings_holder.settings, self.features) - gui_log.add_line(LogType.GSHEET_SELECTION, report.as_html()) - except Exception as error: - gui_log.add_line( - LogType.GSHEET_SELECTION, - f"Failed to load people: {error}", - ) - raise KnownFailureError from error - if not self.people: - gui_log.add_line( - LogType.GSHEET_SELECTION, - "Failed to load people", - ) - raise KnownFailureError - - def run_selection(self, test_selection: bool) -> None: - assert self.features is not None and self.people is not None - gui_log.reset(LogType.DETAILED_LOG, "Selecting... please wait...
") - try: - success, people_selected, report = core.run_stratification( - features=self.features, - people=self.people, - number_people_wanted=self.panel_size_num, - settings=settings_holder.settings, - test_selection=test_selection, - number_selections=self.number_selections, - ) - except Exception as err: - gui_log.add_lines( - LogType.DETAILED_LOG, - [f"Unexpected error during selection: {err}", "Selection failed, process ended."], - ) - return - gui_log.add_line(LogType.DETAILED_LOG, report.as_html()) - if not success: - gui_log.add_line(LogType.DETAILED_LOG, "No panels written to spreadsheet, process ended.") - return - - gui_log.add_line(LogType.DETAILED_LOG, "About to write to spreadsheet.") - try: - selected_rows, remaining_rows, _ = core.selected_remaining_tables( - full_people=self.people, - people_selected=people_selected[0], - features=self.features, - settings=settings_holder.settings, - ) - self.data_source.selected_tab_name = self.original_selected_tab_name - self.data_source.remaining_tab_name = self.remaining_tab_name - self.select_data.gen_rem_tab = self._safe_gen_rem_tab() - dupes, report = self.select_data.output_selected_remaining( - selected_rows, - remaining_rows, - settings_holder.settings, - ) - gui_log.add_line(LogType.DETAILED_LOG, report.as_html()) - gui_log.add_line( - LogType.DETAILED_LOG, - f"In the remaining tab there are {len(dupes)} people who share the same address as " - f"someone else in the tab. They are highlighted in orange.", - ) - gui_log.add_line(LogType.DETAILED_LOG, "All spreadsheet writing has finished.") - gui_log.add_line(LogType.DETAILED_LOG, "Selection process finished.") - except Exception as err: - gui_log.add_lines( - LogType.DETAILED_LOG, - [f"Unexpected error during writing selection: {err}", "Writing failed, process ended."], - ) - - -# globals - GUI event handlers -csv_handler = CSVHandler() -g_sheet_handler = GSheetHandler() - - -####################### -# CSV functions for eel -####################### - - -@eel.expose -def handle_csv_file_features_content(file_contents): - csv_handler.add_feature_content(file_contents) - - -# 'selection' means people... -@eel.expose -def handle_csv_file_people_content(file_contents): - csv_handler.add_people_content(file_contents) - - -@eel.expose -def update_csv_panel_size(panel_size): - csv_handler.update_panel_size(panel_size) - - -@eel.expose -def csv_run_selection(): - csv_handler.run_selection(test_selection=False) - - -@eel.expose -def csv_run_test_selection(): - csv_handler.run_selection(test_selection=True) - - -########################### -# G Sheet functions for eel -########################### - - -@eel.expose -def update_g_sheet_name(g_sheet_name): - g_sheet_handler.update_g_sheet_name(g_sheet_name) - - -@eel.expose -def load_g_sheet(): - g_sheet_handler.load_g_sheet() - - -############################# -###Start Advanced Settings### -############################# -@eel.expose -def update_respondents_tab_name(people_tab_name): - g_sheet_handler.update_people_tab_name(people_tab_name) - - -@eel.expose -def reload_respondents_tab(): - g_sheet_handler.update_people_tab_name("") - - -@eel.expose -def update_features_tab_name(features_tab_name): - g_sheet_handler.update_features_tab_name(features_tab_name) - - -@eel.expose -def reload_features_tab(): - g_sheet_handler.update_features_tab_name("") - - -@eel.expose -def update_gen_rem_tab(gen_rem_tab): - g_sheet_handler.update_gen_rem_tab(gen_rem_tab) - - -@eel.expose -def reload_gen_rem_tab(): - g_sheet_handler.update_gen_rem_tab(gen_rem_tab=False) - - -@eel.expose -def update_number_selections(number_selections): - g_sheet_handler.update_number_selections(number_selections) - - -@eel.expose -def reload_number_selections(): - g_sheet_handler.update_number_selections("") - - -########################### -###End Advanced Settings### -########################### - - -@eel.expose -def update_g_sheet_panel_size(panel_size): - g_sheet_handler.update_panel_size(panel_size) - - -@eel.expose -def g_sheet_run_selection(): - g_sheet_handler.run_selection(test_selection=False) - - -@eel.expose -def g_sheet_run_test_selection(): - g_sheet_handler.run_selection(test_selection=True) - - -MIN_WINDOWS_VERSION = 10 - - -def main(): - default_size = (800, 800) - eel.init("web") # Give folder containing web files - try: - eel.start("main.html", size=default_size) - except OSError: - # on Windows 10 try Edge if Chrome not available - if sys.platform in ("win32", "win64") and int(platform.release()) >= MIN_WINDOWS_VERSION: - eel.start("main.html", mode="edge", size=default_size) - else: - raise - - -if __name__ == "__main__": - main() diff --git a/strat-select.spec b/strat-select.spec new file mode 100644 index 0000000..e834401 --- /dev/null +++ b/strat-select.spec @@ -0,0 +1,153 @@ +# ABOUTME: The PyInstaller build recipe - one spec covering Linux, Windows and macOS. +# ABOUTME: Run it with `uv run pyinstaller strat-select.spec`. +# -*- mode: python ; coding: utf-8 -*- + +import sys + +MACOS = sys.platform == "darwin" +WINDOWS = sys.platform in ("win32", "cygwin") + +NAMES = {"darwin": "strat-select-macos", "win32": "strat-select-win", "cygwin": "strat-select-win"} +NAME = NAMES.get(sys.platform, "strat-select-linux") + +# Qt modules we do not use. PySide6-Essentials keeps QtWebEngine and the addons out of +# the wheel set entirely; this drops what is left over from the bundle. QtDBus and +# QtNetwork stay - the Linux platform plugin wants the first, and Qt reaches for the +# second in places that are not obvious. +EXCLUDES = [ + "PySide6.Qt3DAnimation", + "PySide6.Qt3DCore", + "PySide6.Qt3DExtras", + "PySide6.Qt3DInput", + "PySide6.Qt3DLogic", + "PySide6.Qt3DRender", + "PySide6.QtBluetooth", + "PySide6.QtCharts", + "PySide6.QtDataVisualization", + "PySide6.QtDesigner", + "PySide6.QtHelp", + "PySide6.QtMultimedia", + "PySide6.QtMultimediaWidgets", + "PySide6.QtNfc", + "PySide6.QtOpenGL", + "PySide6.QtOpenGLWidgets", + "PySide6.QtPdf", + "PySide6.QtPdfWidgets", + "PySide6.QtPositioning", + "PySide6.QtQml", + "PySide6.QtQuick", + "PySide6.QtQuick3D", + "PySide6.QtQuickControls2", + "PySide6.QtQuickWidgets", + "PySide6.QtRemoteObjects", + "PySide6.QtScxml", + "PySide6.QtSensors", + "PySide6.QtSerialPort", + "PySide6.QtSpatialAudio", + "PySide6.QtSql", + "PySide6.QtStateMachine", + "PySide6.QtTest", + "PySide6.QtTextToSpeech", + "PySide6.QtWebChannel", + "PySide6.QtWebEngineCore", + "PySide6.QtWebEngineQuick", + "PySide6.QtWebEngineWidgets", + "PySide6.QtWebSockets", + # nothing here draws a chart or opens a notebook + "matplotlib", + "IPython", + "tkinter", + "pytest", + # The library's default solver backend is highspy. python-mip is still one of its + # hard dependencies, but we never ask for it - and since 1.17 it keeps CBC in a + # separate cbcbox wheel that unpacks to 275MB, which is bigger than the rest of the + # bundle put together. Leaving both out means find_spec("mip") comes back empty and + # sortition_algorithms sets MIP_AVAILABLE to False, which is what we want. + # If you ever need a mip backend, these two come out and the bundle grows a lot. + "mip", + "cbcbox", +] + +analysis = Analysis( + ["main.py"], + pathex=[], + binaries=[], + datas=[("strat_app/resources/*.svg", "strat_app/resources")], + # osqp picks its algebra backend at runtime with importlib, so PyInstaller never + # sees osqp.ext_builtin. cvxpy probes every solver it knows about when it builds + # the installed list, and without the backend that probe logs + # "RuntimeError: No algebra backend available!" where users can see it. + # We solve with SCS, so this is noise rather than a failure - but it is alarming + # noise, and a few hundred KB buys its way out of it. + # https://github.com/orgs/osqp/discussions/657 + hiddenimports=["osqp.ext_builtin"], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=EXCLUDES, + noarchive=False, + optimize=0, + # cvxpy lists its own package directory at import time on Python 3.12+, to build a + # list of prefixes for warnings.warn. That directory does not exist when the modules + # live in the archive, so it has to be written out as files as well. + module_collection_mode={"cvxpy": "pyz+py"}, +) +pyz = PYZ(analysis.pure) + +if MACOS: + # onedir, because an app bundle needs one + exe = EXE( + pyz, + analysis.scripts, + [], + exclude_binaries=True, + name=NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=False, + # This was pinned because python-mip had no arm64 macOS wheel. We no longer + # bundle mip at all, and highspy ships arm64 wheels, so nothing here needs + # x86_64 any more - an arm64 build is a change to the runner and this line, + # and wants testing on a real ARM Mac rather than being done blind. + target_arch="x86_64", + codesign_identity=None, + entitlements_file=None, + ) + collected = COLLECT( + exe, + analysis.binaries, + analysis.datas, + strip=False, + upx=False, + upx_exclude=[], + name=NAME, + ) + app = BUNDLE( + collected, + name=f"{NAME}.app", + icon=None, + bundle_identifier="org.sortitionfoundation.strat-select", + ) +else: + exe = EXE( + pyz, + analysis.scripts, + analysis.binaries, + analysis.datas, + [], + name=NAME, + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + upx_exclude=[], + runtime_tmpdir=None, + console=not WINDOWS, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, + ) diff --git a/strat_app/__init__.py b/strat_app/__init__.py new file mode 100644 index 0000000..d867c98 --- /dev/null +++ b/strat_app/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: The stratification selection app - a GUI wrapper around sortition-algorithms. +# ABOUTME: `sessions` holds the UI-agnostic logic, `qt` the widgets that display it. diff --git a/strat_app/__main__.py b/strat_app/__main__.py new file mode 100644 index 0000000..a89f936 --- /dev/null +++ b/strat_app/__main__.py @@ -0,0 +1,51 @@ +# ABOUTME: The application entry point - builds the QApplication and shows the window. +# ABOUTME: `uv run python -m strat_app`, or the packaged binary, both end up here. + +import argparse +import sys +import tempfile +from collections.abc import Sequence +from pathlib import Path + +from PySide6.QtWidgets import QApplication + +from strat_app.qt.main_window import WINDOW_TITLE, MainWindow +from strat_app.qt.workers import user_log_handler +from strat_app.self_test import run_self_test + +ORGANISATION = "Sortition Foundation" + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(prog="strat-select", description=WINDOW_TITLE) + parser.add_argument( + "--self-test", + action="store_true", + help="build the window, run a small selection, and exit - used to smoke test a packaged build", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + app = QApplication(sys.argv[:1]) + app.setApplicationName(WINDOW_TITLE) + app.setOrganizationName(ORGANISATION) + + if args.self_test: + # a settings file of its own, so a smoke test does not write to the user's home + with tempfile.TemporaryDirectory() as scratch: + window = MainWindow(settings_path=Path(scratch) / "sf_stratification_settings.toml") + window.show() + return run_self_test(window) + + window = MainWindow() + # the library logs its progress as a run goes along; this is what puts those lines + # in the detailed log rather than on a stdout that a windowed build does not have + with user_log_handler(window.append_detailed_log): + window.show() + return app.exec() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/strat_app/qt/__init__.py b/strat_app/qt/__init__.py new file mode 100644 index 0000000..d0168da --- /dev/null +++ b/strat_app/qt/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: The Qt widgets that display the session layer - the only place PySide6 is imported. +# ABOUTME: Widgets hold no logic of their own beyond what it takes to draw and to dispatch. diff --git a/strat_app/qt/csv_tab.py b/strat_app/qt/csv_tab.py new file mode 100644 index 0000000..033cb4c --- /dev/null +++ b/strat_app/qt/csv_tab.py @@ -0,0 +1,271 @@ +# ABOUTME: The CSV tab - choose two CSV files, run a selection, save the two outputs. +# ABOUTME: Implements CsvView; file dialogs are kept in slots of their own so tests can skip them. + +from pathlib import Path +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import ( + QFileDialog, + QGroupBox, + QHBoxLayout, + QLabel, + QProgressBar, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from strat_app.qt.report_view import ReportBrowser + +if TYPE_CHECKING: + from strat_app.sessions.csv_session import CsvSession + +CSV_FILE_FILTER = "CSV files (*.csv);;All files (*)" +PANEL_SIZE_LABEL = "Specify the number of people to select" +NO_FILE_CHOSEN = "No file chosen" + + +class CsvTab(QWidget): + def __init__(self) -> None: + super().__init__() + self._session: CsvSession | None = None + self._run_enabled_before_busy = False + self._selected_contents = "" + self._selected_filename = "selected.csv" + self._remaining_contents = "" + self._remaining_filename = "remaining.csv" + + layout = QVBoxLayout(self) + layout.addWidget(QLabel("

Option A: CSV file input and output

")) + layout.addWidget(self._build_file_inputs()) + layout.addWidget(self._build_information()) + layout.addWidget(self._build_selection()) + layout.addWidget(self._build_output()) + layout.addStretch() + + self._connect_signals() + + ############### + # the widgets + ############### + + def _build_file_inputs(self) -> QGroupBox: + box = QGroupBox() + layout = QVBoxLayout(box) + + layout.addWidget(QLabel("Step 1: Select a CSV file with feature/category information")) + self.features_button = QPushButton("Choose categories file…") + self.features_label = QLabel(NO_FILE_CHOSEN) + layout.addLayout(_row(self.features_button, self.features_label)) + + layout.addWidget(QLabel("Step 2: Select a CSV file with people to select from")) + self.people_button = QPushButton("Choose people file…") + self.people_button.setEnabled(False) + self.people_label = QLabel(NO_FILE_CHOSEN) + layout.addLayout(_row(self.people_button, self.people_label)) + return box + + def _build_information(self) -> QGroupBox: + box = QGroupBox("Category and people information:") + layout = QVBoxLayout(box) + self.features_output = ReportBrowser() + self.features_output.show_entries(["Number of categories: No input yet"]) + self.people_output = ReportBrowser() + self.people_output.show_entries(["Number of people: No input yet"]) + layout.addWidget(self.features_output) + layout.addWidget(self.people_output) + return box + + def _build_selection(self) -> QGroupBox: + box = QGroupBox("Set number of people and run selection") + layout = QVBoxLayout(box) + self.panel_size_label = QLabel(PANEL_SIZE_LABEL) + self.panel_size_spin = QSpinBox() + self.panel_size_spin.setRange(0, 0) + layout.addWidget(self.panel_size_label) + layout.addWidget(self.panel_size_spin) + + self.run_button = QPushButton("Run the Random Selection") + self.run_test_button = QPushButton("(Produce a Test Panel)") + self.set_run_enabled(enabled=False) + layout.addLayout(_row(self.run_button, self.run_test_button)) + self.busy_bar = QProgressBar() + self.busy_bar.setRange(0, 0) + self.busy_bar.setVisible(False) + layout.addWidget(self.busy_bar) + self.progress_label = QLabel() + self.progress_label.setVisible(False) + layout.addWidget(self.progress_label) + return box + + def _build_output(self) -> QGroupBox: + box = QGroupBox("After selection... save output:") + layout = QVBoxLayout(box) + self.save_selected_button = QPushButton("Save Selected…") + self.save_selected_button.setEnabled(False) + self.save_remaining_button = QPushButton("Save Remaining…") + self.save_remaining_button.setEnabled(False) + layout.addLayout(_row(self.save_selected_button, self.save_remaining_button)) + return box + + def _connect_signals(self) -> None: + """Connected last, so nothing fires at a session that is not attached yet.""" + self.features_button.clicked.connect(self.choose_features_file) + self.people_button.clicked.connect(self.choose_people_file) + self.panel_size_spin.valueChanged.connect(self._panel_size_changed) + self.run_button.clicked.connect(self.run_selection) + self.run_test_button.clicked.connect(self.run_test_selection) + self.save_selected_button.clicked.connect(self.choose_selected_save_path) + self.save_remaining_button.clicked.connect(self.choose_remaining_save_path) + + @property + def session(self) -> "CsvSession": + assert self._session is not None, "the tab needs a session before it can be used" + return self._session + + @session.setter + def session(self, session: "CsvSession") -> None: + self._session = session + + def run_selection(self) -> None: + self.session.run_selection(test_selection=False) + + def run_test_selection(self) -> None: + self.session.run_selection(test_selection=True) + + #################################### + # user actions - dialogs and files + #################################### + + def choose_features_file(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Select the categories CSV file", "", CSV_FILE_FILTER) + if path: + self.load_features_file(Path(path)) + + def choose_people_file(self) -> None: + path, _ = QFileDialog.getOpenFileName(self, "Select the people CSV file", "", CSV_FILE_FILTER) + if path: + self.load_people_file(Path(path)) + + def load_features_file(self, path: Path) -> None: + self.features_label.setText(path.name) + self.session.add_feature_content(_read_csv(path)) + + def load_people_file(self, path: Path) -> None: + self.people_label.setText(path.name) + self.session.add_people_content(_read_csv(path)) + + def choose_selected_save_path(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, "Save the selected people", self._selected_filename, CSV_FILE_FILTER + ) + if path: + self.write_selected(Path(path)) + + def choose_remaining_save_path(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, "Save the remaining people", self._remaining_filename, CSV_FILE_FILTER + ) + if path: + self.write_remaining(Path(path)) + + def write_selected(self, path: Path) -> None: + _write_csv(path, self._selected_contents) + + def write_remaining(self, path: Path) -> None: + _write_csv(path, self._remaining_contents) + + def _panel_size_changed(self, size: int) -> None: + self.session.set_panel_size(size) + + ########################## + # the CsvView protocol + ########################## + + def set_busy(self, busy: bool) -> None: + """Show that work is in flight, and refuse to start a second lot of it.""" + self.busy_bar.setVisible(busy) + self.progress_label.setVisible(busy) + if busy: + self._reset_progress() + self._run_enabled_before_busy = self.run_button.isEnabled() + self.set_run_enabled(enabled=False) + else: + self.set_run_enabled(enabled=self._run_enabled_before_busy) + + def _reset_progress(self) -> None: + """ + Back to "something is happening", knowing nothing about what. + + Without this a second run starts out showing where the first one finished. + """ + self.busy_bar.setRange(0, 0) + self.progress_label.setText("") + + def start_progress_phase(self, name: str, total: int | None, message: str) -> None: + """ + A new phase of the library's work. A total of None means it has no fixed end. + + `name` is the library's stable identifier for the phase. Nothing uses it yet - + the human-readable message is what goes on screen - but it is what a future + per-phase icon or ordering would key off, so it stays in the signature. + """ + self.busy_bar.setRange(0, total or 0) + self.busy_bar.setValue(0) + self.progress_label.setText(message) + + def set_progress(self, current: int, total: int | None, message: str) -> None: + if total is not None: + self.busy_bar.setValue(current) + self.progress_label.setText(message) + + def set_people_input_enabled(self, enabled: bool) -> None: + self.people_button.setEnabled(enabled) + + def set_panel_size_range(self, minimum: int, maximum: int) -> None: + self.panel_size_spin.setRange(minimum, maximum) + self.panel_size_label.setText(_panel_size_label(minimum, maximum)) + + def set_panel_size(self, size: int) -> None: + self.panel_size_spin.setValue(size) + + def set_run_enabled(self, enabled: bool) -> None: + self.run_button.setEnabled(enabled) + self.run_test_button.setEnabled(enabled) + + def offer_selected(self, contents: str, filename: str) -> None: + self._selected_contents = contents + self._selected_filename = filename + self.save_selected_button.setEnabled(True) + + def offer_remaining(self, contents: str, filename: str) -> None: + self._remaining_contents = contents + self._remaining_filename = filename + self.save_remaining_button.setEnabled(True) + + +def _row(*widgets: QWidget) -> QHBoxLayout: + layout = QHBoxLayout() + for widget in widgets: + layout.addWidget(widget) + layout.addStretch() + return layout + + +def _panel_size_label(minimum: int, maximum: int) -> str: + """Once we know the range, say what it is - as the old page did.""" + if maximum <= 0: + return PANEL_SIZE_LABEL + return f"Step 3: {PANEL_SIZE_LABEL} ({minimum}-{maximum})" + + +def _read_csv(path: Path) -> str: + # utf-8-sig so a byte order mark from a spreadsheet export does not end up glued + # to the name of the first column + return path.read_text(encoding="utf-8-sig") + + +def _write_csv(path: Path, contents: str) -> None: + # the library writes its own line endings, so ask for no translation on the way out + path.write_text(contents, encoding="utf-8", newline="") diff --git a/strat_app/qt/gsheet_tab.py b/strat_app/qt/gsheet_tab.py new file mode 100644 index 0000000..2e62bca --- /dev/null +++ b/strat_app/qt/gsheet_tab.py @@ -0,0 +1,273 @@ +# ABOUTME: The Google Sheet tab - name a spreadsheet, load it, run a selection, write tabs back. +# ABOUTME: Implements GSheetView; the advanced settings live in a collapsible group box. + +from typing import TYPE_CHECKING + +from PySide6.QtWidgets import ( + QCheckBox, + QFormLayout, + QGroupBox, + QHBoxLayout, + QLabel, + QLineEdit, + QProgressBar, + QPushButton, + QSpinBox, + QVBoxLayout, + QWidget, +) + +from strat_app.qt.report_view import ReportBrowser + +if TYPE_CHECKING: + from strat_app.sessions.gsheet_session import GSheetSession + +PANEL_SIZE_LABEL = "Specify the number of people to select" +RELOAD_HINT = 'If you change this, then you must click "Load G-Sheet" afterwards.' +MAX_SELECTIONS = 1000 + + +class GSheetTab(QWidget): + def __init__(self) -> None: + super().__init__() + self._session: GSheetSession | None = None + self._run_enabled_before_busy = False + self._test_run_enabled_before_busy = False + self._load_enabled_before_busy = False + + layout = QVBoxLayout(self) + layout.addWidget(QLabel("

Option B: Google Sheet input and output

")) + layout.addWidget(self._build_sheet_input()) + layout.addWidget(self._build_advanced_settings()) + layout.addLayout(_row(self.load_button)) + layout.addWidget(self._build_information()) + layout.addWidget(self._build_selection()) + layout.addStretch() + + self._connect_signals() + + ############### + # the widgets + ############### + + def _build_sheet_input(self) -> QGroupBox: + box = QGroupBox() + layout = QVBoxLayout(box) + layout.addWidget(QLabel("Google spreadsheet name:")) + self.sheet_name_edit = QLineEdit() + layout.addWidget(self.sheet_name_edit) + layout.addWidget(_hint(RELOAD_HINT)) + self.load_button = QPushButton("Load G-Sheet") + self.load_button.setEnabled(False) + return box + + def _build_advanced_settings(self) -> QGroupBox: + box = QGroupBox("Advanced settings") + box.setCheckable(True) + box.setChecked(False) + self.advanced_settings = box + # the rows go in a widget of their own so that hiding them collapses the box, + # rather than leaving an empty strip where they used to be + self.advanced_contents = QWidget() + outer = QVBoxLayout(box) + outer.addWidget(self.advanced_contents) + layout = QFormLayout(self.advanced_contents) + + self.people_tab_edit = QLineEdit("Respondents") + layout.addRow("Google spreadsheet respondents tab:", self.people_tab_edit) + layout.addRow("", _hint(RELOAD_HINT)) + + self.features_tab_edit = QLineEdit("Categories") + layout.addRow("Google spreadsheet categories tab:", self.features_tab_edit) + layout.addRow("", _hint(RELOAD_HINT)) + + self.gen_rem_tab_check = QCheckBox("Generate remaining tab") + self.gen_rem_tab_check.setChecked(True) + layout.addRow(self.gen_rem_tab_check) + layout.addRow( + "", + _hint( + "If this is checked then the remaining tab will be written to your sheet. " + 'If you change the check box, then you must click "Load G-Sheet" afterwards.' + ), + ) + + self.number_selections_spin = QSpinBox() + self.number_selections_spin.setRange(1, MAX_SELECTIONS) + layout.addRow("Number of selections needed:", self.number_selections_spin) + layout.addRow("", _hint(RELOAD_HINT)) + + self.advanced_contents.setVisible(False) + box.toggled.connect(self.advanced_contents.setVisible) + return box + + def _build_information(self) -> QGroupBox: + box = QGroupBox("Category and people information:") + layout = QVBoxLayout(box) + self.features_output = ReportBrowser() + self.features_output.show_entries(["Number of categories: No input yet"]) + self.people_output = ReportBrowser() + self.people_output.show_entries(["Number of people: No input yet"]) + layout.addWidget(self.features_output) + layout.addWidget(self.people_output) + return box + + def _build_selection(self) -> QGroupBox: + box = QGroupBox("Set number of people and run selection") + layout = QVBoxLayout(box) + self.panel_size_label = QLabel(PANEL_SIZE_LABEL) + self.panel_size_spin = QSpinBox() + self.panel_size_spin.setRange(0, 0) + layout.addWidget(self.panel_size_label) + layout.addWidget(self.panel_size_spin) + + self.run_button = QPushButton("Run the Random Selection") + self.run_test_button = QPushButton("(Produce a Test Panel)") + self.set_run_enabled(enabled=False) + self.set_test_run_enabled(enabled=False) + layout.addLayout(_row(self.run_button, self.run_test_button)) + self.busy_bar = QProgressBar() + self.busy_bar.setRange(0, 0) + self.busy_bar.setVisible(False) + layout.addWidget(self.busy_bar) + self.progress_label = QLabel() + self.progress_label.setVisible(False) + layout.addWidget(self.progress_label) + return box + + def _connect_signals(self) -> None: + """Connected last, so nothing fires at a session that is not attached yet.""" + self.sheet_name_edit.textChanged.connect(self._sheet_name_changed) + self.people_tab_edit.textChanged.connect(self._people_tab_name_changed) + self.features_tab_edit.textChanged.connect(self._features_tab_name_changed) + self.gen_rem_tab_check.toggled.connect(self._gen_rem_tab_toggled) + self.number_selections_spin.valueChanged.connect(self._number_selections_changed) + self.load_button.clicked.connect(self.load_g_sheet) + self.panel_size_spin.valueChanged.connect(self._panel_size_changed) + self.run_button.clicked.connect(self.run_selection) + self.run_test_button.clicked.connect(self.run_test_selection) + + @property + def session(self) -> "GSheetSession": + assert self._session is not None, "the tab needs a session before it can be used" + return self._session + + @session.setter + def session(self, session: "GSheetSession") -> None: + self._session = session + + #################################### + # user actions reaching the session + #################################### + + def _sheet_name_changed(self, text: str) -> None: + self.session.update_g_sheet_name(text) + + def _people_tab_name_changed(self, text: str) -> None: + self.session.update_people_tab_name(text) + + def _features_tab_name_changed(self, text: str) -> None: + self.session.update_features_tab_name(text) + + def _gen_rem_tab_toggled(self, checked: bool) -> None: + self.session.update_gen_rem_tab(gen_rem_tab=checked) + + def _number_selections_changed(self, value: int) -> None: + self.session.set_number_selections(value) + + def _panel_size_changed(self, size: int) -> None: + self.session.set_panel_size(size) + + def load_g_sheet(self) -> None: + self.session.load_g_sheet() + + def run_selection(self) -> None: + self.session.run_selection(test_selection=False) + + def run_test_selection(self) -> None: + self.session.run_selection(test_selection=True) + + ########################### + # the GSheetView protocol + ########################### + + def set_busy(self, busy: bool) -> None: + """Show that work is in flight, and refuse to start a second lot of it.""" + self.busy_bar.setVisible(busy) + self.progress_label.setVisible(busy) + if busy: + self._reset_progress() + self._run_enabled_before_busy = self.run_button.isEnabled() + self._test_run_enabled_before_busy = self.run_test_button.isEnabled() + self._load_enabled_before_busy = self.load_button.isEnabled() + self.set_run_enabled(enabled=False) + self.set_test_run_enabled(enabled=False) + self.set_load_enabled(enabled=False) + else: + self.set_run_enabled(enabled=self._run_enabled_before_busy) + self.set_test_run_enabled(enabled=self._test_run_enabled_before_busy) + self.set_load_enabled(enabled=self._load_enabled_before_busy) + + def _reset_progress(self) -> None: + """ + Back to "something is happening", knowing nothing about what. + + Without this a second run starts out showing where the first one finished. + """ + self.busy_bar.setRange(0, 0) + self.progress_label.setText("") + + def start_progress_phase(self, name: str, total: int | None, message: str) -> None: + """ + A new phase of the library's work. A total of None means it has no fixed end. + + `name` is the library's stable identifier for the phase. Nothing uses it yet - + the human-readable message is what goes on screen - but it is what a future + per-phase icon or ordering would key off, so it stays in the signature. + """ + self.busy_bar.setRange(0, total or 0) + self.busy_bar.setValue(0) + self.progress_label.setText(message) + + def set_progress(self, current: int, total: int | None, message: str) -> None: + if total is not None: + self.busy_bar.setValue(current) + self.progress_label.setText(message) + + def set_load_enabled(self, enabled: bool) -> None: + self.load_button.setEnabled(enabled) + + def set_panel_size_range(self, minimum: int, maximum: int) -> None: + self.panel_size_spin.setRange(minimum, maximum) + self.panel_size_label.setText(_panel_size_label(minimum, maximum)) + + def set_panel_size(self, size: int) -> None: + self.panel_size_spin.setValue(size) + + def set_run_enabled(self, enabled: bool) -> None: + self.run_button.setEnabled(enabled) + + def set_test_run_enabled(self, enabled: bool) -> None: + self.run_test_button.setEnabled(enabled) + + +def _row(*widgets: QWidget) -> QHBoxLayout: + layout = QHBoxLayout() + for widget in widgets: + layout.addWidget(widget) + layout.addStretch() + return layout + + +def _hint(text: str) -> QLabel: + label = QLabel(text) + label.setWordWrap(True) + label.setEnabled(False) # greyed out, as the old page's muted help text was + return label + + +def _panel_size_label(minimum: int, maximum: int) -> str: + """Once we know the range, say what it is - as the old page did.""" + if maximum <= 0: + return PANEL_SIZE_LABEL + return f"Step 3: {PANEL_SIZE_LABEL} ({minimum}-{maximum})" diff --git a/strat_app/qt/log_panel.py b/strat_app/qt/log_panel.py new file mode 100644 index 0000000..32c6b00 --- /dev/null +++ b/strat_app/qt/log_panel.py @@ -0,0 +1,40 @@ +# ABOUTME: The collapsible detailed log at the bottom of the window, and the log router. +# ABOUTME: LogDisplay is what the session layer sees as its LogView. + +from collections.abc import Sequence + +from PySide6.QtWidgets import QGroupBox, QVBoxLayout + +from strat_app.qt.report_view import ReportBrowser +from strat_app.sessions.view import LogEntry, LogSection + +DETAILED_LOG_TITLE = "Detailed Log" + + +class LogPanel(QGroupBox): + """The detailed log, which the user can collapse when they are not interested.""" + + def __init__(self) -> None: + super().__init__(DETAILED_LOG_TITLE) + self.setCheckable(True) + self.setChecked(True) + # the live log from the library already shows these lines as they happen + self.browser = ReportBrowser(include_logged=False) + layout = QVBoxLayout(self) + layout.addWidget(self.browser) + self.toggled.connect(self.browser.setVisible) + + +class LogDisplay: + """ + Sends each log section to the output area it belongs in. + + The session layer knows only about sections; which widget shows a section is a + decision for the window that assembled them. + """ + + def __init__(self, areas: dict[LogSection, ReportBrowser]) -> None: + self.areas = areas + + def show_log(self, section: LogSection, entries: Sequence[LogEntry]) -> None: + self.areas[section].show_entries(entries) diff --git a/strat_app/qt/main_window.py b/strat_app/qt/main_window.py new file mode 100644 index 0000000..fffbeb1 --- /dev/null +++ b/strat_app/qt/main_window.py @@ -0,0 +1,162 @@ +# ABOUTME: The application window - the two tabs, the shared detailed log, and the wiring. +# ABOUTME: This is where sessions meet the widgets that display them. + +import sys +from pathlib import Path + +from PySide6.QtCore import Qt +from PySide6.QtGui import QCloseEvent +from PySide6.QtSvgWidgets import QSvgWidget +from PySide6.QtWidgets import ( + QHBoxLayout, + QLabel, + QMainWindow, + QScrollArea, + QSplitter, + QTabWidget, + QVBoxLayout, + QWidget, +) + +from strat_app.qt.csv_tab import CsvTab +from strat_app.qt.gsheet_tab import GSheetTab +from strat_app.qt.log_panel import LogDisplay, LogPanel +from strat_app.qt.progress import QtProgressReporter +from strat_app.qt.workers import QtTaskRunner, QueuedLogView +from strat_app.sessions.csv_session import CsvSession +from strat_app.sessions.gsheet_session import GSheetSession +from strat_app.sessions.log import GuiLog +from strat_app.sessions.view import LogSection +from strat_app.settings_holder import DEFAULT_SETTINGS_PATH, SettingsHolder + +WINDOW_TITLE = "Sortition Foundation - Stratification & Selection" +CSV_TAB_INDEX = 0 +GSHEET_TAB_INDEX = 1 +CSV_TAB_TITLE = "CSV file input & output" +GSHEET_TAB_TITLE = "Google Sheet input & output" +LOGO_HEIGHT = 50 +DEFAULT_SIZE = (800, 800) + + +def resources_dir() -> Path: + """ + Where the app's bundled files live. + + PyInstaller unpacks them into a temporary directory it points sys._MEIPASS at, + rather than next to the module they were checked in beside. + """ + bundle_dir = getattr(sys, "_MEIPASS", None) + if bundle_dir: + return Path(bundle_dir) / "strat_app" / "resources" + return Path(__file__).parent.parent / "resources" + + +LOGO_NAME = "logo_sortition-foundation_alt.svg" + + +def _scrollable(widget: QWidget) -> QScrollArea: + """ + Let a tab scroll rather than squash its contents. + + The old UI was a web page, so it scrolled when it ran out of room. Without this a + short window silently overlaps the rows of the advanced settings. + """ + area = QScrollArea() + area.setWidgetResizable(True) + area.setWidget(widget) + return area + + +def tab_title(title: str) -> str: + """ + Escape a tab title for Qt. + + Qt reads a single ampersand as marking the next letter as a keyboard shortcut, and + swallows it. Both of our titles contain one, so both need doubling up. + """ + return title.replace("&", "&&") + + +class MainWindow(QMainWindow): + def __init__(self, settings_path: Path = DEFAULT_SETTINGS_PATH) -> None: + super().__init__() + self.setWindowTitle(WINDOW_TITLE) + self.resize(*DEFAULT_SIZE) + + self.csv_tab = CsvTab() + self.gsheet_tab = GSheetTab() + self.log_panel = LogPanel() + + self.tabs = QTabWidget() + self.tabs.addTab(_scrollable(self.csv_tab), tab_title(CSV_TAB_TITLE)) + self.tabs.addTab(_scrollable(self.gsheet_tab), tab_title(GSHEET_TAB_TITLE)) + # the old page opened on the Google Sheet tab + self.tabs.setCurrentIndex(GSHEET_TAB_INDEX) + + # a splitter so the user can decide how much room the log gets + body = QSplitter(Qt.Orientation.Vertical) + body.addWidget(self.tabs) + body.addWidget(self.log_panel) + body.setStretchFactor(0, 3) + body.setStretchFactor(1, 1) + + central = QWidget() + layout = QVBoxLayout(central) + layout.addLayout(self._build_header()) + layout.addWidget(body) + self.setCentralWidget(central) + + # the log is written to from worker threads, so it goes through a queued view + self.gui_log = GuiLog(QueuedLogView(self._build_log_display())) + self.task_runner = QtTaskRunner() + settings_holder = SettingsHolder(settings_path) + self.csv_tab.session = CsvSession(self.csv_tab, self.gui_log, settings_holder, runner=self.task_runner) + self.gsheet_tab.session = GSheetSession(self.gsheet_tab, self.gui_log, settings_holder, runner=self.task_runner) + # a reporter each, so a run on one tab cannot drive the other tab's bar + self.csv_progress = self._build_progress_reporter(self.csv_tab) + self.gsheet_progress = self._build_progress_reporter(self.gsheet_tab) + + def _build_progress_reporter(self, tab: CsvTab | GSheetTab) -> QtProgressReporter: + """ + Point a reporter at a tab's progress bar and hand it to that tab's session. + + The library calls the reporter from the worker thread, so the connections are + queued and the tab is only ever touched on the GUI thread. + """ + reporter = QtProgressReporter() + reporter.connect_phase_started(tab.start_progress_phase) + reporter.connect_progressed(tab.set_progress) + tab.session.progress_reporter = reporter + return reporter + + def append_detailed_log(self, line: str) -> None: + """Where the library's live log lines land while a selection is running.""" + self.gui_log.add(LogSection.DETAILED_LOG, line) + + def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 + """Let any work in flight finish rather than killing its thread.""" + self.task_runner.wait() + super().closeEvent(event) + + def _build_header(self) -> QHBoxLayout: + header = QHBoxLayout() + header.addWidget(QLabel(f"

{WINDOW_TITLE}

")) + header.addStretch() + logo_path = resources_dir() / LOGO_NAME + if logo_path.is_file(): + logo = QSvgWidget(str(logo_path)) + size = logo.sizeHint() + logo.setFixedSize(LOGO_HEIGHT * size.width() // size.height(), LOGO_HEIGHT) + header.addWidget(logo) + return header + + def _build_log_display(self) -> LogDisplay: + return LogDisplay( + { + LogSection.CSV_FEATURES: self.csv_tab.features_output, + LogSection.CSV_SELECTION: self.csv_tab.people_output, + LogSection.GSHEET_FEATURES: self.gsheet_tab.features_output, + LogSection.GSHEET_SELECTION: self.gsheet_tab.people_output, + LogSection.DETAILED_LOG: self.log_panel.browser, + } + ) diff --git a/strat_app/qt/progress.py b/strat_app/qt/progress.py new file mode 100644 index 0000000..f1e1eb9 --- /dev/null +++ b/strat_app/qt/progress.py @@ -0,0 +1,46 @@ +# ABOUTME: Carries the library's progress events from the worker thread to the GUI thread. +# ABOUTME: Satisfies sortition_algorithms' ProgressReporter protocol; the throttling is elsewhere. + +from PySide6.QtCore import QObject, Qt, Signal + +from strat_app.sessions.progress import ProgressThrottle + + +class QtProgressReporter(QObject): + """ + A ProgressReporter that turns the library's events into Qt signals. + + The library calls this from wherever the selection is running, which for us is a + QThread. The signals are emitted with a queued connection, so the slots run on the + GUI thread and may touch widgets. + + Everything is emitted through a ProgressThrottle, because the library states it + does not throttle and calls update() every iteration of its inner loops. + """ + + phase_started = Signal(str, object, str) # name, total or None, message + progressed = Signal(int, object, str) # current, total or None, message + + def __init__(self, throttle: ProgressThrottle | None = None) -> None: + super().__init__() + self._throttle = throttle or ProgressThrottle() + + def connect_phase_started(self, receiver: object) -> None: + self.phase_started.connect(receiver, Qt.ConnectionType.QueuedConnection) + + def connect_progressed(self, receiver: object) -> None: + self.progressed.connect(receiver, Qt.ConnectionType.QueuedConnection) + + def start_phase(self, name: str, total: int | None = None, *, message: str | None = None) -> None: + phase = self._throttle.start_phase(name, total, message or "") + self.phase_started.emit(phase.name, phase.total, phase.message) + + def update(self, current: int, *, message: str | None = None) -> None: + update = self._throttle.update(current, message or "") + if update is not None: + self.progressed.emit(update.current, update.total, update.message) + + def end_phase(self) -> None: + update = self._throttle.end_phase() + if update is not None: + self.progressed.emit(update.current, update.total, update.message) diff --git a/strat_app/qt/report_view.py b/strat_app/qt/report_view.py new file mode 100644 index 0000000..98872bb --- /dev/null +++ b/strat_app/qt/report_view.py @@ -0,0 +1,42 @@ +# ABOUTME: Renders the session layer's log entries into a Qt rich text widget. +# ABOUTME: The library's report HTML goes in here and nowhere else, so it can be swapped out. + +import html +from collections.abc import Sequence + +from PySide6.QtWidgets import QTextBrowser + +from strat_app.sessions.view import LogEntry + +MINIMUM_HEIGHT = 120 + + +def entries_as_html(entries: Sequence[LogEntry], *, include_logged: bool = True) -> str: + """ + Render log entries as the small subset of HTML that Qt's rich text engine handles. + + Plain text is escaped; reports are rendered by the library, which gives us bold, + red for the critical lines, and tables. No browser and no JS is involved - this is + Qt's own rich text, not a web page. + + `include_logged` is for the detailed log. The library both logs its progress lines + and puts them in the report, so an area that is already showing the live log would + otherwise show every one of them twice. + """ + parts = [ + html.escape(entry) if isinstance(entry, str) else entry.as_html(include_logged=include_logged) + for entry in entries + ] + return "
".join(part for part in parts if part.strip()) + + +class ReportBrowser(QTextBrowser): + """A read-only area showing whatever the session layer has to say.""" + + def __init__(self, *, include_logged: bool = True) -> None: + super().__init__() + self.setMinimumHeight(MINIMUM_HEIGHT) + self.include_logged = include_logged + + def show_entries(self, entries: Sequence[LogEntry]) -> None: + self.setHtml(entries_as_html(entries, include_logged=self.include_logged)) diff --git a/strat_app/qt/workers.py b/strat_app/qt/workers.py new file mode 100644 index 0000000..9a84aa9 --- /dev/null +++ b/strat_app/qt/workers.py @@ -0,0 +1,167 @@ +# ABOUTME: Runs slow work on a QThread and gets the results back on the GUI thread. +# ABOUTME: Also bridges the library's user_logger into the app, so long runs report progress. + +import logging +from collections.abc import Callable, Generator, Sequence +from contextlib import contextmanager +from typing import Any + +from PySide6.QtCore import QObject, Qt, QThread, Signal + +from strat_app.sessions.view import LogEntry, LogSection, LogView + +USER_LOGGER_NAME = "sortition_algorithms_user" + + +class _Task(QObject): + """One piece of work, living on its own thread.""" + + done = Signal(object, object) # the callback to call, and the result + failed = Signal(object, object) # the callback to call, and the error + + def __init__( + self, + work: Callable[[], Any], + on_done: Callable[[Any], None], + on_error: Callable[[Exception], None], + ) -> None: + super().__init__() + self._work = work + self._on_done = on_done + self._on_error = on_error + + def run(self) -> None: + try: + result = self._work() + except Exception as error: + self.failed.emit(self._on_error, error) + else: + self.done.emit(self._on_done, result) + + +class QtTaskRunner(QObject): + """ + A TaskRunner that runs the work on a QThread. + + The callbacks are delivered through this object, which lives on the GUI thread, so + a queued connection lands them there too - which is what lets them touch widgets. + """ + + finished = Signal() + + def __init__(self) -> None: + super().__init__() + self._threads: list[tuple[QThread, _Task]] = [] + + def run( + self, + work: Callable[[], Any], + on_done: Callable[[Any], None], + on_error: Callable[[Exception], None], + ) -> None: + thread = QThread() + task = _Task(work, on_done, on_error) + task.moveToThread(thread) + thread.started.connect(task.run) + task.done.connect(self._deliver_done, Qt.ConnectionType.QueuedConnection) + task.failed.connect(self._deliver_failed, Qt.ConnectionType.QueuedConnection) + task.done.connect(thread.quit) + task.failed.connect(thread.quit) + self._threads.append((thread, task)) + thread.start() + + def running(self) -> bool: + return any(thread.isRunning() for thread, _ in self._threads) + + def wait(self, timeout_ms: int = 30_000) -> None: + """Let the threads finish - used when shutting down.""" + for thread, _ in self._threads: + thread.quit() + thread.wait(timeout_ms) + + def _deliver_done(self, callback: Callable[[Any], None], result: Any) -> None: + try: + callback(result) + finally: + self._reap() + self.finished.emit() + + def _deliver_failed(self, callback: Callable[[Exception], None], error: Exception) -> None: + try: + callback(error) + finally: + self._reap() + self.finished.emit() + + def _reap(self) -> None: + self._threads = [pair for pair in self._threads if pair[0].isRunning()] + + +class QueuedLogView(QObject): + """ + A LogView that can be called from any thread. + + Log entries are the one thing the session layer produces while work is in flight, + so they need a safe way across to the widget that shows them. + """ + + _entries = Signal(object, object) + + def __init__(self, view: LogView) -> None: + super().__init__() + self._view = view + self._entries.connect(self._show, Qt.ConnectionType.QueuedConnection) + + def show_log(self, section: LogSection, entries: Sequence[LogEntry]) -> None: + # a copy, because the session goes on appending to its own list + self._entries.emit(section, list(entries)) + + def _show(self, section: LogSection, entries: Sequence[LogEntry]) -> None: + self._view.show_log(section, entries) + + +class UserLogHandler(logging.Handler): + """ + A logging handler that hands each record to Qt. + + The library logs to `sortition_algorithms_user` as a run progresses - which algorithm + it picked, whether every candidate can appear in some panel. Sending those through a + signal is what makes the detailed log fill in during a run rather than all at once at + the end. + """ + + def __init__(self) -> None: + super().__init__() + self._emitter = _LineEmitter() + + def connect(self, receiver: Callable[[str], None]) -> None: + self._emitter.line.connect(receiver, Qt.ConnectionType.QueuedConnection) + + def emit(self, record: logging.LogRecord) -> None: + self._emitter.line.emit(self.format(record)) + + +class _LineEmitter(QObject): + line = Signal(str) + + +@contextmanager +def user_log_handler(receiver: Callable[[str], None]) -> Generator[UserLogHandler, None, None]: + """ + Send the library's user-facing log to `receiver` for as long as the block runs. + + The original handlers are put back afterwards, so a second run does not log twice. + """ + handler = UserLogHandler() + handler.connect(receiver) + logger = logging.getLogger(USER_LOGGER_NAME) + original = logger.handlers[:] + for old in original: + logger.removeHandler(old) + logger.addHandler(handler) + try: + yield handler + finally: + logger.removeHandler(handler) + for old in original: + logger.addHandler(old) diff --git a/web/logo_sortition-foundation_alt.svg b/strat_app/resources/logo_sortition-foundation_alt.svg similarity index 98% rename from web/logo_sortition-foundation_alt.svg rename to strat_app/resources/logo_sortition-foundation_alt.svg index e600b93..68e725d 100644 --- a/web/logo_sortition-foundation_alt.svg +++ b/strat_app/resources/logo_sortition-foundation_alt.svg @@ -1,212 +1,212 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/strat_app/self_test.py b/strat_app/self_test.py new file mode 100644 index 0000000..5b68461 --- /dev/null +++ b/strat_app/self_test.py @@ -0,0 +1,69 @@ +# ABOUTME: The --self-test check: build the window, run a small selection, report and exit. +# ABOUTME: Run against a packaged build, it catches the packaging faults that reach users first. + +from PySide6.QtCore import QEventLoop, QTimer + +from strat_app.qt.main_window import MainWindow + +TIMEOUT_MS = 120_000 +PANEL_SIZE = 4 + +# A tiny problem, chosen so the answer is obvious: two categories wanting two or three +# people each, and a pool of ten to pick four from. +CATEGORIES = "category,name,min,max\ngender,Female,2,3\ngender,Male,2,3\n" +PEOPLE_COLUMNS = ( + "nationbuilder_id,first_name,last_name,mobile_number,email,primary_address1," + "primary_address2,primary_city,zip_royal_mail,tag_list,age,gender" +) +POOL_SIZE = 10 + + +def people_csv() -> str: + rows = [PEOPLE_COLUMNS] + for index in range(POOL_SIZE): + gender = "Female" if index % 2 else "Male" + rows.append( + f"id-{index},First{index},Last{index},0770090000{index},person{index}@example.org," + f"{index} Test Street,,Testville,TE{index} 1ZZ,,{30 + index},{gender}" + ) + return "\n".join(rows) + "\n" + + +def run_self_test(window: MainWindow) -> int: + """ + Put the app through its paces without a user, and return an exit code. + + Building the window catches a missing Qt platform plugin. Running a selection + catches the scientific stack failing to import - which a packaged build manages to + do in ways a development one never does. + """ + tab = window.csv_tab + tab.session.add_feature_content(CATEGORIES) + if tab.session.features is None: + print("self test: the categories did not load") + return 1 + tab.session.add_people_content(people_csv()) + if tab.session.people is None: + print("self test: the people did not load") + return 1 + + tab.session.set_panel_size(PANEL_SIZE) + _run_and_wait(window) + + if not tab.save_selected_button.isEnabled(): + print("self test: the selection produced no output") + return 1 + print(f"self test: ok - selected {PANEL_SIZE} of {POOL_SIZE}") + return 0 + + +def _run_and_wait(window: MainWindow) -> None: + """Run a selection and spin the event loop until the worker is done, or time is up.""" + loop = QEventLoop() + window.task_runner.finished.connect(loop.quit) + timer = QTimer() + timer.setSingleShot(True) + timer.timeout.connect(loop.quit) + timer.start(TIMEOUT_MS) + window.csv_tab.run_selection() + loop.exec() diff --git a/strat_app/sessions/__init__.py b/strat_app/sessions/__init__.py new file mode 100644 index 0000000..2f71909 --- /dev/null +++ b/strat_app/sessions/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: The session layer - everything the app does that is not drawing widgets. +# ABOUTME: Nothing in here may import Qt; it talks to the UI through the view protocols. diff --git a/strat_app/sessions/csv_session.py b/strat_app/sessions/csv_session.py new file mode 100644 index 0000000..cb4a2c7 --- /dev/null +++ b/strat_app/sessions/csv_session.py @@ -0,0 +1,171 @@ +# ABOUTME: The CSV tab's logic - load categories and people, run a selection, offer the output. +# ABOUTME: Knows nothing about widgets; everything it wants to show goes through CsvView. + +from io import StringIO +from typing import TYPE_CHECKING + +from sortition_algorithms import adapters, core, features, people +from sortition_algorithms.utils import RunReport + +from strat_app.sessions.log import GuiLog +from strat_app.sessions.tasks import SynchronousRunner, TaskRunner +from strat_app.sessions.view import CsvView, LogSection +from strat_app.settings_holder import SettingsHolder + +if TYPE_CHECKING: + from sortition_algorithms.progress import ProgressReporter + +SELECTED_FILENAME = "selected.csv" +REMAINING_FILENAME = "remaining.csv" + + +class CsvSession: + def __init__( + self, + view: CsvView, + gui_log: GuiLog, + settings_holder: SettingsHolder, + runner: TaskRunner | None = None, + ) -> None: + self.view = view + self.gui_log = gui_log + self.settings_holder = settings_holder + self.runner = runner or SynchronousRunner() + # whoever builds the session supplies one that reaches the UI; None is fine and + # means the library reports its progress to nobody + self.progress_reporter: ProgressReporter | None = None + self.data_source = adapters.CSVStringDataSource("", "") + self.select_data = adapters.SelectionData(self.data_source) + self.features: features.FeatureCollection | None = None + self.people: people.People | None = None + # cache this in case we need to reload + self.people_contents: str = "" + self.panel_size = 0 # Number of people in each panel + + def set_panel_size(self, panel_size: int) -> None: + self.panel_size = panel_size + self.update_run_button() + + def update_run_button(self) -> None: + self.view.set_run_enabled(bool(self.features and self.people and self.panel_size > 0)) + + def add_feature_content(self, file_contents: str) -> None: + self.gui_log.reset(LogSection.CSV_FEATURES) + if not file_contents: + self.gui_log.add(LogSection.CSV_FEATURES, "No file contents - was the file empty?") + return + self.settings_holder.init_settings_log(self.gui_log, LogSection.CSV_FEATURES) + if not self.settings_holder.loaded(): + return + try: + self.data_source.features_data = file_contents + self.features, report = self.select_data.load_features() + self.gui_log.add(LogSection.CSV_FEATURES, report) + except Exception as error: + self.gui_log.add(LogSection.CSV_FEATURES, f"Failed to load features: {error}") + if not self.features: + return + self.view.set_people_input_enabled(enabled=True) + min_size = features.minimum_selection(self.features) + max_size = features.maximum_selection(self.features) + self.view.set_panel_size_range(min_size, max_size) + # if these are the same just set the value! + if min_size == max_size and min_size > 0: + self.panel_size = min_size + self.view.set_panel_size(min_size) + # reset people, might need to be reloaded with features + if self.people: + self.add_people_content(self.people_contents) + + def add_people_content(self, file_contents: str) -> None: + self.gui_log.reset(LogSection.CSV_SELECTION) + assert self.features is not None + try: + self.data_source.people_data = file_contents + self.people, report = self.select_data.load_people(self.settings_holder.settings, self.features) + # now we've done a successful load, cache the results + self.people_contents = file_contents + self.gui_log.add(LogSection.CSV_SELECTION, report) + self.gui_log.add(LogSection.CSV_SELECTION, f"Loaded {self.people.count} people.") + self.gui_log.add(LogSection.CSV_SELECTION, "Successfully loaded features and people.") + except Exception as error: + self.gui_log.add(LogSection.CSV_SELECTION, f"Failed to load people: {error}") + self.update_run_button() + + def _fresh_output_buffers(self) -> None: + """ + Start each run from empty output buffers. + + The data source appends to whatever is already in them, so without this a second + run would leave you with both panels one after the other in the saved file. + """ + self.data_source.selected_file = StringIO() + self.data_source.remaining_file = StringIO() + + def run_selection(self, test_selection: bool) -> None: + """ + Start a selection. + + The selection itself can take minutes, so it goes to the runner. Everything + after it - reporting and writing the output - happens in the callbacks, back + wherever the session lives. + """ + assert self.people is not None and self.features is not None + # they may have hit this button again, so clear the output area so it's more obvious + self.gui_log.reset(LogSection.DETAILED_LOG, "Selecting... please wait...") + self.view.set_busy(busy=True) + self.runner.run( + lambda: self._stratify(test_selection=test_selection), + self._selection_finished, + self._selection_failed, + ) + + def _stratify(self, *, test_selection: bool) -> tuple[bool, list[frozenset[str]], RunReport]: + """Runs wherever the runner puts it - so it touches no view and no widget.""" + assert self.people is not None and self.features is not None + return core.run_stratification( + features=self.features, + people=self.people, + number_people_wanted=self.panel_size, + settings=self.settings_holder.settings, + test_selection=test_selection, + progress_reporter=self.progress_reporter, + ) + + def _selection_failed(self, error: Exception) -> None: + self.view.set_busy(busy=False) + self.gui_log.add_all( + LogSection.DETAILED_LOG, + [f"Unexpected error during selection: {error}", "Selection failed, process ended."], + ) + + def _selection_finished(self, result: tuple[bool, list[frozenset[str]], RunReport]) -> None: + success, people_selected, report = result + self.gui_log.add(LogSection.DETAILED_LOG, report) + if not success: + self.view.set_busy(busy=False) + self.gui_log.add(LogSection.DETAILED_LOG, "No panels written to CSV, process ended.") + return + try: + self._write_output(people_selected[0]) + except Exception as err: + self.gui_log.add_all( + LogSection.DETAILED_LOG, + [f"Unexpected error during writing selection: {err}", "Writing failed, process ended."], + ) + finally: + self.view.set_busy(busy=False) + + def _write_output(self, people_selected: frozenset[str]) -> None: + """Writing a CSV to memory is quick, so this stays alongside the view.""" + assert self.people is not None and self.features is not None + selected_rows, remaining_rows, _ = core.selected_remaining_tables( + full_people=self.people, + people_selected=people_selected, + features=self.features, + settings=self.settings_holder.settings, + ) + self._fresh_output_buffers() + self.select_data.output_selected_remaining(selected_rows, remaining_rows, self.settings_holder.settings) + self.view.offer_selected(self.data_source.selected_file.getvalue(), SELECTED_FILENAME) + self.view.offer_remaining(self.data_source.remaining_file.getvalue(), REMAINING_FILENAME) diff --git a/strat_app/sessions/gsheet_session.py b/strat_app/sessions/gsheet_session.py new file mode 100644 index 0000000..67444ac --- /dev/null +++ b/strat_app/sessions/gsheet_session.py @@ -0,0 +1,334 @@ +# ABOUTME: The Google Sheet tab's logic - read categories and people from tabs, write the output back. +# ABOUTME: Knows nothing about widgets; everything it wants to show goes through GSheetView. + +from pathlib import Path +from typing import TYPE_CHECKING, NamedTuple + +import gspread +from sortition_algorithms import adapters, core, features, people +from sortition_algorithms.utils import ReportLevel, RunReport + +from strat_app.sessions.log import GuiLog +from strat_app.sessions.tasks import SynchronousRunner, TaskRunner +from strat_app.sessions.view import GSheetView, LogSection +from strat_app.settings_holder import SettingsHolder + +if TYPE_CHECKING: + from sortition_algorithms.progress import ProgressReporter + +DEFAULT_AUTH_JSON_PATH = Path.home() / "secret_do_not_commit.json" + + +class KnownFailureError(Exception): + """ + Raised when the failure has already been reported, so nobody reports it twice. + + Carries the section it was reported in only so the caller does not have to guess. + """ + + def __init__(self, section: LogSection = LogSection.GSHEET_FEATURES) -> None: + super().__init__("already reported") + self.section = section + + +class LoadedSheet(NamedTuple): + """What reading a spreadsheet gives us, ready to be applied to the session.""" + + features: features.FeatureCollection + people: people.People + + +class GSheetSession: + clear_message = "Number of features: You must (re)load sheet..." + + def __init__( + self, + view: GSheetView, + gui_log: GuiLog, + settings_holder: SettingsHolder, + data_source: adapters.GSheetDataSource | None = None, + runner: TaskRunner | None = None, + ) -> None: + self.view = view + self.gui_log = gui_log + self.settings_holder = settings_holder + self.runner = runner or SynchronousRunner() + # whoever builds the session supplies one that reaches the UI; None is fine and + # means the library reports its progress to nobody + self.progress_reporter: ProgressReporter | None = None + # injectable so tests can drive the whole flow against a fake spreadsheet + self.data_source = data_source or adapters.GSheetDataSource( + feature_tab_name="", + people_tab_name="", + auth_json_path=DEFAULT_AUTH_JSON_PATH, + ) + self.select_data = adapters.SelectionData(self.data_source) + self.features: features.FeatureCollection | None = None + self.people: people.People | None = None + self.g_sheet_name = "" + self.features_tab_name = "Categories" + self.people_tab_name = "Respondents" + self.gen_rem_tab = True + self.number_selections = 1 # How many panels to create + self.panel_size = 0 # Number of people in each panel + + def _clear_messages(self, features_message: str = clear_message) -> None: + """Clear all messages (and optionally put a new message in place)""" + self.gui_log.reset(LogSection.GSHEET_FEATURES, features_message) + self.gui_log.reset(LogSection.GSHEET_SELECTION) + self.gui_log.reset(LogSection.DETAILED_LOG) + self._reset_panel_size() + + def _reset_panel_size(self) -> None: + """ + Nothing is loaded, so there is no panel size and no range to choose from. + + The range has to be cleared before the size, or a view that clamps the size to + the range - which is the whole point of clamping - would refuse to go back to 0. + """ + self.panel_size = 0 + self.view.set_panel_size_range(0, 0) + self.view.set_panel_size(0) + + def _reset_spreadsheet(self, *, reset_features: bool = True) -> None: + if reset_features: + self.features = None + self.people = None + self._reset_panel_size() + self.update_run_button() + + def update_g_sheet_name(self, g_sheet_name: str) -> None: + self._clear_messages() + self._reset_spreadsheet() + self.g_sheet_name = g_sheet_name + if self.g_sheet_name != "": + self.view.set_load_enabled(enabled=True) + + def set_panel_size(self, panel_size: int) -> None: + self.panel_size = panel_size + self.update_run_button() + + def update_run_button(self) -> None: + """ + A test panel is only possible for a single selection, so refuse it otherwise. + + The library raises rather than reports if it is asked for a test selection of + several panels, so the warning's promise that the button is refused has to be + kept here rather than left to the user to honour. + """ + ready = bool(self.features and self.people and self.panel_size > 0) + self.view.set_run_enabled(ready) + self.view.set_test_run_enabled(ready and self.number_selections == 1) + + def update_people_tab_name(self, people_tab_name: str) -> None: + self._clear_messages() + self._reset_spreadsheet(reset_features=False) + self.people_tab_name = people_tab_name + + def update_features_tab_name(self, features_tab_name: str) -> None: + self._clear_messages() + self._reset_spreadsheet() + self.features_tab_name = features_tab_name + + def update_gen_rem_tab(self, gen_rem_tab: bool) -> None: + self.gen_rem_tab = gen_rem_tab + + def _safe_gen_rem_tab(self) -> bool: + """Get self.gen_rem_tab - but set to false if number_selections > 1""" + # never generate a remaining tab if doing a multiple selection + if self.number_selections > 1: + return False + return self.gen_rem_tab + + def set_number_selections(self, number_selections: int) -> None: + self._clear_messages() + self.number_selections = number_selections + self.update_run_button() + + def _multiple_selections_warning(self) -> RunReport: + report = RunReport() + report.add_line( + f"WARNING: You've asked for {self.number_selections} selections. " + f"You cannot use the 'Produce a Test Panel' button if you want more " + f"than 1 selection and no Remaining tab will be created.", + level=ReportLevel.IMPORTANT, + ) + return report + + # do features and people at same time... + def load_g_sheet(self) -> None: + """ + Start reading the spreadsheet. + + Talking to Google is slow enough to be worth keeping off the GUI thread, so + the reading goes to the runner and the results are applied in the callbacks. + """ + # forget about any previously loaded spreadsheet, and disable the run button + self._reset_spreadsheet() + # this can happen if they enter something and then delete it... + if self.g_sheet_name == "": + self._clear_messages("Please enter a spreadsheet name...") + return + self._clear_messages(f"Requesting category data from spreadsheet tab {self.features_tab_name} ...") + self.settings_holder.init_settings_log(self.gui_log, LogSection.GSHEET_FEATURES) + if not self.settings_holder.loaded(): + return + if self.number_selections > 1: + self.gui_log.add(LogSection.GSHEET_SELECTION, self._multiple_selections_warning()) + self.view.set_busy(busy=True) + self.runner.run(self._read_sheet, self._sheet_loaded, self._sheet_failed) + + def _read_sheet(self) -> LoadedSheet: + """ + Runs wherever the runner puts it - so no view calls, only the log. + + The log is safe to write to from anywhere, and these messages are the only + sign of life while the spreadsheet is being read. + """ + self.data_source.set_g_sheet_name(self.g_sheet_name) + loaded_features = self._read_features() + self.gui_log.add( + LogSection.GSHEET_SELECTION, + f"Requesting people data from spreadsheet tab {self.people_tab_name} ...", + ) + return LoadedSheet(loaded_features, self._read_people(loaded_features)) + + def _read_features(self) -> features.FeatureCollection: + try: + self.data_source.feature_tab_name = self.features_tab_name + loaded_features, report = self.select_data.load_features() + self.gui_log.add(LogSection.GSHEET_FEATURES, report) + except gspread.exceptions.APIError as error: + self.gui_log.add( + LogSection.GSHEET_FEATURES, + f"API error causing delay. Please wait a couple of seconds while gsheet updates. " + f"After waiting you may need to reload sheet. " + f"For the record, the API error is {error}", + ) + raise KnownFailureError from error + except Exception as error: + self.gui_log.add(LogSection.GSHEET_FEATURES, f"Failed to load features: {error}") + raise KnownFailureError from error + if not loaded_features: + self.gui_log.add(LogSection.GSHEET_FEATURES, "Failed to load features") + raise KnownFailureError + return loaded_features + + def _read_people(self, loaded_features: features.FeatureCollection) -> people.People: + try: + self.data_source.people_tab_name = self.people_tab_name + loaded_people, report = self.select_data.load_people(self.settings_holder.settings, loaded_features) + self.gui_log.add(LogSection.GSHEET_SELECTION, report) + except Exception as error: + self.gui_log.add(LogSection.GSHEET_SELECTION, f"Failed to load people: {error}") + raise KnownFailureError(LogSection.GSHEET_SELECTION) from error + if not loaded_people: + self.gui_log.add(LogSection.GSHEET_SELECTION, "Failed to load people") + raise KnownFailureError(LogSection.GSHEET_SELECTION) + return loaded_people + + def _sheet_loaded(self, loaded: LoadedSheet) -> None: + self.view.set_busy(busy=False) + self.features = loaded.features + self.people = loaded.people + min_size = features.minimum_selection(self.features) + max_size = features.maximum_selection(self.features) + self.view.set_panel_size_range(min_size, max_size) + # if these are the same just set the value! + if min_size == max_size and min_size > 0: + self.panel_size = min_size + self.view.set_panel_size(min_size) + self.gui_log.add(LogSection.GSHEET_SELECTION, "Successfully loaded features and people.") + self.update_run_button() + self.view.set_load_enabled(enabled=True) + + def _sheet_failed(self, error: Exception) -> None: + self.view.set_busy(busy=False) + if isinstance(error, KnownFailureError): + # whatever raised this has already said what went wrong, so don't repeat it + self.gui_log.add_all( + LogSection.GSHEET_FEATURES, + ["Loading spreadsheet failed, see above messages.", "Fix the problems and try loading again."], + ) + return + self.gui_log.add(LogSection.GSHEET_FEATURES, f"Loading spreadsheet failed: {error}") + + def run_selection(self, test_selection: bool) -> None: + assert self.features is not None and self.people is not None + self.gui_log.reset(LogSection.DETAILED_LOG, "Selecting... please wait...") + self.view.set_busy(busy=True) + self.runner.run( + lambda: self._stratify(test_selection=test_selection), + self._selection_finished, + self._selection_failed, + ) + + def _stratify(self, *, test_selection: bool) -> tuple[bool, list[frozenset[str]], RunReport]: + """Runs wherever the runner puts it - so it touches no view and no widget.""" + assert self.features is not None and self.people is not None + return core.run_stratification( + features=self.features, + people=self.people, + number_people_wanted=self.panel_size, + settings=self.settings_holder.settings, + test_selection=test_selection, + number_selections=self.number_selections, + progress_reporter=self.progress_reporter, + ) + + def _selection_failed(self, error: Exception) -> None: + self.view.set_busy(busy=False) + self.gui_log.add_all( + LogSection.DETAILED_LOG, + [f"Unexpected error during selection: {error}", "Selection failed, process ended."], + ) + + def _selection_finished(self, result: tuple[bool, list[frozenset[str]], RunReport]) -> None: + success, people_selected, report = result + self.gui_log.add(LogSection.DETAILED_LOG, report) + if not success: + self.view.set_busy(busy=False) + self.gui_log.add(LogSection.DETAILED_LOG, "No panels written to spreadsheet, process ended.") + return + + self.gui_log.add(LogSection.DETAILED_LOG, "About to write to spreadsheet.") + # writing goes back to the runner - it is several round trips to Google + self.runner.run( + lambda: self._write_output(people_selected[0]), + self._writing_finished, + self._writing_failed, + ) + + def _write_output(self, people_selected: frozenset[str]) -> tuple[list[int], RunReport]: + assert self.features is not None and self.people is not None + selected_rows, remaining_rows, _ = core.selected_remaining_tables( + full_people=self.people, + people_selected=people_selected, + features=self.features, + settings=self.settings_holder.settings, + ) + self.select_data.gen_rem_tab = self._safe_gen_rem_tab() + return self.select_data.output_selected_remaining( + selected_rows, + remaining_rows, + self.settings_holder.settings, + ) + + def _writing_finished(self, result: tuple[list[int], RunReport]) -> None: + dupes, report = result + self.view.set_busy(busy=False) + self.gui_log.add(LogSection.DETAILED_LOG, report) + self.gui_log.add( + LogSection.DETAILED_LOG, + f"In the remaining tab there are {len(dupes)} people who share the same address as " + f"someone else in the tab. They are highlighted in orange.", + ) + self.gui_log.add(LogSection.DETAILED_LOG, "All spreadsheet writing has finished.") + self.gui_log.add(LogSection.DETAILED_LOG, "Selection process finished.") + + def _writing_failed(self, error: Exception) -> None: + self.view.set_busy(busy=False) + self.gui_log.add_all( + LogSection.DETAILED_LOG, + [f"Unexpected error during writing selection: {error}", "Writing failed, process ended."], + ) diff --git a/strat_app/sessions/log.py b/strat_app/sessions/log.py new file mode 100644 index 0000000..5c65608 --- /dev/null +++ b/strat_app/sessions/log.py @@ -0,0 +1,42 @@ +# ABOUTME: Accumulates the messages shown in each of the app's output areas. +# ABOUTME: Holds text and library reports as they are; rendering them is the view's job. + +from collections.abc import Iterable + +from strat_app.sessions.view import LogEntry, LogSection, LogView + + +class GuiLog: + """ + The messages currently shown in each output area. + + Each section accumulates entries until something resets it - typically the start + of a new load or a new selection run. Every change pushes the whole section to + the view, which is cheap and means the view holds no state of its own. + + Slow work adds to the log as it goes, from whatever thread it is running on, so + the view supplied here has to be safe to call from anywhere. + """ + + def __init__(self, view: LogView) -> None: + self.view = view + self.entries: dict[LogSection, list[LogEntry]] = {section: [] for section in LogSection} + + def reset(self, section: LogSection, first_entry: LogEntry | None = None) -> None: + self.entries[section] = [] if first_entry is None else [first_entry] + self.update_area(section) + + def add(self, section: LogSection, entry: LogEntry) -> None: + self.entries[section].append(entry) + self.update_area(section) + + def add_all(self, section: LogSection, entries: Iterable[LogEntry]) -> None: + self.entries[section] += entries + self.update_area(section) + + def update_area(self, section: LogSection) -> None: + self.view.show_log(section, self.entries[section]) + + def update_all_areas(self) -> None: + for section in LogSection: + self.update_area(section) diff --git a/strat_app/sessions/progress.py b/strat_app/sessions/progress.py new file mode 100644 index 0000000..ca68261 --- /dev/null +++ b/strat_app/sessions/progress.py @@ -0,0 +1,78 @@ +# ABOUTME: Decides which of the library's progress events are worth showing the user. +# ABOUTME: Free of Qt, so the deciding can be tested against a clock a test controls. + +import time +from collections.abc import Callable +from typing import NamedTuple + +# Ten a second is more than the eye can follow and far fewer than the library emits. +DEFAULT_MIN_INTERVAL_SECONDS = 0.1 + + +class PhaseStart(NamedTuple): + """A new phase of work, with a total if the library knows one.""" + + name: str + total: int | None + message: str + + +class ProgressUpdate(NamedTuple): + """Progress within the current phase. A total of None means it has no fixed end.""" + + current: int + total: int | None + message: str + + +class ProgressThrottle: + """ + Turns the library's flood of progress events into a trickle worth displaying. + + The library calls `update()` every iteration of its inner loops and says plainly + that it does not throttle - its docs mention hundreds of calls a second on a fast + solver. Passing each one on would flood whatever is displaying them. + + Phase changes and the last value of a phase are never dropped. Those are the two + the user would actually notice missing: a label lagging behind the bar, and a + determinate bar stopping at 187/200 because the 200 arrived too soon after the 187. + """ + + def __init__( + self, + min_interval_seconds: float = DEFAULT_MIN_INTERVAL_SECONDS, + now: Callable[[], float] = time.monotonic, + ) -> None: + self._min_interval = min_interval_seconds + self._now = now + self._total: int | None = None + self._in_phase = False + self._last_sent = 0.0 + self._pending: ProgressUpdate | None = None + + def start_phase(self, name: str, total: int | None, message: str) -> PhaseStart: + self._total = total + self._in_phase = True + self._pending = None + # not `self._now()` - so the first update of a phase is never held back + self._last_sent = 0.0 + return PhaseStart(name=name, total=total, message=message) + + def update(self, current: int, message: str) -> ProgressUpdate | None: + if not self._in_phase: + return None + update = ProgressUpdate(current=current, total=self._total, message=message) + now = self._now() + if now - self._last_sent < self._min_interval: + self._pending = update + return None + self._last_sent = now + self._pending = None + return update + + def end_phase(self) -> ProgressUpdate | None: + """Flush whatever the throttle was sitting on, so a phase ends where it really got to.""" + pending = self._pending + self._pending = None + self._in_phase = False + return pending diff --git a/strat_app/sessions/tasks.py b/strat_app/sessions/tasks.py new file mode 100644 index 0000000..f826540 --- /dev/null +++ b/strat_app/sessions/tasks.py @@ -0,0 +1,39 @@ +# ABOUTME: How the session layer runs slow work without knowing anything about threads. +# ABOUTME: Tests use the synchronous runner; the app supplies a Qt one that uses a QThread. + +from collections.abc import Callable +from typing import Any, Protocol + + +class TaskRunner(Protocol): + """ + Runs a piece of slow work, then calls back with the result or the error. + + Whoever supplies the runner decides where the work happens. The one rule an + implementation must keep is that the callbacks happen where the session lives - + for the Qt app, the GUI thread - because they touch the session and its view. + """ + + def run( + self, + work: Callable[[], Any], + on_done: Callable[[Any], None], + on_error: Callable[[Exception], None], + ) -> None: ... + + +class SynchronousRunner: + """Runs the work there and then. No threads, so tests stay predictable.""" + + def run( + self, + work: Callable[[], Any], + on_done: Callable[[Any], None], + on_error: Callable[[Exception], None], + ) -> None: + try: + result = work() + except Exception as error: + on_error(error) + else: + on_done(result) diff --git a/strat_app/sessions/view.py b/strat_app/sessions/view.py new file mode 100644 index 0000000..eaad189 --- /dev/null +++ b/strat_app/sessions/view.py @@ -0,0 +1,61 @@ +# ABOUTME: The protocols the session layer uses to talk to whatever is displaying it. +# ABOUTME: Deliberately free of any UI library, so the sessions can be tested without one. + +from collections.abc import Sequence +from enum import Enum +from typing import Protocol + +from sortition_algorithms.utils import RunReport + + +class LogSection(Enum): + """The output areas of the app that accumulate messages.""" + + CSV_FEATURES = 1 + CSV_SELECTION = 2 + GSHEET_FEATURES = 3 + GSHEET_SELECTION = 4 + DETAILED_LOG = 5 + + +# A line of plain text, or a report from the sortition-algorithms library. The view +# decides how to render each; the session layer never produces markup. +LogEntry = str | RunReport + + +class LogView(Protocol): + def show_log(self, section: LogSection, entries: Sequence[LogEntry]) -> None: ... + + +class CsvView(Protocol): + def set_busy(self, busy: bool) -> None: ... + + def set_progress(self, current: int, total: int | None, message: str) -> None: ... + + def set_people_input_enabled(self, enabled: bool) -> None: ... + + def set_panel_size_range(self, minimum: int, maximum: int) -> None: ... + + def set_panel_size(self, size: int) -> None: ... + + def set_run_enabled(self, enabled: bool) -> None: ... + + def offer_selected(self, contents: str, filename: str) -> None: ... + + def offer_remaining(self, contents: str, filename: str) -> None: ... + + +class GSheetView(Protocol): + def set_busy(self, busy: bool) -> None: ... + + def set_progress(self, current: int, total: int | None, message: str) -> None: ... + + def set_load_enabled(self, enabled: bool) -> None: ... + + def set_panel_size_range(self, minimum: int, maximum: int) -> None: ... + + def set_panel_size(self, size: int) -> None: ... + + def set_run_enabled(self, enabled: bool) -> None: ... + + def set_test_run_enabled(self, enabled: bool) -> None: ... diff --git a/strat_app/settings_holder.py b/strat_app/settings_holder.py new file mode 100644 index 0000000..80dc8cf --- /dev/null +++ b/strat_app/settings_holder.py @@ -0,0 +1,82 @@ +# ABOUTME: Loads the user's settings TOML once and remembers whether it worked. +# ABOUTME: Reports problems into a log section so they surface early, next to the user's action. + +from pathlib import Path + +from sortition_algorithms import Settings + +from strat_app.sessions.log import GuiLog +from strat_app.sessions.view import LogSection + +DEFAULT_SETTINGS_PATH = Path.home() / "sf_stratification_settings.toml" + +# The library accepts more than this app ships. python-mip is left out of the packaged +# build because it brings 275MB of CBC with it, and diversimax needs pandas and +# scikit-learn, which we do not install. Both are settings a user can write down and +# neither fails until a run is well under way, so we refuse them at load time instead. +SUPPORTED_SOLVER_BACKENDS = ("highspy",) +SUPPORTED_SELECTION_ALGORITHMS = ("legacy", "maximin", "nash", "leximin") + + +def unsupported_message(settings: Settings) -> str: + """ + Say what in these settings this build cannot honour, or nothing if it can honour it all. + + Deliberately a refusal rather than a quiet substitution. Swapping the user's solver + or algorithm for a working one would have two machines with the same settings file + selecting panels by different methods without saying so. + """ + problems = [ + _unsupported("solver_backend", settings.solver_backend, SUPPORTED_SOLVER_BACKENDS), + _unsupported("selection_algorithm", settings.selection_algorithm, SUPPORTED_SELECTION_ALGORITHMS), + ] + return "\n".join(problem for problem in problems if problem) + + +def _unsupported(name: str, value: str, supported: tuple[str, ...]) -> str: + if value in supported: + return "" + return ( + f"The {name} '{value}' is not available in this build. " + f"Edit {name} in your settings file to one of: {', '.join(supported)} - " + f"then restart this app." + ) + + +class SettingsHolder: + def __init__(self, settings_path: Path = DEFAULT_SETTINGS_PATH) -> None: + self.settings_path = settings_path + self._settings: Settings | None = None + + @property + def settings(self) -> Settings: + self.init_settings() + assert self._settings is not None + return self._settings + + def init_settings(self) -> str: + """ + Load the settings if they are not loaded yet. + + Called from lots of places to report the error early. Returns a message to show + the user, or an empty string when there is nothing to say. + """ + if self._settings is None: + try: + settings, report = Settings.load_from_file(settings_file_path=self.settings_path) + except Exception as error: + return f"Error reading in settings file: {error}" + unsupported = unsupported_message(settings) + if unsupported: + return unsupported + self._settings = settings + return report.as_text() + return "" + + def init_settings_log(self, gui_log: GuiLog, section: LogSection) -> None: + message = self.init_settings() + if message: + gui_log.add(section, message) + + def loaded(self) -> bool: + return self._settings is not None diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..0793e72 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: Marks tests as a package so test helpers can be imported by module path. +# ABOUTME: Deliberately empty apart from this comment. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4b5d14b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,47 @@ +# ABOUTME: Shared pytest fixtures and settings that every test in the suite relies on. +# ABOUTME: Also forces Qt to the offscreen platform so the suite runs headless. + +import os +from pathlib import Path + +import pytest + +# Must happen before anything imports QtGui, so it lives at module level rather than +# in a fixture. setdefault means you can still run with a real window if you want to. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +FIXTURE_DIR = Path(__file__).parent / "fixtures" +CATEGORIES_CSV = FIXTURE_DIR / "categories_no_flex.csv" +PEOPLE_CSV = FIXTURE_DIR / "people.csv" +PEOPLE_TOO_FEW_CSV = FIXTURE_DIR / "people_too_few.csv" + +# The first thing the library logs once a selection is properly under way, and so the +# first sign of life the detailed log can show. The default algorithm is maximin; pick +# a different one in the settings and this line changes with it. +ALGORITHM_LINE = "Using maximin algorithm." + + +@pytest.fixture +def categories_contents() -> str: + return CATEGORIES_CSV.read_text() + + +@pytest.fixture +def people_contents() -> str: + return PEOPLE_CSV.read_text() + + +@pytest.fixture +def people_too_few_contents() -> str: + return PEOPLE_TOO_FEW_CSV.read_text() + + +@pytest.fixture +def settings_path(tmp_path: Path) -> Path: + """ + A path for the settings TOML file, inside tmp_path. + + The file does not exist, so `Settings.load_from_file()` writes the default settings + there - which is what the app does on a machine that has never run it before. + """ + return tmp_path / "sf_stratification_settings.toml" diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..1026e6e --- /dev/null +++ b/tests/e2e/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: Marks tests/e2e as a package so test helpers can be imported by module path. +# ABOUTME: Deliberately empty apart from this comment. diff --git a/tests/e2e/test_csv_flow.py b/tests/e2e/test_csv_flow.py new file mode 100644 index 0000000..71df075 --- /dev/null +++ b/tests/e2e/test_csv_flow.py @@ -0,0 +1,152 @@ +# ABOUTME: End to end test of the CSV flow - real window, real library, real files on disk. +# ABOUTME: Everything except the file dialogs, which are driven through the seam behind them. + +import csv + +import pytest + +from strat_app.qt.main_window import CSV_TAB_INDEX, MainWindow +from strat_app.qt.workers import user_log_handler +from tests.conftest import ALGORITHM_LINE, CATEGORIES_CSV, PEOPLE_CSV, PEOPLE_TOO_FEW_CSV + +PANEL_MIN = 22 +PANEL_MAX = 24 +PEOPLE_COUNT = 200 +TIMEOUT_MS = 120_000 + + +@pytest.fixture +def window(qtbot, settings_path) -> MainWindow: + window = MainWindow(settings_path=settings_path) + qtbot.addWidget(window) + window.tabs.setCurrentIndex(CSV_TAB_INDEX) + return window + + +def ids_from(path) -> set[str]: + with path.open(encoding="utf-8", newline="") as csv_file: + return {row["nationbuilder_id"] for row in csv.DictReader(csv_file)} + + +def test_the_whole_csv_flow(qtbot, window: MainWindow, tmp_path) -> None: + tab = window.csv_tab + + # step 1 - the categories + tab.load_features_file(CATEGORIES_CSV) + qtbot.waitUntil(lambda: "Number of features found: 4" in tab.features_output.toPlainText(), timeout=TIMEOUT_MS) + assert tab.people_button.isEnabled() + assert (tab.panel_size_spin.minimum(), tab.panel_size_spin.maximum()) == (PANEL_MIN, PANEL_MAX) + + # step 2 - the people + tab.load_people_file(PEOPLE_CSV) + qtbot.waitUntil(lambda: f"Loaded {PEOPLE_COUNT} people." in tab.people_output.toPlainText(), timeout=TIMEOUT_MS) + + # step 3 - the panel size, and running + tab.panel_size_spin.setValue(PANEL_MIN) + assert tab.run_button.isEnabled() + + with qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS): + tab.run_selection() + + # step 4 - the output + assert tab.save_selected_button.isEnabled() + selected = tmp_path / "selected.csv" + remaining = tmp_path / "remaining.csv" + tab.write_selected(selected) + tab.write_remaining(remaining) + + selected_ids = ids_from(selected) + remaining_ids = ids_from(remaining) + pool_ids = ids_from(PEOPLE_CSV) + assert len(selected_ids) == PANEL_MIN + assert selected_ids <= pool_ids + assert selected_ids | remaining_ids == pool_ids + assert not selected_ids & remaining_ids + + +def test_a_test_panel_can_be_produced(qtbot, window: MainWindow, tmp_path) -> None: + tab = window.csv_tab + tab.load_features_file(CATEGORIES_CSV) + tab.load_people_file(PEOPLE_CSV) + tab.panel_size_spin.setValue(PANEL_MIN) + + with qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS): + tab.run_test_selection() + + selected = tmp_path / "selected.csv" + tab.write_selected(selected) + assert len(ids_from(selected)) == PANEL_MIN + + +def test_the_detailed_log_fills_in_as_the_run_goes(qtbot, window: MainWindow) -> None: + tab = window.csv_tab + tab.load_features_file(CATEGORIES_CSV) + tab.load_people_file(PEOPLE_CSV) + tab.panel_size_spin.setValue(PANEL_MIN) + + with ( + user_log_handler(window.append_detailed_log), + qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS), + ): + tab.run_selection() + + qtbot.waitUntil(lambda: ALGORITHM_LINE in window.log_panel.browser.toPlainText(), timeout=TIMEOUT_MS) + assert "Selecting... please wait..." in window.log_panel.browser.toPlainText() + + +def test_the_progress_bar_becomes_determinate_during_a_real_run(qtbot, window: MainWindow) -> None: + """ + A real maximin run starts with multiplicative_weights, which reports a total. + + This is the whole point of the bump: the bar can say how far through it is rather + than only that something is happening. Recording the maxima as they arrive rather + than checking at the end, because the bar goes back to indeterminate for the + convergence loop that follows. + """ + tab = window.csv_tab + tab.load_features_file(CATEGORIES_CSV) + tab.load_people_file(PEOPLE_CSV) + tab.panel_size_spin.setValue(PANEL_MIN) + maxima: list[int] = [] + window.csv_progress.connect_phase_started(lambda name, total, message: maxima.append(tab.busy_bar.maximum())) + + with qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS): + tab.run_selection() + qtbot.waitUntil(lambda: bool(maxima), timeout=TIMEOUT_MS) + + assert max(maxima) > 0 + + +def test_running_twice_leaves_only_the_second_panel(qtbot, window: MainWindow, tmp_path) -> None: + """The old app appended one run's output to the last one's.""" + tab = window.csv_tab + tab.load_features_file(CATEGORIES_CSV) + tab.load_people_file(PEOPLE_CSV) + tab.panel_size_spin.setValue(PANEL_MIN) + + for _ in range(2): + with qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS): + tab.run_selection() + + selected = tmp_path / "selected.csv" + tab.write_selected(selected) + assert len(ids_from(selected)) == PANEL_MIN + + +def test_an_impossible_panel_size_says_so(qtbot, window: MainWindow) -> None: + tab = window.csv_tab + tab.load_features_file(CATEGORIES_CSV) + # a pool of five people cannot fill a panel of twenty two + tab.load_people_file(PEOPLE_TOO_FEW_CSV) + tab.panel_size_spin.setValue(PANEL_MIN) + + with qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS): + tab.run_selection() + + qtbot.waitUntil( + lambda: "No panels written to CSV, process ended." in window.log_panel.browser.toPlainText(), + timeout=TIMEOUT_MS, + ) + assert not tab.save_selected_button.isEnabled() + # and the app is still usable afterwards + assert not window.csv_tab.busy_bar.isVisibleTo(window.csv_tab) diff --git a/tests/e2e/test_entry_point.py b/tests/e2e/test_entry_point.py new file mode 100644 index 0000000..a64e4dc --- /dev/null +++ b/tests/e2e/test_entry_point.py @@ -0,0 +1,41 @@ +# ABOUTME: Tests the app entry point, including the --self-test flag CI runs against a build. +# ABOUTME: The classic PySide6 packaging failure only ever shows up in the packaged artefact. + +import subprocess +import sys + +import main as pyinstaller_entry_point +from strat_app.__main__ import main, parse_args + +SELF_TEST_TIMEOUT_S = 120 + + +def test_the_self_test_flag_is_off_by_default() -> None: + assert parse_args([]).self_test is False + + +def test_the_self_test_flag_is_recognised() -> None: + assert parse_args(["--self-test"]).self_test is True + + +def test_the_app_builds_and_exits_cleanly() -> None: + """ + Run it as a subprocess, because it makes a QApplication of its own. + + This is the same call the packaged binary gets in CI - if Qt cannot find its + platform plugin, this is where it says so. + """ + result = subprocess.run( + [sys.executable, "-m", "strat_app", "--self-test"], + capture_output=True, + text=True, + timeout=SELF_TEST_TIMEOUT_S, + check=False, + ) + + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + + +def test_main_is_importable_from_the_pyinstaller_entry_point() -> None: + """main.py is what PyInstaller builds; it must keep pointing at the real thing.""" + assert pyinstaller_entry_point.main is main diff --git a/tests/e2e/test_gsheet_flow.py b/tests/e2e/test_gsheet_flow.py new file mode 100644 index 0000000..a2ac51c --- /dev/null +++ b/tests/e2e/test_gsheet_flow.py @@ -0,0 +1,107 @@ +# ABOUTME: End to end test of the Google Sheet tab against a fake spreadsheet. +# ABOUTME: Proves the tab, the worker and the wiring - it does not prove gspread itself. + +from typing import TYPE_CHECKING, cast + +import pytest + +from strat_app.qt.main_window import GSHEET_TAB_INDEX, MainWindow +from strat_app.sessions.gsheet_session import GSheetSession +from strat_app.settings_holder import SettingsHolder +from tests.fakes import FakeGSheetDataSource + +if TYPE_CHECKING: + from sortition_algorithms import adapters + +PANEL_MIN = 22 +PANEL_MAX = 24 +SHEET_NAME = "My Assembly" +TIMEOUT_MS = 120_000 + + +@pytest.fixture +def data_source(categories_contents: str, people_contents: str) -> FakeGSheetDataSource: + return FakeGSheetDataSource( + tabs={"Categories": categories_contents, "Respondents": people_contents}, + sheet_names=[SHEET_NAME], + ) + + +@pytest.fixture +def window(qtbot, settings_path, data_source: FakeGSheetDataSource) -> MainWindow: + window = MainWindow(settings_path=settings_path) + qtbot.addWidget(window) + window.tabs.setCurrentIndex(GSHEET_TAB_INDEX) + # the only substitution in the whole flow: a spreadsheet we do not need credentials for + window.gsheet_tab.session = GSheetSession( + window.gsheet_tab, + window.gui_log, + SettingsHolder(settings_path), + data_source=cast("adapters.GSheetDataSource", data_source), + runner=window.task_runner, + ) + return window + + +def test_the_whole_g_sheet_flow(qtbot, window: MainWindow, data_source: FakeGSheetDataSource) -> None: + tab = window.gsheet_tab + + # step 1 - name the spreadsheet + qtbot.keyClicks(tab.sheet_name_edit, SHEET_NAME) + assert tab.load_button.isEnabled() + + # step 2 - load it + with qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS): + tab.load_g_sheet() + + qtbot.waitUntil(lambda: "Number of features found: 4" in tab.features_output.toPlainText(), timeout=TIMEOUT_MS) + qtbot.waitUntil( + lambda: "Successfully loaded features and people." in tab.people_output.toPlainText(), timeout=TIMEOUT_MS + ) + assert (tab.panel_size_spin.minimum(), tab.panel_size_spin.maximum()) == (PANEL_MIN, PANEL_MAX) + + # step 3 - the panel size, and running. The write is a second trip to the runner. + tab.panel_size_spin.setValue(PANEL_MIN) + assert tab.run_button.isEnabled() + + with qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS): + tab.run_selection() + with qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS): + pass + + qtbot.waitUntil(lambda: "Selection process finished." in window.log_panel.browser.toPlainText(), timeout=TIMEOUT_MS) + written = data_source.selected_file.getvalue().strip().splitlines() + assert len(written) == PANEL_MIN + 1 + assert not tab.busy_bar.isVisibleTo(tab) + + +def test_a_missing_tab_is_reported_and_leaves_the_tab_usable(qtbot, window: MainWindow) -> None: + tab = window.gsheet_tab + qtbot.keyClicks(tab.sheet_name_edit, SHEET_NAME) + tab.features_tab_edit.setText("Not A Tab") + + with qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS): + tab.load_g_sheet() + + # wait for the last line, since each update reaches the widget on its own event loop turn + qtbot.waitUntil( + lambda: "Fix the problems and try loading again." in tab.features_output.toPlainText(), timeout=TIMEOUT_MS + ) + assert "no tab called 'Not A Tab'" in tab.features_output.toPlainText() + assert not tab.busy_bar.isVisibleTo(tab) + assert tab.load_button.isEnabled() + assert not tab.run_button.isEnabled() + + +def test_asking_for_two_selections_warns_and_skips_the_remaining_tab( + qtbot, window: MainWindow, data_source: FakeGSheetDataSource +) -> None: + tab = window.gsheet_tab + qtbot.keyClicks(tab.sheet_name_edit, SHEET_NAME) + tab.number_selections_spin.setValue(2) + + with qtbot.waitSignal(window.task_runner.finished, timeout=TIMEOUT_MS): + tab.load_g_sheet() + + qtbot.waitUntil(lambda: "You've asked for 2 selections" in tab.people_output.toPlainText(), timeout=TIMEOUT_MS) + assert tab.session._safe_gen_rem_tab() is False # noqa: SLF001 diff --git a/tests/fakes.py b/tests/fakes.py new file mode 100644 index 0000000..a063f8a --- /dev/null +++ b/tests/fakes.py @@ -0,0 +1,128 @@ +# ABOUTME: Test doubles used across the suite - a recorder for view calls and a fake spreadsheet. +# ABOUTME: The fake data source is backed by CSV fixtures so gsheet tests need no credentials. + +from collections.abc import Generator, Iterable, Sequence +from contextlib import contextmanager +from typing import Any + +from sortition_algorithms import adapters +from sortition_algorithms.utils import RunReport + +from strat_app.sessions.view import LogEntry, LogSection + +# what a data source yields to the library: the column headers and the rows +TabData = Generator[tuple[Iterable[str], Iterable[dict[str, str]]], None, None] + + +class CallRecorder: + """ + Records every call made to it, so a test can assert on the sequence. + + Any attribute is callable and returns None, which is enough to stand in for + both the eel module and the view protocols - all of their methods return None. + """ + + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[Any, ...]]] = [] + + def __getattr__(self, name: str) -> Any: + def record(*args: Any, **kwargs: Any) -> None: + self.calls.append((name, (*args, *kwargs.values()))) + + return record + + @property + def names(self) -> list[str]: + """The names of the calls made, in order.""" + return [name for name, _ in self.calls] + + def args_for(self, name: str) -> list[tuple[Any, ...]]: + """The arguments of every call to `name`, in order.""" + return [args for called_name, args in self.calls if called_name == name] + + def last_args(self, name: str) -> tuple[Any, ...]: + """The arguments of the most recent call to `name`.""" + return self.args_for(name)[-1] + + def called(self, name: str) -> bool: + return name in self.names + + def reset(self) -> None: + self.calls = [] + + +class RecordingLogView: + """ + A LogView that keeps the entries instead of displaying them. + + Tests mostly want to know "does this message appear in that output area", so it + renders reports to plain text on request rather than storing them rendered. + """ + + def __init__(self) -> None: + self.sections: dict[LogSection, list[LogEntry]] = {section: [] for section in LogSection} + + def show_log(self, section: LogSection, entries: Sequence[LogEntry]) -> None: + self.sections[section] = list(entries) + + def text(self, section: LogSection) -> str: + """Everything currently shown in a section, as plain text.""" + parts = [entry if isinstance(entry, str) else entry.as_text() for entry in self.sections[section]] + return "\n".join(part for part in parts if part.strip()) + + def entries(self, section: LogSection) -> list[LogEntry]: + return self.sections[section] + + +class TabNotFoundError(Exception): + """Raised by FakeGSheetDataSource when asked for a tab that isn't there.""" + + +class FakeGSheetDataSource(adapters.CSVStringDataSource): + """ + A stand-in for GSheetDataSource with the same API, backed by strings. + + The real one talks to the Google Sheets API, which we have no credentials for in + the test suite. This gives the same surface - a spreadsheet name, named tabs, and + output written to new tabs - without the network, so the session and widget code + can be exercised for real. + """ + + def __init__(self, tabs: dict[str, str], sheet_names: Iterable[str] = ()) -> None: + super().__init__("", "") + self.tabs = tabs + self.sheet_names = list(sheet_names) + self.feature_tab_name = "" + self.people_tab_name = "" + self.already_selected_tab_name = "" + self.selected_tab_name = "" + self.remaining_tab_name = "" + self.g_sheet_name = "" + self.highlighted_dupes: list[int] | None = None + + def set_g_sheet_name(self, g_sheet_name: str) -> None: + if self.sheet_names and g_sheet_name not in self.sheet_names: + msg = f"Cannot find spreadsheet {g_sheet_name}" + raise TabNotFoundError(msg) + self.g_sheet_name = g_sheet_name + + def _tab_contents(self, tab_name: str) -> str: + if tab_name not in self.tabs: + msg = f"no tab called '{tab_name}' in the spreadsheet" + raise TabNotFoundError(msg) + return self.tabs[tab_name] + + @contextmanager + def read_feature_data(self, report: RunReport) -> TabData: + self.features_data = self._tab_contents(self.feature_tab_name) + with super().read_feature_data(report) as headers_and_body: + yield headers_and_body + + @contextmanager + def read_people_data(self, report: RunReport) -> TabData: + self.people_data = self._tab_contents(self.people_tab_name) + with super().read_people_data(report) as headers_and_body: + yield headers_and_body + + def highlight_dupes(self, dupes: list[int]) -> None: + self.highlighted_dupes = dupes diff --git a/tests/fixtures/categories_no_flex.csv b/tests/fixtures/categories_no_flex.csv new file mode 100644 index 0000000..ce9a0e3 --- /dev/null +++ b/tests/fixtures/categories_no_flex.csv @@ -0,0 +1,21 @@ +category,name,min,max +gender,Female,11,12 +gender,Male,10,11 +gender,Non-binary or other,0,1 +age_bracket,0-15,0,0 +age_bracket,16-29,5,7 +age_bracket,30-44,5,7 +age_bracket,45-59,5,6 +age_bracket,60+,5,6 +edu_level,No qualifications,5,7 +edu_level,Level 1,5,7 +edu_level,Level 2 or 3,5,6 +edu_level,Level 4 and above,5,6 +geo_bucket,Central Scotland,3,5 +geo_bucket,Glasgow,3,5 +geo_bucket,Highlands and Islands,2,5 +geo_bucket,Lothian,3,5 +geo_bucket,Mid Scotland and Fife,3,5 +geo_bucket,North East Scotland,2,5 +geo_bucket,South Scotland,3,5 +geo_bucket,West Scotland,3,5 diff --git a/tests/fixtures/people.csv b/tests/fixtures/people.csv new file mode 100644 index 0000000..5407304 --- /dev/null +++ b/tests/fixtures/people.csv @@ -0,0 +1,201 @@ +nationbuilder_id,first_name,last_name,mobile_number,email,primary_address1,primary_address2,primary_city,zip_royal_mail,tag_list,age,gender,age_bracket,edu_level,geo_bucket +nb-0001,Quinn,Laird,07700 900000,quinn.laird1@example.org,1 Bruce Street,,Edinburgh,EH1 00ZZ,,42,Male,30-44,Level 2 or 3,North East Scotland +nb-0002,Fraser,Campbell,07700 900001,fraser.campbell2@example.org,2 Cathedral Lane,,Edinburgh,EH1 01ZZ,,48,Male,45-59,Level 2 or 3,Central Scotland +nb-0003,Vikram,Nowak,07700 900002,vikram.nowak3@example.org,3 Holyrood Way,,Edinburgh,EH1 02ZZ,,24,Non-binary or other,16-29,Level 1,Central Scotland +nb-0004,Ursula,Brown,07700 900003,ursula.brown4@example.org,4 Gorgie Terrace,,Edinburgh,EH1 03ZZ,,22,Male,16-29,Level 4 and above,Highlands and Islands +nb-0005,Zara,Stewart,07700 900004,zara.stewart5@example.org,5 Dunbar Road,,Edinburgh,EH1 04ZZ,,17,Male,16-29,Level 2 or 3,Glasgow +nb-0006,Chandra,MacLeod,07700 900005,chandra.macleod6@example.org,6 Gorgie Terrace,,Edinburgh,EH1 05ZZ,,73,Female,60+,No qualifications,Mid Scotland and Fife +nb-0007,Hamish,Campbell,07700 900006,hamish.campbell7@example.org,7 Holyrood Way,,Edinburgh,EH1 06ZZ,,19,Male,16-29,Level 1,Central Scotland +nb-0008,Priya,Laird,07700 900007,priya.laird8@example.org,8 Holyrood Way,,Edinburgh,EH1 07ZZ,,59,Female,45-59,Level 4 and above,Highlands and Islands +nb-0009,Rowan,Docherty,07700 900008,rowan.docherty9@example.org,9 Dunbar Road,,Edinburgh,EH1 08ZZ,,40,Female,30-44,Level 1,Lothian +nb-0010,Oscar,Laird,07700 900009,oscar.laird10@example.org,10 Elm Grove,,Edinburgh,EH1 09ZZ,,21,Male,16-29,Level 2 or 3,North East Scotland +nb-0011,Fraser,Grant,07700 900010,fraser.grant11@example.org,11 Dunbar Road,,Edinburgh,EH1 10ZZ,,87,Male,60+,Level 1,North East Scotland +nb-0012,Tomas,Brown,07700 900011,tomas.brown12@example.org,12 Holyrood Way,,Edinburgh,EH1 11ZZ,,19,Male,16-29,No qualifications,Central Scotland +nb-0013,Quinn,Nowak,07700 900012,quinn.nowak13@example.org,13 Holyrood Way,,Edinburgh,EH1 12ZZ,,27,Female,16-29,Level 1,West Scotland +nb-0014,Fraser,Docherty,07700 900013,fraser.docherty14@example.org,14 Dunbar Road,,Edinburgh,EH1 13ZZ,,63,Male,60+,Level 4 and above,Glasgow +nb-0015,Gita,Ellis,07700 900014,gita.ellis15@example.org,15 Bruce Street,,Edinburgh,EH1 14ZZ,,34,Female,30-44,Level 2 or 3,North East Scotland +nb-0016,Tomas,Johnstone,07700 900015,tomas.johnstone16@example.org,16 Dunbar Road,,Edinburgh,EH1 15ZZ,,42,Female,30-44,Level 1,North East Scotland +nb-0017,Yusuf,Henderson,07700 900016,yusuf.henderson17@example.org,17 Holyrood Way,,Edinburgh,EH1 16ZZ,,29,Male,16-29,Level 2 or 3,Glasgow +nb-0018,Vikram,Robertson,07700 900017,vikram.robertson18@example.org,18 Fettes Row,,Edinburgh,EH1 17ZZ,,58,Male,45-59,No qualifications,Glasgow +nb-0019,Chandra,Laird,07700 900018,chandra.laird19@example.org,19 Bruce Street,,Edinburgh,EH1 18ZZ,,30,Male,30-44,Level 2 or 3,Central Scotland +nb-0020,Alice,Quigley,07700 900019,alice.quigley20@example.org,20 Bruce Street,,Edinburgh,EH1 19ZZ,,20,Female,16-29,No qualifications,West Scotland +nb-0021,Wendy,Henderson,07700 900020,wendy.henderson21@example.org,21 Gorgie Terrace,,Edinburgh,EH1 20ZZ,,73,Male,60+,Level 2 or 3,Central Scotland +nb-0022,Chandra,Thomson,07700 900021,chandra.thomson22@example.org,22 Holyrood Way,,Edinburgh,EH1 21ZZ,,27,Female,16-29,Level 4 and above,South Scotland +nb-0023,Wendy,Nowak,07700 900022,wendy.nowak23@example.org,23 Fettes Row,,Edinburgh,EH1 22ZZ,,37,Male,30-44,Level 2 or 3,Mid Scotland and Fife +nb-0024,Iona,Laird,07700 900023,iona.laird24@example.org,24 Fettes Row,,Edinburgh,EH1 23ZZ,,79,Female,60+,Level 1,Glasgow +nb-0025,Bob,Ellis,07700 900024,bob.ellis25@example.org,25 Acacia Avenue,,Edinburgh,EH1 24ZZ,,62,Male,60+,No qualifications,Lothian +nb-0026,Chandra,Ferguson,07700 900025,chandra.ferguson26@example.org,26 Elm Grove,,Edinburgh,EH1 25ZZ,,22,Male,16-29,Level 2 or 3,Mid Scotland and Fife +nb-0027,Chandra,Anderson,07700 900026,chandra.anderson27@example.org,27 Elm Grove,,Edinburgh,EH1 26ZZ,,85,Male,60+,Level 4 and above,West Scotland +nb-0028,Fraser,Johnstone,07700 900027,fraser.johnstone28@example.org,28 Bruce Street,,Edinburgh,EH1 27ZZ,,80,Male,60+,Level 1,South Scotland +nb-0029,Yusuf,Docherty,07700 900028,yusuf.docherty29@example.org,29 Holyrood Way,,Edinburgh,EH1 28ZZ,,31,Female,30-44,Level 2 or 3,Central Scotland +nb-0030,Chandra,Ibrahim,07700 900029,chandra.ibrahim30@example.org,30 Fettes Row,,Edinburgh,EH1 29ZZ,,73,Female,60+,Level 4 and above,North East Scotland +nb-0031,Rowan,Nowak,07700 900030,rowan.nowak31@example.org,31 Bruce Street,,Edinburgh,EH1 30ZZ,,69,Male,60+,Level 4 and above,Central Scotland +nb-0032,Wendy,Stewart,07700 900031,wendy.stewart32@example.org,32 Cathedral Lane,,Edinburgh,EH1 31ZZ,,66,Female,60+,Level 2 or 3,Central Scotland +nb-0033,Vikram,Henderson,07700 900032,vikram.henderson33@example.org,33 Acacia Avenue,,Edinburgh,EH1 32ZZ,,51,Female,45-59,Level 2 or 3,Highlands and Islands +nb-0034,Morag,Brown,07700 900033,morag.brown34@example.org,34 Dunbar Road,,Edinburgh,EH1 33ZZ,,67,Female,60+,No qualifications,Mid Scotland and Fife +nb-0035,Zara,Brown,07700 900034,zara.brown35@example.org,35 Holyrood Way,,Edinburgh,EH1 34ZZ,,34,Male,30-44,Level 1,Lothian +nb-0036,Bob,Laird,07700 900035,bob.laird36@example.org,36 Cathedral Lane,,Edinburgh,EH1 35ZZ,,29,Male,16-29,Level 4 and above,South Scotland +nb-0037,Lachlan,Ogilvie,07700 900036,lachlan.ogilvie37@example.org,37 Bruce Street,,Edinburgh,EH1 36ZZ,,55,Female,45-59,Level 4 and above,North East Scotland +nb-0038,Struan,Anderson,07700 900037,struan.anderson38@example.org,38 Cathedral Lane,,Edinburgh,EH1 37ZZ,,48,Non-binary or other,45-59,Level 2 or 3,Central Scotland +nb-0039,Vikram,Grant,07700 900038,vikram.grant39@example.org,39 Elm Grove,,Edinburgh,EH1 38ZZ,,71,Female,60+,Level 2 or 3,South Scotland +nb-0040,Bob,Anderson,07700 900039,bob.anderson40@example.org,40 Fettes Row,,Edinburgh,EH1 39ZZ,,55,Female,45-59,Level 4 and above,Glasgow +nb-0041,Fraser,Ogilvie,07700 900040,fraser.ogilvie41@example.org,41 Gorgie Terrace,,Edinburgh,EH1 40ZZ,,35,Female,30-44,Level 4 and above,Mid Scotland and Fife +nb-0042,Oscar,Ibrahim,07700 900041,oscar.ibrahim42@example.org,42 Gorgie Terrace,,Edinburgh,EH1 41ZZ,,19,Male,16-29,Level 1,North East Scotland +nb-0043,Gita,Laird,07700 900042,gita.laird43@example.org,43 Dunbar Road,,Edinburgh,EH1 42ZZ,,51,Male,45-59,Level 1,Lothian +nb-0044,Alice,Thomson,07700 900043,alice.thomson44@example.org,44 Gorgie Terrace,,Edinburgh,EH1 43ZZ,,19,Male,16-29,Level 4 and above,Highlands and Islands +nb-0045,Zara,Stewart,07700 900044,zara.stewart45@example.org,45 Acacia Avenue,,Edinburgh,EH1 44ZZ,,23,Male,16-29,No qualifications,Lothian +nb-0046,Morag,Thomson,07700 900045,morag.thomson46@example.org,46 Holyrood Way,,Edinburgh,EH1 45ZZ,,32,Female,30-44,Level 2 or 3,Highlands and Islands +nb-0047,Tomas,Brown,07700 900046,tomas.brown47@example.org,47 Acacia Avenue,,Edinburgh,EH1 46ZZ,,76,Female,60+,Level 1,Glasgow +nb-0048,Oscar,Ogilvie,07700 900047,oscar.ogilvie48@example.org,48 Acacia Avenue,,Edinburgh,EH1 47ZZ,,43,Male,30-44,Level 2 or 3,Mid Scotland and Fife +nb-0049,Hamish,Ibrahim,07700 900048,hamish.ibrahim49@example.org,49 Acacia Avenue,,Edinburgh,EH1 48ZZ,,80,Male,60+,No qualifications,Central Scotland +nb-0050,Niamh,Brown,07700 900049,niamh.brown50@example.org,50 Fettes Row,,Edinburgh,EH1 49ZZ,,25,Female,16-29,No qualifications,Glasgow +nb-0051,Yusuf,Laird,07700 900050,yusuf.laird51@example.org,51 Dunbar Road,,Edinburgh,EH1 50ZZ,,47,Male,45-59,Level 1,Highlands and Islands +nb-0052,Bob,Docherty,07700 900051,bob.docherty52@example.org,52 Cathedral Lane,,Edinburgh,EH1 51ZZ,,56,Female,45-59,No qualifications,South Scotland +nb-0053,Rowan,Nowak,07700 900052,rowan.nowak53@example.org,53 Fettes Row,,Edinburgh,EH1 52ZZ,,80,Female,60+,Level 4 and above,North East Scotland +nb-0054,Morag,Thomson,07700 900053,morag.thomson54@example.org,54 Elm Grove,,Edinburgh,EH1 53ZZ,,20,Non-binary or other,16-29,No qualifications,Mid Scotland and Fife +nb-0055,Xander,Laird,07700 900054,xander.laird55@example.org,55 Dunbar Road,,Edinburgh,EH1 54ZZ,,48,Female,45-59,Level 1,South Scotland +nb-0056,Gita,Grant,07700 900055,gita.grant56@example.org,56 Elm Grove,,Edinburgh,EH1 55ZZ,,49,Female,45-59,Level 2 or 3,Highlands and Islands +nb-0057,Oscar,Henderson,07700 900056,oscar.henderson57@example.org,57 Holyrood Way,,Edinburgh,EH1 56ZZ,,31,Male,30-44,Level 2 or 3,Mid Scotland and Fife +nb-0058,Jamal,Stewart,07700 900057,jamal.stewart58@example.org,58 Acacia Avenue,,Edinburgh,EH1 57ZZ,,46,Female,45-59,Level 2 or 3,Central Scotland +nb-0059,Wendy,Docherty,07700 900058,wendy.docherty59@example.org,59 Gorgie Terrace,,Edinburgh,EH1 58ZZ,,17,Female,16-29,Level 2 or 3,South Scotland +nb-0060,Gita,Johnstone,07700 900059,gita.johnstone60@example.org,60 Acacia Avenue,,Edinburgh,EH1 59ZZ,,34,Male,30-44,Level 2 or 3,Mid Scotland and Fife +nb-0061,Rowan,Laird,07700 900060,rowan.laird61@example.org,61 Fettes Row,,Edinburgh,EH1 60ZZ,,58,Male,45-59,Level 1,Highlands and Islands +nb-0062,Gita,Campbell,07700 900061,gita.campbell62@example.org,62 Dunbar Road,,Edinburgh,EH1 61ZZ,,51,Male,45-59,No qualifications,North East Scotland +nb-0063,Quinn,Nowak,07700 900062,quinn.nowak63@example.org,63 Acacia Avenue,,Edinburgh,EH1 62ZZ,,70,Female,60+,Level 1,Highlands and Islands +nb-0064,Tomas,Quigley,07700 900063,tomas.quigley64@example.org,64 Elm Grove,,Edinburgh,EH1 63ZZ,,40,Male,30-44,Level 2 or 3,Central Scotland +nb-0065,Hamish,Anderson,07700 900064,hamish.anderson65@example.org,65 Bruce Street,,Edinburgh,EH1 64ZZ,,19,Male,16-29,Level 1,North East Scotland +nb-0066,Tomas,Robertson,07700 900065,tomas.robertson66@example.org,66 Fettes Row,,Edinburgh,EH1 65ZZ,,76,Female,60+,No qualifications,North East Scotland +nb-0067,Eilidh,Ibrahim,07700 900066,eilidh.ibrahim67@example.org,67 Elm Grove,,Edinburgh,EH1 66ZZ,,37,Female,30-44,Level 2 or 3,Glasgow +nb-0068,Alice,Quigley,07700 900067,alice.quigley68@example.org,68 Bruce Street,,Edinburgh,EH1 67ZZ,,29,Non-binary or other,16-29,No qualifications,West Scotland +nb-0069,Dara,Kaur,07700 900068,dara.kaur69@example.org,69 Gorgie Terrace,,Edinburgh,EH1 68ZZ,,30,Male,30-44,Level 2 or 3,South Scotland +nb-0070,Tomas,Docherty,07700 900069,tomas.docherty70@example.org,70 Dunbar Road,,Edinburgh,EH1 69ZZ,,33,Male,30-44,No qualifications,West Scotland +nb-0071,Priya,Ogilvie,07700 900070,priya.ogilvie71@example.org,71 Acacia Avenue,,Edinburgh,EH1 70ZZ,,17,Male,16-29,Level 2 or 3,West Scotland +nb-0072,Tomas,Brown,07700 900071,tomas.brown72@example.org,72 Holyrood Way,,Edinburgh,EH1 71ZZ,,52,Male,45-59,Level 2 or 3,West Scotland +nb-0073,Yusuf,Ibrahim,07700 900072,yusuf.ibrahim73@example.org,73 Cathedral Lane,,Edinburgh,EH1 72ZZ,,75,Female,60+,No qualifications,Lothian +nb-0074,Vikram,Campbell,07700 900073,vikram.campbell74@example.org,74 Gorgie Terrace,,Edinburgh,EH1 73ZZ,,84,Female,60+,Level 1,South Scotland +nb-0075,Zara,Paterson,07700 900074,zara.paterson75@example.org,75 Acacia Avenue,,Edinburgh,EH1 74ZZ,,23,Male,16-29,Level 2 or 3,Highlands and Islands +nb-0076,Bob,Ferguson,07700 900075,bob.ferguson76@example.org,76 Elm Grove,,Edinburgh,EH1 75ZZ,,18,Male,16-29,No qualifications,South Scotland +nb-0077,Quinn,Ibrahim,07700 900076,quinn.ibrahim77@example.org,77 Acacia Avenue,,Edinburgh,EH1 76ZZ,,21,Male,16-29,Level 2 or 3,Lothian +nb-0078,Fraser,Ibrahim,07700 900077,fraser.ibrahim78@example.org,78 Gorgie Terrace,,Edinburgh,EH1 77ZZ,,59,Male,45-59,Level 2 or 3,Highlands and Islands +nb-0079,Vikram,Ellis,07700 900078,vikram.ellis79@example.org,79 Elm Grove,,Edinburgh,EH1 78ZZ,,67,Non-binary or other,60+,Level 1,South Scotland +nb-0080,Yusuf,Brown,07700 900079,yusuf.brown80@example.org,80 Holyrood Way,,Edinburgh,EH1 79ZZ,,86,Non-binary or other,60+,Level 2 or 3,Highlands and Islands +nb-0081,Alice,Kaur,07700 900080,alice.kaur81@example.org,81 Fettes Row,,Edinburgh,EH1 80ZZ,,43,Male,30-44,No qualifications,Central Scotland +nb-0082,Rowan,Docherty,07700 900081,rowan.docherty82@example.org,82 Elm Grove,,Edinburgh,EH1 81ZZ,,24,Male,16-29,Level 4 and above,Glasgow +nb-0083,Tomas,Laird,07700 900082,tomas.laird83@example.org,83 Bruce Street,,Edinburgh,EH1 82ZZ,,45,Male,45-59,Level 1,Lothian +nb-0084,Vikram,Ibrahim,07700 900083,vikram.ibrahim84@example.org,84 Cathedral Lane,,Edinburgh,EH1 83ZZ,,50,Male,45-59,Level 2 or 3,Mid Scotland and Fife +nb-0085,Oscar,Anderson,07700 900084,oscar.anderson85@example.org,85 Holyrood Way,,Edinburgh,EH1 84ZZ,,20,Male,16-29,No qualifications,Central Scotland +nb-0086,Hamish,Nowak,07700 900085,hamish.nowak86@example.org,86 Dunbar Road,,Edinburgh,EH1 85ZZ,,36,Female,30-44,Level 2 or 3,Mid Scotland and Fife +nb-0087,Niamh,Stewart,07700 900086,niamh.stewart87@example.org,87 Holyrood Way,,Edinburgh,EH1 86ZZ,,52,Female,45-59,Level 2 or 3,Mid Scotland and Fife +nb-0088,Jamal,Thomson,07700 900087,jamal.thomson88@example.org,88 Acacia Avenue,,Edinburgh,EH1 87ZZ,,79,Female,60+,Level 4 and above,Highlands and Islands +nb-0089,Iona,Quigley,07700 900088,iona.quigley89@example.org,89 Dunbar Road,,Edinburgh,EH1 88ZZ,,86,Female,60+,Level 4 and above,Lothian +nb-0090,Oscar,Campbell,07700 900089,oscar.campbell90@example.org,90 Bruce Street,,Edinburgh,EH1 89ZZ,,35,Non-binary or other,30-44,Level 4 and above,Lothian +nb-0091,Xander,Ellis,07700 900090,xander.ellis91@example.org,91 Gorgie Terrace,,Edinburgh,EH1 90ZZ,,37,Male,30-44,Level 4 and above,Glasgow +nb-0092,Hamish,Laird,07700 900091,hamish.laird92@example.org,92 Elm Grove,,Edinburgh,EH1 91ZZ,,54,Female,45-59,Level 2 or 3,Central Scotland +nb-0093,Morag,Anderson,07700 900092,morag.anderson93@example.org,93 Dunbar Road,,Edinburgh,EH1 92ZZ,,76,Female,60+,Level 2 or 3,West Scotland +nb-0094,Fraser,Thomson,07700 900093,fraser.thomson94@example.org,94 Holyrood Way,,Edinburgh,EH1 93ZZ,,38,Female,30-44,No qualifications,Glasgow +nb-0095,Ursula,Johnstone,07700 900094,ursula.johnstone95@example.org,95 Bruce Street,,Edinburgh,EH1 94ZZ,,43,Male,30-44,Level 2 or 3,South Scotland +nb-0096,Iona,Campbell,07700 900095,iona.campbell96@example.org,96 Gorgie Terrace,,Edinburgh,EH1 95ZZ,,21,Male,16-29,Level 2 or 3,South Scotland +nb-0097,Bob,Kaur,07700 900096,bob.kaur97@example.org,97 Dunbar Road,,Edinburgh,EH1 96ZZ,,56,Female,45-59,Level 1,Lothian +nb-0098,Quinn,Ellis,07700 900097,quinn.ellis98@example.org,98 Elm Grove,,Edinburgh,EH1 97ZZ,,49,Female,45-59,No qualifications,North East Scotland +nb-0099,Struan,Laird,07700 900098,struan.laird99@example.org,99 Holyrood Way,,Edinburgh,EH1 98ZZ,,44,Female,30-44,Level 4 and above,West Scotland +nb-0100,Ursula,Stewart,07700 900099,ursula.stewart100@example.org,100 Dunbar Road,,Edinburgh,EH1 99ZZ,,86,Male,60+,Level 2 or 3,South Scotland +nb-0101,Iona,Nowak,07700 900100,iona.nowak101@example.org,101 Elm Grove,,Edinburgh,EH2 00ZZ,,76,Female,60+,Level 2 or 3,Highlands and Islands +nb-0102,Jamal,Grant,07700 900101,jamal.grant102@example.org,102 Bruce Street,,Edinburgh,EH2 01ZZ,,74,Male,60+,Level 1,Central Scotland +nb-0103,Jamal,Stewart,07700 900102,jamal.stewart103@example.org,103 Acacia Avenue,,Edinburgh,EH2 02ZZ,,36,Female,30-44,No qualifications,Glasgow +nb-0104,Lachlan,Stewart,07700 900103,lachlan.stewart104@example.org,104 Fettes Row,,Edinburgh,EH2 03ZZ,,59,Male,45-59,Level 4 and above,West Scotland +nb-0105,Fraser,Thomson,07700 900104,fraser.thomson105@example.org,105 Bruce Street,,Edinburgh,EH2 04ZZ,,26,Female,16-29,No qualifications,North East Scotland +nb-0106,Vikram,Anderson,07700 900105,vikram.anderson106@example.org,106 Fettes Row,,Edinburgh,EH2 05ZZ,,47,Female,45-59,Level 1,West Scotland +nb-0107,Priya,Campbell,07700 900106,priya.campbell107@example.org,107 Holyrood Way,,Edinburgh,EH2 06ZZ,,23,Female,16-29,Level 1,North East Scotland +nb-0108,Alice,Kaur,07700 900107,alice.kaur108@example.org,108 Fettes Row,,Edinburgh,EH2 07ZZ,,41,Female,30-44,No qualifications,South Scotland +nb-0109,Struan,Grant,07700 900108,struan.grant109@example.org,109 Elm Grove,,Edinburgh,EH2 08ZZ,,42,Female,30-44,Level 2 or 3,Mid Scotland and Fife +nb-0110,Fraser,Nowak,07700 900109,fraser.nowak110@example.org,110 Holyrood Way,,Edinburgh,EH2 09ZZ,,66,Male,60+,Level 1,Lothian +nb-0111,Bob,Docherty,07700 900110,bob.docherty111@example.org,111 Cathedral Lane,,Edinburgh,EH2 10ZZ,,57,Female,45-59,Level 2 or 3,South Scotland +nb-0112,Kirsty,Thomson,07700 900111,kirsty.thomson112@example.org,112 Dunbar Road,,Edinburgh,EH2 11ZZ,,36,Female,30-44,Level 4 and above,Highlands and Islands +nb-0113,Morag,Laird,07700 900112,morag.laird113@example.org,113 Cathedral Lane,,Edinburgh,EH2 12ZZ,,76,Male,60+,Level 2 or 3,South Scotland +nb-0114,Tomas,Kaur,07700 900113,tomas.kaur114@example.org,114 Fettes Row,,Edinburgh,EH2 13ZZ,,51,Female,45-59,Level 4 and above,Central Scotland +nb-0115,Iona,Brown,07700 900114,iona.brown115@example.org,115 Elm Grove,,Edinburgh,EH2 14ZZ,,41,Male,30-44,Level 2 or 3,Highlands and Islands +nb-0116,Gita,Stewart,07700 900115,gita.stewart116@example.org,116 Fettes Row,,Edinburgh,EH2 15ZZ,,54,Female,45-59,No qualifications,South Scotland +nb-0117,Dara,Henderson,07700 900116,dara.henderson117@example.org,117 Bruce Street,,Edinburgh,EH2 16ZZ,,24,Male,16-29,Level 4 and above,Mid Scotland and Fife +nb-0118,Tomas,Ferguson,07700 900117,tomas.ferguson118@example.org,118 Holyrood Way,,Edinburgh,EH2 17ZZ,,68,Female,60+,Level 4 and above,Central Scotland +nb-0119,Morag,Nowak,07700 900118,morag.nowak119@example.org,119 Elm Grove,,Edinburgh,EH2 18ZZ,,49,Non-binary or other,45-59,Level 2 or 3,Central Scotland +nb-0120,Zara,Robertson,07700 900119,zara.robertson120@example.org,120 Acacia Avenue,,Edinburgh,EH2 19ZZ,,21,Female,16-29,Level 4 and above,Highlands and Islands +nb-0121,Ursula,Brown,07700 900120,ursula.brown121@example.org,121 Holyrood Way,,Edinburgh,EH2 20ZZ,,16,Female,16-29,Level 1,Central Scotland +nb-0122,Morag,Ogilvie,07700 900121,morag.ogilvie122@example.org,122 Gorgie Terrace,,Edinburgh,EH2 21ZZ,,57,Female,45-59,Level 1,West Scotland +nb-0123,Rowan,Ellis,07700 900122,rowan.ellis123@example.org,123 Elm Grove,,Edinburgh,EH2 22ZZ,,53,Male,45-59,Level 1,Mid Scotland and Fife +nb-0124,Iona,Ferguson,07700 900123,iona.ferguson124@example.org,124 Holyrood Way,,Edinburgh,EH2 23ZZ,,29,Female,16-29,No qualifications,Glasgow +nb-0125,Gita,Anderson,07700 900124,gita.anderson125@example.org,125 Elm Grove,,Edinburgh,EH2 24ZZ,,24,Female,16-29,Level 2 or 3,South Scotland +nb-0126,Xander,Grant,07700 900125,xander.grant126@example.org,126 Elm Grove,,Edinburgh,EH2 25ZZ,,57,Male,45-59,Level 2 or 3,Highlands and Islands +nb-0127,Dara,Laird,07700 900126,dara.laird127@example.org,127 Acacia Avenue,,Edinburgh,EH2 26ZZ,,50,Male,45-59,Level 2 or 3,Glasgow +nb-0128,Oscar,Ogilvie,07700 900127,oscar.ogilvie128@example.org,128 Fettes Row,,Edinburgh,EH2 27ZZ,,83,Female,60+,Level 1,Central Scotland +nb-0129,Wendy,Ibrahim,07700 900128,wendy.ibrahim129@example.org,129 Fettes Row,,Edinburgh,EH2 28ZZ,,36,Non-binary or other,30-44,Level 1,Lothian +nb-0130,Jamal,Campbell,07700 900129,jamal.campbell130@example.org,130 Bruce Street,,Edinburgh,EH2 29ZZ,,77,Female,60+,Level 1,South Scotland +nb-0131,Bob,Nowak,07700 900130,bob.nowak131@example.org,131 Elm Grove,,Edinburgh,EH2 30ZZ,,30,Male,30-44,Level 1,Central Scotland +nb-0132,Wendy,Johnstone,07700 900131,wendy.johnstone132@example.org,132 Dunbar Road,,Edinburgh,EH2 31ZZ,,67,Female,60+,Level 2 or 3,Mid Scotland and Fife +nb-0133,Gita,Stewart,07700 900132,gita.stewart133@example.org,133 Acacia Avenue,,Edinburgh,EH2 32ZZ,,30,Male,30-44,No qualifications,West Scotland +nb-0134,Tomas,MacLeod,07700 900133,tomas.macleod134@example.org,134 Gorgie Terrace,,Edinburgh,EH2 33ZZ,,59,Non-binary or other,45-59,Level 4 and above,Glasgow +nb-0135,Eilidh,Ferguson,07700 900134,eilidh.ferguson135@example.org,135 Gorgie Terrace,,Edinburgh,EH2 34ZZ,,50,Male,45-59,Level 4 and above,Glasgow +nb-0136,Morag,Johnstone,07700 900135,morag.johnstone136@example.org,136 Bruce Street,,Edinburgh,EH2 35ZZ,,19,Male,16-29,Level 4 and above,North East Scotland +nb-0137,Rowan,Ellis,07700 900136,rowan.ellis137@example.org,137 Elm Grove,,Edinburgh,EH2 36ZZ,,70,Female,60+,No qualifications,Central Scotland +nb-0138,Wendy,Johnstone,07700 900137,wendy.johnstone138@example.org,138 Elm Grove,,Edinburgh,EH2 37ZZ,,65,Non-binary or other,60+,No qualifications,Highlands and Islands +nb-0139,Ursula,Brown,07700 900138,ursula.brown139@example.org,139 Holyrood Way,,Edinburgh,EH2 38ZZ,,33,Female,30-44,Level 2 or 3,West Scotland +nb-0140,Xander,Ogilvie,07700 900139,xander.ogilvie140@example.org,140 Gorgie Terrace,,Edinburgh,EH2 39ZZ,,63,Male,60+,Level 1,North East Scotland +nb-0141,Zara,Laird,07700 900140,zara.laird141@example.org,141 Gorgie Terrace,,Edinburgh,EH2 40ZZ,,18,Female,16-29,Level 2 or 3,Mid Scotland and Fife +nb-0142,Jamal,Paterson,07700 900141,jamal.paterson142@example.org,142 Cathedral Lane,,Edinburgh,EH2 41ZZ,,49,Female,45-59,No qualifications,Central Scotland +nb-0143,Bob,Grant,07700 900142,bob.grant143@example.org,143 Cathedral Lane,,Edinburgh,EH2 42ZZ,,45,Male,45-59,No qualifications,North East Scotland +nb-0144,Bob,Kaur,07700 900143,bob.kaur144@example.org,144 Cathedral Lane,,Edinburgh,EH2 43ZZ,,50,Male,45-59,Level 4 and above,Mid Scotland and Fife +nb-0145,Fraser,Robertson,07700 900144,fraser.robertson145@example.org,145 Dunbar Road,,Edinburgh,EH2 44ZZ,,64,Male,60+,Level 2 or 3,West Scotland +nb-0146,Alice,Grant,07700 900145,alice.grant146@example.org,146 Fettes Row,,Edinburgh,EH2 45ZZ,,18,Female,16-29,Level 2 or 3,Mid Scotland and Fife +nb-0147,Xander,MacLeod,07700 900146,xander.macleod147@example.org,147 Fettes Row,,Edinburgh,EH2 46ZZ,,40,Male,30-44,Level 1,Mid Scotland and Fife +nb-0148,Chandra,Brown,07700 900147,chandra.brown148@example.org,148 Acacia Avenue,,Edinburgh,EH2 47ZZ,,21,Male,16-29,Level 2 or 3,Lothian +nb-0149,Ursula,Stewart,07700 900148,ursula.stewart149@example.org,149 Gorgie Terrace,,Edinburgh,EH2 48ZZ,,59,Male,45-59,Level 4 and above,West Scotland +nb-0150,Dara,Henderson,07700 900149,dara.henderson150@example.org,150 Gorgie Terrace,,Edinburgh,EH2 49ZZ,,46,Male,45-59,Level 4 and above,South Scotland +nb-0151,Morag,Henderson,07700 900150,morag.henderson151@example.org,151 Gorgie Terrace,,Edinburgh,EH2 50ZZ,,43,Male,30-44,Level 2 or 3,Highlands and Islands +nb-0152,Lachlan,Ibrahim,07700 900151,lachlan.ibrahim152@example.org,152 Dunbar Road,,Edinburgh,EH2 51ZZ,,17,Male,16-29,Level 1,West Scotland +nb-0153,Oscar,Paterson,07700 900152,oscar.paterson153@example.org,153 Elm Grove,,Edinburgh,EH2 52ZZ,,20,Non-binary or other,16-29,Level 4 and above,Glasgow +nb-0154,Morag,Brown,07700 900153,morag.brown154@example.org,154 Dunbar Road,,Edinburgh,EH2 53ZZ,,44,Male,30-44,Level 1,Highlands and Islands +nb-0155,Lachlan,Ogilvie,07700 900154,lachlan.ogilvie155@example.org,155 Elm Grove,,Edinburgh,EH2 54ZZ,,74,Male,60+,No qualifications,South Scotland +nb-0156,Kirsty,MacLeod,07700 900155,kirsty.macleod156@example.org,156 Gorgie Terrace,,Edinburgh,EH2 55ZZ,,42,Male,30-44,Level 4 and above,Highlands and Islands +nb-0157,Iona,Grant,07700 900156,iona.grant157@example.org,157 Fettes Row,,Edinburgh,EH2 56ZZ,,17,Female,16-29,Level 1,North East Scotland +nb-0158,Fraser,Ogilvie,07700 900157,fraser.ogilvie158@example.org,158 Fettes Row,,Edinburgh,EH2 57ZZ,,78,Male,60+,Level 2 or 3,South Scotland +nb-0159,Lachlan,Henderson,07700 900158,lachlan.henderson159@example.org,159 Bruce Street,,Edinburgh,EH2 58ZZ,,62,Female,60+,No qualifications,West Scotland +nb-0160,Tomas,Ogilvie,07700 900159,tomas.ogilvie160@example.org,160 Bruce Street,,Edinburgh,EH2 59ZZ,,45,Female,45-59,Level 2 or 3,West Scotland +nb-0161,Fraser,Ferguson,07700 900160,fraser.ferguson161@example.org,161 Dunbar Road,,Edinburgh,EH2 60ZZ,,77,Male,60+,Level 1,Central Scotland +nb-0162,Iona,Docherty,07700 900161,iona.docherty162@example.org,162 Gorgie Terrace,,Edinburgh,EH2 61ZZ,,43,Female,30-44,Level 1,Highlands and Islands +nb-0163,Struan,Ellis,07700 900162,struan.ellis163@example.org,163 Holyrood Way,,Edinburgh,EH2 62ZZ,,25,Female,16-29,No qualifications,North East Scotland +nb-0164,Tomas,Quigley,07700 900163,tomas.quigley164@example.org,164 Holyrood Way,,Edinburgh,EH2 63ZZ,,26,Female,16-29,Level 4 and above,Lothian +nb-0165,Quinn,Anderson,07700 900164,quinn.anderson165@example.org,165 Holyrood Way,,Edinburgh,EH2 64ZZ,,26,Female,16-29,Level 2 or 3,Highlands and Islands +nb-0166,Dara,Quigley,07700 900165,dara.quigley166@example.org,166 Bruce Street,,Edinburgh,EH2 65ZZ,,55,Female,45-59,Level 2 or 3,West Scotland +nb-0167,Dara,Thomson,07700 900166,dara.thomson167@example.org,167 Cathedral Lane,,Edinburgh,EH2 66ZZ,,46,Male,45-59,Level 1,Glasgow +nb-0168,Lachlan,Kaur,07700 900167,lachlan.kaur168@example.org,168 Gorgie Terrace,,Edinburgh,EH2 67ZZ,,51,Male,45-59,Level 1,Mid Scotland and Fife +nb-0169,Bob,MacLeod,07700 900168,bob.macleod169@example.org,169 Cathedral Lane,,Edinburgh,EH2 68ZZ,,54,Male,45-59,Level 2 or 3,Lothian +nb-0170,Ursula,MacLeod,07700 900169,ursula.macleod170@example.org,170 Elm Grove,,Edinburgh,EH2 69ZZ,,50,Male,45-59,No qualifications,Mid Scotland and Fife +nb-0171,Struan,Ibrahim,07700 900170,struan.ibrahim171@example.org,171 Dunbar Road,,Edinburgh,EH2 70ZZ,,29,Female,16-29,Level 2 or 3,Mid Scotland and Fife +nb-0172,Wendy,Kaur,07700 900171,wendy.kaur172@example.org,172 Elm Grove,,Edinburgh,EH2 71ZZ,,68,Male,60+,Level 4 and above,Mid Scotland and Fife +nb-0173,Vikram,Kaur,07700 900172,vikram.kaur173@example.org,173 Acacia Avenue,,Edinburgh,EH2 72ZZ,,78,Male,60+,Level 1,Central Scotland +nb-0174,Chandra,Grant,07700 900173,chandra.grant174@example.org,174 Dunbar Road,,Edinburgh,EH2 73ZZ,,32,Male,30-44,Level 1,Highlands and Islands +nb-0175,Yusuf,Brown,07700 900174,yusuf.brown175@example.org,175 Cathedral Lane,,Edinburgh,EH2 74ZZ,,64,Female,60+,Level 2 or 3,Highlands and Islands +nb-0176,Eilidh,Ellis,07700 900175,eilidh.ellis176@example.org,176 Acacia Avenue,,Edinburgh,EH2 75ZZ,,57,Female,45-59,No qualifications,Lothian +nb-0177,Fraser,Johnstone,07700 900176,fraser.johnstone177@example.org,177 Holyrood Way,,Edinburgh,EH2 76ZZ,,26,Non-binary or other,16-29,Level 1,South Scotland +nb-0178,Priya,Brown,07700 900177,priya.brown178@example.org,178 Cathedral Lane,,Edinburgh,EH2 77ZZ,,45,Female,45-59,Level 1,Central Scotland +nb-0179,Oscar,Kaur,07700 900178,oscar.kaur179@example.org,179 Elm Grove,,Edinburgh,EH2 78ZZ,,55,Female,45-59,Level 4 and above,West Scotland +nb-0180,Zara,Stewart,07700 900179,zara.stewart180@example.org,180 Dunbar Road,,Edinburgh,EH2 79ZZ,,54,Female,45-59,Level 1,West Scotland +nb-0181,Wendy,Ellis,07700 900180,wendy.ellis181@example.org,181 Cathedral Lane,,Edinburgh,EH2 80ZZ,,19,Female,16-29,No qualifications,Central Scotland +nb-0182,Fraser,Quigley,07700 900181,fraser.quigley182@example.org,182 Dunbar Road,,Edinburgh,EH2 81ZZ,,17,Male,16-29,Level 2 or 3,Mid Scotland and Fife +nb-0183,Morag,Kaur,07700 900182,morag.kaur183@example.org,183 Cathedral Lane,,Edinburgh,EH2 82ZZ,,37,Male,30-44,Level 4 and above,Central Scotland +nb-0184,Fraser,Campbell,07700 900183,fraser.campbell184@example.org,184 Holyrood Way,,Edinburgh,EH2 83ZZ,,54,Male,45-59,Level 4 and above,North East Scotland +nb-0185,Fraser,Stewart,07700 900184,fraser.stewart185@example.org,185 Cathedral Lane,,Edinburgh,EH2 84ZZ,,68,Male,60+,Level 2 or 3,North East Scotland +nb-0186,Dara,Robertson,07700 900185,dara.robertson186@example.org,186 Cathedral Lane,,Edinburgh,EH2 85ZZ,,28,Male,16-29,No qualifications,Glasgow +nb-0187,Tomas,Kaur,07700 900186,tomas.kaur187@example.org,187 Acacia Avenue,,Edinburgh,EH2 86ZZ,,84,Female,60+,Level 2 or 3,Central Scotland +nb-0188,Fraser,Henderson,07700 900187,fraser.henderson188@example.org,188 Gorgie Terrace,,Edinburgh,EH2 87ZZ,,67,Male,60+,No qualifications,Highlands and Islands +nb-0189,Morag,Quigley,07700 900188,morag.quigley189@example.org,189 Holyrood Way,,Edinburgh,EH2 88ZZ,,36,Male,30-44,No qualifications,South Scotland +nb-0190,Xander,Ellis,07700 900189,xander.ellis190@example.org,190 Fettes Row,,Edinburgh,EH2 89ZZ,,17,Female,16-29,No qualifications,Mid Scotland and Fife +nb-0191,Dara,Ferguson,07700 900190,dara.ferguson191@example.org,191 Bruce Street,,Edinburgh,EH2 90ZZ,,45,Female,45-59,Level 2 or 3,Glasgow +nb-0192,Vikram,Campbell,07700 900191,vikram.campbell192@example.org,192 Dunbar Road,,Edinburgh,EH2 91ZZ,,44,Male,30-44,Level 1,Highlands and Islands +nb-0193,Rowan,Thomson,07700 900192,rowan.thomson193@example.org,193 Elm Grove,,Edinburgh,EH2 92ZZ,,75,Male,60+,No qualifications,West Scotland +nb-0194,Chandra,Paterson,07700 900193,chandra.paterson194@example.org,194 Gorgie Terrace,,Edinburgh,EH2 93ZZ,,22,Male,16-29,No qualifications,North East Scotland +nb-0195,Gita,Quigley,07700 900194,gita.quigley195@example.org,195 Fettes Row,,Edinburgh,EH2 94ZZ,,16,Female,16-29,Level 4 and above,North East Scotland +nb-0196,Ursula,Johnstone,07700 900195,ursula.johnstone196@example.org,196 Acacia Avenue,,Edinburgh,EH2 95ZZ,,86,Non-binary or other,60+,Level 1,West Scotland +nb-0197,Niamh,Robertson,07700 900196,niamh.robertson197@example.org,197 Acacia Avenue,,Edinburgh,EH2 96ZZ,,77,Female,60+,Level 2 or 3,Lothian +nb-0198,Bob,Stewart,07700 900197,bob.stewart198@example.org,198 Acacia Avenue,,Edinburgh,EH2 97ZZ,,20,Female,16-29,No qualifications,West Scotland +nb-0199,Lachlan,Kaur,07700 900198,lachlan.kaur199@example.org,199 Gorgie Terrace,,Edinburgh,EH2 98ZZ,,26,Female,16-29,Level 2 or 3,North East Scotland +nb-0200,Lachlan,Laird,07700 900199,lachlan.laird200@example.org,200 Cathedral Lane,,Edinburgh,EH2 99ZZ,,68,Non-binary or other,60+,Level 1,Highlands and Islands diff --git a/tests/fixtures/people_too_few.csv b/tests/fixtures/people_too_few.csv new file mode 100644 index 0000000..db48a78 --- /dev/null +++ b/tests/fixtures/people_too_few.csv @@ -0,0 +1,6 @@ +nationbuilder_id,first_name,last_name,mobile_number,email,primary_address1,primary_address2,primary_city,zip_royal_mail,tag_list,age,gender,age_bracket,edu_level,geo_bucket +nb-0001,Iona,Docherty,07700 900000,iona.docherty1@example.org,1 Holyrood Way,,Edinburgh,EH1 00ZZ,,28,Female,16-29,Level 4 and above,West Scotland +nb-0002,Dara,Paterson,07700 900001,dara.paterson2@example.org,2 Acacia Avenue,,Edinburgh,EH1 01ZZ,,44,Male,30-44,Level 4 and above,South Scotland +nb-0003,Wendy,Ogilvie,07700 900002,wendy.ogilvie3@example.org,3 Elm Grove,,Edinburgh,EH1 02ZZ,,27,Male,16-29,Level 1,Glasgow +nb-0004,Alice,Anderson,07700 900003,alice.anderson4@example.org,4 Acacia Avenue,,Edinburgh,EH1 03ZZ,,22,Male,16-29,Level 1,South Scotland +nb-0005,Yusuf,Ogilvie,07700 900004,yusuf.ogilvie5@example.org,5 Holyrood Way,,Edinburgh,EH1 04ZZ,,38,Male,30-44,Level 1,North East Scotland diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..ce6728f --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: Marks tests/integration as a package so test helpers can be imported by module path. +# ABOUTME: Deliberately empty apart from this comment. diff --git a/tests/integration/test_csv_tab.py b/tests/integration/test_csv_tab.py new file mode 100644 index 0000000..478b9a4 --- /dev/null +++ b/tests/integration/test_csv_tab.py @@ -0,0 +1,227 @@ +# ABOUTME: Tests for the CSV tab widget - that it obeys CsvView and drives the session. +# ABOUTME: File dialogs are never opened; the tests call the methods the dialogs would. + +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest +from PySide6.QtCore import Qt + +from strat_app.qt.csv_tab import CsvTab +from tests.conftest import CATEGORIES_CSV, PEOPLE_CSV +from tests.fakes import CallRecorder + +if TYPE_CHECKING: + from strat_app.sessions.csv_session import CsvSession + from strat_app.sessions.view import CsvView + +PANEL_MIN = 22 +PANEL_MAX = 24 + + +@pytest.fixture +def session() -> CallRecorder: + return CallRecorder() + + +@pytest.fixture +def tab(qtbot, session: CallRecorder) -> CsvTab: + tab = CsvTab() + tab.session = cast("CsvSession", session) + qtbot.addWidget(tab) + return tab + + +#################################### +# the tab as a view for the session +#################################### + + +def test_starts_with_people_input_and_running_disabled(tab: CsvTab) -> None: + assert not tab.people_button.isEnabled() + assert not tab.run_button.isEnabled() + assert not tab.run_test_button.isEnabled() + assert not tab.save_selected_button.isEnabled() + assert not tab.save_remaining_button.isEnabled() + + +def test_the_categories_button_is_available_from_the_start(tab: CsvTab) -> None: + assert tab.features_button.isEnabled() + + +def test_enabling_the_people_input(tab: CsvTab) -> None: + tab.set_people_input_enabled(enabled=True) + + assert tab.people_button.isEnabled() + + +def test_setting_the_panel_size_range_bounds_the_spin_box(tab: CsvTab) -> None: + tab.set_panel_size_range(PANEL_MIN, PANEL_MAX) + + assert tab.panel_size_spin.minimum() == PANEL_MIN + assert tab.panel_size_spin.maximum() == PANEL_MAX + + +def test_the_panel_size_range_is_shown_in_the_label(tab: CsvTab) -> None: + tab.set_panel_size_range(PANEL_MIN, PANEL_MAX) + + assert f"({PANEL_MIN}-{PANEL_MAX})" in tab.panel_size_label.text() + + +def test_a_size_outside_the_range_cannot_be_set(tab: CsvTab) -> None: + """The point of the spin box - the old text box let you type anything at all.""" + tab.set_panel_size_range(PANEL_MIN, PANEL_MAX) + + tab.panel_size_spin.setValue(PANEL_MAX + 10) + + assert tab.panel_size_spin.value() == PANEL_MAX + + +def test_setting_the_panel_size(tab: CsvTab) -> None: + tab.set_panel_size_range(PANEL_MIN, PANEL_MAX) + + tab.set_panel_size(PANEL_MIN + 1) + + assert tab.panel_size_spin.value() == PANEL_MIN + 1 + + +def test_enabling_and_disabling_the_run_buttons(tab: CsvTab) -> None: + tab.set_run_enabled(enabled=True) + assert tab.run_button.isEnabled() + assert tab.run_test_button.isEnabled() + + tab.set_run_enabled(enabled=False) + assert not tab.run_button.isEnabled() + assert not tab.run_test_button.isEnabled() + + +def test_offering_the_output_enables_saving_it(tab: CsvTab) -> None: + tab.offer_selected("id,name\n1,Alice\n", "selected.csv") + tab.offer_remaining("id,name\n2,Bob\n", "remaining.csv") + + assert tab.save_selected_button.isEnabled() + assert tab.save_remaining_button.isEnabled() + + +def test_saving_writes_what_was_offered(tab: CsvTab, tmp_path: Path) -> None: + tab.offer_selected("id,name\n1,Alice\n", "selected.csv") + target = tmp_path / "chosen-name.csv" + + tab.write_selected(target) + + assert target.read_text() == "id,name\n1,Alice\n" + + +def test_saving_the_remaining_writes_what_was_offered(tab: CsvTab, tmp_path: Path) -> None: + tab.offer_remaining("id,name\n2,Bob\n", "remaining.csv") + target = tmp_path / "left-over.csv" + + tab.write_remaining(target) + + assert target.read_text() == "id,name\n2,Bob\n" + + +####################################### +# user actions reaching the session +####################################### + + +def test_choosing_a_categories_file_hands_the_contents_to_the_session( + tab: CsvTab, session: CallRecorder, categories_contents: str +) -> None: + tab.load_features_file(CATEGORIES_CSV) + + assert session.last_args("add_feature_content") == (categories_contents,) + + +def test_the_chosen_categories_file_is_shown(tab: CsvTab) -> None: + tab.load_features_file(CATEGORIES_CSV) + + assert CATEGORIES_CSV.name in tab.features_label.text() + + +def test_choosing_a_people_file_hands_the_contents_to_the_session( + tab: CsvTab, session: CallRecorder, people_contents: str +) -> None: + tab.load_people_file(PEOPLE_CSV) + + assert session.last_args("add_people_content") == (people_contents,) + + +def test_changing_the_spin_box_tells_the_session(tab: CsvTab, session: CallRecorder) -> None: + tab.set_panel_size_range(PANEL_MIN, PANEL_MAX) + + tab.panel_size_spin.setValue(PANEL_MAX) + + assert session.last_args("set_panel_size") == (PANEL_MAX,) + + +def test_clicking_run_starts_a_real_selection(qtbot, tab: CsvTab, session: CallRecorder) -> None: + tab.set_run_enabled(enabled=True) + + qtbot.mouseClick(tab.run_button, Qt.MouseButton.LeftButton) + + assert session.last_args("run_selection") == (False,) + + +def test_clicking_the_test_panel_button_asks_for_a_test_selection(qtbot, tab: CsvTab, session: CallRecorder) -> None: + tab.set_run_enabled(enabled=True) + + qtbot.mouseClick(tab.run_test_button, Qt.MouseButton.LeftButton) + + assert session.last_args("run_selection") == (True,) + + +def test_the_tab_satisfies_the_view_protocol(tab: CsvTab) -> None: + """The annotation is the test - mypy checks the widget against the protocol.""" + view: CsvView = tab + + assert view is tab + + +################### +# the busy indicator +################### + + +def test_the_busy_indicator_starts_hidden(tab: CsvTab) -> None: + assert not tab.busy_bar.isVisibleTo(tab) + + +def test_the_busy_indicator_shows_while_work_is_in_flight(tab: CsvTab) -> None: + tab.set_busy(busy=True) + + assert tab.busy_bar.isVisibleTo(tab) + + +def test_the_busy_indicator_is_indeterminate(tab: CsvTab) -> None: + """0.11.5 of the library reports no phases, so there is no percentage to show.""" + assert tab.busy_bar.minimum() == 0 + assert tab.busy_bar.maximum() == 0 + + +def test_running_is_refused_while_already_running(tab: CsvTab) -> None: + tab.set_run_enabled(enabled=True) + + tab.set_busy(busy=True) + + assert not tab.run_button.isEnabled() + assert not tab.run_test_button.isEnabled() + + +def test_the_run_buttons_come_back_when_the_work_finishes(tab: CsvTab) -> None: + tab.set_run_enabled(enabled=True) + tab.set_busy(busy=True) + + tab.set_busy(busy=False) + + assert tab.run_button.isEnabled() + + +def test_the_run_buttons_stay_disabled_if_they_were_before(tab: CsvTab) -> None: + tab.set_run_enabled(enabled=False) + tab.set_busy(busy=True) + + tab.set_busy(busy=False) + + assert not tab.run_button.isEnabled() diff --git a/tests/integration/test_gsheet_tab.py b/tests/integration/test_gsheet_tab.py new file mode 100644 index 0000000..be3e776 --- /dev/null +++ b/tests/integration/test_gsheet_tab.py @@ -0,0 +1,243 @@ +# ABOUTME: Tests for the Google Sheet tab widget - that it obeys GSheetView and drives the session. +# ABOUTME: No spreadsheet is touched; the session is a recorder. + +from typing import TYPE_CHECKING, cast + +import pytest +from PySide6.QtCore import Qt + +from strat_app.qt.gsheet_tab import GSheetTab +from tests.fakes import CallRecorder + +if TYPE_CHECKING: + from strat_app.sessions.gsheet_session import GSheetSession + from strat_app.sessions.view import GSheetView + +PANEL_MIN = 22 +PANEL_MAX = 24 + + +@pytest.fixture +def session() -> CallRecorder: + return CallRecorder() + + +@pytest.fixture +def tab(qtbot, session: CallRecorder) -> GSheetTab: + tab = GSheetTab() + tab.session = cast("GSheetSession", session) + qtbot.addWidget(tab) + return tab + + +#################################### +# the tab as a view for the session +#################################### + + +def test_starts_with_loading_and_running_disabled(tab: GSheetTab) -> None: + assert not tab.load_button.isEnabled() + assert not tab.run_button.isEnabled() + assert not tab.run_test_button.isEnabled() + + +def test_the_two_run_buttons_are_controlled_separately(tab: GSheetTab) -> None: + """A test panel is refused for several selections, so it needs its own switch.""" + tab.set_run_enabled(enabled=True) + tab.set_test_run_enabled(enabled=False) + + assert tab.run_button.isEnabled() + assert not tab.run_test_button.isEnabled() + + +def test_going_busy_disables_both_run_buttons_and_restores_them(tab: GSheetTab) -> None: + tab.set_run_enabled(enabled=True) + tab.set_test_run_enabled(enabled=True) + + tab.set_busy(busy=True) + + assert not tab.run_button.isEnabled() + assert not tab.run_test_button.isEnabled() + + tab.set_busy(busy=False) + + assert tab.run_button.isEnabled() + assert tab.run_test_button.isEnabled() + + +def test_a_test_panel_refused_before_a_run_stays_refused_after_it(tab: GSheetTab) -> None: + """Going busy must not hand back a button that was deliberately disabled.""" + tab.set_run_enabled(enabled=True) + tab.set_test_run_enabled(enabled=False) + + tab.set_busy(busy=True) + tab.set_busy(busy=False) + + assert tab.run_button.isEnabled() + assert not tab.run_test_button.isEnabled() + + +def test_the_advanced_settings_start_hidden_with_the_current_defaults(tab: GSheetTab) -> None: + assert not tab.advanced_settings.isChecked() + assert tab.features_tab_edit.text() == "Categories" + assert tab.people_tab_edit.text() == "Respondents" + assert tab.gen_rem_tab_check.isChecked() + assert tab.number_selections_spin.value() == 1 + + +def test_opening_the_advanced_settings_shows_them(tab: GSheetTab) -> None: + assert not tab.advanced_contents.isVisibleTo(tab.advanced_settings) + + tab.advanced_settings.setChecked(True) + + assert tab.advanced_contents.isVisibleTo(tab.advanced_settings) + + +def test_the_number_of_selections_cannot_go_below_one(tab: GSheetTab) -> None: + tab.number_selections_spin.setValue(0) + + assert tab.number_selections_spin.value() == 1 + + +def test_enabling_the_load_button(tab: GSheetTab) -> None: + tab.set_load_enabled(enabled=True) + + assert tab.load_button.isEnabled() + + +def test_setting_the_panel_size_range_bounds_the_spin_box(tab: GSheetTab) -> None: + tab.set_panel_size_range(PANEL_MIN, PANEL_MAX) + + assert tab.panel_size_spin.minimum() == PANEL_MIN + assert tab.panel_size_spin.maximum() == PANEL_MAX + assert f"({PANEL_MIN}-{PANEL_MAX})" in tab.panel_size_label.text() + + +def test_clearing_the_range_puts_the_label_back(tab: GSheetTab) -> None: + tab.set_panel_size_range(PANEL_MIN, PANEL_MAX) + + tab.set_panel_size_range(0, 0) + + assert "(" not in tab.panel_size_label.text() + assert tab.panel_size_spin.value() == 0 + + +def test_enabling_and_disabling_the_run_buttons(tab: GSheetTab) -> None: + tab.set_run_enabled(enabled=True) + tab.set_test_run_enabled(enabled=True) + assert tab.run_button.isEnabled() + assert tab.run_test_button.isEnabled() + + tab.set_run_enabled(enabled=False) + tab.set_test_run_enabled(enabled=False) + assert not tab.run_button.isEnabled() + assert not tab.run_test_button.isEnabled() + + +####################################### +# user actions reaching the session +####################################### + + +def test_typing_a_sheet_name_tells_the_session(qtbot, tab: GSheetTab, session: CallRecorder) -> None: + qtbot.keyClicks(tab.sheet_name_edit, "My Assembly") + + assert session.last_args("update_g_sheet_name") == ("My Assembly",) + + +def test_clicking_load_loads_the_sheet(qtbot, tab: GSheetTab, session: CallRecorder) -> None: + tab.set_load_enabled(enabled=True) + + qtbot.mouseClick(tab.load_button, Qt.MouseButton.LeftButton) + + assert session.called("load_g_sheet") + + +def test_editing_the_categories_tab_name_tells_the_session(tab: GSheetTab, session: CallRecorder) -> None: + tab.features_tab_edit.setText("Other Categories") + + assert session.last_args("update_features_tab_name") == ("Other Categories",) + + +def test_editing_the_respondents_tab_name_tells_the_session(tab: GSheetTab, session: CallRecorder) -> None: + tab.people_tab_edit.setText("Other Respondents") + + assert session.last_args("update_people_tab_name") == ("Other Respondents",) + + +def test_unchecking_the_remaining_tab_tells_the_session(tab: GSheetTab, session: CallRecorder) -> None: + tab.gen_rem_tab_check.setChecked(False) + + assert session.last_args("update_gen_rem_tab") == (False,) + + +def test_changing_the_number_of_selections_tells_the_session(tab: GSheetTab, session: CallRecorder) -> None: + tab.number_selections_spin.setValue(3) + + assert session.last_args("set_number_selections") == (3,) + + +def test_changing_the_spin_box_tells_the_session(tab: GSheetTab, session: CallRecorder) -> None: + tab.set_panel_size_range(PANEL_MIN, PANEL_MAX) + + tab.panel_size_spin.setValue(PANEL_MAX) + + assert session.last_args("set_panel_size") == (PANEL_MAX,) + + +def test_clicking_run_starts_a_real_selection(qtbot, tab: GSheetTab, session: CallRecorder) -> None: + tab.set_run_enabled(enabled=True) + + qtbot.mouseClick(tab.run_button, Qt.MouseButton.LeftButton) + + assert session.last_args("run_selection") == (False,) + + +def test_clicking_the_test_panel_button_asks_for_a_test_selection(qtbot, tab: GSheetTab, session: CallRecorder) -> None: + tab.set_test_run_enabled(enabled=True) + + qtbot.mouseClick(tab.run_test_button, Qt.MouseButton.LeftButton) + + assert session.last_args("run_selection") == (True,) + + +def test_the_tab_satisfies_the_view_protocol(tab: GSheetTab) -> None: + """The annotation is the test - mypy checks the widget against the protocol.""" + view: GSheetView = tab + + assert view is tab + + +################### +# the busy indicator +################### + + +def test_the_busy_indicator_starts_hidden(tab: GSheetTab) -> None: + assert not tab.busy_bar.isVisibleTo(tab) + + +def test_the_busy_indicator_shows_while_work_is_in_flight(tab: GSheetTab) -> None: + tab.set_busy(busy=True) + + assert tab.busy_bar.isVisibleTo(tab) + + +def test_loading_is_refused_while_already_loading(tab: GSheetTab) -> None: + tab.set_load_enabled(enabled=True) + + tab.set_busy(busy=True) + + assert not tab.load_button.isEnabled() + assert not tab.run_button.isEnabled() + + +def test_the_buttons_come_back_when_the_work_finishes(tab: GSheetTab) -> None: + tab.set_load_enabled(enabled=True) + tab.set_run_enabled(enabled=True) + tab.set_busy(busy=True) + + tab.set_busy(busy=False) + + assert tab.load_button.isEnabled() + assert tab.run_button.isEnabled() diff --git a/tests/integration/test_main_window.py b/tests/integration/test_main_window.py new file mode 100644 index 0000000..e9af090 --- /dev/null +++ b/tests/integration/test_main_window.py @@ -0,0 +1,98 @@ +# ABOUTME: Tests for the window that holds the two tabs and the shared detailed log. +# ABOUTME: Also covers routing log sections to the output area each one belongs in. + +import pytest + +from strat_app.qt.log_panel import LogDisplay, LogPanel +from strat_app.qt.main_window import GSHEET_TAB_INDEX, LOGO_NAME, MainWindow, resources_dir, tab_title +from strat_app.sessions.view import LogSection + +CSV_TAB_TITLE = "CSV file input & output" +GSHEET_TAB_TITLE = "Google Sheet input & output" + + +@pytest.fixture +def window(qtbot, settings_path) -> MainWindow: + window = MainWindow(settings_path=settings_path) + qtbot.addWidget(window) + return window + + +def test_the_window_has_both_tabs(window: MainWindow) -> None: + titles = [window.tabs.tabText(index) for index in range(window.tabs.count())] + + assert titles == [tab_title(CSV_TAB_TITLE), tab_title(GSHEET_TAB_TITLE)] + + +def test_the_ampersand_in_a_tab_title_is_not_read_as_a_shortcut(window: MainWindow) -> None: + """Unescaped, Qt swallows the ampersand and the tab reads "CSV file input _output".""" + assert window.tabs.tabText(0) == "CSV file input && output" + + +def test_the_google_sheet_tab_is_the_one_showing(window: MainWindow) -> None: + """Same as the old page, which marked the gsheet tab active.""" + assert window.tabs.currentIndex() == GSHEET_TAB_INDEX + + +def test_the_detailed_log_starts_open(window: MainWindow) -> None: + assert window.log_panel.isChecked() + + +def test_the_detailed_log_can_be_collapsed(window: MainWindow) -> None: + window.log_panel.setChecked(False) + + assert not window.log_panel.browser.isVisibleTo(window.log_panel) + + +def test_each_tab_has_a_session_that_can_talk_back_to_it(window: MainWindow) -> None: + assert window.csv_tab.session.view is window.csv_tab + assert window.gsheet_tab.session.view is window.gsheet_tab + + +def test_both_tabs_share_one_settings_holder(window: MainWindow) -> None: + """Otherwise a broken settings file gets reported twice, once per tab.""" + assert window.csv_tab.session.settings_holder is window.gsheet_tab.session.settings_holder + + +@pytest.mark.parametrize( + ("section", "attribute"), + [ + (LogSection.CSV_FEATURES, "csv_features"), + (LogSection.CSV_SELECTION, "csv_selection"), + (LogSection.GSHEET_FEATURES, "gsheet_features"), + (LogSection.GSHEET_SELECTION, "gsheet_selection"), + (LogSection.DETAILED_LOG, "detailed_log"), + ], +) +def test_each_log_section_reaches_its_own_output_area( + qtbot, window: MainWindow, section: LogSection, attribute: str +) -> None: + areas = { + "csv_features": window.csv_tab.features_output, + "csv_selection": window.csv_tab.people_output, + "gsheet_features": window.gsheet_tab.features_output, + "gsheet_selection": window.gsheet_tab.people_output, + "detailed_log": window.log_panel.browser, + } + + window.gui_log.reset(section, "a message for this section") + + # delivery is queued, since worker threads write to the log too + qtbot.waitUntil(lambda: "a message for this section" in areas[attribute].toPlainText(), timeout=5000) + for name, area in areas.items(): + if name != attribute: + assert "a message for this section" not in area.toPlainText() + + +def test_the_log_display_complains_about_an_unrouted_section(qtbot) -> None: + panel = LogPanel() + qtbot.addWidget(panel) + display = LogDisplay({LogSection.DETAILED_LOG: panel.browser}) + + with pytest.raises(KeyError): + display.show_log(LogSection.CSV_FEATURES, ["nowhere to put this"]) + + +def test_the_logo_is_where_the_window_looks_for_it() -> None: + """A missing logo is survivable, but it should not be missing in the repo.""" + assert (resources_dir() / LOGO_NAME).is_file() diff --git a/tests/integration/test_progress.py b/tests/integration/test_progress.py new file mode 100644 index 0000000..9d59460 --- /dev/null +++ b/tests/integration/test_progress.py @@ -0,0 +1,132 @@ +# ABOUTME: Tests the bridge carrying the library's progress events over to the GUI thread. +# ABOUTME: The library calls the reporter from a worker thread, so the crossing has to be safe. + +import threading +from collections.abc import Generator + +import pytest +from sortition_algorithms.progress import ProgressReporter + +from strat_app.qt.progress import QtProgressReporter +from strat_app.qt.workers import QtTaskRunner + +TIMEOUT_MS = 10_000 + + +@pytest.fixture +def reporter(qtbot) -> QtProgressReporter: + return QtProgressReporter() + + +@pytest.fixture +def runner(qtbot) -> Generator[QtTaskRunner, None, None]: + """ + A runner that is waited on before the test ends. + + Letting one go out of scope with a QThread still winding down aborts the whole + process, which shows up as an occasional SIGABRT somewhere else entirely. + """ + made = QtTaskRunner() + yield made + made.wait() + + +def test_it_satisfies_the_library_protocol(reporter: QtProgressReporter) -> None: + """The library checks against this protocol at runtime, so a missing method is fatal.""" + assert isinstance(reporter, ProgressReporter) + + +def test_a_phase_start_carries_its_name_total_and_message(qtbot, reporter: QtProgressReporter) -> None: + with qtbot.waitSignal(reporter.phase_started, timeout=TIMEOUT_MS) as blocker: + reporter.start_phase("multiplicative_weights", 200, message="Searching for diverse committees") + + assert blocker.args == ["multiplicative_weights", 200, "Searching for diverse committees"] + + +def test_a_phase_with_no_total_says_so(qtbot, reporter: QtProgressReporter) -> None: + """A convergence loop has no fixed end, and the bar has to know to go indeterminate.""" + with qtbot.waitSignal(reporter.phase_started, timeout=TIMEOUT_MS) as blocker: + reporter.start_phase("maximin_optimization", None, message="Optimizing") + + assert blocker.args == ["maximin_optimization", None, "Optimizing"] + + +def test_an_update_carries_the_phase_total_with_it(qtbot, reporter: QtProgressReporter) -> None: + reporter.start_phase("multiplicative_weights", 200, message="Searching") + + with qtbot.waitSignal(reporter.progressed, timeout=TIMEOUT_MS) as blocker: + reporter.update(7, message="Round 7/200") + + assert blocker.args == [7, 200, "Round 7/200"] + + +def test_the_library_calling_without_a_message_is_survivable(qtbot, reporter: QtProgressReporter) -> None: + """`message` is documented as always supplied, but the protocol allows None.""" + reporter.start_phase("diversimax", None) + + with qtbot.waitSignal(reporter.progressed, timeout=TIMEOUT_MS) as blocker: + reporter.update(1) + + assert blocker.args == [1, None, ""] + + +def test_hammering_update_does_not_flood_the_event_queue(qtbot, reporter: QtProgressReporter) -> None: + """The whole reason the throttle exists - one signal per library call would drown Qt.""" + emitted: list[int] = [] + reporter.progressed.connect(lambda current, total, message: emitted.append(current)) + + reporter.start_phase("maximin_optimization", None, message="Optimizing") + for current in range(2000): + reporter.update(current, message=f"Iteration {current}") + + assert len(emitted) < 20 + + +def test_ending_a_phase_reports_where_it_actually_finished(qtbot, reporter: QtProgressReporter) -> None: + emitted: list[int] = [] + reporter.progressed.connect(lambda current, total, message: emitted.append(current)) + + reporter.start_phase("multiplicative_weights", 200, message="Searching") + for current in range(1, 201): + reporter.update(current, message=f"Round {current}/200") + reporter.end_phase() + + assert emitted[-1] == 200 + + +def test_the_events_arrive_on_the_gui_thread(qtbot, reporter: QtProgressReporter, runner: QtTaskRunner) -> None: + """ + The library drives the reporter from the worker thread; widgets only tolerate one. + + This is the test that would have caught a direct connection, which would run the + slot on whichever thread emitted - and touching a widget from there is a crash. + """ + seen: list[threading.Thread] = [] + reporter.phase_started.connect(lambda name, total, message: seen.append(threading.current_thread())) + + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + runner.run( + lambda: reporter.start_phase("multiplicative_weights", 200, message="Searching"), + lambda result: None, + lambda error: None, + ) + qtbot.waitUntil(lambda: bool(seen), timeout=TIMEOUT_MS) + + assert seen[0] is threading.main_thread() + + +def test_connecting_through_the_helper_defers_the_slot(qtbot, reporter: QtProgressReporter) -> None: + """ + A queued connection is what keeps the worker thread out of the widgets. + + It also means nothing a slot does - including raising - can reach back into the + library's call stack and interrupt a ten minute selection, because emit() has + already returned by the time the slot runs. + """ + seen: list[str] = [] + reporter.connect_phase_started(lambda name, total, message: seen.append(name)) + + reporter.start_phase("multiplicative_weights", 200, message="Searching") + + assert seen == [] + qtbot.waitUntil(lambda: seen == ["multiplicative_weights"], timeout=TIMEOUT_MS) diff --git a/tests/integration/test_progress_bar.py b/tests/integration/test_progress_bar.py new file mode 100644 index 0000000..6da5788 --- /dev/null +++ b/tests/integration/test_progress_bar.py @@ -0,0 +1,104 @@ +# ABOUTME: Tests that both tabs turn the library's progress events into an honest bar. +# ABOUTME: Determinate where the library knows a total, and busy where it does not. + +from typing import TYPE_CHECKING, cast + +import pytest + +from strat_app.qt.csv_tab import CsvTab +from strat_app.qt.gsheet_tab import GSheetTab +from tests.fakes import CallRecorder + +if TYPE_CHECKING: + from strat_app.sessions.csv_session import CsvSession + from strat_app.sessions.gsheet_session import GSheetSession + +# The two tabs deliberately duplicate this code rather than share a widget, so the +# tests run against both to keep the two copies honest about behaving the same. +TABS = ["csv", "gsheet"] + + +@pytest.fixture(params=TABS) +def tab(request, qtbot) -> CsvTab | GSheetTab: + if request.param == "csv": + made: CsvTab | GSheetTab = CsvTab() + made.session = cast("CsvSession", CallRecorder()) + else: + made = GSheetTab() + made.session = cast("GSheetSession", CallRecorder()) + qtbot.addWidget(made) + return made + + +def test_the_bar_is_hidden_until_there_is_work(tab: CsvTab | GSheetTab) -> None: + assert not tab.busy_bar.isVisibleTo(tab) + + +def test_going_busy_starts_out_indeterminate(tab: CsvTab | GSheetTab) -> None: + """Nothing is known about the work yet, so all we can honestly show is that it started.""" + tab.set_busy(busy=True) + + assert tab.busy_bar.maximum() == 0 + assert tab.progress_label.text() == "" + + +def test_a_phase_with_a_total_makes_the_bar_determinate(tab: CsvTab | GSheetTab) -> None: + tab.set_busy(busy=True) + + tab.start_progress_phase("multiplicative_weights", 200, "Searching for diverse committees") + + assert tab.busy_bar.maximum() == 200 + assert tab.busy_bar.value() == 0 + assert tab.progress_label.text() == "Searching for diverse committees" + + +def test_a_phase_without_a_total_leaves_the_bar_busy(tab: CsvTab | GSheetTab) -> None: + """A convergence loop has no fixed end - a percentage would be a lie.""" + tab.set_busy(busy=True) + + tab.start_progress_phase("maximin_optimization", None, "Optimizing maximin distribution") + + assert tab.busy_bar.maximum() == 0 + assert tab.progress_label.text() == "Optimizing maximin distribution" + + +def test_progress_moves_the_bar_and_the_label(tab: CsvTab | GSheetTab) -> None: + tab.set_busy(busy=True) + tab.start_progress_phase("multiplicative_weights", 200, "Searching") + + tab.set_progress(47, 200, "Round 47/200: 31 committees found") + + assert tab.busy_bar.value() == 47 + assert tab.progress_label.text() == "Round 47/200: 31 committees found" + + +def test_progress_in_an_indeterminate_phase_only_moves_the_label(tab: CsvTab | GSheetTab) -> None: + tab.set_busy(busy=True) + tab.start_progress_phase("maximin_optimization", None, "Optimizing") + + tab.set_progress(12, None, "Iteration 12") + + assert tab.busy_bar.maximum() == 0 + assert tab.progress_label.text() == "Iteration 12" + + +def test_a_second_run_does_not_start_where_the_first_finished(tab: CsvTab | GSheetTab) -> None: + """Otherwise the bar sits full and the label stale for as long as the new run takes.""" + tab.set_busy(busy=True) + tab.start_progress_phase("multiplicative_weights", 200, "Searching") + tab.set_progress(200, 200, "Round 200/200") + tab.set_busy(busy=False) + + tab.set_busy(busy=True) + + assert tab.busy_bar.maximum() == 0 + assert tab.progress_label.text() == "" + + +def test_the_label_is_hidden_along_with_the_bar(tab: CsvTab | GSheetTab) -> None: + tab.set_busy(busy=True) + assert tab.progress_label.isVisibleTo(tab) + + tab.set_busy(busy=False) + + assert not tab.progress_label.isVisibleTo(tab) diff --git a/tests/integration/test_report_view.py b/tests/integration/test_report_view.py new file mode 100644 index 0000000..3fc8278 --- /dev/null +++ b/tests/integration/test_report_view.py @@ -0,0 +1,67 @@ +# ABOUTME: Tests for rendering log entries into a Qt rich text widget. +# ABOUTME: This is the one place the library's HTML is used, so it is the one place tested for it. + +from sortition_algorithms.utils import ReportLevel, RunReport +from sortition_algorithms.utils import RunReport as Report + +from strat_app.qt.report_view import ReportBrowser, entries_as_html + + +def test_plain_text_entries_are_escaped() -> None: + assert entries_as_html(["1 < 2 & 3 > 2"]) == "1 < 2 & 3 > 2" + + +def test_blank_entries_are_dropped() -> None: + assert entries_as_html(["first", "", " ", "second"]) == "first
second" + + +def test_reports_are_rendered_by_the_library() -> None: + report = RunReport() + report.add_line("a line") + + assert entries_as_html([report]) == report.as_html() + + +def test_a_report_table_reaches_the_widget(qtbot) -> None: + report = Report() + report.add_table(["name", "count"], [["Female", 12]]) + browser = ReportBrowser() + qtbot.addWidget(browser) + + browser.show_entries([report]) + + shown = browser.toPlainText() + assert "Female" in shown + assert "12" in shown + + +def test_a_critical_line_reaches_the_widget_in_red(qtbot) -> None: + """Infeasible targets are flagged in red, which is the whole reason for the rich text.""" + report = Report() + report.add_line("Failed to find a panel", level=ReportLevel.CRITICAL) + browser = ReportBrowser() + qtbot.addWidget(browser) + + browser.show_entries([report]) + + assert "Failed to find a panel" in browser.toPlainText() + # Qt has parsed the library's `color: red` and stored it as its own colour + assert "#ff0000" in browser.toHtml() + + +def test_showing_entries_replaces_what_was_there(qtbot) -> None: + browser = ReportBrowser() + qtbot.addWidget(browser) + browser.show_entries(["old news"]) + + browser.show_entries(["new news"]) + + assert "old news" not in browser.toPlainText() + assert "new news" in browser.toPlainText() + + +def test_the_browser_is_read_only(qtbot) -> None: + browser = ReportBrowser() + qtbot.addWidget(browser) + + assert browser.isReadOnly() diff --git a/tests/integration/test_threaded_selection.py b/tests/integration/test_threaded_selection.py new file mode 100644 index 0000000..e88574d --- /dev/null +++ b/tests/integration/test_threaded_selection.py @@ -0,0 +1,132 @@ +# ABOUTME: Tests a real selection running on a worker thread with the window responsive. +# ABOUTME: Small pool, real library - this is the path a long run takes, just quicker. + +import threading + +import pytest + +from strat_app.qt.csv_tab import CsvTab +from strat_app.qt.log_panel import LogDisplay, LogPanel +from strat_app.qt.workers import QtTaskRunner, QueuedLogView, user_log_handler +from strat_app.sessions.csv_session import CsvSession +from strat_app.sessions.log import GuiLog +from strat_app.sessions.view import LogSection +from strat_app.settings_holder import SettingsHolder +from tests.conftest import ALGORITHM_LINE + +PANEL_MIN = 22 +TIMEOUT_MS = 120_000 + + +@pytest.fixture +def log_panel(qtbot) -> LogPanel: + panel = LogPanel() + qtbot.addWidget(panel) + return panel + + +@pytest.fixture +def runner() -> QtTaskRunner: + return QtTaskRunner() + + +@pytest.fixture +def tab(qtbot, log_panel: LogPanel, settings_path, runner: QtTaskRunner) -> CsvTab: + tab = CsvTab() + qtbot.addWidget(tab) + display = LogDisplay( + { + LogSection.CSV_FEATURES: tab.features_output, + LogSection.CSV_SELECTION: tab.people_output, + LogSection.DETAILED_LOG: log_panel.browser, + } + ) + tab.session = CsvSession(tab, GuiLog(QueuedLogView(display)), SettingsHolder(settings_path), runner=runner) + return tab + + +def test_a_selection_runs_without_blocking_the_gui_thread( + qtbot, tab: CsvTab, runner: QtTaskRunner, categories_contents: str, people_contents: str +) -> None: + tab.session.add_feature_content(categories_contents) + tab.session.add_people_content(people_contents) + tab.panel_size_spin.setValue(PANEL_MIN) + + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + tab.run_selection() + # the GUI thread is still ours while the selection is in flight + assert tab.busy_bar.isVisibleTo(tab) + + assert not tab.busy_bar.isVisibleTo(tab) + assert tab.save_selected_button.isEnabled() + assert tab.save_remaining_button.isEnabled() + + +def test_the_output_is_written_where_asked( + qtbot, tab: CsvTab, runner: QtTaskRunner, tmp_path, categories_contents: str, people_contents: str +) -> None: + tab.session.add_feature_content(categories_contents) + tab.session.add_people_content(people_contents) + tab.panel_size_spin.setValue(PANEL_MIN) + + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + tab.run_selection() + + selected = tmp_path / "selected.csv" + tab.write_selected(selected) + assert len(selected.read_text().strip().splitlines()) == PANEL_MIN + 1 + + +def test_the_library_log_arrives_while_the_selection_runs( + qtbot, tab: CsvTab, runner: QtTaskRunner, log_panel: LogPanel, categories_contents: str, people_contents: str +) -> None: + """The lines the library logs as it goes are the only sign of life on a long run.""" + tab.session.add_feature_content(categories_contents) + tab.session.add_people_content(people_contents) + tab.panel_size_spin.setValue(PANEL_MIN) + + def receive(line: str) -> None: + tab.session.gui_log.add(LogSection.DETAILED_LOG, line) + + with user_log_handler(receive), qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + tab.run_selection() + + qtbot.waitUntil(lambda: ALGORITHM_LINE in log_panel.browser.toPlainText(), timeout=TIMEOUT_MS) + + +def test_the_report_does_not_repeat_the_lines_already_logged( + qtbot, tab: CsvTab, runner: QtTaskRunner, log_panel: LogPanel, categories_contents: str, people_contents: str +) -> None: + tab.session.add_feature_content(categories_contents) + tab.session.add_people_content(people_contents) + tab.panel_size_spin.setValue(PANEL_MIN) + + def receive(line: str) -> None: + tab.session.gui_log.add(LogSection.DETAILED_LOG, line) + + with user_log_handler(receive), qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + tab.run_selection() + + qtbot.waitUntil(lambda: ALGORITHM_LINE in log_panel.browser.toPlainText(), timeout=TIMEOUT_MS) + assert log_panel.browser.toPlainText().count(ALGORITHM_LINE) == 1 + + +def test_the_work_itself_leaves_the_gui_thread( + qtbot, monkeypatch, tab: CsvTab, runner: QtTaskRunner, categories_contents: str, people_contents: str +) -> None: + tab.session.add_feature_content(categories_contents) + tab.session.add_people_content(people_contents) + tab.panel_size_spin.setValue(PANEL_MIN) + threads: list[threading.Thread] = [] + original = tab.session._stratify # noqa: SLF001 + + def watched(*, test_selection: bool) -> object: + threads.append(threading.current_thread()) + return original(test_selection=test_selection) + + monkeypatch.setattr(tab.session, "_stratify", watched) + + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + tab.run_selection() + + assert threads and threading.main_thread() not in threads diff --git a/tests/integration/test_workers.py b/tests/integration/test_workers.py new file mode 100644 index 0000000..f39a990 --- /dev/null +++ b/tests/integration/test_workers.py @@ -0,0 +1,161 @@ +# ABOUTME: Tests for running work off the GUI thread and getting the results back safely. +# ABOUTME: A frozen window looks like a crash, and a widget touched off-thread is a real crash. + +import logging +import threading +from collections.abc import Callable +from typing import Any + +import pytest +from PySide6.QtCore import QThread + +from strat_app.qt.workers import QtTaskRunner, QueuedLogView, UserLogHandler, user_log_handler +from strat_app.sessions.view import LogSection + +TIMEOUT_MS = 10_000 + + +@pytest.fixture +def runner(qtbot) -> QtTaskRunner: + return QtTaskRunner() + + +def test_the_work_happens_off_the_gui_thread(qtbot, runner: QtTaskRunner) -> None: + where_the_work_ran = {} + + def work() -> str: + where_the_work_ran["thread"] = threading.current_thread() + return "done" + + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + runner.run(work, lambda result: None, lambda error: None) + + assert where_the_work_ran["thread"] is not threading.main_thread() + + +def test_the_result_comes_back_on_the_gui_thread(qtbot, runner: QtTaskRunner) -> None: + """Anything touching a widget runs here, so it had better be the GUI thread.""" + seen: dict[str, Any] = {} + + def on_done(result: str) -> None: + seen["result"] = result + seen["thread"] = threading.current_thread() + + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + runner.run(lambda: "the answer", on_done, lambda error: None) + + assert seen["result"] == "the answer" + assert seen["thread"] is threading.main_thread() + + +def test_an_exception_reaches_the_error_callback_on_the_gui_thread(qtbot, runner: QtTaskRunner) -> None: + seen: dict[str, Any] = {} + + def explode() -> None: + msg = "it went wrong" + raise RuntimeError(msg) + + def on_error(error: Exception) -> None: + seen["error"] = error + seen["thread"] = threading.current_thread() + + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + runner.run(explode, lambda result: None, on_error) + + assert str(seen["error"]) == "it went wrong" + assert seen["thread"] is threading.main_thread() + + +def test_the_thread_is_cleaned_up_afterwards(qtbot, runner: QtTaskRunner) -> None: + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + runner.run(lambda: None, lambda result: None, lambda error: None) + + qtbot.waitUntil(lambda: not runner.running(), timeout=TIMEOUT_MS) + + +def test_several_tasks_in_a_row(qtbot, runner: QtTaskRunner) -> None: + results: list[int] = [] + + for number in range(3): + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + runner.run(_doubler(number), results.append, lambda error: None) + + assert results == [0, 2, 4] + + +############################# +# the live log from the library +############################# + + +def test_a_log_record_from_a_worker_thread_reaches_the_view(qtbot, runner: QtTaskRunner) -> None: + """This is what makes the detailed log fill in while a long selection runs.""" + shown: list[str] = [] + handler = UserLogHandler() + handler.connect(shown.append) + + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + runner.run(lambda: handler.handle(_record("a message from the solver")), lambda r: None, lambda e: None) + + qtbot.waitUntil(lambda: "a message from the solver" in shown, timeout=TIMEOUT_MS) + + +def test_the_handler_can_be_installed_over_the_library_logger(qtbot) -> None: + user_logger = logging.getLogger("sortition_algorithms_user") + original = user_logger.handlers[:] + lines: list[str] = [] + + with user_log_handler(lines.append): + user_logger.info("during the run") + # delivery is queued, so it lands on the next turn of the event loop + qtbot.waitUntil(lambda: lines == ["during the run"], timeout=TIMEOUT_MS) + + user_logger.info("after the run") + + assert lines == ["during the run"] + assert user_logger.handlers == original + + +def test_the_queued_log_view_passes_entries_through(qtbot) -> None: + seen: dict[LogSection, list[str]] = {} + + class Sink: + def show_log(self, section: LogSection, entries) -> None: + seen[section] = [str(entry) for entry in entries] + + view = QueuedLogView(Sink()) + + view.show_log(LogSection.DETAILED_LOG, ["hello"]) + + qtbot.waitUntil(lambda: seen.get(LogSection.DETAILED_LOG) == ["hello"], timeout=TIMEOUT_MS) + + +def test_the_queued_log_view_delivers_from_a_worker_thread(qtbot, runner: QtTaskRunner) -> None: + seen: dict[LogSection, list[str]] = {} + threads: list[threading.Thread] = [] + + class Sink: + def show_log(self, section: LogSection, entries) -> None: + seen[section] = [str(entry) for entry in entries] + threads.append(threading.current_thread()) + + view = QueuedLogView(Sink()) + + with qtbot.waitSignal(runner.finished, timeout=TIMEOUT_MS): + runner.run(lambda: view.show_log(LogSection.CSV_FEATURES, ["from the worker"]), lambda r: None, lambda e: None) + + qtbot.waitUntil(lambda: seen.get(LogSection.CSV_FEATURES) == ["from the worker"], timeout=TIMEOUT_MS) + assert threads == [threading.main_thread()] + + +def _doubler(number: int) -> Callable[[], int]: + return lambda: number * 2 + + +def _record(message: str) -> logging.LogRecord: + return logging.LogRecord("sortition_algorithms_user", logging.INFO, __file__, 1, message, None, None) + + +def test_qthread_is_importable() -> None: + """Guards against the worker module quietly dropping its Qt import.""" + assert QThread is not None diff --git a/tests/test_harness.py b/tests/test_harness.py new file mode 100644 index 0000000..3549d8f --- /dev/null +++ b/tests/test_harness.py @@ -0,0 +1,19 @@ +# ABOUTME: Smoke tests for the test harness itself - fixtures on disk, Qt able to start. +# ABOUTME: These fail loudly if the headless Qt setup breaks in CI, before any real test runs. + +from pathlib import Path + +from PySide6.QtWidgets import QLabel + +from tests.conftest import CATEGORIES_CSV, PEOPLE_CSV + + +def test_fixtures_are_present() -> None: + assert Path(CATEGORIES_CSV).is_file() + assert Path(PEOPLE_CSV).is_file() + + +def test_qtbot_can_make_a_widget(qtbot) -> None: + label = QLabel("hello") + qtbot.addWidget(label) + assert label.text() == "hello" diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..c679b71 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1,2 @@ +# ABOUTME: Marks tests/unit as a package so test helpers can be imported by module path. +# ABOUTME: Deliberately empty apart from this comment. diff --git a/tests/unit/test_csv_session.py b/tests/unit/test_csv_session.py new file mode 100644 index 0000000..1fc8591 --- /dev/null +++ b/tests/unit/test_csv_session.py @@ -0,0 +1,352 @@ +# ABOUTME: Unit tests for the CSV tab's logic, driven through a recording view. +# ABOUTME: No Qt, no network - these are the tests the eel app never had. + +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest +from sortition_algorithms.progress import NullProgressReporter +from sortition_algorithms.utils import RunReport + +from strat_app.sessions.csv_session import CsvSession +from strat_app.sessions.log import GuiLog +from strat_app.sessions.view import LogSection +from strat_app.settings_holder import SettingsHolder +from tests.fakes import CallRecorder, RecordingLogView + +if TYPE_CHECKING: + from sortition_algorithms import people + +PANEL_MIN = 22 +PANEL_MAX = 24 +PEOPLE_COUNT = 200 + + +@pytest.fixture +def log() -> RecordingLogView: + return RecordingLogView() + + +@pytest.fixture +def view() -> CallRecorder: + return CallRecorder() + + +@pytest.fixture +def session(view: CallRecorder, log: RecordingLogView, settings_path: Path) -> CsvSession: + return CsvSession(view, GuiLog(log), SettingsHolder(settings_path)) + + +@pytest.fixture +def loaded_session(session: CsvSession, categories_contents: str, people_contents: str) -> CsvSession: + session.add_feature_content(categories_contents) + session.add_people_content(people_contents) + return session + + +########################### +# loading the categories +########################### + + +def test_loading_categories_enables_people_input_and_sets_the_range( + session: CsvSession, + view: CallRecorder, + log: RecordingLogView, + categories_contents: str, +) -> None: + session.add_feature_content(categories_contents) + + assert view.last_args("set_people_input_enabled") == (True,) + assert view.last_args("set_panel_size_range") == (PANEL_MIN, PANEL_MAX) + assert "Number of features found: 4" in log.text(LogSection.CSV_FEATURES) + + +def test_loading_categories_does_not_touch_the_run_button( + session: CsvSession, + view: CallRecorder, + categories_contents: str, +) -> None: + """Loading categories leaves the run button as it was - people are still needed.""" + session.add_feature_content(categories_contents) + + assert not view.called("set_run_enabled") + + +def test_empty_categories_file_reports_and_stops( + session: CsvSession, + view: CallRecorder, + log: RecordingLogView, +) -> None: + session.add_feature_content("") + + assert "No file contents - was the file empty?" in log.text(LogSection.CSV_FEATURES) + assert not view.called("set_people_input_enabled") + assert session.features is None + + +def test_unparseable_categories_file_reports_the_error( + session: CsvSession, + view: CallRecorder, + log: RecordingLogView, +) -> None: + session.add_feature_content("this is not,a categories csv\n1,2\n") + + assert "Failed to load features" in log.text(LogSection.CSV_FEATURES) + assert not view.called("set_people_input_enabled") + + +def test_unreadable_settings_file_stops_the_load( + view: CallRecorder, + log: RecordingLogView, + tmp_path: Path, + categories_contents: str, +) -> None: + settings_file = tmp_path / "settings.toml" + settings_file.write_text("this is not: valid toml [[[") + session = CsvSession(view, GuiLog(log), SettingsHolder(settings_file)) + + session.add_feature_content(categories_contents) + + assert "Error reading in settings file" in log.text(LogSection.CSV_FEATURES) + assert session.features is None + assert not view.called("set_people_input_enabled") + + +def test_categories_with_no_flexibility_set_the_panel_size(session: CsvSession, view: CallRecorder) -> None: + """When min and max selection are equal there is only one possible size, so it is filled in.""" + categories = "category,name,min,max\ngender,Female,5,5\ngender,Male,5,5\n" + + session.add_feature_content(categories) + + assert view.last_args("set_panel_size") == (10,) + assert session.panel_size == 10 + + +####################### +# loading the people +####################### + + +def test_loading_people_reports_the_count( + session: CsvSession, + log: RecordingLogView, + categories_contents: str, + people_contents: str, +) -> None: + session.add_feature_content(categories_contents) + + session.add_people_content(people_contents) + + selection_log = log.text(LogSection.CSV_SELECTION) + assert f"Loaded {PEOPLE_COUNT} people." in selection_log + assert "Successfully loaded features and people." in selection_log + + +def test_loading_people_before_categories_is_an_error(session: CsvSession, people_contents: str) -> None: + """The people input is disabled until categories load, so this should be unreachable.""" + with pytest.raises(AssertionError): + session.add_people_content(people_contents) + + +def test_people_that_do_not_match_the_categories_are_reported( + session: CsvSession, + view: CallRecorder, + log: RecordingLogView, + categories_contents: str, +) -> None: + session.add_feature_content(categories_contents) + + session.add_people_content("nationbuilder_id,first_name\n1,Alice\n") + + assert "Failed to load people" in log.text(LogSection.CSV_SELECTION) + assert view.last_args("set_run_enabled") == (False,) + + +def test_reloading_categories_reparses_the_people( + loaded_session: CsvSession, + log: RecordingLogView, + categories_contents: str, +) -> None: + """People are held as parsed data, so new categories mean the people must be read again.""" + loaded_session.add_feature_content(categories_contents) + + assert f"Loaded {PEOPLE_COUNT} people." in log.text(LogSection.CSV_SELECTION) + + +##################### +# the panel size +##################### + + +def test_setting_the_panel_size_is_not_echoed_back_to_the_view(loaded_session: CsvSession, view: CallRecorder) -> None: + """The view is the source of truth while the user is choosing, so it is left alone.""" + loaded_session.set_panel_size(PANEL_MIN) + + assert not view.called("set_panel_size") + + +@pytest.mark.parametrize( + ("with_features", "with_people", "panel_size", "expected"), + [ + (False, False, 0, False), + (False, False, PANEL_MIN, False), + (False, True, PANEL_MIN, False), + (True, False, PANEL_MIN, False), + (True, True, 0, False), + (True, True, PANEL_MIN, True), + ], +) +def test_run_button_needs_categories_people_and_a_size( + session: CsvSession, + view: CallRecorder, + categories_contents: str, + with_features: bool, + with_people: bool, + panel_size: int, + expected: bool, +) -> None: + if with_features: + session.add_feature_content(categories_contents) + if with_people: + # people are only loadable once features are, so set the state directly + session.people = cast("people.People", object()) + + session.set_panel_size(panel_size) + + assert view.last_args("set_run_enabled") == (expected,) + + +##################### +# running a selection +##################### + + +def test_running_a_selection_offers_both_outputs(loaded_session: CsvSession, view: CallRecorder) -> None: + loaded_session.set_panel_size(PANEL_MIN) + + loaded_session.run_selection(test_selection=False) + + selected_contents, selected_name = view.last_args("offer_selected") + remaining_contents, remaining_name = view.last_args("offer_remaining") + assert selected_name == "selected.csv" + assert remaining_name == "remaining.csv" + # header row plus one row per person + assert len(selected_contents.strip().splitlines()) == PANEL_MIN + 1 + assert len(remaining_contents.strip().splitlines()) == PEOPLE_COUNT - PANEL_MIN + 1 + + +def test_a_selection_starts_by_clearing_the_log(loaded_session: CsvSession, log: RecordingLogView) -> None: + loaded_session.set_panel_size(PANEL_MIN) + + loaded_session.run_selection(test_selection=False) + + assert log.entries(LogSection.DETAILED_LOG)[0] == "Selecting... please wait..." + + +def test_running_a_selection_twice_offers_only_the_second_panel( + loaded_session: CsvSession, + view: CallRecorder, +) -> None: + """The old app appended the second run's output to the first, so pin the fix down.""" + loaded_session.set_panel_size(PANEL_MIN) + + loaded_session.run_selection(test_selection=False) + loaded_session.run_selection(test_selection=False) + + selected_contents, _ = view.last_args("offer_selected") + assert len(selected_contents.strip().splitlines()) == PANEL_MIN + 1 + + +def test_the_selected_and_remaining_output_partitions_the_pool(loaded_session: CsvSession, view: CallRecorder) -> None: + loaded_session.set_panel_size(PANEL_MIN) + + loaded_session.run_selection(test_selection=False) + + selected_ids = ids_from_csv(view.last_args("offer_selected")[0]) + remaining_ids = ids_from_csv(view.last_args("offer_remaining")[0]) + assert len(selected_ids) == PANEL_MIN + assert not selected_ids & remaining_ids + assert len(selected_ids | remaining_ids) == PEOPLE_COUNT + + +def ids_from_csv(contents: str) -> set[str]: + lines = contents.strip().splitlines() + return {line.split(",")[0] for line in lines[1:]} + + +def test_an_impossible_selection_offers_no_output( + session: CsvSession, + view: CallRecorder, + log: RecordingLogView, + categories_contents: str, + people_too_few_contents: str, +) -> None: + session.add_feature_content(categories_contents) + session.add_people_content(people_too_few_contents) + session.set_panel_size(PANEL_MIN) + + session.run_selection(test_selection=False) + + assert "No panels written to CSV, process ended." in log.text(LogSection.DETAILED_LOG) + assert not view.called("offer_selected") + + +def test_the_progress_reporter_is_handed_to_the_library( + loaded_session: CsvSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Without this the library reports progress into the void and the bar never moves.""" + captured: dict[str, object] = {} + reporter = NullProgressReporter() + + def capture(**kwargs: object) -> tuple[bool, list[frozenset[str]], RunReport]: + captured.update(kwargs) + return True, [frozenset()], RunReport() + + monkeypatch.setattr("strat_app.sessions.csv_session.core.run_stratification", capture) + loaded_session.progress_reporter = reporter + loaded_session.set_panel_size(PANEL_MIN) + + loaded_session.run_selection(test_selection=False) + + assert captured["progress_reporter"] is reporter + + +def test_no_progress_reporter_is_still_a_valid_call( + loaded_session: CsvSession, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """0.12 takes progress_reporter=None happily, so there is no need to omit the argument.""" + captured: dict[str, object] = {} + + def capture(**kwargs: object) -> tuple[bool, list[frozenset[str]], RunReport]: + captured.update(kwargs) + return True, [frozenset()], RunReport() + + monkeypatch.setattr("strat_app.sessions.csv_session.core.run_stratification", capture) + loaded_session.set_panel_size(PANEL_MIN) + + loaded_session.run_selection(test_selection=False) + + assert captured["progress_reporter"] is None + + +def test_an_error_during_selection_is_reported_and_leaves_the_app_usable( + loaded_session: CsvSession, + log: RecordingLogView, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def explode(**kwargs: object) -> None: + msg = "the solver fell over" + raise RuntimeError(msg) + + monkeypatch.setattr("strat_app.sessions.csv_session.core.run_stratification", explode) + loaded_session.set_panel_size(PANEL_MIN) + + loaded_session.run_selection(test_selection=False) + + detailed_log = log.text(LogSection.DETAILED_LOG) + assert "Unexpected error during selection: the solver fell over" in detailed_log + assert "Selection failed, process ended." in detailed_log + assert loaded_session.people is not None diff --git a/tests/unit/test_gsheet_session.py b/tests/unit/test_gsheet_session.py new file mode 100644 index 0000000..e636899 --- /dev/null +++ b/tests/unit/test_gsheet_session.py @@ -0,0 +1,379 @@ +# ABOUTME: Unit tests for the Google Sheet tab's logic, against a fixture-backed data source. +# ABOUTME: No credentials and no network, so the whole flow runs anywhere the suite does. + +from pathlib import Path +from typing import cast + +import gspread +import pytest +from sortition_algorithms import adapters + +from strat_app.sessions.gsheet_session import GSheetSession +from strat_app.sessions.log import GuiLog +from strat_app.sessions.view import LogSection +from strat_app.settings_holder import SettingsHolder +from tests.fakes import CallRecorder, FakeGSheetDataSource, RecordingLogView + +PANEL_MIN = 22 +PANEL_MAX = 24 +SHEET_NAME = "My Assembly" + + +@pytest.fixture +def log() -> RecordingLogView: + return RecordingLogView() + + +@pytest.fixture +def view() -> CallRecorder: + return CallRecorder() + + +@pytest.fixture +def data_source(categories_contents: str, people_contents: str) -> FakeGSheetDataSource: + return FakeGSheetDataSource( + tabs={"Categories": categories_contents, "Respondents": people_contents}, + sheet_names=[SHEET_NAME], + ) + + +def make_session( + view: CallRecorder, log: RecordingLogView, settings_path: Path, data_source: FakeGSheetDataSource +) -> GSheetSession: + """The fake stands in for a GSheetDataSource without inheriting from it, hence the cast.""" + return GSheetSession( + view, + GuiLog(log), + SettingsHolder(settings_path), + data_source=cast("adapters.GSheetDataSource", data_source), + ) + + +@pytest.fixture +def session( + view: CallRecorder, + log: RecordingLogView, + settings_path: Path, + data_source: FakeGSheetDataSource, +) -> GSheetSession: + return make_session(view, log, settings_path, data_source) + + +@pytest.fixture +def loaded_session(session: GSheetSession) -> GSheetSession: + session.update_g_sheet_name(SHEET_NAME) + session.load_g_sheet() + return session + + +############################ +# the spreadsheet name +############################ + + +def test_entering_a_sheet_name_enables_the_load_button(session: GSheetSession, view: CallRecorder) -> None: + session.update_g_sheet_name(SHEET_NAME) + + assert view.last_args("set_load_enabled") == (True,) + assert view.last_args("set_run_enabled") == (False,) + + +def test_clearing_the_sheet_name_leaves_the_load_button_alone(session: GSheetSession, view: CallRecorder) -> None: + session.update_g_sheet_name("") + + assert not view.called("set_load_enabled") + + +def test_loading_with_no_sheet_name_asks_for_one( + session: GSheetSession, + view: CallRecorder, + log: RecordingLogView, +) -> None: + session.load_g_sheet() + + assert log.text(LogSection.GSHEET_FEATURES) == "Please enter a spreadsheet name..." + assert not view.called("set_load_enabled") + + +def test_a_missing_spreadsheet_is_reported( + session: GSheetSession, + log: RecordingLogView, + data_source: FakeGSheetDataSource, +) -> None: + session.update_g_sheet_name("No Such Sheet") + + session.load_g_sheet() + + assert "Loading spreadsheet failed: Cannot find spreadsheet No Such Sheet" in log.text(LogSection.GSHEET_FEATURES) + + +########################### +# loading the spreadsheet +########################### + + +def test_loading_a_sheet_reports_categories_and_people(loaded_session: GSheetSession, log: RecordingLogView) -> None: + assert "Number of features found: 4" in log.text(LogSection.GSHEET_FEATURES) + assert "Successfully loaded features and people." in log.text(LogSection.GSHEET_SELECTION) + + +def test_loading_a_sheet_sets_the_panel_size_range(loaded_session: GSheetSession, view: CallRecorder) -> None: + assert view.last_args("set_panel_size_range") == (PANEL_MIN, PANEL_MAX) + + +def test_a_missing_tab_is_reported_once(session: GSheetSession, log: RecordingLogView) -> None: + session.update_g_sheet_name(SHEET_NAME) + session.update_features_tab_name("Not A Tab") + + session.load_g_sheet() + + output = log.text(LogSection.GSHEET_FEATURES) + assert "no tab called 'Not A Tab'" in output + assert "Loading spreadsheet failed, see above messages." in output + # KnownFailureError means the error is not reported a second time + assert output.count("no tab called") == 1 + + +def test_changing_the_categories_tab_resets_features_and_people(loaded_session: GSheetSession) -> None: + loaded_session.update_features_tab_name("Other Categories") + + assert loaded_session.features is None + assert loaded_session.people is None + + +def test_changing_the_respondents_tab_resets_only_the_people(loaded_session: GSheetSession) -> None: + """Categories are unaffected by a different respondents tab, so they are kept.""" + loaded_session.update_people_tab_name("Other Respondents") + + assert loaded_session.features is not None + assert loaded_session.people is None + + +def test_resetting_clears_the_range_before_the_size(loaded_session: GSheetSession, view: CallRecorder) -> None: + """A view that clamps the size to the range can only go back to zero in that order.""" + view.reset() + + loaded_session.update_features_tab_name("Other Categories") + + assert view.names.index("set_panel_size_range") < view.names.index("set_panel_size") + assert view.last_args("set_panel_size_range") == (0, 0) + assert view.last_args("set_panel_size") == (0,) + + +######################################### +# messy header rows, which 0.12 tolerates +######################################### + + +def pad_header(contents: str) -> str: + """Put spaces around every name in the header row, as a hand-edited sheet often has.""" + header, _, body = contents.partition("\n") + padded = ",".join(f" {name} " for name in header.split(",")) + return f"{padded}\n{body}" + + +def test_padded_respondent_headers_are_tolerated( + view: CallRecorder, + log: RecordingLogView, + settings_path: Path, + categories_contents: str, + people_contents: str, +) -> None: + """ + 0.12 strips whitespace from header names, so a hand-edited tab stops being an error. + + This is the fix that matters most to us of everything in the bump - a stray space in + a spreadsheet header used to produce a parse error that named a column the user could + see was spelled correctly. + """ + data_source = FakeGSheetDataSource( + tabs={"Categories": categories_contents, "Respondents": pad_header(people_contents)}, + sheet_names=[SHEET_NAME], + ) + session = make_session(view, log, settings_path, data_source) + session.update_g_sheet_name(SHEET_NAME) + + session.load_g_sheet() + + assert session.people is not None + assert "Successfully loaded features and people." in log.text(LogSection.GSHEET_SELECTION) + + +def test_padded_category_headers_are_not_tolerated_yet( + view: CallRecorder, + log: RecordingLogView, + settings_path: Path, + categories_contents: str, +) -> None: + """ + The same padding on the categories tab still fails, which is an upstream gap. + + 0.12 put normalise_iter() into SelectionData.load_people and + load_already_selected but not load_features, so only two of the three loaders + strip their header names. The error names columns the user can see spelled + correctly in front of them, which is the confusion the fix was meant to remove. + + Written down rather than worked around: this belongs upstream, and is issue 5 in + llm-working/upstream.md. When that lands this test goes red and should become the + positive assertion its sibling above already makes. + """ + data_source = FakeGSheetDataSource( + tabs={"Categories": pad_header(categories_contents), "Respondents": ""}, + sheet_names=[SHEET_NAME], + ) + session = make_session(view, log, settings_path, data_source) + session.update_g_sheet_name(SHEET_NAME) + + session.load_g_sheet() + + assert session.features is None + assert "Did not find required column name 'category'" in log.text(LogSection.GSHEET_FEATURES) + + +############################### +# more than one selection +############################### + + +def test_asking_for_several_selections_warns_and_skips_the_remaining_tab( + session: GSheetSession, + log: RecordingLogView, +) -> None: + session.update_g_sheet_name(SHEET_NAME) + session.set_number_selections(2) + + session.load_g_sheet() + + assert "You've asked for 2 selections" in log.text(LogSection.GSHEET_SELECTION) + assert session._safe_gen_rem_tab() is False # noqa: SLF001 + + +def test_one_selection_keeps_the_remaining_tab(session: GSheetSession) -> None: + session.set_number_selections(1) + + assert session._safe_gen_rem_tab() is True # noqa: SLF001 + + +def test_turning_off_the_remaining_tab_is_respected(session: GSheetSession) -> None: + session.update_gen_rem_tab(gen_rem_tab=False) + + assert session._safe_gen_rem_tab() is False # noqa: SLF001 + + +def test_the_test_panel_button_is_refused_for_several_selections( + loaded_session: GSheetSession, view: CallRecorder +) -> None: + """ + The warning already promises the button is refused, so the button had better be. + + The library raises for a test selection of more than one panel, and since 0.12 that + is a ConfigurationError, which run_stratification lets through instead of turning + into a report. So the tidy message the user used to get is gone, and the honest fix + is not to let them ask. + """ + loaded_session.set_number_selections(2) + loaded_session.set_panel_size(PANEL_MIN) + + assert view.last_args("set_run_enabled") == (True,) + assert view.last_args("set_test_run_enabled") == (False,) + + +def test_the_test_panel_button_comes_back_for_one_selection(loaded_session: GSheetSession, view: CallRecorder) -> None: + loaded_session.set_number_selections(2) + loaded_session.set_panel_size(PANEL_MIN) + + loaded_session.set_number_selections(1) + loaded_session.set_panel_size(PANEL_MIN) + + assert view.last_args("set_test_run_enabled") == (True,) + + +def test_the_test_panel_button_stays_off_until_a_sheet_is_loaded(session: GSheetSession, view: CallRecorder) -> None: + session.set_number_selections(1) + + assert view.last_args("set_test_run_enabled") == (False,) + + +########################### +# running a selection +########################### + + +def test_running_a_selection_writes_the_output( + loaded_session: GSheetSession, + log: RecordingLogView, + data_source: FakeGSheetDataSource, +) -> None: + loaded_session.set_panel_size(PANEL_MIN) + + loaded_session.run_selection(test_selection=False) + + detailed_log = log.text(LogSection.DETAILED_LOG) + assert "All spreadsheet writing has finished." in detailed_log + assert "Selection process finished." in detailed_log + written = data_source.selected_file.getvalue().strip().splitlines() + assert written[0].startswith("nationbuilder_id") + assert len(written) == PANEL_MIN + 1 + + +def test_the_remaining_tab_reports_shared_addresses( + loaded_session: GSheetSession, + log: RecordingLogView, + data_source: FakeGSheetDataSource, +) -> None: + loaded_session.set_panel_size(PANEL_MIN) + + loaded_session.run_selection(test_selection=False) + + # nobody in the fixture shares an address, so there is nothing to highlight + assert data_source.highlighted_dupes == [] + assert "there are 0 people who share the same address" in log.text(LogSection.DETAILED_LOG) + + +def test_an_impossible_selection_writes_nothing( + view: CallRecorder, + log: RecordingLogView, + settings_path: Path, + categories_contents: str, + people_too_few_contents: str, +) -> None: + data_source = FakeGSheetDataSource( + tabs={"Categories": categories_contents, "Respondents": people_too_few_contents}, + sheet_names=[SHEET_NAME], + ) + session = make_session(view, log, settings_path, data_source) + session.update_g_sheet_name(SHEET_NAME) + session.load_g_sheet() + session.set_panel_size(PANEL_MIN) + + session.run_selection(test_selection=False) + + assert "No panels written to spreadsheet, process ended." in log.text(LogSection.DETAILED_LOG) + assert data_source.selected_file.getvalue() == "" + + +def test_an_api_error_while_loading_explains_the_delay( + session: GSheetSession, + log: RecordingLogView, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """gspread raises this when the sheet was written to moments ago.""" + + class FakeResponse: + status_code = 429 + text = "quota exceeded" + + def json(self) -> dict[str, object]: + return {"error": {"code": 429, "message": "quota exceeded", "status": "RESOURCE_EXHAUSTED"}} + + def raise_api_error(*args: object, **kwargs: object) -> None: + raise gspread.exceptions.APIError(FakeResponse()) # type: ignore[arg-type] + + monkeypatch.setattr(adapters.SelectionData, "load_features", raise_api_error) + session.update_g_sheet_name(SHEET_NAME) + + session.load_g_sheet() + + output = log.text(LogSection.GSHEET_FEATURES) + assert "API error causing delay" in output + assert "Loading spreadsheet failed, see above messages." in output diff --git a/tests/unit/test_gui_log.py b/tests/unit/test_gui_log.py new file mode 100644 index 0000000..85851f5 --- /dev/null +++ b/tests/unit/test_gui_log.py @@ -0,0 +1,70 @@ +# ABOUTME: Unit tests for the accumulating log that feeds the app's output areas. +# ABOUTME: Sections must not leak into each other, and a reset must really reset. + +from sortition_algorithms.utils import RunReport + +from strat_app.sessions.log import GuiLog +from strat_app.sessions.view import LogSection +from tests.fakes import RecordingLogView + + +def test_entries_accumulate_in_the_section_they_were_added_to() -> None: + log_view = RecordingLogView() + gui_log = GuiLog(log_view) + + gui_log.add(LogSection.CSV_FEATURES, "first") + gui_log.add(LogSection.CSV_FEATURES, "second") + + assert log_view.text(LogSection.CSV_FEATURES) == "first\nsecond" + assert log_view.text(LogSection.CSV_SELECTION) == "" + + +def test_adding_several_entries_at_once() -> None: + log_view = RecordingLogView() + gui_log = GuiLog(log_view) + + gui_log.add_all(LogSection.DETAILED_LOG, ["one", "two"]) + + assert log_view.text(LogSection.DETAILED_LOG) == "one\ntwo" + + +def test_resetting_a_section_empties_it() -> None: + log_view = RecordingLogView() + gui_log = GuiLog(log_view) + gui_log.add(LogSection.DETAILED_LOG, "old news") + + gui_log.reset(LogSection.DETAILED_LOG) + + assert log_view.text(LogSection.DETAILED_LOG) == "" + + +def test_resetting_a_section_can_leave_a_message_behind() -> None: + log_view = RecordingLogView() + gui_log = GuiLog(log_view) + gui_log.add(LogSection.DETAILED_LOG, "old news") + + gui_log.reset(LogSection.DETAILED_LOG, "Selecting... please wait...") + + assert log_view.text(LogSection.DETAILED_LOG) == "Selecting... please wait..." + + +def test_reports_are_kept_as_reports_so_the_view_can_render_them() -> None: + log_view = RecordingLogView() + gui_log = GuiLog(log_view) + report = RunReport() + report.add_line("something happened") + + gui_log.add(LogSection.CSV_FEATURES, report) + + assert log_view.entries(LogSection.CSV_FEATURES) == [report] + assert "something happened" in log_view.text(LogSection.CSV_FEATURES) + + +def test_updating_all_areas_pushes_every_section() -> None: + log_view = RecordingLogView() + gui_log = GuiLog(log_view) + gui_log.entries[LogSection.CSV_FEATURES] = ["set behind the view's back"] + + gui_log.update_all_areas() + + assert log_view.text(LogSection.CSV_FEATURES) == "set behind the view's back" diff --git a/tests/unit/test_progress_throttle.py b/tests/unit/test_progress_throttle.py new file mode 100644 index 0000000..624e6f9 --- /dev/null +++ b/tests/unit/test_progress_throttle.py @@ -0,0 +1,138 @@ +# ABOUTME: Unit tests for the throttle that stands between the library and the GUI thread. +# ABOUTME: No Qt here - the deciding is pure, so it is tested against a clock we control. + +import pytest + +from strat_app.sessions.progress import PhaseStart, ProgressThrottle, ProgressUpdate + +INTERVAL = 0.1 + + +class FakeClock: + """A clock that only moves when a test says so.""" + + def __init__(self) -> None: + self.now = 1000.0 + + def __call__(self) -> float: + return self.now + + def advance(self, seconds: float) -> None: + self.now += seconds + + +@pytest.fixture +def clock() -> FakeClock: + return FakeClock() + + +@pytest.fixture +def throttle(clock: FakeClock) -> ProgressThrottle: + return ProgressThrottle(min_interval_seconds=INTERVAL, now=clock) + + +def test_a_phase_start_always_goes_through(throttle: ProgressThrottle) -> None: + assert throttle.start_phase("multiplicative_weights", 200, "Searching") == PhaseStart( + name="multiplicative_weights", total=200, message="Searching" + ) + + +def test_the_first_update_in_a_phase_goes_through(throttle: ProgressThrottle) -> None: + throttle.start_phase("multiplicative_weights", 200, "Searching") + + assert throttle.update(1, "Round 1/200") == ProgressUpdate(current=1, total=200, message="Round 1/200") + + +def test_a_second_update_too_soon_is_dropped(throttle: ProgressThrottle, clock: FakeClock) -> None: + throttle.start_phase("multiplicative_weights", 200, "Searching") + throttle.update(1, "Round 1/200") + + clock.advance(INTERVAL / 2) + + assert throttle.update(2, "Round 2/200") is None + + +def test_an_update_after_the_interval_goes_through(throttle: ProgressThrottle, clock: FakeClock) -> None: + throttle.start_phase("multiplicative_weights", 200, "Searching") + throttle.update(1, "Round 1/200") + + clock.advance(INTERVAL) + + assert throttle.update(2, "Round 2/200") is not None + + +def test_hammering_update_is_bounded_by_the_clock(throttle: ProgressThrottle, clock: FakeClock) -> None: + """ + The library calls update() every iteration and explicitly does not throttle. + + Its own docs say that can be hundreds of times per second on a fast solver. One Qt + signal per call would flood the GUI thread's event queue, so this is the test that + stops a later refactor quietly removing the throttle. + """ + throttle.start_phase("maximin_optimization", None, "Optimizing") + let_through = 0 + + for current in range(1000): + if throttle.update(current, f"Round {current}") is not None: + let_through += 1 + clock.advance(INTERVAL / 100) + + assert let_through == pytest.approx(10, abs=1) + + +def test_a_phase_start_is_not_throttled_away_behind_an_update(throttle: ProgressThrottle, clock: FakeClock) -> None: + """A phase change is what the label shows, so it must never wait for the interval.""" + throttle.start_phase("multiplicative_weights", 200, "Searching") + throttle.update(1, "Round 1/200") + + assert throttle.start_phase("maximin_optimization", None, "Optimizing") is not None + + +def test_the_first_update_of_a_new_phase_is_not_throttled(throttle: ProgressThrottle) -> None: + throttle.start_phase("multiplicative_weights", 200, "Searching") + throttle.update(1, "Round 1/200") + throttle.start_phase("maximin_optimization", None, "Optimizing") + + assert throttle.update(1, "Iteration 1") is not None + + +def test_ending_a_phase_reports_the_last_value_even_if_it_was_throttled( + throttle: ProgressThrottle, clock: FakeClock +) -> None: + """Otherwise a determinate bar stops at whatever it happened to be showing.""" + throttle.start_phase("multiplicative_weights", 200, "Searching") + throttle.update(1, "Round 1/200") + clock.advance(INTERVAL / 2) + throttle.update(200, "Round 200/200") + + assert throttle.end_phase() == ProgressUpdate(current=200, total=200, message="Round 200/200") + + +def test_ending_a_phase_whose_last_value_was_already_reported_says_nothing( + throttle: ProgressThrottle, clock: FakeClock +) -> None: + throttle.start_phase("multiplicative_weights", 200, "Searching") + clock.advance(INTERVAL) + throttle.update(200, "Round 200/200") + + assert throttle.end_phase() is None + + +def test_ending_a_phase_with_no_updates_says_nothing(throttle: ProgressThrottle) -> None: + throttle.start_phase("diversimax", None, "Solving") + + assert throttle.end_phase() is None + + +def test_an_update_outside_any_phase_is_ignored(throttle: ProgressThrottle) -> None: + """The library should not do this, but a reporter that crashes on it helps nobody.""" + assert throttle.update(1, "Round 1") is None + + +def test_the_total_comes_from_the_phase_not_the_update(throttle: ProgressThrottle) -> None: + throttle.start_phase("maximin_optimization", None, "Optimizing") + + update = throttle.update(7, "Iteration 7") + + assert update is not None + assert update.total is None diff --git a/tests/unit/test_settings_holder.py b/tests/unit/test_settings_holder.py new file mode 100644 index 0000000..4964e3f --- /dev/null +++ b/tests/unit/test_settings_holder.py @@ -0,0 +1,163 @@ +# ABOUTME: Unit tests for loading the settings TOML once and reporting problems with it. +# ABOUTME: Every path here is one the user hits on a fresh machine or a fat-fingered edit. + +from pathlib import Path + +import pytest + +from strat_app.sessions.log import GuiLog +from strat_app.sessions.view import LogSection +from strat_app.settings_holder import SettingsHolder +from tests.fakes import RecordingLogView + + +def write_settings(path: Path, **overrides: str) -> Path: + """Write the smallest settings file the library will load, plus any overrides.""" + lines = [ + 'id_column = "nationbuilder_id"', + 'columns_to_keep = ["first_name"]', + "check_same_address = false", + *[f'{key} = "{value}"' for key, value in overrides.items()], + ] + path.write_text("\n".join(lines) + "\n") + return path + + +def test_a_missing_settings_file_is_written_with_the_defaults(settings_path: Path) -> None: + holder = SettingsHolder(settings_path) + + message = holder.init_settings() + + assert settings_path.is_file() + assert "Wrote default settings" in message + assert holder.loaded() + + +def test_settings_are_only_loaded_once(settings_path: Path) -> None: + holder = SettingsHolder(settings_path) + holder.init_settings() + + assert holder.init_settings() == "" + + +def test_a_broken_settings_file_is_reported_and_not_loaded(tmp_path: Path) -> None: + settings_file = tmp_path / "settings.toml" + settings_file.write_text("id_column = ") + holder = SettingsHolder(settings_file) + + message = holder.init_settings() + + assert "Error reading in settings file" in message + assert not holder.loaded() + + +def test_a_broken_settings_file_is_reported_every_time_it_is_asked_for(tmp_path: Path) -> None: + """Nothing is cached on failure, so the user sees the problem wherever they go next.""" + settings_file = tmp_path / "settings.toml" + settings_file.write_text("id_column = ") + holder = SettingsHolder(settings_file) + + assert "Error reading in settings file" in holder.init_settings() + assert "Error reading in settings file" in holder.init_settings() + + +def test_the_message_goes_into_the_log_section_asked_for(settings_path: Path) -> None: + log = RecordingLogView() + holder = SettingsHolder(settings_path) + + holder.init_settings_log(GuiLog(log), LogSection.GSHEET_FEATURES) + + assert "Wrote default settings" in log.text(LogSection.GSHEET_FEATURES) + assert log.text(LogSection.CSV_FEATURES) == "" + + +def test_nothing_is_logged_when_there_is_nothing_to_say(settings_path: Path) -> None: + log = RecordingLogView() + holder = SettingsHolder(settings_path) + holder.init_settings() + + holder.init_settings_log(GuiLog(log), LogSection.CSV_FEATURES) + + assert log.text(LogSection.CSV_FEATURES) == "" + + +@pytest.mark.parametrize("backend", ["mip", "mip-cbc", "mip-highs", "mip-gurobi"]) +def test_a_mip_solver_backend_is_refused(tmp_path: Path, backend: str) -> None: + """ + The mip solver is not in the build, so saying so now beats failing ten minutes in. + + python-mip keeps CBC in a 275MB wheel we deliberately leave out of the bundle, so + these are all values the library accepts and this build cannot honour. + """ + holder = SettingsHolder(write_settings(tmp_path / "settings.toml", solver_backend=backend)) + + message = holder.init_settings() + + assert backend in message + assert "highspy" in message + assert not holder.loaded() + + +def test_the_diversimax_algorithm_is_refused(tmp_path: Path) -> None: + """diversimax needs pandas and scikit-learn, which this app does not ship.""" + holder = SettingsHolder(write_settings(tmp_path / "settings.toml", selection_algorithm="diversimax")) + + message = holder.init_settings() + + assert "diversimax" in message + assert not holder.loaded() + + +def test_the_refusal_names_the_algorithms_that_do_work(tmp_path: Path) -> None: + holder = SettingsHolder(write_settings(tmp_path / "settings.toml", selection_algorithm="diversimax")) + + message = holder.init_settings() + + assert "maximin" in message + + +@pytest.mark.parametrize("algorithm", ["legacy", "maximin", "nash", "leximin"]) +def test_the_algorithms_this_build_supports_are_accepted(tmp_path: Path, algorithm: str) -> None: + holder = SettingsHolder(write_settings(tmp_path / "settings.toml", selection_algorithm=algorithm)) + + assert "not available in this build" not in holder.init_settings() + assert holder.loaded() + + +def test_the_highspy_backend_is_accepted(tmp_path: Path) -> None: + holder = SettingsHolder(write_settings(tmp_path / "settings.toml", solver_backend="highspy")) + + assert "not available in this build" not in holder.init_settings() + assert holder.loaded() + + +def test_the_settings_file_written_for_a_new_user_is_supported(settings_path: Path) -> None: + """ + The library writes its own defaults on a fresh machine, so they had better pass. + + If the library ever changes DEFAULT_BACKEND to something we exclude, this is the + test that says so before a user finds out. + """ + holder = SettingsHolder(settings_path) + + message = holder.init_settings() + + assert "not available in this build" not in message + assert holder.loaded() + + +def test_an_unsupported_setting_is_reported_every_time_it_is_asked_for(tmp_path: Path) -> None: + """Nothing is cached on refusal, so the user sees the problem wherever they go next.""" + holder = SettingsHolder(write_settings(tmp_path / "settings.toml", solver_backend="mip")) + + assert "not available in this build" in holder.init_settings() + assert "not available in this build" in holder.init_settings() + + +def test_an_unsupported_setting_is_reported_into_the_log_section(tmp_path: Path) -> None: + log = RecordingLogView() + holder = SettingsHolder(write_settings(tmp_path / "settings.toml", solver_backend="mip")) + + holder.init_settings_log(GuiLog(log), LogSection.CSV_FEATURES) + + assert "not available in this build" in log.text(LogSection.CSV_FEATURES) diff --git a/uv.lock b/uv.lock index 030948a..a73add8 100644 --- a/uv.lock +++ b/uv.lock @@ -7,6 +7,10 @@ resolution-markers = [ "sys_platform != 'emscripten' and sys_platform != 'win32'", ] +[options] +exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer-span = "P1W" + [[package]] name = "altgraph" version = "0.17.5" @@ -17,32 +21,38 @@ wheels = [ ] [[package]] -name = "attrs" -version = "26.1.0" +name = "ast-serialize" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] [[package]] -name = "bottle" -version = "0.13.4" +name = "attrs" +version = "26.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/71/cca6167c06d00c81375fd668719df245864076d284f7cb46a694cbeb5454/bottle-0.13.4.tar.gz", hash = "sha256:787e78327e12b227938de02248333d788cfe45987edca735f8f88e03472c3f47", size = 98717, upload-time = "2025-06-15T10:08:59.439Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/f6/b55ec74cfe68c6584163faa311503c20b0da4c09883a41e8e00d6726c954/bottle-0.13.4-py2.py3-none-any.whl", hash = "sha256:045684fbd2764eac9cdeb824861d1551d113e8b683d8d26e296898d3dd99a12e", size = 103807, upload-time = "2025-06-15T10:08:57.691Z" }, -] - -[[package]] -name = "bottle-websocket" -version = "0.2.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bottle" }, - { name = "gevent-websocket" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/8e/a22666b4bb0a6e31de579504077df2b1c2f1438136777c728e6cfabef295/bottle-websocket-0.2.9.tar.gz", hash = "sha256:9887f70dc0c7592ed8d0d11a14aa95dede6cd08d50d83d5b81fd963e5fec738b", size = 2007, upload-time = "2015-09-21T05:34:24.871Z" } [[package]] name = "cattrs" @@ -57,6 +67,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/56/60547f7801b97c67e97491dc3d9ade9fbccbd0325058fd3dfcb2f5d98d90/cattrs-26.1.0-py3-none-any.whl", hash = "sha256:d1e0804c42639494d469d08d4f26d6b9de9b8ab26b446db7b5f8c2e97f7c3096", size = 73054, upload-time = "2026-02-18T22:15:17.958Z" }, ] +[[package]] +name = "cbcbox" +version = "2.929" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/9e/c41844f3a746500b88982817233f3f2f4fa6d3a554cedb9222bac9efcbea/cbcbox-2.929-py3-none-macosx_15_0_arm64.whl", hash = "sha256:610fd250f737b19f599d56cca299682a4519472b3ff6a13044502eb71e03931e", size = 59626733, upload-time = "2026-03-23T15:28:20.758Z" }, + { url = "https://files.pythonhosted.org/packages/3f/08/172af0d618ede8668862ae9c83ffcd929bc966610c1ce357b268edb33645/cbcbox-2.929-py3-none-macosx_15_0_x86_64.whl", hash = "sha256:b369071081176fd55ba9d104c2f2330133c36f0264ebe0215c50a366f290a050", size = 87883726, upload-time = "2026-03-23T15:28:24.874Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c7/a57493dd959a57dcb8dc31aeef468c0e95d066d4bbf8fc59f9dda3a1fd97/cbcbox-2.929-py3-none-manylinux2014_aarch64.whl", hash = "sha256:62841ab4ed4e1c368bcba8d62c66cd7dd03f3dfaf1ecf4a01c6385a122d1946e", size = 145740150, upload-time = "2026-03-23T15:28:29.617Z" }, + { url = "https://files.pythonhosted.org/packages/4c/86/502b81d060603e1016a8cb471064e4e65d8009c2feed60ea878b8513a728/cbcbox-2.929-py3-none-manylinux2014_x86_64.whl", hash = "sha256:a57a403cc44fb2af0aa53cb790a7642390f1836a5ff6a7a0b31b30eb6597e3e0", size = 181208771, upload-time = "2026-03-23T15:28:35.544Z" }, + { url = "https://files.pythonhosted.org/packages/0c/85/2fef9c7c3054ebfb9b0e84fabbeac776a18514b025efa5abd768c889f560/cbcbox-2.929-py3-none-win_amd64.whl", hash = "sha256:0a91420befb965ec1763aab62d7908d3a89a51af845e2a4e05c09d99a67f1151", size = 135305901, upload-time = "2026-03-23T15:28:42.021Z" }, +] + [[package]] name = "certifi" version = "2026.4.22" @@ -273,73 +295,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/00/03faa5dfbcfdb29b7e28928dbef85e975724b435cd5a5db66354c9fccecf/cvxpy-1.8.2-cp312-cp312-win_amd64.whl", hash = "sha256:f6d4e54aaa6a5d681334843294d5203b818885332dfad179b652c9b986e670c6", size = 1296931, upload-time = "2026-03-22T21:45:27.604Z" }, ] -[[package]] -name = "eel" -version = "0.18.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "bottle" }, - { name = "bottle-websocket" }, - { name = "future" }, - { name = "importlib-resources" }, - { name = "pyparsing" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0c/e2/425bc21d095f2c51a319d120c83111bb23ff5ee887cb9cbbf840df4a21b4/eel-0.18.2.tar.gz", hash = "sha256:0f70b0f8aa2da57859b35d448ea89ad2102d56b206f8500f130df069e9df2c3a", size = 26849, upload-time = "2025-06-22T18:21:36.535Z" } - -[[package]] -name = "future" -version = "1.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/b2/4140c69c6a66432916b26158687e821ba631a4c9273c474343badf84d3ba/future-1.0.0.tar.gz", hash = "sha256:bd2968309307861edae1458a4f8a4f3598c03be43b97521076aebf5d94c07b05", size = 1228490, upload-time = "2024-02-21T11:52:38.461Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/71/ae30dadffc90b9006d77af76b393cb9dfbfc9629f339fc1574a1c52e6806/future-1.0.0-py3-none-any.whl", hash = "sha256:929292d34f5872e70396626ef385ec22355a1fae8ad29e1a734c3e43f9fbc216", size = 491326, upload-time = "2024-02-21T11:52:35.956Z" }, -] - -[[package]] -name = "gevent" -version = "24.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cffi", marker = "platform_python_implementation == 'CPython' and sys_platform == 'win32'" }, - { name = "greenlet", marker = "platform_python_implementation == 'CPython'" }, - { name = "zope-event" }, - { name = "zope-interface" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/27/24/a3a7b713acfcf1177207f49ec25c665123f8972f42bee641bcc9f32961f4/gevent-24.2.1.tar.gz", hash = "sha256:432fc76f680acf7cf188c2ee0f5d3ab73b63c1f03114c7cd8a34cebbe5aa2056", size = 6147507, upload-time = "2024-02-14T11:31:10.128Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/64/34/e561fb53ec80e81a83b76667c004c838a292dde8adf80ff289558b4a4df8/gevent-24.2.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:03aa5879acd6b7076f6a2a307410fb1e0d288b84b03cdfd8c74db8b4bc882fc5", size = 3017855, upload-time = "2024-02-14T11:26:23.685Z" }, - { url = "https://files.pythonhosted.org/packages/4a/db/64295bfd9a51874b715e82ba5ab971f2c298cf283297e4cf5bec37db17d9/gevent-24.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8bb35ce57a63c9a6896c71a285818a3922d8ca05d150fd1fe49a7f57287b836", size = 4877510, upload-time = "2024-02-14T12:09:43.242Z" }, - { url = "https://files.pythonhosted.org/packages/40/9c/8880eef385b31f694222f5c94b2b487a8b37b99aceeed3e93cb0cb038511/gevent-24.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7f87c2c02e03d99b95cfa6f7a776409083a9e4d468912e18c7680437b29222c", size = 5010258, upload-time = "2024-02-14T12:07:34.016Z" }, - { url = "https://files.pythonhosted.org/packages/9c/0e/bf924a9998137d51e8ba84bd600ff5de17e405284811b26307748c0e0f9b/gevent-24.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968581d1717bbcf170758580f5f97a2925854943c45a19be4d47299507db2eb7", size = 5066723, upload-time = "2024-02-14T12:10:56.261Z" }, - { url = "https://files.pythonhosted.org/packages/a1/bc/0f776a3f5a3c57e3f6bbe8abc3d39cc591f58aa03808b50af4f73ae4b238/gevent-24.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7899a38d0ae7e817e99adb217f586d0a4620e315e4de577444ebeeed2c5729be", size = 6700752, upload-time = "2024-02-14T11:53:59.856Z" }, - { url = "https://files.pythonhosted.org/packages/58/b8/aaf9ff71ba9a7012e04400726b0e0e6986460030dfae3168482069422305/gevent-24.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f5e8e8d60e18d5f7fd49983f0c4696deeddaf6e608fbab33397671e2fcc6cc91", size = 6679723, upload-time = "2024-02-14T11:59:14.753Z" }, - { url = "https://files.pythonhosted.org/packages/74/ee/6febc62ddd399b0f060785bea8ae3c994ce47dfe6ec46ece3b1a90cc496b/gevent-24.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fbfdce91239fe306772faab57597186710d5699213f4df099d1612da7320d682", size = 5434796, upload-time = "2024-02-14T12:25:50.016Z" }, - { url = "https://files.pythonhosted.org/packages/15/12/7c91964af7112b3b435aa836401d8ca212ba9d43bcfea34c770b73515740/gevent-24.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cdf66977a976d6a3cfb006afdf825d1482f84f7b81179db33941f2fc9673bb1d", size = 6776495, upload-time = "2024-02-14T12:01:16.975Z" }, - { url = "https://files.pythonhosted.org/packages/18/b1/bbaf6047b13c4b83cd81007298f4f8ddffd8674c130736423e79e7bb8b6a/gevent-24.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1dffb395e500613e0452b9503153f8f7ba587c67dd4a85fc7cd7aa7430cb02cc", size = 1525019, upload-time = "2024-02-14T11:39:23.072Z" }, - { url = "https://files.pythonhosted.org/packages/50/72/eb98be1cec2a3d0f46d3af49b034deb48a6d6d9a1958ee110bc2e1e600ac/gevent-24.2.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:6c47ae7d1174617b3509f5d884935e788f325eb8f1a7efc95d295c68d83cce40", size = 3007004, upload-time = "2024-02-14T11:28:20.476Z" }, - { url = "https://files.pythonhosted.org/packages/f7/14/4cc83275fcdfa1977224cc266b710dc71b810d6760f575d259ca3be7b4dd/gevent-24.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7cac622e11b4253ac4536a654fe221249065d9a69feb6cdcd4d9af3503602e0", size = 5142074, upload-time = "2024-02-14T12:09:45.269Z" }, - { url = "https://files.pythonhosted.org/packages/56/ce/583d29e524c5666f7d66116e818449bee649bba8088d0ac48bec6c006215/gevent-24.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf5b9c72b884c6f0c4ed26ef204ee1f768b9437330422492c319470954bc4cc7", size = 5307651, upload-time = "2024-02-14T12:07:36.645Z" }, - { url = "https://files.pythonhosted.org/packages/69/e7/072dfbf5c534516dcc91367d5dd5806ec8860b66c1df26b9d603493c1adb/gevent-24.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5de3c676e57177b38857f6e3cdfbe8f38d1cd754b63200c0615eaa31f514b4f", size = 5406093, upload-time = "2024-02-14T12:10:58.794Z" }, - { url = "https://files.pythonhosted.org/packages/d9/d3/f9d0f62cb6cb0421d0da2cffd10bad13b0f5d641c57ce35927bf8554661e/gevent-24.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4faf846ed132fd7ebfbbf4fde588a62d21faa0faa06e6f468b7faa6f436b661", size = 6730420, upload-time = "2024-02-14T11:54:02.399Z" }, - { url = "https://files.pythonhosted.org/packages/5b/eb/6b0e902e29283253324fe32317b805df289f05f0ef3e9859a721d403b71e/gevent-24.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:368a277bd9278ddb0fde308e6a43f544222d76ed0c4166e0d9f6b036586819d9", size = 6711332, upload-time = "2024-02-14T11:59:16.68Z" }, - { url = "https://files.pythonhosted.org/packages/0d/8b/02a07125324e23d64ec342ae7a4cff8dc7271114e787317a5f219027bf1b/gevent-24.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f8a04cf0c5b7139bc6368b461257d4a757ea2fe89b3773e494d235b7dd51119f", size = 5482031, upload-time = "2024-02-14T12:25:52.872Z" }, - { url = "https://files.pythonhosted.org/packages/5f/fe/288ccd562ac20d5e4ae2624313b699ee35c76be1faa9104b414bfe714a67/gevent-24.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9d8d0642c63d453179058abc4143e30718b19a85cbf58c2744c9a63f06a1d388", size = 6812353, upload-time = "2024-02-14T12:01:19.819Z" }, - { url = "https://files.pythonhosted.org/packages/2e/90/d9fcdc22864d0cf471630071c264289b9a803892d6f55e895a69c2e3574b/gevent-24.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:94138682e68ec197db42ad7442d3cf9b328069c3ad8e4e5022e6b5cd3e7ffae5", size = 1523715, upload-time = "2024-02-14T11:31:09.195Z" }, -] - -[[package]] -name = "gevent-websocket" -version = "0.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "gevent" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/98/d2/6fa19239ff1ab072af40ebf339acd91fb97f34617c2ee625b8e34bf42393/gevent-websocket-0.10.1.tar.gz", hash = "sha256:7eaef32968290c9121f7c35b973e2cc302ffb076d018c9068d2f5ca8b2d85fb0", size = 18366, upload-time = "2017-03-12T22:46:05.68Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/84/2dc373eb6493e00c884cc11e6c059ec97abae2678d42f06bf780570b0193/gevent_websocket-0.10.1-py3-none-any.whl", hash = "sha256:17b67d91282f8f4c973eba0551183fc84f56f1c90c8f6b6b30256f31f66f5242", size = 22987, upload-time = "2017-03-12T22:46:03.611Z" }, -] - [[package]] name = "google-auth" version = "2.52.0" @@ -366,34 +321,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261, upload-time = "2026-05-07T08:02:13.798Z" }, ] -[[package]] -name = "greenlet" -version = "3.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3c/3f/dbf99fb14bfeb88c28f16729215478c0e265cacd6dc22270c8f31bb6892f/greenlet-3.5.0.tar.gz", hash = "sha256:d419647372241bc68e957bf38d5c1f98852155e4146bd1e4121adea81f4f01e4", size = 196995, upload-time = "2026-04-27T13:37:15.544Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8b/0f/a91f143f356523ff682309732b175765a9bc2836fd7c081c2c67fedc1ad4/greenlet-3.5.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:8f1cc966c126639cd152fdaa52624d2655f492faa79e013fea161de3e6dda082", size = 284726, upload-time = "2026-04-27T12:20:51.402Z" }, - { url = "https://files.pythonhosted.org/packages/95/82/800646c7ffc5dbabd75ddd2f6b519bb898c0c9c969e5d0473bfe5d20bcce/greenlet-3.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:362624e6a8e5bca3b8233e45eef33903a100e9539a2b995c364d595dbc4018b3", size = 604264, upload-time = "2026-04-27T12:52:39.494Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ac/354867c0bba812fc33b15bc55aedafedd0aee3c7dd91dfca22444157dc0c/greenlet-3.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5ecd83806b0f4c2f53b1018e0005cd82269ea01d42befc0368730028d850ed1c", size = 616099, upload-time = "2026-04-27T12:59:39.623Z" }, - { url = "https://files.pythonhosted.org/packages/c9/ab/192090c4a5b30df148c22bf4b8895457d739a7c7c5a7b9c41e5dd7f537f2/greenlet-3.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa94cb2288681e3a11645958f1871d48ee9211bd2f66628fdace505927d6e564", size = 623976, upload-time = "2026-04-27T13:02:37.363Z" }, - { url = "https://files.pythonhosted.org/packages/ff/b0/815bece7399e01cadb69014219eebd0042339875c59a59b0820a46ece356/greenlet-3.5.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ff251e9a0279522e62f6176412869395a64ddf2b5c5f782ff609a8216a4e662", size = 615198, upload-time = "2026-04-27T12:25:25.928Z" }, - { url = "https://files.pythonhosted.org/packages/24/11/05eb2b9b188c6df7d68a89c99134d644a7af616a40b9808e8e6ced315d5d/greenlet-3.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:64d6ac45f7271f48e45f67c95b54ef73534c52ec041fcda8edf520c6d811f4bc", size = 418379, upload-time = "2026-04-27T13:05:12.755Z" }, - { url = "https://files.pythonhosted.org/packages/10/80/3b2c0a895d6698f6ddb31b07942ebfa982f3e30888bc5546a5b5990de8b2/greenlet-3.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d874e79afd41a96e11ff4c5d0bc90a80973e476fda1c2c64985667397df432b", size = 1574927, upload-time = "2026-04-27T12:53:25.81Z" }, - { url = "https://files.pythonhosted.org/packages/44/0e/f354af514a4c61454dbc68e44d47544a5a4d6317e30b77ddfa3a09f4c5f3/greenlet-3.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0ed006e4b86c59de7467eb2601cd1b77b5a7d657d1ee55e30fe30d76451edba4", size = 1642683, upload-time = "2026-04-27T12:25:23.9Z" }, - { url = "https://files.pythonhosted.org/packages/fa/6a/87f38255201e993a1915265ebb80cd7c2c78b04a45744995abbf6b259fd8/greenlet-3.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:703cb211b820dbffbbc55a16bfc6e4583a6e6e990f33a119d2cc8b83211119c8", size = 238115, upload-time = "2026-04-27T12:21:48.845Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f8/450fe3c5938fa737ea4d22699772e6e34e8e24431a47bf4e8a1ceed4a98e/greenlet-3.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:6c18dfb59c70f5a94acd271c72e90128c3c776e41e5f07767908c8c1b74ad339", size = 235017, upload-time = "2026-04-27T12:22:26.768Z" }, - { url = "https://files.pythonhosted.org/packages/ef/32/f2ce6d4cac3e55bc6173f92dbe627e782e1850f89d986c3606feb63aafa7/greenlet-3.5.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:db2910d3c809444e0a20147361f343fe2798e106af8d9d8506f5305302655a9f", size = 286228, upload-time = "2026-04-27T12:20:34.421Z" }, - { url = "https://files.pythonhosted.org/packages/b7/aa/caed9e5adf742315fc7be2a84196373aab4816e540e38ba0d76cb7584d68/greenlet-3.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ec9ea74e7268ace7f9aab1b1a4e730193fc661b39a993cd91c606c32d4a3628", size = 601775, upload-time = "2026-04-27T12:52:41.045Z" }, - { url = "https://files.pythonhosted.org/packages/c7/af/90ae08497400a941595d12774447f752d3dfe0fbb012e35b76bc5c0ff37e/greenlet-3.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54d243512da35485fc7a6bf3c178fdda6327a9d6506fcdd62b1abd1e41b2927b", size = 614436, upload-time = "2026-04-27T12:59:41.595Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/4eeadf8cb3403ac274245ba75f07844abc7fa5f6787583fc9156ba741e0f/greenlet-3.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:41353ec2ecedf7aa8f682753a41919f8718031a6edac46b8d3dc7ed9e1ceb136", size = 620610, upload-time = "2026-04-27T13:02:39.194Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e0/2e13df68f367e2f9960616927d60857dd7e56aaadd59a47c644216b2f920/greenlet-3.5.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d280a7f5c331622c69f97eb167f33577ff2d1df282c41cd15907fc0a3ca198c", size = 611388, upload-time = "2026-04-27T12:25:28.008Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ef/f913b3c0eb7d26d86a2401c5e1546c9d46b657efee724b06f6f4ac5d8824/greenlet-3.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:58c1c374fe2b3d852f9b6b11a7dff4c85404e51b9a596fd9e89cf904eb09866d", size = 422775, upload-time = "2026-04-27T13:05:14.261Z" }, - { url = "https://files.pythonhosted.org/packages/82/f7/393c64055132ac0d488ef6be549253b7e6274194863967ddc0bc8f5b87b8/greenlet-3.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1eb67d5adefb5bd2e182d42678a328979a209e4e82eb93575708185d31d1f588", size = 1570768, upload-time = "2026-04-27T12:53:28.099Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4b/eaf7735253522cf56d1b74d672a58f54fc114702ceaf05def59aae72f6e1/greenlet-3.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2628d6c86f6cb0cb45e0c3c54058bbec559f57eaae699447748cb3928150577e", size = 1635983, upload-time = "2026-04-27T12:25:26.903Z" }, - { url = "https://files.pythonhosted.org/packages/4c/fe/4fb3a0805bd5165da5ebf858da7cc01cce8061674106d2cf5bdab32cbfde/greenlet-3.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:d4d9f0624c775f2dfc56ba54d515a8c771044346852a918b405914f6b19d7fd8", size = 238840, upload-time = "2026-04-27T12:23:54.806Z" }, - { url = "https://files.pythonhosted.org/packages/cb/cb/baa584cb00532126ffe12d9787db0a60c5a4f55c27bfe2666df5d4c30a32/greenlet-3.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:83ed9f27f1680b50e89f40f6df348a290ea234b249a4003d366663a12eab94f2", size = 235615, upload-time = "2026-04-27T12:21:38.57Z" }, -] - [[package]] name = "gspread" version = "6.2.1" @@ -409,33 +336,33 @@ wheels = [ [[package]] name = "highspy" -version = "1.14.0" +version = "1.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7a/66/e74b1a805f65c52666e3b54cfc1ba783e745c2c8a7abaae9e7ef2d9e7270/highspy-1.14.0.tar.gz", hash = "sha256:b09cb5e3179a25fc615b8b0941130b0f71e19372c119f3dd620d63b54cd3ca4c", size = 1654913, upload-time = "2026-04-06T15:53:31.738Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/73/4e/c813156aa513eb3344b333c1424373cebf1f5843868b2ba5c49c64beecde/highspy-1.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:69558127aabad8b5718a58009dc3d36618c3aa5aa5e733206c32ce396189a132", size = 2310870, upload-time = "2026-04-06T15:51:51.586Z" }, - { url = "https://files.pythonhosted.org/packages/3f/6c/d4baa83e8745d729764bf960b51828e9c99c90b4f5cd99e65b59fbf2b6f9/highspy-1.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3699fc8072a70664d5bbaf1c9239f8e2e8700c5090f57486f2ce4567f9b2b6aa", size = 2116701, upload-time = "2026-04-06T15:51:53.175Z" }, - { url = "https://files.pythonhosted.org/packages/cd/64/9dbafa1f3f9ec9293c4038d64b4a49a7a577e1ffcc5c48cf861849d2cff0/highspy-1.14.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4dd237b94494b14693edbebd05a0068fa02ae36b2629e6353be42bc2b491c1f0", size = 2404995, upload-time = "2026-04-06T15:51:54.664Z" }, - { url = "https://files.pythonhosted.org/packages/94/d6/d73cfcce4d3863d9839174a42fd976d84bb7781c132bf0bccdc74e83d9a7/highspy-1.14.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:526c54ff6e0384abcc495cda08fb2f98156544031afb9ac8bb02f20968d743ba", size = 2629054, upload-time = "2026-04-06T15:51:56.157Z" }, - { url = "https://files.pythonhosted.org/packages/71/c2/5ec46d5381815b849f25f4327af187d70325aa693bccdad960228c98ebb1/highspy-1.14.0-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:60265c69fb9d199526b8190f3bd42bc241896a335c299570d1dfc9d6e97f8895", size = 2787400, upload-time = "2026-04-06T15:51:57.682Z" }, - { url = "https://files.pythonhosted.org/packages/25/f0/1e89d849701388886d39ffda26f64bdc835d63f26aa4b5d865067d56fae1/highspy-1.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:895b3450ece50c1cdcd348aa5c6efd6ee823598f3a21c23677d57a06e3c6f28a", size = 3465733, upload-time = "2026-04-06T15:51:59.9Z" }, - { url = "https://files.pythonhosted.org/packages/fd/31/b3477f7ca17526167e5eff9d194ba8ea3eca0f04e98a248b26cc6612aa8f/highspy-1.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:b50202f02c7db95163b0ad92160167541dad263b19327164dcf0f827cf9f69b3", size = 4044165, upload-time = "2026-04-06T15:52:01.482Z" }, - { url = "https://files.pythonhosted.org/packages/5d/85/67bead3e385de8f433572809b3cab6d7694c1a606edac2fbf94309b746d4/highspy-1.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e12591c1d495ebb5f6d8dc50e99ed48f338dfb59741929cd68c2c2b40d97a58f", size = 3707245, upload-time = "2026-04-06T15:52:03.05Z" }, - { url = "https://files.pythonhosted.org/packages/08/1c/0f61f66855a39e22f1f8e7d1ab3632b2a27cc1f42ee628fd9c317a5b4616/highspy-1.14.0-cp311-cp311-win32.whl", hash = "sha256:9ba82456280ef72cde8e45ecf6bdb2a244c56d80f7e44bb2e5ef7a9abf21f4d9", size = 1953948, upload-time = "2026-04-06T15:52:04.549Z" }, - { url = "https://files.pythonhosted.org/packages/f8/bd/4eaa775022d55519101a51a2b1ad5c46cfa8c725a400033943a79138001f/highspy-1.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:e726092e35237dccdd8093f8c91be195a5826e48aab349d6e7856e32c0e87b41", size = 2320210, upload-time = "2026-04-06T15:52:06.417Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3d/83ee11de10ff6499efdb6edbb4586b472a4e9f982c0f6c5d3faa670bf1c3/highspy-1.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a8d0339887d65f5ef20c59be529af33115197d47f775ee21fca911a87d30f92", size = 2311831, upload-time = "2026-04-06T15:52:08.042Z" }, - { url = "https://files.pythonhosted.org/packages/b6/74/51cfca0c382886e302c4e6b9a50f9b160d214a88b4bc5937f5f8e2452dd9/highspy-1.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2f61406a287b19680ece9a8c0bc3926e31d26ad2b4df8ba49f22666ce762fb7d", size = 2122237, upload-time = "2026-04-06T15:52:09.587Z" }, - { url = "https://files.pythonhosted.org/packages/78/69/a60f9dc033712f564089700441fb08c7f89db32bccab7926ef95db6b2306/highspy-1.14.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74ee022cc8cc0e3a576f9b2309287974ea80db68adf8c9f1c5698dc725ec0497", size = 2409165, upload-time = "2026-04-06T15:52:11.067Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c5/efa6d74704aa0bc5ffce9975553f6d13f4527e6fad8f79e7cacadfedd3d3/highspy-1.14.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb7c564ee426355671edf6ad17b2ece5aaa74f21b326ed5e7a8ba5bdc880f207", size = 2632243, upload-time = "2026-04-06T15:52:12.529Z" }, - { url = "https://files.pythonhosted.org/packages/f2/1f/a701fee9ca318e6d175d719f8916d090dd7c8100c28bc591adac9fc2db35/highspy-1.14.0-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:44d80756dde11941336933d777ae85b913c303fe40c3b7eb55fae0fe62bbea08", size = 2792226, upload-time = "2026-04-06T15:52:14.065Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a4/b1db0018292e46d75d7aa7889f220d0fa0f2243a628e57790de80f1fc22f/highspy-1.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ba00a35b6c4b96eb2d20d75238c6046ecf82ac85a603706e5f588d28c3b3abf", size = 3465843, upload-time = "2026-04-06T15:52:15.692Z" }, - { url = "https://files.pythonhosted.org/packages/6c/41/64c4b290a5237e14fbc7e4812d110aecf457273bbade8b27ca9b9ea86c5b/highspy-1.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e991b5ac8af64d686f2a56fb59391b4bb81a50d647fe1e31a1b0ff34d9d2bb51", size = 4042641, upload-time = "2026-04-06T15:52:17.573Z" }, - { url = "https://files.pythonhosted.org/packages/30/3a/cff37994f2fd313467749bc9939e5baab4aef0210c79547471d6bbda5e81/highspy-1.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c10554bd0a37d4be126f24cbb7f5aca5a4252dd6e572b51b2433ad6d94ce7a9d", size = 3712946, upload-time = "2026-04-06T15:52:19.466Z" }, - { url = "https://files.pythonhosted.org/packages/6d/60/f328af00a9f05838e766aa7808dc733bb74a4fa79adcf5fdd665cfb8d7ed/highspy-1.14.0-cp312-cp312-win32.whl", hash = "sha256:7290540f0352192e43bdc790a59a82cee1f8029bd8d6b9ca20b54b651256bab4", size = 1955124, upload-time = "2026-04-06T15:52:20.965Z" }, - { url = "https://files.pythonhosted.org/packages/69/ea/0b47c49b6df4474c603b6a232278d5d6e6afddcb9da5044e06dfec579222/highspy-1.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:c0568d0fb514dc82776c3be1041988fe428c9df2be0ce98c1bff6382c0d2a5ba", size = 2323321, upload-time = "2026-04-06T15:52:22.418Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/87/02/c6b658f79911fee921721da728b9ab8f5e19ff06121fff36f90f77127f4d/highspy-1.15.1.tar.gz", hash = "sha256:20ed2fbf1cb64bf3044ee6632364b7e2653d93e6901e2b19fd3d5df10702e8c5", size = 1703256, upload-time = "2026-07-02T12:03:25.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/69/74e9614a6e49f5e07fbb91a5cebad79bd31903888ca6b4f1c75dcf01df3b/highspy-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:45eb9f022f9083ef2e56d66f972d5fd40e6634f4497194b1f3f215ca0e8ea958", size = 4871151, upload-time = "2026-07-02T12:01:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5d/815b8f0488fda02e765b85e7a1ed2bf26b7d705fb1240ca842c4493d9414/highspy-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4b4c7e7af8d7927ed77836e9b869cbae55d6a74b85bb90d04776440b5e14c32b", size = 4468478, upload-time = "2026-07-02T12:01:44.995Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ea/def24ab38ff3ea983eb197dcbf01ec9ad82e2aa3380b72f01792486865e1/highspy-1.15.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:070c1ce9238b9e8b4c273253647ab0dbafc1839c195a52c7ef1eeb7ef6976f05", size = 4634480, upload-time = "2026-07-02T12:01:46.822Z" }, + { url = "https://files.pythonhosted.org/packages/ed/bb/0588b8137df2a0ae55a9a8ad40eff40561b60d6f76dbffd5ab4f4f6095f0/highspy-1.15.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a24329c328942b37a6a318ecf163d07dd387974f071b98b4498725eaea80f06f", size = 5035498, upload-time = "2026-07-02T12:01:48.717Z" }, + { url = "https://files.pythonhosted.org/packages/f2/50/5196e807cec6847b2bee8ceefb080b8175ea81aaa673249678398cf3181c/highspy-1.15.1-cp311-cp311-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:138506088c7f6106cbb58d1cd0ef14793dfb47477fd83a7ae0db5b104d1cf969", size = 5857714, upload-time = "2026-07-02T12:01:51.04Z" }, + { url = "https://files.pythonhosted.org/packages/32/25/80c96a11bcf3bdf4ffaa7b1e26629b6da340703b2bbc4e0349d40d7a8aaa/highspy-1.15.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00e1c13912501e96893136a1805b56b74cb4868fa04c1c2eacc5c0454304e08e", size = 6191044, upload-time = "2026-07-02T12:01:52.856Z" }, + { url = "https://files.pythonhosted.org/packages/11/10/27a7b87dbf56ab88d223fb9f1b6917c9df259c09980460ff56731c2406ef/highspy-1.15.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b5be1c777d0b57b6dc26e1d9754923e642a17c6313bcdf5186473644b214f0b", size = 7234613, upload-time = "2026-07-02T12:01:54.936Z" }, + { url = "https://files.pythonhosted.org/packages/a7/1e/82159a50b8a17daf2e94453a8e0af3e381943223727fd9db6d4bebc1533a/highspy-1.15.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5de2dddc554442f3572bb4a36116278bee79568fbd726a697251d2606b79a5a1", size = 6632799, upload-time = "2026-07-02T12:01:57.967Z" }, + { url = "https://files.pythonhosted.org/packages/3c/69/59d4303d38d6c48cc6f79c12b11f756b381e8e5eed5c0733d8c82317240f/highspy-1.15.1-cp311-cp311-win32.whl", hash = "sha256:605d3204e41a465f9ce2f254571a90e8781605451a5e6a548f6b4be8988afb4f", size = 2304839, upload-time = "2026-07-02T12:01:59.752Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/6714276be39f1f0f7c2795fb466c39640bbcc541e151342f05f61d2f24a5/highspy-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:4715fcfbcff50fdbcc288499116f7e5722a9f9d2647087d54317febb94ec2b32", size = 2709129, upload-time = "2026-07-02T12:02:01.722Z" }, + { url = "https://files.pythonhosted.org/packages/de/59/b79a7b1711ddfcca36674ddb41759e98eb1797f4a94513e7dd215e32e94d/highspy-1.15.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a781dc8432568ea990fcdcc8d6e4365e67aa4848ca1f99275db096645b27cae3", size = 4878738, upload-time = "2026-07-02T12:02:03.82Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e4/ae08124f71187628471a177e6db1ed2c1c45e9dceadc45f7111dfd7c2254/highspy-1.15.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9499d631edeb9642fc08dee59ca6c5815be1764c13a336c58ab7ba063011aa24", size = 4473938, upload-time = "2026-07-02T12:02:05.754Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7f/185b8c9579a9e4ef88eda45d1fdaf8d23a3a640f73c403a7b29fc0f0c4be/highspy-1.15.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef048fa722cdeb80062d271b8ba211cd6650ab73419762d80da7642bbd4a8420", size = 4636755, upload-time = "2026-07-02T12:02:07.996Z" }, + { url = "https://files.pythonhosted.org/packages/82/6b/18bec60d8585df860b8d33d310e99e7893eaabe3c8e9ebfa7e387ba9d2a4/highspy-1.15.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9730647160a6481426729f46d9989a0507d05f3cf96f9fb180f4ab9891bea67b", size = 5034168, upload-time = "2026-07-02T12:02:09.89Z" }, + { url = "https://files.pythonhosted.org/packages/d4/51/e43f06e64e994ccb41a336ff78802c0dae63aed46c17acd52167b5ca3d76/highspy-1.15.1-cp312-cp312-manylinux_2_26_i686.manylinux_2_28_i686.whl", hash = "sha256:6a6a2f21ee31a9205a928fbbc3f8c054893c1aec34f6a7c56588317e2800e673", size = 5861937, upload-time = "2026-07-02T12:02:11.801Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2a/5501a23cac55926e4b0554352b4285734b417dbec385c593f2ae405ea637/highspy-1.15.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9a6760962b3e813814dc5e88301890d7cce975de5ce97cc3aed589cfdd461811", size = 6192004, upload-time = "2026-07-02T12:02:14.544Z" }, + { url = "https://files.pythonhosted.org/packages/94/08/fb7d30ea0e6c83fb943b16bf31951ba13a5be01a638ec13962a477009b91/highspy-1.15.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:787c92d5ff274256ba8848ab174cfc65d5af696f51bffe87423c85b2ea25c3fe", size = 7233098, upload-time = "2026-07-02T12:02:16.609Z" }, + { url = "https://files.pythonhosted.org/packages/23/77/9a07df7181834cfb61dafa5594e5eedc78369797c6487806bd3221d20667/highspy-1.15.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dd9ee8e139e7260ec1306a48e30f1bd7937d9cfb8cb201d25da10e1099e5129b", size = 6636717, upload-time = "2026-07-02T12:02:18.72Z" }, + { url = "https://files.pythonhosted.org/packages/9a/25/5083d8e3d5cf5ff5edf5bcc03e3f693630ab59142c9d0a0bcbb2d315c50e/highspy-1.15.1-cp312-cp312-win32.whl", hash = "sha256:01c6585e83938ecf4139248b074b2ee736816d63716a20dc608b1d2fc9637b66", size = 2306753, upload-time = "2026-07-02T12:02:20.837Z" }, + { url = "https://files.pythonhosted.org/packages/d4/01/05521ca6b38e34e68d707888c378d3bcac34e62715b739e7c0c9b9887993/highspy-1.15.1-cp312-cp312-win_amd64.whl", hash = "sha256:8c548165270608a40147a7ea6d985fd62a65fabf0f075b3c0c59ea910b724223", size = 2711114, upload-time = "2026-07-02T12:02:22.621Z" }, ] [[package]] @@ -459,15 +386,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, ] -[[package]] -name = "importlib-resources" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, -] - [[package]] name = "iniconfig" version = "2.3.0" @@ -498,12 +416,46 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" }, ] +[[package]] +name = "librt" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/dc/2f/3908645ddddab7120b46295e541ead308109fa48dbec7d67d7a778870d60/librt-0.13.0.tar.gz", hash = "sha256:1d2a610c14ac0d0750ee0a3ab8548e83155258387891caaca04def4bf7289781", size = 211402, upload-time = "2026-07-08T12:26:29.834Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/25/a6498964cfeec270c468cffdc118f69c29b412593610d55fa1327ca51ff4/librt-0.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1b5a7bbff495baedbd9b916c367d66854008f8f3b575908ded477c499dc60082", size = 148029, upload-time = "2026-07-08T12:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/dc86d1bffd8e0c2818bace29d9f7783cfbb8e0673bf3673b5bbd5bbe0420/librt-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34bc7938b9fdf14fe32a406c19c71faf894c5cee7e7474bd0be2f17200b82d14", size = 153036, upload-time = "2026-07-08T12:24:47.257Z" }, + { url = "https://files.pythonhosted.org/packages/29/3f/b923826660f02f286186cd9303d52bb05ced0a13708edc104dc8480920e3/librt-0.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f40e56b61b41be5f7dec938cfeffd660668cf4b5e72c78e7bd671d66b7bc2c79", size = 493062, upload-time = "2026-07-08T12:24:48.483Z" }, + { url = "https://files.pythonhosted.org/packages/88/87/6c0980a9c9b1302cb68d108906697b89eceb55889bb1dcf77c109aa56ca5/librt-0.13.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:9c5d02b89de5acd0379a51ec44a89476fb03df6145442e1c8ecd6bee2f91b176", size = 485510, upload-time = "2026-07-08T12:24:49.727Z" }, + { url = "https://files.pythonhosted.org/packages/32/81/795ae3b9df5dd94079fb807e38191855e023e8c6249014ae6bc3f0d9a490/librt-0.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7db9a3ff32ef5f7d1703d93831a3316cdf0b537de6a1cc03cc8fdd09b9194e89", size = 515909, upload-time = "2026-07-08T12:24:51.135Z" }, + { url = "https://files.pythonhosted.org/packages/20/e5/182de15abce8907108a6fdb41487de65beb5099b74dc5841b19b099168db/librt-0.13.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3dbb2a31882456cadc7053378e81ad7ed7693db4ac9f98ab5f81ef034aa8ec9f", size = 508620, upload-time = "2026-07-08T12:24:52.358Z" }, + { url = "https://files.pythonhosted.org/packages/32/03/33978d32db76e1f66377e8f78e42a2ca3c162143331677d1f50bbad36cfb/librt-0.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c6014e3c80f9c1fe268ef8b0e0ef113bac672cc032f2f93866e7ddad4f3e663d", size = 530363, upload-time = "2026-07-08T12:24:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/e6/f5/b291fbd2d00f7d8287bcbf67b5aa0c6afed4bc26cef23e079629c47a2c04/librt-0.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:091b60a4d2174fc1ec5c34cdc0b72efb6224753d76b7da61ebeab7a191aec8bd", size = 534209, upload-time = "2026-07-08T12:24:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/6f41f17939d191bc21609f220da8509316bc62797f078545fe83be522e78/librt-0.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:66cb1138f384a191a6d75f986064841fcfdc0cea98f7bd9c9ab9b38049917588", size = 514254, upload-time = "2026-07-08T12:24:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/af/c2/2e4befa5410a7443019c14abccc94ff619797171f6b72013635fb87f31d7/librt-0.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:17221a7569f8f292aa0014226e48aa25b8c2b08da18088cd230953d0ea0f9cd1", size = 557611, upload-time = "2026-07-08T12:24:57.561Z" }, + { url = "https://files.pythonhosted.org/packages/ab/54/8b69f81448417adbc040a2185f4e2eece1e1994b7dcfaeed4662b30f98a5/librt-0.13.0-cp311-cp311-win32.whl", hash = "sha256:fc67741da44c6eaa90e01eafb586bbba9b51eb5b6ed381ee6f5ae72eb3316d21", size = 104906, upload-time = "2026-07-08T12:24:58.806Z" }, + { url = "https://files.pythonhosted.org/packages/76/5a/f4aaf37b50f2fde12c8c663b83fdd499cdc24f957f19543d7414bfcc9e25/librt-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:cc99dfb62b23c9207c33d0be8a2e2af7a42e21e6ea388b380a0c948c7b88953b", size = 125852, upload-time = "2026-07-08T12:25:00.065Z" }, + { url = "https://files.pythonhosted.org/packages/f2/99/bf1820e6feeabc2f218c24450ec0c995d6a91e8ba0fd3caf042c9e8adb2a/librt-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:40ccd13c252d3fe473ffc8a57be7565abc8b64cf1b108344c859d5164f7f3e0c", size = 111832, upload-time = "2026-07-08T12:25:01.148Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/b2933ddae222dac338476abb872641169a5cfed2c2bb5444a5b07b32b0c3/librt-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:30536798f4504c0fad0885b1d371b0539abb081e4570c9d7c641cb51141b49f0", size = 150990, upload-time = "2026-07-08T12:25:02.42Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/db98f744ca50e6efc9c95c70ee49b77aefac31f6a3fc7c83754a42d6a74f/librt-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93d24ebb82aa4420b1409c389e7857bc35bd0b668007ac8172427d5c73cc8cc5", size = 155238, upload-time = "2026-07-08T12:25:03.681Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/a197e7bc72baf2c61ce7fdc6906a5054dc05bd8da0819aa894e4857bf87e/librt-0.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb8a1adce42d8b75485a5d56a9623a50bcab995b6079f1dac59fc44034dd93d9", size = 503073, upload-time = "2026-07-08T12:25:05.049Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e7/7887712e27da7c1ab80fcabb1de6eb24243964f6557cae530d4b70706dbd/librt-0.13.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0763ca2ab66058174f9dee426dc64f5e0a89c24a7df8d3fe3f1836c04e25de4b", size = 496528, upload-time = "2026-07-08T12:25:06.26Z" }, + { url = "https://files.pythonhosted.org/packages/94/f0/f2283385bb6b950b26a1410f4ce51ec27231e0b3a4b925c46366d218b198/librt-0.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b222493da6e7b6199db9bd79502436cf5a27da3c1f7fa83c7e285444fc93fd03", size = 531786, upload-time = "2026-07-08T12:25:07.658Z" }, + { url = "https://files.pythonhosted.org/packages/36/11/69ac3b54766ffba5fd7e5acebfb048d66dbe1f9f2d14516c2b3edc59cf87/librt-0.13.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fadc63331f4388c3dc90090448f682a7e9feafc11481391c1e94f2f907a3976e", size = 524393, upload-time = "2026-07-08T12:25:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/61/5f/d72f95fd444a926a3c14b4e24979474116988dd57a45be242077c45d3c22/librt-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:70d9c62a4cffd9f23396cd5ef93fc5d11b31596b9b7d6306074abe3d5fcf09bd", size = 543026, upload-time = "2026-07-08T12:25:10.459Z" }, + { url = "https://files.pythonhosted.org/packages/c4/08/dcd9993ad192737a004ba263d549f8ea605b326b952e7d6205c7d4170b76/librt-0.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:66c0e7e6b02a155576df2c77ec933a70b72da726e248c494abf690923e624348", size = 546829, upload-time = "2026-07-08T12:25:11.716Z" }, + { url = "https://files.pythonhosted.org/packages/96/d5/6d9bb2f54e4109a956b7128836529653eb9d740f784bc47ed10a02c1000e/librt-0.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ac04bcd3328eb91d99dfedf6a60d9c1f15d3434e6f6daf922f0420f7d90b85c7", size = 535700, upload-time = "2026-07-08T12:25:13.144Z" }, + { url = "https://files.pythonhosted.org/packages/8c/f2/10946922503858a359492fa27f13e86228bde702116a740ac7b3cd185f24/librt-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:db327e7271e653c32040b85ae6188059c924b57d7e1e29f935523fa017cd4e82", size = 573566, upload-time = "2026-07-08T12:25:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/48/a8/94f00e3c99479a18088af3685ea016c42f3c7d5d1964d8dbb40c08d7f1aa/librt-0.13.0-cp312-cp312-win32.whl", hash = "sha256:860bd1d8ba48456ce08feaf8d343a8aaeb2fa086f2bcaa2a923fa3f7a3ff9aa3", size = 106099, upload-time = "2026-07-08T12:25:16.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/7b/2da9c74c1ed25a89cc4e1c8e007ea2eb4a0f1fafa3e70d757fe3242c5c5c/librt-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:e54a315caf843c8d77e388cadc56ea9ded569935ee2d2347d7ea94992e5aa6fa", size = 126934, upload-time = "2026-07-08T12:25:17.275Z" }, + { url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" }, +] + [[package]] name = "macholib" version = "1.16.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "altgraph" }, + { name = "altgraph", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/10/2f/97589876ea967487978071c9042518d28b958d87b17dceb7cdc1d881f963/macholib-1.16.4.tar.gz", hash = "sha256:f408c93ab2e995cd2c46e34fe328b130404be143469e41bc366c807448979362", size = 59427, upload-time = "2025-11-22T08:28:38.373Z" } wheels = [ @@ -542,14 +494,54 @@ wheels = [ [[package]] name = "mip" -version = "1.15.0" +version = "1.17.6" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "cbcbox" }, { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/30/84/41ebb2db20fbc199768c317ee8238d50b55fd6ffaa4c17e2cb94f0d03152/mip-1.15.0.tar.gz", hash = "sha256:7f6f0381cfe2c52c1b8640203da2cb56974b26e23950ddfb1a76b37d916f197e", size = 24569566, upload-time = "2023-01-04T13:51:46.934Z" } +sdist = { url = "https://files.pythonhosted.org/packages/96/15/7496ce00eafc5b99e98bb696588527754d7629f11ac6208fdc9355f6eec6/mip-1.17.6.tar.gz", hash = "sha256:9da8074b80bd3ef788513d5a214ef832916d82aa66487da11a49c7da9f89d270", size = 9443716, upload-time = "2026-03-23T16:20:09.009Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/49/c9/d4ba71d5d73cf57596b8637ab20eda547c6cde296860f6b6192568809e70/mip-1.15.0-py3-none-any.whl", hash = "sha256:9a48c993ddc9a48591a59e4a1221b400eb1e35fc087052085774360330bb9226", size = 15269834, upload-time = "2023-01-04T13:51:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/65/1d/0eb2531e779be687a3249e7f2ba5e465dd34611072390252e5fcf89e7fef/mip-1.17.6-py3-none-any.whl", hash = "sha256:4fb7ff5d7beacbe7d007de172306a76aff8fb36760fa63a5d88a8cb3aa6f4a57", size = 88214, upload-time = "2026-03-23T16:20:07.289Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/12/af/4e516a05d3ca2eb9283e9ec45b2c02225c1514dd6da49fd3c9eaa6639370/mypy-2.3.0.tar.gz", hash = "sha256:465965d41cd9a2726694e983e8ce7113259327bec798115d1e1dfa2a52fb666e", size = 3988104, upload-time = "2026-07-13T11:34:53.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/b9/d75b3082b05f1b3028828aeb18e74ae5ab0a0936051bbf1f32f59f654747/mypy-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3419d00717afbc5265b50dd14b1278f29ea4884dd398ab67873489ac093fd329", size = 14838725, upload-time = "2026-07-13T11:32:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/a9/50/79a65c6ea6e115bc73296038a4543b2d5c91f07912b918a2c616a2514bba/mypy-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cfca8ee88544090f86b6dcce05ec55d66eb48a762412ac2507810ba4bd793b6f", size = 13911128, upload-time = "2026-07-13T11:32:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/90/48/e11ed7716c26953ca321f726e452e374dbf81a6f2b8b212ec02af29b6b8f/mypy-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75cbb4b9ef04a0c84a957f07abc4504fbf64b8dcc145675101f2d3a78a4b1d6a", size = 14146742, upload-time = "2026-07-13T11:33:03.313Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/6807565b1c4861ef66f7fdd98b51c61556356eab80235717b46c53bb8627/mypy-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:982e3d53dd23d0a4cef67dd66791fdbede0cf38f9eb617bf47663554c51e1e36", size = 15081418, upload-time = "2026-07-13T11:31:13.899Z" }, + { url = "https://files.pythonhosted.org/packages/00/80/1ea14c5d80e589e415973db3e47c78c2219a305b808b2b506395342c1d79/mypy-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:85c5385b93012ffa3b31479ab579aef5415f4f3a32c6cf1ae07a984d2a0ff461", size = 15328164, upload-time = "2026-07-13T11:31:35.723Z" }, + { url = "https://files.pythonhosted.org/packages/37/28/8223157404a3d51920078459c37f80fbdc590e1d8ea049dc5ce48643022a/mypy-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:13b1b16e2fa39f3b2e33fb1c468abc7a69369fa2e886b4b87b5afc81472325cd", size = 11136472, upload-time = "2026-07-13T11:27:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/6f/cc/ea27e5959c5f258585a756b252031f3b313583d81b5064b2bebc41d3706b/mypy-2.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5cd2f027a972a4a5f2278a11fac9747f5f81a53a30b714d74950b6807e55568", size = 10135800, upload-time = "2026-07-13T11:30:08.92Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/0e7e592619e2133596a47cdd642534b0456545c218430bd3b9d8fefdd1b1/mypy-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d53fc67b9d28a43c6199077f49fea0f05839e36cf6158500331c9549225e5a5", size = 15026523, upload-time = "2026-07-13T11:34:49.206Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/1e1731df090a857df2807177a4626863e5ac0f0256513c35780efe53986f/mypy-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fbc00cee7bdbb9291979ddc9d08034a29dfcda4932628c9bbc28c1edd589df0c", size = 14032189, upload-time = "2026-07-13T11:33:57.168Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/cab921f4a806e171f34113e6181dd23c55358ccf6a80741269ef594a410e/mypy-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04e617030eca5221909c8b7d8d7fd1c637948199aa2100b2ad9813feb07e1491", size = 14198696, upload-time = "2026-07-13T11:32:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/66/80/e6d008bb19fe446e3662d85e0e2717bf9f2d611a2164fb29d6e067dbf46c/mypy-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56c184d2c20ca6b6378d58d1960270a767f41f5e44acbbd27f05effef4f4e1d7", size = 15286904, upload-time = "2026-07-13T11:34:27.594Z" }, + { url = "https://files.pythonhosted.org/packages/db/83/94397c9293608a364aa03e8084fb34ede4ae976a260384b9b52929308135/mypy-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3961a4a34b05f7c74b0f05aa51fbfe99a2d1e126038df40318d15c8f558b7ef3", size = 15528342, upload-time = "2026-07-13T11:34:07.819Z" }, + { url = "https://files.pythonhosted.org/packages/cf/96/d8b37d819adec6cfccfb1fd3afc1735d94717ddeafb45536db9c6943e09b/mypy-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:b1942b9314d4c784b8ea1dbab4972603290e5dd5630f06675f13aec97526bc4c", size = 11218346, upload-time = "2026-07-13T11:28:27.745Z" }, + { url = "https://files.pythonhosted.org/packages/2b/cd/cd9f725b19b19e5b530a154cf9bcf9e94279c5d55b3c34fb42b3aa48ea1b/mypy-2.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:be51653d7669d7d7955d613b8d0bb57d5b652eaf71a873ddf65ac87254dd2595", size = 10204525, upload-time = "2026-07-13T11:31:02.552Z" }, + { url = "https://files.pythonhosted.org/packages/2c/fa/fdc54fe583ba3cafbcedfb70eeeaf03849f75b1827a07096c7bd996f582d/mypy-2.3.0-py3-none-any.whl", hash = "sha256:6b1cdb579446b60432432b2b2403a6201b4b475a004d7f488511c9ba177c9e88", size = 2753292, upload-time = "2026-07-13T11:33:18.48Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] [[package]] @@ -649,32 +641,12 @@ wheels = [ ] [[package]] -name = "pandas" -version = "3.0.3" +name = "pathspec" +version = "1.1.1" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "numpy" }, - { name = "python-dateutil" }, - { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495, upload-time = "2026-05-11T18:52:11.558Z" }, - { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250, upload-time = "2026-05-11T18:52:17.005Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558, upload-time = "2026-05-11T18:52:19.865Z" }, - { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611, upload-time = "2026-05-11T18:52:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670, upload-time = "2026-05-11T18:52:25.4Z" }, - { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708, upload-time = "2026-05-11T18:52:28.139Z" }, - { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609, upload-time = "2026-05-11T18:52:30.982Z" }, - { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596, upload-time = "2026-05-11T18:52:33.334Z" }, - { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846, upload-time = "2026-05-11T18:52:36.143Z" }, - { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550, upload-time = "2026-05-11T18:52:38.976Z" }, - { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965, upload-time = "2026-05-11T18:52:41.915Z" }, - { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600, upload-time = "2026-05-11T18:52:45.02Z" }, - { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824, upload-time = "2026-05-11T18:52:48.058Z" }, - { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889, upload-time = "2026-05-11T18:52:50.905Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463, upload-time = "2026-05-11T18:52:53.386Z" }, - { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158, upload-time = "2026-05-11T18:52:56.038Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -784,6 +756,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "pyside6-essentials" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "shiboken6" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" }, + { url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" }, +] + [[package]] name = "pytest" version = "9.0.3" @@ -815,15 +802,17 @@ wheels = [ ] [[package]] -name = "python-dateutil" -version = "2.9.0.post0" +name = "pytest-qt" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, + { name = "pluggy" }, + { name = "pytest" }, + { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/61/8bdec02663c18bf5016709b909411dce04a868710477dc9b9844ffcf8dd2/pytest_qt-4.5.0.tar.gz", hash = "sha256:51620e01c488f065d2036425cbc1cbcf8a6972295105fd285321eb47e66a319f", size = 128702, upload-time = "2025-07-01T17:24:39.889Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d0/8339b888ad64a3d4e508fed8245a402b503846e1972c10ad60955883dcbb/pytest_qt-4.5.0-py3-none-any.whl", hash = "sha256:ed21ea9b861247f7d18090a26bfbda8fb51d7a8a7b6f776157426ff2ccf26eff", size = 37214, upload-time = "2025-07-01T17:24:38.226Z" }, ] [[package]] @@ -900,32 +889,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] -[[package]] -name = "scikit-learn" -version = "1.8.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "joblib" }, - { name = "numpy" }, - { name = "scipy" }, - { name = "threadpoolctl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" }, - { url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" }, - { url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" }, - { url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" }, - { url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" }, - { url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" }, - { url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" }, - { url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" }, - { url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" }, - { url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" }, - { url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" }, -] - [[package]] name = "scipy" version = "1.17.1" @@ -988,6 +951,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" }, ] +[[package]] +name = "shiboken6" +version = "6.11.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" }, + { url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" }, + { url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" }, + { url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -999,24 +974,23 @@ wheels = [ [[package]] name = "sortition-algorithms" -version = "0.11.5" +version = "0.12.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "cattrs" }, { name = "cvxpy" }, { name = "gspread" }, + { name = "highspy" }, { name = "mip" }, { name = "numpy" }, { name = "oauth2client" }, - { name = "pandas" }, { name = "requests" }, - { name = "scikit-learn" }, { name = "tabulate" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ed/84/0a5ed049807f53a9dd86afc968d59e2b4c64364c4be24ba471aa559cb6e2/sortition_algorithms-0.11.5.tar.gz", hash = "sha256:07cb3091f7002cba42acd613e5337081c02cdea793c4e890a3a62a9b17827702", size = 207184, upload-time = "2026-01-29T11:40:44.817Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0e/7a/976879b25d2b7d17032f52d43349d9dbcc55607b93475613e89492b58b4b/sortition_algorithms-0.12.10.tar.gz", hash = "sha256:45ce234be0ef4867950b640af4f6b30138ed61ed95d7d18b54d2d983cbc7a2f4", size = 407417, upload-time = "2026-07-14T08:34:12.931Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/ca/6842dc46cce9c3e62b74dcc2b6269a4addd81f2a137b99b7797234c07267/sortition_algorithms-0.11.5-py3-none-any.whl", hash = "sha256:cb1717bf57fd37237c6dd02c830e06369de615d071548e451ec0b3833ff969a6", size = 84043, upload-time = "2026-01-29T11:40:46.132Z" }, + { url = "https://files.pythonhosted.org/packages/5d/80/449346a372af9bff0d4d82b89397296e86ea53e8c24ada865ab5ac43399e/sortition_algorithms-0.12.10-py3-none-any.whl", hash = "sha256:993e8ed1668a3e853cf2fed134e3d8c43b5f07302f4987ded3681644003c58cb", size = 98217, upload-time = "2026-07-14T08:34:11.45Z" }, ] [[package]] @@ -1024,9 +998,9 @@ name = "stratification-app" version = "0.1.0" source = { virtual = "." } dependencies = [ - { name = "eel" }, { name = "oauth2client" }, { name = "pyinstaller" }, + { name = "pyside6-essentials" }, { name = "setuptools" }, { name = "sortition-algorithms" }, { name = "toml" }, @@ -1034,25 +1008,29 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "mypy" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pytest-qt" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ - { name = "eel", specifier = "==0.18.2" }, { name = "oauth2client", specifier = "<5" }, { name = "pyinstaller", specifier = "<7" }, + { name = "pyside6-essentials", specifier = ">=6.11.1" }, { name = "setuptools", specifier = "<82" }, - { name = "sortition-algorithms", specifier = "==0.11.5" }, + { name = "sortition-algorithms", specifier = "==0.12.10" }, { name = "toml" }, ] [package.metadata.requires-dev] dev = [ + { name = "mypy", specifier = ">=2.3.0" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-cov", specifier = ">=6.0.0" }, + { name = "pytest-qt", specifier = ">=4.5.0" }, { name = "ruff", specifier = ">=0.9.7" }, ] @@ -1065,15 +1043,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/55/db07de81b5c630da5cbf5c7df646580ca26dfaefa593667fc6f2fe016d2e/tabulate-0.10.0-py3-none-any.whl", hash = "sha256:f0b0622e567335c8fabaaa659f1b33bcb6ddfe2e496071b743aa113f8774f2d3", size = 39814, upload-time = "2026-03-04T18:55:31.284Z" }, ] -[[package]] -name = "threadpoolctl" -version = "3.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, -] - [[package]] name = "toml" version = "0.10.2" @@ -1119,15 +1088,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] -[[package]] -name = "tzdata" -version = "2026.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" @@ -1136,34 +1096,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] - -[[package]] -name = "zope-event" -version = "6.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/93/41/faa10af34d48d9cd6fa0249a1162943ad84a9590bd1a06939981e6640416/zope_event-6.2.tar.gz", hash = "sha256:b97d5d6327067ee6b9dfcbdf606ade9ade70991e19c162e808ea39e5fcf0f8d3", size = 18958, upload-time = "2026-04-28T06:24:10.578Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/9e/33/848922889e946d4befc415c219fe516af75c49555d8e736e183bfd30db42/zope_event-6.2-py3-none-any.whl", hash = "sha256:5e755153ac4faf64c10a4b6dd3307680166a3edf65b38df22df592610f8fa874", size = 6525, upload-time = "2026-04-28T06:24:09.176Z" }, -] - -[[package]] -name = "zope-interface" -version = "8.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/65/34a6e6e4dfa260c4c55ee02bb2fc53625e126ff0181485286cf0c9d453d6/zope_interface-8.4.tar.gz", hash = "sha256:9dbee7925a23aa6349738892c911019d4095a96cff487b743482073ecbc174a8", size = 257736, upload-time = "2026-04-25T07:22:10.439Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/79/11/bd982648e1e62d7c06a56017fd88d1beea2ebc8d7a5972cce137e774aff2/zope_interface-8.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:8b733af6e89a2b0b8edf5ff7a37988fe4e1788806e84e72127b88c47858f0da6", size = 210908, upload-time = "2026-04-25T07:27:53.363Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4f/fa87d3bd69d22b93fa5b968597a3dd0a297e44aa87e4611b0ca74c4aeec1/zope_interface-8.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:265bad2df2ec070f23ff863249a89b408b11908fd4207662781fd18e3c6fc912", size = 211235, upload-time = "2026-04-25T07:27:55.392Z" }, - { url = "https://files.pythonhosted.org/packages/eb/74/67379f7df4400ee45299c5200f17ec6c493e8a120ff4e5e9d26b09e32956/zope_interface-8.4-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e195e76767847afb5379ffd67690c17d3c6efdab58dc0e477cf81ac94d5a5a15", size = 259918, upload-time = "2026-04-25T07:27:57.705Z" }, - { url = "https://files.pythonhosted.org/packages/b2/4e/c5106672b5c0b9071ce988d54124277762c3085cf1bc72e965e7173f1c26/zope_interface-8.4-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ec1a56b6cf9a757cbbce9da38284a01473b92b96c1517eabd99150f51f1bb69", size = 264343, upload-time = "2026-04-25T07:27:59.96Z" }, - { url = "https://files.pythonhosted.org/packages/fe/49/270c11e54e01b96d2efc59acbeb006a4171b8fafb75926c27d2184c32949/zope_interface-8.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:04c2c9b58e9c177628715d85e94834efa807c1f9f0a2f57ae0f7b553e8266ac4", size = 265629, upload-time = "2026-04-25T07:28:02.086Z" }, - { url = "https://files.pythonhosted.org/packages/b9/03/4ef05ada2230f05f08f579c45b60f127cce2bf379148cb7c21401052ca9d/zope_interface-8.4-cp311-cp311-win_amd64.whl", hash = "sha256:376d0ef005a131b349e2088e302aa094fa23c826d2ec8a7db4b00fb33c71e0d9", size = 214595, upload-time = "2026-04-25T07:28:04.408Z" }, - { url = "https://files.pythonhosted.org/packages/6a/f6/22a304f4061d7ec02e20816d804ab0e844564055b25d471371173c44d73e/zope_interface-8.4-cp311-cp311-win_arm64.whl", hash = "sha256:caffd033b27e311b45e15f01923cc9e73c6bfd8e843b4532e29b59ee432bf893", size = 212904, upload-time = "2026-04-25T07:28:06.045Z" }, - { url = "https://files.pythonhosted.org/packages/b8/96/0017b980424125cf98a9851d8fd3e24939818b7a82ecdd19ae672bb2413f/zope_interface-8.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:84064876ed96ddd0744e3ad5d37134c758d77885e54113567792671405a02bac", size = 211604, upload-time = "2026-04-25T07:28:08.13Z" }, - { url = "https://files.pythonhosted.org/packages/59/4c/2cf5c45477fdd58a2c786d0c0d1817cbaaff8743d98ae72c643c4fe3be7b/zope_interface-8.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:81ed23698bfb588c48b1756129814b890febac971ff6c8a414f82601773145bb", size = 211783, upload-time = "2026-04-25T07:28:10.028Z" }, - { url = "https://files.pythonhosted.org/packages/fa/8c/efabdafc25ed44ef9c1084aad9870bb6c2c9b78e542684efe6865c0f0067/zope_interface-8.4-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e0b9d7e958657fad414f8272afcdf0b8a873fbbb2bb6a6287232d2f11a232bf8", size = 264752, upload-time = "2026-04-25T07:28:11.773Z" }, - { url = "https://files.pythonhosted.org/packages/53/5a/c4d52c58d5fee4ff67cc02f0dec24d0e84428520f67a52f1e4086f0e7779/zope_interface-8.4-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:eef0a49e041f4dc4d2a6ab894b4fd0c5354e0e8037e731fb953531e59b0d3d33", size = 269829, upload-time = "2026-04-25T07:28:13.988Z" }, - { url = "https://files.pythonhosted.org/packages/16/d2/df8f339c93bb5adee695546ba90d0daa2917338a4792281f6b8e652a9328/zope_interface-8.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b302f955c36e924e1f4fe70dd9105ff06235857861c6ae72c3b10b016aeee99", size = 269452, upload-time = "2026-04-25T07:28:16.403Z" }, - { url = "https://files.pythonhosted.org/packages/17/4b/bd97b1a21bb2c16d66a42f6c7a43c0a5afcfaf14c68d3b7d2ee6afb28e52/zope_interface-8.4-cp312-cp312-win_amd64.whl", hash = "sha256:4ae6a1e111642dbf724f635424dcaf5a5c8abbde49eac3f452f5323ffaa10232", size = 214420, upload-time = "2026-04-25T07:28:18.405Z" }, - { url = "https://files.pythonhosted.org/packages/7d/85/1477f23cf3b0476608ca987b4338f91439abb5b96564ac26b26d2cde38fd/zope_interface-8.4-cp312-cp312-win_arm64.whl", hash = "sha256:2e9e4aa33b76877af903d5532545e64d24ade0f6f80d9d1a31e6efcea76a60bc", size = 212992, upload-time = "2026-04-25T07:28:20.48Z" }, -] diff --git a/web/js/main.js b/web/js/main.js deleted file mode 100644 index c65b879..0000000 --- a/web/js/main.js +++ /dev/null @@ -1,286 +0,0 @@ -$(function () { - - - function init_page() { - // CSV elements - const csv_file_features_input = document.getElementById("csv-file-features"); - csv_file_features_input.addEventListener("click", clear_file_value, false); - csv_file_features_input.addEventListener("change", handle_csv_file_features, false); - const csv_file_people_input = document.getElementById("csv-file-people"); - csv_file_people_input.addEventListener("click", clear_file_value, false); - csv_file_people_input.addEventListener("change", handle_csv_file_people, false); - const csv_run_btn = document.getElementById('csv-run-btn'); - csv_run_btn.addEventListener('click', handle_csv_run_button, false); - const csv_run_test_btn = document.getElementById('csv-run-test-btn'); - csv_run_test_btn.addEventListener('click', handle_csv_run_test_button, false); - const csv_panel_size_input = document.getElementById("csv-panel-size"); - csv_panel_size_input.addEventListener("input", handle_csv_panel_size, false); - - // Google Spreadsheet elements - const g_sheet_name_input = document.getElementById("g-sheet-name"); - g_sheet_name_input.addEventListener("input", handle_g_sheet_name, false); - const g_sheet_respondents_tab_name_input = document.getElementById("g-sheet-respondents-tab");//for respondents tab - g_sheet_respondents_tab_name_input.addEventListener("input", handle_respondents_tab_name, false);//for features/categories tab - const g_sheet_features_tab_name_input = document.getElementById("g-sheet-features-tab");//for features/categories tab - g_sheet_features_tab_name_input.addEventListener("input", handle_features_tab_name, false);//for features/categories tab - const g_sheet_gen_rem_tab_input = document.getElementById("g-sheet-gen-rem-tab");//for generate remaining tab checkbox - g_sheet_gen_rem_tab_input.addEventListener('change', handle_gen_rem_tab, false);// for generate remaining tab checkbox - const load_g_sheet_btn = document.getElementById('load-g-sheet-btn'); - load_g_sheet_btn.addEventListener('click', handle_load_g_sheet_btn, false); - const g_sheet_number_selections_input = document.getElementById("g-sheet-number-selections"); - g_sheet_number_selections_input.addEventListener("input", handle_g_sheet_number_selections, false); - const g_sheet_panel_size_input = document.getElementById("g-sheet-panel-size"); - g_sheet_panel_size_input.addEventListener("input", handle_g_sheet_panel_size, false); - const g_sheet_run_btn = document.getElementById('g-sheet-run-btn'); - g_sheet_run_btn.addEventListener('click', handle_g_sheet_run_button, false); - const g_sheet_run_test_btn = document.getElementById('g-sheet-run-test-btn'); - g_sheet_run_test_btn.addEventListener('click', handle_g_sheet_run_test_button, false); - } - - eel.expose(alert_user); - function alert_user(message, is_error) { - const alerts_div = document.getElementById("user-alerts"); - alerts_div.classList.add("alert"); - if (is_error) { - alerts_div.classList.add("alert-danger"); - } else { - alerts_div.classList.add("alert-info"); - } - alerts_div.textContent = message; - } - - ////////////////////////////////////////// - // functions for CSV files only - calling from JS to Python - ////////////////////////////////////////// - - // this allows repeat upload of a file - // from: https://stackoverflow.com/a/12102992/3189 - function clear_file_value() { - this.value = null; - } - - function handle_csv_file_features() { - const file_handle = this.files[0]; - const reader = new FileReader(); - reader.onload = csv_file_features_loaded; - reader.readAsText(file_handle); - } - - function handle_csv_file_people() { - const file_handle = this.files[0]; - const reader = new FileReader(); - reader.onload = csv_file_people_loaded; - reader.readAsText(file_handle); - } - - function csv_file_features_loaded(e) { - const file_contents = e.target.result; - eel.handle_csv_file_features_content(file_contents); - } - - function csv_file_people_loaded(e) { - const file_contents = e.target.result; - eel.handle_csv_file_people_content(file_contents); - } - - function enable_download(download_link_id, file_contents, filename) { - let download_link = document.getElementById(download_link_id); - download_link.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(file_contents)); - download_link.setAttribute('download', filename); - download_link.classList.remove("disabled"); - } - - eel.expose(enable_csv_selected_download); - function enable_csv_selected_download(file_contents, filename) { - console.log("in csv_enable_selected_download"); - enable_download("csv-download-selected-btn", file_contents, filename); - } - - eel.expose(enable_csv_remaining_download); - function enable_csv_remaining_download(file_contents, filename) { - console.log("in csv_enable_remaining_download"); - enable_download("csv-download-remaining-btn", file_contents, filename); - } - - function handle_csv_panel_size() { - eel.update_csv_panel_size(this.value); - } - - function handle_csv_run_button() { - eel.csv_run_selection(); - } - - function handle_csv_run_test_button() { - eel.csv_run_test_selection(); - } - - ////////////////////////////////////////// - // functions for CSV files only - calling from Python to JS - ////////////////////////////////////////// - - eel.expose(update_csv_features_output_area); - function update_csv_features_output_area(output_html) { - const output_area = document.getElementById("csv-output-area-features-target-p"); - output_area.innerHTML = output_html; - } - - eel.expose(update_csv_selection_output_area); - function update_csv_selection_output_area(output_html) { - const output_area = document.getElementById("csv-output-area-selection-target-p"); - output_area.innerHTML = output_html; - } - - eel.expose(update_csv_selection_range); - function update_csv_selection_range(min_selection, max_selection) { - const selection_label = document.querySelector("label[for=csv-panel-size]"); - selection_label.textContent = "Step 3: Specify the number of people to select (" + - min_selection + "-" + max_selection + ")"; - const selection_input = document.getElementById("csv-panel-size"); - selection_input.setAttribute("min", min_selection); - selection_input.setAttribute("max", max_selection); - } - - eel.expose(enable_csv_selection_content); - function enable_csv_selection_content() { - var csv_file_people_input = document.getElementById("csv-file-people"); - csv_file_people_input.disabled = false; - } - - eel.expose(set_csv_panel_size); - function set_csv_panel_size(panel_size) { - var csv_panel_size_input = document.getElementById("csv-panel-size"); - csv_panel_size_input.value = panel_size; - } - - eel.expose(enable_csv_run_button); - function enable_csv_run_button() { - var csv_run_btn = document.getElementById('csv-run-btn'); - var csv_run_test_btn = document.getElementById('csv-run-test-btn'); - csv_run_btn.disabled = false; - csv_run_test_btn.disabled = false; - } - - eel.expose(disable_csv_run_button); - function disable_csv_run_button() { - var csv_run_btn = document.getElementById('csv-run-btn'); - var csv_run_test_btn = document.getElementById('csv-run-test-btn'); - csv_run_btn.disabled = true; - csv_run_test_btn.disabled = true; - } - - - ///////////////////////////////////////// - // functions for Google Spreadsheets only - calling from JS to Python - ///////////////////////////////////////// - - function handle_g_sheet_name() { - eel.update_g_sheet_name(this.value); - } - - function handle_respondents_tab_name() { - eel.update_respondents_tab_name(this.value); - } - - function handle_features_tab_name() { - eel.update_features_tab_name(this.value); - } - - function handle_gen_rem_tab() { - if (this.checked == true) { - eel.update_gen_rem_tab(true); - } else { - eel.update_gen_rem_tab(false); - } - } - - function handle_load_g_sheet_btn() { - eel.load_g_sheet(); - } - - eel.expose(enable_load_g_sheet_btn); - function enable_load_g_sheet_btn() { - var load_g_sheet_btn = document.getElementById('load-g-sheet-btn'); - load_g_sheet_btn.disabled = false; - } - - function handle_g_sheet_number_selections() { - eel.update_number_selections(this.value); - } - - function handle_g_sheet_panel_size() { - eel.update_g_sheet_panel_size(this.value); - } - - function handle_g_sheet_run_button() { - eel.g_sheet_run_selection(); - } - - function handle_g_sheet_run_test_button() { - eel.g_sheet_run_test_selection(); - } - - ///////////////////////////////////////// - // functions for Google Spreadsheets only - calling from Python to JS - ///////////////////////////////////////// - - eel.expose(update_g_sheet_features_output_area); - function update_g_sheet_features_output_area(output_html) { - const output_area = document.getElementById("g-sheet-output-area-features-target-p"); - output_area.innerHTML = output_html; - } - - eel.expose(update_g_sheet_selection_range); - function update_g_sheet_selection_range(min_selection, max_selection) { - const selection_label = document.querySelector("label[for=g-sheet-panel-size]"); - selection_label.textContent = "Step 3: Specify the number of people to select (" + - min_selection + "-" + max_selection + ")"; - const selection_input = document.getElementById("g-sheet-panel-size"); - selection_input.setAttribute("min", min_selection); - selection_input.setAttribute("max", max_selection); - } - - eel.expose(update_g_sheet_selection_output_area); - function update_g_sheet_selection_output_area(output_html) { - const output_area = document.getElementById("g-sheet-output-area-selection-target-p"); - output_area.innerHTML = output_html; - } - - eel.expose(enable_g_sheet_selection_content); - function enable_g_sheet_selection_content() { - g_sheet_file_people_input.disabled = false; - } - - eel.expose(set_g_sheet_panel_size); - function set_g_sheet_panel_size(panel_size) { - var g_sheet_panel_size_input = document.getElementById("g-sheet-panel-size"); - g_sheet_panel_size_input.value = panel_size; - } - - eel.expose(enable_g_sheet_run_button); - function enable_g_sheet_run_button() { - var g_sheet_run_btn = document.getElementById('g-sheet-run-btn'); - var g_sheet_run_test_btn = document.getElementById('g-sheet-run-test-btn'); - g_sheet_run_btn.disabled = false; - g_sheet_run_test_btn.disabled = false; - } - - eel.expose(disable_g_sheet_run_button); - function disable_g_sheet_run_button() { - var g_sheet_run_btn = document.getElementById('g-sheet-run-btn'); - var g_sheet_run_test_btn = document.getElementById('g-sheet-run-test-btn'); - g_sheet_run_btn.disabled = true; - g_sheet_run_test_btn.disabled = true; - } - - //////////////////////////////////////// - //Some functions for the output area - shared by CSV and GSheets - //////////////////////////////////////// - - eel.expose(update_detailed_log_messages_area); - function update_detailed_log_messages_area(output_html) { - const output_area = document.getElementById("output-area-detailed-log-target-p"); - output_area.innerHTML = output_html; - } - - init_page(); -}()); diff --git a/web/main.html b/web/main.html deleted file mode 100644 index aa31a07..0000000 --- a/web/main.html +++ /dev/null @@ -1,253 +0,0 @@ - - - - - - - - - - - - - - - -
- -

Sortition Foundation - Stratification & Selection

-

- -
-
-
-
-

-

Option A: CSV file input and output

- -
-

-
-
- - -
-
- - -
-
-

-
-

Category and people information:

-
-

Number of categories: No input yet

-
-
-

Number of people: No input yet

-
- -

-
-

-
-

Set number of people and run selection

-
- - -
-
- - -
-
-

-
-
-

After selection... download output:

- Download Selected - Download Remaining -
-
-
-
-
-
-
-
-

Option B: Google Sheet input and output

- -
-

-
-
- - - If you change this, then you must click "Load G-Sheet" afterwards. -
-
- -
-
-
-

- -

- - - If you change this, then you must click "Load G-Sheet" afterwards. -
-
- - - If you change this, then you must click "Load G-Sheet" afterwards. -
-
- - - If this is checked then the remaining tab will be written to your sheet. If you change the check box, then you must click "Load G-Sheet" afterwards. -
-
- - - If you change this, then you must click "Load G-Sheet" afterwards. -
-

-
-
-
-
-
- -
- -
-
-

-
-

Category and people information:

-
-

Number of categories: No input yet

-
-
-

Number of people: No input yet

-
-
- -

- -
-

Set number of people and run selection

-
- - -
-
- - -
-
- - - -
-
-
-

-
-

-

-

-
- -
- Show/hide detailed log -
-
-
-

Detailed Log

-
-

-
-
-
-
-
-

- - - - - - - - - -