diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e69528..2316f63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,11 +14,12 @@ jobs: - name: Install uv uses: astral-sh/setup-uv@v6 - # core + dev + train + export — installs torch/sklearn/onnx so the model, - # metrics and pipeline modules are exercised. The `data` extra (roboflow, - # dvc) is omitted: tests do not touch dataset downloads. - - name: Install (core + dev + train + export + capture) - run: uv sync --extra dev --extra train --extra export --extra capture + # core + dev + train + export + capture + ui — installs torch/sklearn/onnx + # so the model, metrics and pipeline modules are exercised, plus streamlit + # so the UI smoke tests actually run. The `data` extra (roboflow, dvc) is + # omitted: tests do not touch dataset downloads. + - name: Install (core + dev + train + export + capture + ui) + run: uv sync --extra dev --extra train --extra export --extra capture --extra ui - name: Ruff lint run: uv run ruff check . diff --git a/Makefile b/Makefile index 1fb58db..daebc8c 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ # created by uv. Run `make help` for the full list. .DEFAULT_GOAL := help -.PHONY: help setup setup-min lint format test info train eval export bench sweep data ingest clean +.PHONY: help setup setup-min lint format test info train eval export bench sweep data ingest ui clean SWEEP_BACKBONES ?= mobilenet_v3_small,mobilenet_v3_large,efficientnet_b0 SWEEP_EPOCHS ?= 20 @@ -53,6 +53,9 @@ bench: ## Benchmark inference latency / throughput sweep: ## Backbone sweep (override SWEEP_BACKBONES, SWEEP_EPOCHS) uv run almendra sweep --backbones $(SWEEP_BACKBONES) --epochs $(SWEEP_EPOCHS) $(ARGS) +ui: ## Launch the local Streamlit UI (Phase 6) + uv run almendra ui $(ARGS) + clean: ## Remove build artifacts, caches and run outputs rm -rf outputs mlruns .pytest_cache .ruff_cache dist build find . -type d -name __pycache__ -exec rm -rf {} + diff --git a/README.md b/README.md index 12d2c84..4865ecd 100644 --- a/README.md +++ b/README.md @@ -11,11 +11,12 @@ hardware-agnostic export/benchmark toolchain, and a documented physical capture protocol. The model is the focus — reliable and fast — but it must stay easy to re-train as better data arrives. -> **Status: Phase 2 — multi-view model.** The full pipeline (`ingest → train → -> eval → export → bench`) runs on public data — single-view baseline **0.92 test -> macro-F1** — and the multi-view model is trained and shown to be view-count -> robust. See [`docs/research-log.md`](docs/research-log.md) for live progress -> and [the roadmap](#roadmap) below. +> **Status: Phase 6 — local UI.** The full pipeline (`ingest → train → eval → +> export → bench`) runs on public data; Phase 5's Pareto sweep picked +> **MobileNetV3-Large + static INT8** as the current deploy choice (0.86 macro-F1, +> 3.6 MB, ~430 beans/s on a single CPU thread); and a local Streamlit UI now +> wraps the whole toolkit. See [`docs/research-log.md`](docs/research-log.md) +> for the full log. ## The idea @@ -59,6 +60,93 @@ make export # export to ONNX (+ INT8) with a parity check make bench # benchmark inference latency ``` +## Local UI + +A Streamlit app wraps the whole pipeline behind a bilingual ES/EN interface — +tray capture, training (with live charts), evaluation, prediction and settings. +A non-technical user can run almendra end-to-end without touching the CLI. + +![almendra Home page in Spanish](docs/images/ui-home.png) + +### Launch + +```bash +uv sync --extra ui --extra train --extra export --extra capture +make ui +# equivalent: uv run almendra ui +``` + +The app opens at . Flags: + +```bash +uv run almendra ui --port 8888 # use a different port +uv run almendra ui --headless # don't auto-open a browser (SSH / CI) +``` + +| Extra installed | Page it unlocks | +|---|---| +| `ui` | the app itself (Streamlit + Plotly) | +| `train` | Train + Evaluate + mis-classified gallery (PyTorch) | +| `export` | Predict (ONNX Runtime) | +| `capture` | Tray Capture (OpenCV) | + +Skipping an extra is fine — the page that depends on it shows a clear error +instead of crashing. Install later and reload. + +### What's in the app + +1. **🏠 Inicio / Home** — dataset stats, recent runs, a health panel, and an + inline wizard that walks first-time users through Ingest → Train → Eval. +2. **📷 Bandeja / Tray Capture** — drag-and-drop tray photos, see the original + next to the rectified+overlay preview, save per-bean crops to + `data/raw/proprietary_tray/sessions//`. +3. **🧠 Entrenar / Train** — pick a backbone and the key knobs (advanced + controls live behind an expander), launch training as a subprocess, and + watch `train_loss` + `val_macro_f1` update **in real time** as each epoch + completes. + + ![Train page](docs/images/ui-train.png) + +4. **📊 Evaluar / Evaluate** — pick a checkpoint and split, run it, see + accuracy / macro-F1 / missed-defect-rate, per-class breakdown, confusion + matrix heatmap, and a gallery of mis-classified beans. +5. **🚀 Predecir / Predict** — upload a single-bean photo, get the predicted + class, confidence, Top-3, and an accept/reject verdict from the canonical + taxonomy. Uses the most recent ONNX for speed (prefers INT8). +6. **⚙️ Ajustes / Settings** — browse the canonical taxonomy, the YAML data + sources, and the current Hydra config. + +### End-to-end test in 5 minutes + +```bash +# 1. install everything the UI exercises +uv sync --extra ui --extra train --extra export --extra capture + +# 2. (optional) ingest the public Robusta baseline so Train/Evaluate have data +export ROBOFLOW_API_KEY=... # see your Roboflow workspace +make data && make ingest + +# 3. launch the UI +make ui +``` + +Then in the browser: + +1. **Inicio** — confirm the health panel shows Python/PyTorch/Taxonomy green; + the manifest icon flips to ✅ once `data/processed/manifest.jsonl` exists. +2. **Entrenar** — backbone `mobilenet_v3_small`, **3 épocas** (for a smoke + test), **Iniciar entrenamiento**. The Plotly chart should start updating + within a couple of seconds of the first epoch landing. +3. **Evaluar** — pick the run you just trained, leave `split = test`, + **Ejecutar**. You get the headline metrics + confusion matrix + error + gallery. +4. **Predecir** — from a terminal, `uv run almendra export --checkpoint + outputs/ui-/best.pt`. Refresh the Predict page, pick the ONNX + from the dropdown, upload any single-bean image. + +See [`docs/ui.md`](docs/ui.md) for the deeper troubleshooting guide +(stuck subprocesses, port conflicts, missing extras). + ## Repository layout | Path | Purpose | @@ -87,10 +175,10 @@ answer, tracked in [`docs/research-log.md`](docs/research-log.md): - **Phase 0** — Scaffolding ✓ - **Phase 1** — Data pipeline + single-view public baseline ✓ - **Phase 2** — Multi-view fusion model ✓ -- **Phase 3** — Physical capture protocol + proprietary Arabica data *(current)* +- **Phase 3** — Physical capture protocol + proprietary Arabica data *(blocked on data)* - **Phase 4** — Multi-spectral illumination (UV, transillumination) -- **Phase 5** — Speed: backbone sweep, INT8, hardware benchmark -- **Phase 6** — Deployment reference + sorting-machine spec +- **Phase 5** — Speed: backbone sweep, INT8, hardware benchmark ✓ +- **Phase 6** — Local Streamlit UI for the whole toolkit ✓ - *Parallel research track* — NIR / hyperspectral internal-defect inspection ## Data & licensing diff --git a/docs/images/ui-home.png b/docs/images/ui-home.png new file mode 100644 index 0000000..474c37c Binary files /dev/null and b/docs/images/ui-home.png differ diff --git a/docs/images/ui-train.png b/docs/images/ui-train.png new file mode 100644 index 0000000..722d270 Binary files /dev/null and b/docs/images/ui-train.png differ diff --git a/docs/research-log.md b/docs/research-log.md index 1f55b9a..305e395 100644 --- a/docs/research-log.md +++ b/docs/research-log.md @@ -45,6 +45,39 @@ deployment tooling). Headline findings: ## Log +### 2026-05-21 — Phase 6: local Streamlit UI + +- **New optional-deps group `ui`** (`streamlit`, `plotly`) and `almendra ui` + CLI subcommand that exec's `streamlit run` on `src/almendra/ui/app.py`. `make + ui` is the matching shortcut. +- **Six pages** behind a sidebar radio: Home (dataset stats + recent runs + + inline wizard), Tray Capture, Train, Evaluate, Predict, Settings. +- **Bilingual ES/EN from day one** — every visible string lives in a central + dict (`ui/components/i18n.py`) and pages read it through `t("key", lang)`. A + sidebar radio toggles the language; adding a third language is a translation + job, not a rewrite. Default: Spanish. +- **Live training charts** — `train.loop` writes one JSONL line per epoch to + `outputs//live_metrics.jsonl` (controlled by env var + `ALMENDRA_LIVE_METRICS` so the CLI use case is untouched). The Train page + launches training as a subprocess, polls the file every ~2 s, and re-renders + a two-line Plotly chart (train_loss + val_macro_f1). +- **Decoupled, file-based contract** — the UI is stateless across reruns; + everything it shows (runs, checkpoints, ONNX, metrics) is discovered from + disk under `outputs/`. Anything that writes the same JSONL schema works with + the UI. +- **Inline wizard** on Home with three "press to go" buttons that walk + Ingest → Train → Eval with sensible defaults. Advanced controls (gated + fusion, view-dropout, augmentation toggles) live behind an `Advanced` + expander on the Train page so they don't intimidate first-time users. +- **Tests** — `streamlit.testing.v1.AppTest` smoke-tests render every page in + ES *and* EN (12 cases) without exceptions; the i18n dict is checked for + complete coverage; the live-metrics JSONL writer/reader has its own unit + test. All 45 tests in the suites that don't require torch/onnxruntime pass. +- **Scope split** — Phase 6.0 (this PR) ships the six pages, the CLI/Make + entrypoints and tests. Phase 6.1 will add: a dedicated **Labelling** page + with hotkeys + IAA reporting, an **Export & Bench** page, a model-package + zip exporter, and a "Demo mode" using the public Roboflow data. + ### 2026-05-20 — Phase 5: backbone sweep + static INT8 PTQ - Static INT8 PTQ implemented (`quantize_int8_static` in `src/almendra/export/exporter.py`): diff --git a/docs/ui.md b/docs/ui.md new file mode 100644 index 0000000..37f1a4a --- /dev/null +++ b/docs/ui.md @@ -0,0 +1,134 @@ +# Local UI (Phase 6) + +The `almendra ui` command launches a local Streamlit app that wraps the whole +toolkit — tray capture, training, evaluation, prediction and settings — so a +non-technical user can run the pipeline end-to-end without touching the CLI. + +The UI is **bilingual ES/EN** with a sidebar toggle (Spanish default). + +## 1 — Install + +```bash +# Minimum extras for a full end-to-end run: +uv sync --extra ui --extra train --extra export --extra capture +``` + +| Extra | Why you need it | +|---|---| +| `ui` | Streamlit + Plotly (the app itself) | +| `train` | PyTorch + torchvision (Train, Evaluate, mis-classified gallery) | +| `export` | ONNX Runtime (Predict page) | +| `capture` | OpenCV (Tray Capture page) | + +If you skip an extra, the page that depends on it shows a clear error instead +of crashing. You can come back and install it later. + +## 2 — Launch + +```bash +make ui +# equivalent: uv run almendra ui +``` + +This exec's `streamlit run` against `src/almendra/ui/app.py`. By default the +app opens at and your browser auto-opens. + +Flags: + +```bash +uv run almendra ui --port 8888 # use a different port +uv run almendra ui --headless # don't auto-open a browser (SSH) +``` + +The first launch may take a few seconds — Streamlit warms up its caches. + +## 3 — End-to-end test flow + +The pages are designed to be exercised in order. The **inline wizard on Home** +gives you a fast-path button for each step. + +### 3a — Have public data already? + +If you've already run `make data && make ingest` (Roboflow Robusta dataset), +the manifest at `data/processed/manifest.jsonl` will show up on Home and you +can skip straight to **Train**. + +### 3b — Cold start (proprietary tray photos) + +1. **🏠 Home** — confirm the health panel says Python/PyTorch/Taxonomy are all + green. Manifest will show ❌ if you haven't ingested anything yet — fine. +2. **📷 Tray Capture** — drag in *Side A* (required) and *Side B* (optional) + tray photos. Set: + - **Rows / Cols** — wells in your tray. + - **Flip** — `mirror_cols` if you flipped the tray horizontally, `mirror_rows` + if vertically. `identity` if no flip. + - Leave **Margin frac** and **Well frac** at defaults to start. + Hit **Procesar / Process photos**. You should see the original photo next + to a rectified+overlay view (green squares = occupied wells, red = empty). + If markers aren't detected the page tells you so — check the corners are + sharp and in-frame. + Enter a session ID (defaults to a timestamp) and hit **Save crops**. Crops + land in `data/raw/proprietary_tray/sessions//`. +3. *(out of UI for now)* — convert the saved session into a manifest entry. + The proprietary tray ingester is a Phase 3 task; until then, public-data + `almendra ingest` is the path that gives the Train page something to chew. +4. **🧠 Train** — pick a backbone (start with `mobilenet_v3_small` — fastest), + set **Épocas / Epochs** to 3 for a smoke test, press **Iniciar / Start**. + The progress bar fills and the Plotly chart updates in real time as each + epoch completes. The **Best macro-F1** metric tracks the best checkpoint + saved. Press **Detener / Stop** if you want to kill the run early. +5. **📊 Evaluate** — pick the run you just trained from the dropdown, leave + `split = test`, press **Ejecutar / Run**. You get headline accuracy / + macro-F1 / missed-defect-rate cards, a per-class table, a confusion-matrix + heatmap, and a gallery of mis-classified beans. +6. **🚀 Predict** — works once a run has been **exported**. From a terminal: + ```bash + uv run almendra export --checkpoint outputs/ui-/best.pt + ``` + Then refresh the Predict page, pick the ONNX file from the dropdown, upload + a single bean photo, and check the predicted class + Top-3 + accept/reject + verdict. +7. **⚙️ Settings** — read-only view of the canonical taxonomy, data sources + and current Hydra config. Useful for sanity-checking the project paths. + +### 3c — Sanity-check checklist + +Use this to make sure the UI is *actually* doing what it should: + +- [ ] Language toggle in the sidebar instantly swaps every visible string. +- [ ] Home health panel shows ✅ for Taxonomy and the manifest icon flips + between ✅/❌ depending on whether `data/processed/manifest.jsonl` exists. +- [ ] On Train, the live chart **starts appearing within ~2 s of the first + epoch finishing** — confirms the JSONL tail is working. +- [ ] Stopping training mid-run kills the subprocess (check `ps` or `pgrep -f + almendra.cli`). +- [ ] On Evaluate, mis-classified gallery shows real bean thumbnails (not just + captions) when the manifest has accessible image paths. +- [ ] On Predict, the page lists every ONNX under `outputs/*/model*.onnx` and + defaults to the most recently modified INT8 if present. +- [ ] On Settings, every YAML under `data/sources/` is browsable. + +## Troubleshooting + +- **"OpenCV is not installed"** on the Tray Capture page → `uv sync --extra + capture`, then click the page again. +- **"onnxruntime is not installed"** on Predict → `uv sync --extra export`. +- **The Train chart never updates** → check `outputs/ui-/live_metrics.jsonl` + exists and grows; if the file isn't being written, the subprocess didn't + inherit the `ALMENDRA_LIVE_METRICS` env var (file a bug). +- **Port already in use** → `uv run almendra ui --port 8888`. +- **Stuck training subprocess after closing the tab** → `pkill -f + "almendra.cli train"`. The UI's Stop button uses SIGTERM on the process + group, but if you close the browser before pressing Stop the subprocess + keeps running. This is intentional — long runs should survive a tab close. + +## What's *not* in v1 + +These ship in **Phase 6.1**, not this PR: + +- A dedicated **Labelling** page with keyboard hotkeys and inter-annotator + agreement reporting. +- An **Export & Bench** page (currently you drop to the CLI for both). +- A **model-package zip exporter** (ONNX + INT8 + model card + manifest + snapshot). +- A **demo mode** using the public Roboflow data baseline. diff --git a/pyproject.toml b/pyproject.toml index 64ac216..2e0f0a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -57,6 +57,11 @@ capture = [ # ArUco marker detection for the gridded-tray auto-segmentation pipeline. "opencv-contrib-python-headless>=4.9", ] +ui = [ + # Local Streamlit UI (Phase 6) — runs the toolkit end-to-end for non-CLI users. + "streamlit>=1.40", + "plotly>=5.20", +] dev = [ "pytest>=8.0", "ruff>=0.5", diff --git a/src/almendra/cli.py b/src/almendra/cli.py index 24adc01..cb4d6bf 100644 --- a/src/almendra/cli.py +++ b/src/almendra/cli.py @@ -10,6 +10,7 @@ bench benchmark inference latency / throughput sweep train + eval + export + bench across backbones (RQ3/RQ4) tray-check segment beans from gridded-tray photos (capture data prep) + ui launch the local Streamlit UI (Phase 6) The pipeline subcommands accept Hydra-style ``key=value`` overrides, e.g. ``almendra train model=efficientnet_b0 train.epochs=50``. @@ -108,6 +109,38 @@ def cmd_sweep(args: argparse.Namespace) -> int: return 0 +def cmd_ui(args: argparse.Namespace) -> int: + """Launch the local Streamlit UI by exec'ing ``streamlit run`` on the app.""" + import os + from pathlib import Path + + app_path = Path(__file__).parent / "ui" / "app.py" + if not app_path.is_file(): + raise FileNotFoundError(f"UI app not found at {app_path}") + try: + import streamlit.web.cli as stcli # type: ignore + except ImportError as exc: + raise SystemExit( + "Streamlit is not installed. Run `uv sync --extra ui` (or " + "`pip install almendra[ui]`) and try again." + ) from exc + sys.argv = [ + "streamlit", + "run", + str(app_path), + "--server.port", + str(args.port), + "--server.headless", + "true" if args.headless else "false", + "--browser.gatherUsageStats", + "false", + ] + if args.extra: + sys.argv.extend(args.extra) + os.environ.setdefault("ALMENDRA_UI_ROOT", str(Path.cwd())) + return int(stcli.main()) + + def cmd_tray_check(args: argparse.Namespace) -> int: """Segment beans from gridded-tray photos and write crops + a debug overlay.""" from pathlib import Path @@ -200,6 +233,16 @@ def build_parser() -> argparse.ArgumentParser: p_sweep.add_argument("overrides", nargs="*", help="Hydra overrides (key=value)") p_sweep.set_defaults(func=cmd_sweep) + p_ui = sub.add_parser("ui", help="launch the local Streamlit UI") + p_ui.add_argument("--port", type=int, default=8501, help="server port (default: 8501)") + p_ui.add_argument( + "--headless", + action="store_true", + help="don't auto-open a browser (useful over SSH)", + ) + p_ui.add_argument("extra", nargs=argparse.REMAINDER, help="extra args forwarded to streamlit") + p_ui.set_defaults(func=cmd_ui) + p_tray = sub.add_parser("tray-check", help="segment beans from gridded-tray photos") p_tray.add_argument("--rows", type=int, required=True, help="number of well rows") p_tray.add_argument("--cols", type=int, required=True, help="number of well columns") diff --git a/src/almendra/train/loop.py b/src/almendra/train/loop.py index 6410ec3..1f3b58e 100644 --- a/src/almendra/train/loop.py +++ b/src/almendra/train/loop.py @@ -7,7 +7,10 @@ from __future__ import annotations +import json +import os import random +import time from pathlib import Path import mlflow @@ -32,6 +35,26 @@ def set_seed(seed: int) -> None: torch.manual_seed(seed) +def _live_metrics_path(output_dir: Path) -> Path | None: + """Where the UI tails live metrics from. ``ALMENDRA_LIVE_METRICS`` overrides.""" + override = os.environ.get("ALMENDRA_LIVE_METRICS") + if override: + return Path(override) + return output_dir / "live_metrics.jsonl" + + +def _write_live_metric(path: Path | None, payload: dict) -> None: + """Append one JSONL line; the Streamlit UI tails this file. Silent on failure.""" + if path is None: + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(payload) + "\n") + except OSError: + pass + + def resolve_device(name: str) -> torch.device: """Resolve 'auto' to cuda > mps > cpu, or honour an explicit device name.""" if name != "auto": @@ -156,6 +179,21 @@ def run(cfg) -> Path: mlflow.set_tracking_uri(cfg.mlflow.tracking_uri) mlflow.set_experiment(cfg.mlflow.experiment) + live_path = _live_metrics_path(output_dir) + # Truncate any previous run's metrics so the UI tail starts clean. + if live_path is not None: + live_path.parent.mkdir(parents=True, exist_ok=True) + live_path.write_text("") + _write_live_metric( + live_path, + { + "event": "start", + "timestamp": time.time(), + "epochs": epochs, + "backbone": cfg.model.backbone, + }, + ) + best_f1, bad_epochs = -1.0, 0 patience = cfg.train.early_stopping.patience @@ -188,6 +226,18 @@ def run(cfg) -> Path: f"val_macro_f1={metrics['macro_f1']:.4f} " f"val_acc={metrics['accuracy']:.4f}" ) + _write_live_metric( + live_path, + { + "event": "epoch", + "timestamp": time.time(), + "epoch": epoch + 1, + "epochs": epochs, + "train_loss": train_loss, + "val_macro_f1": metrics["macro_f1"], + "val_accuracy": metrics["accuracy"], + }, + ) if metrics["macro_f1"] > best_f1: best_f1, bad_epochs = metrics["macro_f1"], 0 @@ -212,5 +262,9 @@ def run(cfg) -> Path: if ckpt_path.is_file(): mlflow.log_artifact(str(ckpt_path)) + _write_live_metric( + live_path, + {"event": "done", "timestamp": time.time(), "best_val_macro_f1": best_f1}, + ) print(f"\nbest val macro-F1: {best_f1:.4f} -> {ckpt_path}") return ckpt_path diff --git a/src/almendra/ui/__init__.py b/src/almendra/ui/__init__.py new file mode 100644 index 0000000..8d033b9 --- /dev/null +++ b/src/almendra/ui/__init__.py @@ -0,0 +1,6 @@ +"""Local Streamlit UI for the almendra toolkit (Phase 6). + +A non-technical user can prepare data (tray capture + visual cropping), train a +model with live charts, evaluate it, and predict on a single bean — all without +touching the CLI. Launched via ``almendra ui`` or ``make ui``. +""" diff --git a/src/almendra/ui/app.py b/src/almendra/ui/app.py new file mode 100644 index 0000000..2e2e41b --- /dev/null +++ b/src/almendra/ui/app.py @@ -0,0 +1,76 @@ +"""Streamlit entry point for the local almendra UI. + +Run via ``almendra ui`` (which exec's ``streamlit run`` on this file). The app +uses Streamlit's classic radio-based navigation rather than the +``st.Page``/``st.navigation`` multipage API so the tests can drive each page +directly without spinning up a server. +""" + +from __future__ import annotations + +import streamlit as st + +from almendra.ui.components.i18n import t +from almendra.ui.components.state import current_lang, language_toggle +from almendra.ui.views import ( + page_evaluate, + page_home, + page_predict, + page_settings, + page_train, + page_tray, +) + + +def configure_page() -> None: + st.set_page_config( + page_title="almendra", + page_icon="☕", + layout="wide", + initial_sidebar_state="expanded", + ) + + +def render_sidebar() -> str: + lang = language_toggle() + st.sidebar.markdown(f"### {t('app.title', lang)}") + st.sidebar.caption(t("app.tagline", lang)) + st.sidebar.markdown("---") + st.sidebar.markdown(f"**{t('sidebar.nav', lang)}**") + pages = { + "home": t("nav.home", lang), + "tray": t("nav.tray", lang), + "train": t("nav.train", lang), + "evaluate": t("nav.evaluate", lang), + "predict": t("nav.predict", lang), + "settings": t("nav.settings", lang), + } + pick = st.sidebar.radio( + label="nav", + options=list(pages.keys()), + format_func=lambda key: pages[key], + label_visibility="collapsed", + key="almendra.page", + ) + return pick + + +def main() -> None: + configure_page() + page = render_sidebar() + lang = current_lang() + if page == "home": + page_home.render(lang) + elif page == "tray": + page_tray.render(lang) + elif page == "train": + page_train.render(lang) + elif page == "evaluate": + page_evaluate.render(lang) + elif page == "predict": + page_predict.render(lang) + elif page == "settings": + page_settings.render(lang) + + +main() diff --git a/src/almendra/ui/components/__init__.py b/src/almendra/ui/components/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/almendra/ui/components/charts.py b/src/almendra/ui/components/charts.py new file mode 100644 index 0000000..3065fc3 --- /dev/null +++ b/src/almendra/ui/components/charts.py @@ -0,0 +1,68 @@ +"""Plotly chart helpers — used by Train (live) and Evaluate (confusion matrix).""" + +from __future__ import annotations + +from typing import Any + + +def training_curve(epochs: list[int], train_loss: list[float], val_f1: list[float]) -> Any: + """Two-line live training chart: train_loss (left axis) + val_macro_f1 (right).""" + import plotly.graph_objects as go + + fig = go.Figure() + fig.add_trace( + go.Scatter( + x=epochs, + y=train_loss, + name="train_loss", + mode="lines+markers", + line={"color": "#ff8c73"}, + yaxis="y", + ) + ) + fig.add_trace( + go.Scatter( + x=epochs, + y=val_f1, + name="val_macro_f1", + mode="lines+markers", + line={"color": "#13ecda"}, + yaxis="y2", + ) + ) + fig.update_layout( + margin={"l": 10, "r": 10, "t": 30, "b": 30}, + height=360, + xaxis={"title": "epoch"}, + yaxis={"title": "train_loss", "side": "left"}, + yaxis2={ + "title": "val_macro_f1", + "overlaying": "y", + "side": "right", + "range": [0, 1], + }, + legend={"orientation": "h", "yanchor": "bottom", "y": 1.02}, + ) + return fig + + +def confusion_heatmap(matrix: list[list[int]], class_names: list[str]) -> Any: + """Square heatmap of the confusion matrix. Rows = true, cols = pred.""" + import plotly.graph_objects as go + + fig = go.Figure( + data=go.Heatmap( + z=matrix, + x=class_names, + y=class_names, + colorscale="Teal", + hovertemplate="true=%{y}
pred=%{x}
count=%{z}", + ) + ) + fig.update_layout( + margin={"l": 10, "r": 10, "t": 30, "b": 30}, + height=480, + xaxis={"title": "predicted", "tickangle": -45}, + yaxis={"title": "true", "autorange": "reversed"}, + ) + return fig diff --git a/src/almendra/ui/components/i18n.py b/src/almendra/ui/components/i18n.py new file mode 100644 index 0000000..0d6b697 --- /dev/null +++ b/src/almendra/ui/components/i18n.py @@ -0,0 +1,210 @@ +"""Bilingual ES/EN string table. + +Text lives in a single dict so adding a third language later is a translation +job — not a rewrite. Pages call ``t("home.title")`` instead of inlining strings. +""" + +from __future__ import annotations + +from typing import Literal + +Lang = Literal["es", "en"] + +DEFAULT_LANG: Lang = "es" + +_STRINGS: dict[str, dict[Lang, str]] = { + # --- generic --- + "app.title": {"es": "almendra", "en": "almendra"}, + "app.tagline": { + "es": "Clasificador de café verde — herramienta local", + "en": "Green coffee classifier — local toolkit", + }, + "sidebar.language": {"es": "Idioma", "en": "Language"}, + "sidebar.nav": {"es": "Navegación", "en": "Navigation"}, + "common.advanced": {"es": "Avanzado", "en": "Advanced"}, + "common.start": {"es": "Empezar", "en": "Start"}, + "common.stop": {"es": "Detener", "en": "Stop"}, + "common.save": {"es": "Guardar", "en": "Save"}, + "common.run": {"es": "Ejecutar", "en": "Run"}, + "common.required": {"es": "obligatorio", "en": "required"}, + "common.optional": {"es": "opcional", "en": "optional"}, + "common.not_found": {"es": "no disponible", "en": "not available"}, + "common.no_runs": { + "es": "Aún no hay corridas — entrena un modelo para empezar.", + "en": "No runs yet — train a model to get started.", + }, + # --- nav labels --- + "nav.home": {"es": "🏠 Inicio", "en": "🏠 Home"}, + "nav.tray": {"es": "📷 Bandeja", "en": "📷 Tray Capture"}, + "nav.train": {"es": "🧠 Entrenar", "en": "🧠 Train"}, + "nav.evaluate": {"es": "📊 Evaluar", "en": "📊 Evaluate"}, + "nav.predict": {"es": "🚀 Predecir", "en": "🚀 Predict"}, + "nav.settings": {"es": "⚙️ Ajustes", "en": "⚙️ Settings"}, + # --- home --- + "home.title": {"es": "Inicio", "en": "Home"}, + "home.dataset_stats": { + "es": "Estadísticas del dataset", + "en": "Dataset statistics", + }, + "home.no_manifest": { + "es": "Aún no hay manifest. Ve a **Bandeja** para preparar fotos, o " + "ejecuta `almendra ingest` si ya descargaste datasets públicos.", + "en": "No manifest yet. Open **Tray Capture** to prepare photos, or run " + "`almendra ingest` if you've downloaded public datasets.", + }, + "home.recent_runs": {"es": "Corridas recientes", "en": "Recent runs"}, + "home.wizard_header": { + "es": "🪄 ¿Primera vez? Asistente", + "en": "🪄 First time? Wizard", + }, + "home.wizard_intro": { + "es": "Te guío en 3 pasos con valores por defecto sensatos. Puedes " + "ajustar todo después en las páginas de Entrenar y Evaluar.", + "en": "I'll walk you through 3 steps with sensible defaults. You can " + "tweak everything later on the Train and Evaluate pages.", + }, + "home.wizard_step1": { + "es": "1️⃣ Cargar fotos de bandeja", + "en": "1️⃣ Upload tray photos", + }, + "home.wizard_step2": { + "es": "2️⃣ Entrenar con valores por defecto", + "en": "2️⃣ Train with defaults", + }, + "home.wizard_step3": { + "es": "3️⃣ Evaluar el resultado", + "en": "3️⃣ Evaluate the result", + }, + "home.health": {"es": "Estado", "en": "Health"}, + "home.health_python": {"es": "Python", "en": "Python"}, + "home.health_torch": {"es": "PyTorch", "en": "PyTorch"}, + "home.health_taxonomy": {"es": "Taxonomía", "en": "Taxonomy"}, + "home.health_manifest": {"es": "Manifest", "en": "Manifest"}, + # --- tray --- + "tray.title": {"es": "Captura de bandeja", "en": "Tray Capture"}, + "tray.help_banner": { + "es": "Toma fotos cenitales de una bandeja con marcadores ArUco en las " + "4 esquinas. Una foto por cara; volteas la bandeja, otra foto. " + "Ver `capture/protocol.md` para detalle.", + "en": "Take top-down photos of a tray with ArUco markers in the 4 " + "corners. One photo per side; flip the tray, take the other. " + "See `capture/protocol.md` for detail.", + }, + "tray.side_a": {"es": "Cara A (obligatoria)", "en": "Side A (required)"}, + "tray.side_b": { + "es": "Cara B (opcional — habilita emparejamiento)", + "en": "Side B (optional — enables pairing)", + }, + "tray.rows": {"es": "Filas", "en": "Rows"}, + "tray.cols": {"es": "Columnas", "en": "Columns"}, + "tray.flip": {"es": "Modo de volteo", "en": "Flip mode"}, + "tray.marker_dict": {"es": "Diccionario ArUco", "en": "ArUco dictionary"}, + "tray.margin_frac": { + "es": "Margen desde el cuadro de marcadores (0–0.5)", + "en": "Margin inset from marker quad (0–0.5)", + }, + "tray.well_frac": { + "es": "Ancho de ventana de pozo (0.5–1.0)", + "en": "Well window width (0.5–1.0)", + }, + "tray.process": {"es": "Procesar fotos", "en": "Process photos"}, + "tray.original": {"es": "Original", "en": "Original"}, + "tray.rectified": {"es": "Rectificada + overlay", "en": "Rectified + overlay"}, + "tray.beans_found": { + "es": "{n} granos encontrados en {total} pozos", + "en": "{n} beans found across {total} wells", + }, + "tray.paired_summary": { + "es": "{two} granos con dos vistas, {one} con una vista", + "en": "{two} two-view beans, {one} single-view", + }, + "tray.save_crops": {"es": "Guardar recortes", "en": "Save crops"}, + "tray.session_id": {"es": "ID de sesión", "en": "Session ID"}, + "tray.saved_to": {"es": "Guardado en", "en": "Saved to"}, + "tray.error_markers": { + "es": "No se detectaron los 4 marcadores. Tip: asegúrate de que las 4 " + "esquinas estén en cuadro, sin reflejos.", + "en": "Markers not detected. Tip: make sure all 4 corner markers are " + "in frame and not glaring.", + }, + # --- train --- + "train.title": {"es": "Entrenar", "en": "Train"}, + "train.backbone": {"es": "Arquitectura", "en": "Backbone"}, + "train.epochs": {"es": "Épocas", "en": "Epochs"}, + "train.lr": {"es": "Learning rate", "en": "Learning rate"}, + "train.image_size": {"es": "Tamaño de imagen", "en": "Image size"}, + "train.batch_size": {"es": "Batch size", "en": "Batch size"}, + "train.pseudo_views": { + "es": "Usar pseudo-vistas (rotaciones del mismo grano)", + "en": "Use pseudo-views (rotations of the same bean)", + }, + "train.view_dropout": {"es": "View dropout", "en": "View dropout"}, + "train.fusion": {"es": "Cabeza de fusión", "en": "Fusion head"}, + "train.augmentation": {"es": "Aumentación de datos", "en": "Data augmentation"}, + "train.start_btn": {"es": "Iniciar entrenamiento", "en": "Start training"}, + "train.stop_btn": {"es": "Detener", "en": "Stop"}, + "train.running": {"es": "Entrenando…", "en": "Training…"}, + "train.done": {"es": "Listo", "en": "Done"}, + "train.best_so_far": {"es": "Mejor macro-F1", "en": "Best macro-F1"}, + "train.chart_title": {"es": "Métricas por época", "en": "Per-epoch metrics"}, + # --- evaluate --- + "evaluate.title": {"es": "Evaluar", "en": "Evaluate"}, + "evaluate.checkpoint": {"es": "Checkpoint", "en": "Checkpoint"}, + "evaluate.split": {"es": "Split", "en": "Split"}, + "evaluate.no_checkpoints": { + "es": "No hay checkpoints. Entrena un modelo primero.", + "en": "No checkpoints found. Train a model first.", + }, + "evaluate.headline_acc": {"es": "Accuracy", "en": "Accuracy"}, + "evaluate.headline_f1": {"es": "Macro-F1", "en": "Macro-F1"}, + "evaluate.headline_mdr": { + "es": "Defectos no detectados", + "en": "Missed-defect rate", + }, + "evaluate.per_class": {"es": "Por clase", "en": "Per class"}, + "evaluate.confusion": {"es": "Matriz de confusión", "en": "Confusion matrix"}, + "evaluate.gallery": { + "es": "Galería de errores", + "en": "Mis-classified gallery", + }, + "evaluate.gallery_caption": { + "es": "{pred} ⟵ {true}", + "en": "{pred} ⟵ {true}", + }, + # --- predict --- + "predict.title": {"es": "Predecir", "en": "Predict"}, + "predict.upload": { + "es": "Sube una foto de un grano", + "en": "Upload a single-bean photo", + }, + "predict.predicted": {"es": "Clase predicha", "en": "Predicted class"}, + "predict.confidence": {"es": "Confianza", "en": "Confidence"}, + "predict.top3": {"es": "Top-3", "en": "Top-3"}, + "predict.no_model": { + "es": "No hay ONNX. Entrena y exporta un modelo primero.", + "en": "No ONNX model. Train and export one first.", + }, + "predict.verdict_accept": {"es": "✅ Acepta", "en": "✅ Accept"}, + "predict.verdict_reject": {"es": "❌ Rechaza", "en": "❌ Reject"}, + # --- settings --- + "settings.title": {"es": "Ajustes", "en": "Settings"}, + "settings.taxonomy": {"es": "Taxonomía canónica", "en": "Canonical taxonomy"}, + "settings.sources": {"es": "Fuentes de datos", "en": "Data sources"}, + "settings.config": {"es": "Configuración actual", "en": "Current config"}, + "settings.paths": {"es": "Rutas del proyecto", "en": "Project paths"}, +} + + +def t(key: str, lang: Lang | None = None, **fmt: object) -> str: + """Look up a string by key in the active language; format with ``str.format``.""" + if lang is None: + lang = DEFAULT_LANG + entry = _STRINGS.get(key) + if entry is None: + return key + text = entry.get(lang) or entry.get("en") or next(iter(entry.values())) + return text.format(**fmt) if fmt else text + + +def available_languages() -> list[Lang]: + return ["es", "en"] diff --git a/src/almendra/ui/components/instructions.py b/src/almendra/ui/components/instructions.py new file mode 100644 index 0000000..2d34b1c --- /dev/null +++ b/src/almendra/ui/components/instructions.py @@ -0,0 +1,74 @@ +"""Plain-language help/instruction blobs. + +Kept separate from i18n strings because they are paragraph-length and meant to +be rendered as Markdown blocks, not short labels. +""" + +from __future__ import annotations + +from almendra.ui.components.i18n import Lang + +TRAY_HELP: dict[Lang, str] = { + "es": """ +**Cómo tomar buenas fotos de bandeja** + +1. Coloca la bandeja sobre una superficie plana, con luz pareja (sin sombras + duras). +2. Pon **un grano por pozo**. Es OK dejar pozos vacíos — el sistema los detecta. +3. Asegúrate de que los **4 marcadores ArUco** de las esquinas estén en cuadro + y nítidos. La cámara debe estar lo más cenital posible. +4. Toma la foto de **Cara A**. Anota la orientación de la bandeja. +5. Voltea la bandeja **horizontal o verticalmente** (no la rotes) y toma la foto + de **Cara B**. El modo de volteo debe coincidir con el campo *Flip*. +6. Si un grano se mueve al voltear, ignóralo — el emparejamiento se basa en + coincidencia de pozo, no de grano. + +Más detalle en `capture/protocol.md` del repo. +""", + "en": """ +**How to take good tray photos** + +1. Place the tray on a flat surface with even light (no harsh shadows). +2. Put **one bean per well**. Empty wells are fine — the system detects them. +3. Make sure all **4 corner ArUco markers** are in frame and sharp. Shoot as + top-down as possible. +4. Take the **Side A** photo. Note the tray's orientation. +5. Flip the tray **horizontally or vertically** (don't rotate it) and take the + **Side B** photo. The flip mode must match the *Flip* field. +6. If a bean shifts during the flip, ignore it — pairing is by well address, + not by bean appearance. + +More detail in `capture/protocol.md`. +""", +} + +TRAIN_HELP: dict[Lang, str] = { + "es": """ +**Cómo leer las gráficas** + +- `train_loss` (naranja) debería bajar consistente. Si rebota mucho, baja el + learning rate. +- `val_macro_f1` (turquesa) debería subir y estabilizarse. El mejor checkpoint + se guarda automáticamente. +- *Early stopping* corta el entrenamiento si val_macro_f1 no mejora por varias + épocas seguidas. +""", + "en": """ +**Reading the charts** + +- `train_loss` (orange) should fall steadily. If it bounces a lot, lower the + learning rate. +- `val_macro_f1` (teal) should rise then plateau. The best checkpoint is saved + automatically. +- Early stopping cuts training if val_macro_f1 doesn't improve for several + epochs in a row. +""", +} + + +def tray_help(lang: Lang) -> str: + return TRAY_HELP.get(lang, TRAY_HELP["en"]) + + +def train_help(lang: Lang) -> str: + return TRAIN_HELP.get(lang, TRAIN_HELP["en"]) diff --git a/src/almendra/ui/components/state.py b/src/almendra/ui/components/state.py new file mode 100644 index 0000000..bd16e4c --- /dev/null +++ b/src/almendra/ui/components/state.py @@ -0,0 +1,47 @@ +"""Session-state helpers — the small amount of state the UI carries between reruns. + +The UI itself is mostly stateless: each rerun reads files from disk. The pieces +that *do* survive across reruns (language toggle, running training process) +live here behind named accessors so pages don't reach into ``st.session_state`` +with stringly-typed keys. +""" + +from __future__ import annotations + +from typing import Any + +import streamlit as st + +from almendra.ui.components.i18n import DEFAULT_LANG, Lang, available_languages + + +def current_lang() -> Lang: + return st.session_state.get("almendra.lang", DEFAULT_LANG) + + +def set_lang(lang: Lang) -> None: + st.session_state["almendra.lang"] = lang + + +def language_toggle() -> Lang: + """Render a sidebar radio for ES/EN and return the active language.""" + options = available_languages() + index = options.index(current_lang()) if current_lang() in options else 0 + pick = st.sidebar.radio( + "🌐", + options, + index=index, + format_func=lambda code: {"es": "Español", "en": "English"}.get(code, code), + key="almendra.lang_radio", + horizontal=True, + ) + set_lang(pick) + return pick + + +def get_state(key: str, default: Any = None) -> Any: + return st.session_state.get(key, default) + + +def set_state(key: str, value: Any) -> None: + st.session_state[key] = value diff --git a/src/almendra/ui/discovery.py b/src/almendra/ui/discovery.py new file mode 100644 index 0000000..809acab --- /dev/null +++ b/src/almendra/ui/discovery.py @@ -0,0 +1,82 @@ +"""Filesystem discovery helpers — find runs, checkpoints, manifests. + +The UI is stateless across reruns; everything it shows comes from the disk +layout under ``outputs/`` and ``data/processed/``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +def project_root() -> Path: + """Walk up from CWD looking for ``pyproject.toml``; fall back to CWD.""" + here = Path.cwd().resolve() + for parent in (here, *here.parents): + if (parent / "pyproject.toml").is_file(): + return parent + return here + + +def outputs_root() -> Path: + return project_root() / "outputs" + + +def manifest_path() -> Path: + return project_root() / "data" / "processed" / "manifest.jsonl" + + +@dataclass(frozen=True) +class RunInfo: + """One training run on disk.""" + + name: str + path: Path + checkpoint: Path | None + metrics: Path | None + onnx_float: Path | None + onnx_int8: Path | None + modified_at: float + + +def list_runs() -> list[RunInfo]: + """All run dirs under ``outputs/`` that contain at least a metrics file or checkpoint.""" + root = outputs_root() + if not root.is_dir(): + return [] + runs: list[RunInfo] = [] + for path in sorted(root.iterdir()): + if not path.is_dir(): + continue + ckpt = path / "best.pt" + metrics = path / "live_metrics.jsonl" + float_onnx = path / "model.onnx" + int8_onnx = path / "model.int8.onnx" + if not (ckpt.is_file() or metrics.is_file() or float_onnx.is_file()): + continue + runs.append( + RunInfo( + name=path.name, + path=path, + checkpoint=ckpt if ckpt.is_file() else None, + metrics=metrics if metrics.is_file() else None, + onnx_float=float_onnx if float_onnx.is_file() else None, + onnx_int8=int8_onnx if int8_onnx.is_file() else None, + modified_at=path.stat().st_mtime, + ) + ) + return sorted(runs, key=lambda r: r.modified_at, reverse=True) + + +def best_onnx_for_prediction() -> Path | None: + """Most-recently-modified ONNX (prefer INT8) across all runs.""" + candidates: list[Path] = [] + for run in list_runs(): + if run.onnx_int8: + candidates.append(run.onnx_int8) + if run.onnx_float: + candidates.append(run.onnx_float) + if not candidates: + return None + return max(candidates, key=lambda p: p.stat().st_mtime) diff --git a/src/almendra/ui/metrics_io.py b/src/almendra/ui/metrics_io.py new file mode 100644 index 0000000..f9be895 --- /dev/null +++ b/src/almendra/ui/metrics_io.py @@ -0,0 +1,79 @@ +"""Read/write the live-metrics JSONL file shared by training and the UI. + +Protocol: one JSON object per line. ``event`` is ``start``, ``epoch``, or +``done``. The training loop writes; the UI tails. Anything that can write the +file in this format works with the UI — there's no in-process coupling. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class LiveMetrics: + """Snapshot of a training run, reconstructed from its JSONL file.""" + + epochs_total: int + backbone: str + epoch: list[int] + train_loss: list[float] + val_macro_f1: list[float] + val_accuracy: list[float] + done: bool + best_val_macro_f1: float | None + + @property + def epochs_completed(self) -> int: + return len(self.epoch) + + @property + def progress(self) -> float: + if self.epochs_total <= 0: + return 0.0 + return min(1.0, self.epochs_completed / self.epochs_total) + + +def read_live_metrics(path: str | Path) -> LiveMetrics: + """Parse the JSONL file written by the training loop. Missing/empty -> defaults.""" + path = Path(path) + epochs_total = 0 + backbone = "" + epoch: list[int] = [] + train_loss: list[float] = [] + val_macro_f1: list[float] = [] + val_accuracy: list[float] = [] + done = False + best: float | None = None + + if not path.is_file(): + return LiveMetrics(0, "", [], [], [], [], False, None) + + with path.open(encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + event = payload.get("event") + if event == "start": + epochs_total = int(payload.get("epochs", 0)) + backbone = str(payload.get("backbone", "")) + elif event == "epoch": + epoch.append(int(payload["epoch"])) + train_loss.append(float(payload["train_loss"])) + val_macro_f1.append(float(payload["val_macro_f1"])) + val_accuracy.append(float(payload["val_accuracy"])) + epochs_total = max(epochs_total, int(payload.get("epochs", epochs_total))) + elif event == "done": + done = True + if "best_val_macro_f1" in payload: + best = float(payload["best_val_macro_f1"]) + return LiveMetrics( + epochs_total, backbone, epoch, train_loss, val_macro_f1, val_accuracy, done, best + ) diff --git a/src/almendra/ui/process.py b/src/almendra/ui/process.py new file mode 100644 index 0000000..64d5690 --- /dev/null +++ b/src/almendra/ui/process.py @@ -0,0 +1,79 @@ +"""Subprocess wrapper for long-running tasks (training). + +We don't run training inside Streamlit's process: a) it blocks the UI; b) it +ties checkpoint lifecycle to the browser tab. Instead we launch ``almendra +train`` as a subprocess and tail its live-metrics JSONL file from the UI. +""" + +from __future__ import annotations + +import contextlib +import os +import signal +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class TrainHandle: + """A running training subprocess + the file it writes metrics to.""" + + pid: int + metrics_path: Path + output_dir: Path + + +def start_training( + output_dir: Path, + overrides: list[str] | None = None, + cwd: Path | None = None, +) -> TrainHandle: + """Launch ``almendra train`` as a detached subprocess. + + `overrides` are Hydra-style ``key=value`` strings. The metrics file is + placed inside ``output_dir`` so concurrent runs don't collide. + """ + output_dir = Path(output_dir).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + metrics_path = output_dir / "live_metrics.jsonl" + metrics_path.write_text("") # clean tail target + + env = os.environ.copy() + env["ALMENDRA_LIVE_METRICS"] = str(metrics_path) + + cmd = [sys.executable, "-m", "almendra.cli", "train", f"output_dir={output_dir}"] + if overrides: + cmd.extend(overrides) + + proc = subprocess.Popen( # noqa: S603 — we build cmd ourselves + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + env=env, + cwd=str(cwd) if cwd else None, + start_new_session=True, + ) + return TrainHandle(pid=proc.pid, metrics_path=metrics_path, output_dir=output_dir) + + +def is_running(pid: int) -> bool: + """True if the PID is alive. Conservative: any error -> assume not running.""" + if pid <= 0: + return False + try: + os.kill(pid, 0) + except (ProcessLookupError, PermissionError): + return False + except OSError: + return False + return True + + +def stop(pid: int) -> None: + """Best-effort SIGTERM to the training subprocess (the whole new session).""" + if not is_running(pid): + return + with contextlib.suppress(ProcessLookupError, PermissionError, OSError): + os.killpg(os.getpgid(pid), signal.SIGTERM) diff --git a/src/almendra/ui/views/__init__.py b/src/almendra/ui/views/__init__.py new file mode 100644 index 0000000..89a4f42 --- /dev/null +++ b/src/almendra/ui/views/__init__.py @@ -0,0 +1,24 @@ +"""Streamlit page modules — each exposes ``render(lang)``. + +The directory is named ``views/`` (not ``pages/``) so Streamlit does not +auto-discover it as its multi-page navigation. The app uses its own sidebar +radio for navigation. +""" + +from almendra.ui.views import ( + page_evaluate, + page_home, + page_predict, + page_settings, + page_train, + page_tray, +) + +__all__ = [ + "page_home", + "page_tray", + "page_train", + "page_evaluate", + "page_predict", + "page_settings", +] diff --git a/src/almendra/ui/views/page_evaluate.py b/src/almendra/ui/views/page_evaluate.py new file mode 100644 index 0000000..bb1c38b --- /dev/null +++ b/src/almendra/ui/views/page_evaluate.py @@ -0,0 +1,195 @@ +"""Evaluate page — checkpoint picker, metrics, confusion matrix, error gallery.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import streamlit as st + +from almendra.ui.components.charts import confusion_heatmap +from almendra.ui.components.i18n import Lang, t +from almendra.ui.discovery import list_runs, project_root + + +def _run_evaluation(ckpt_path: Path, split: str) -> dict[str, Any] | None: + """Run evaluation in-process. Heavy: only triggered on explicit click.""" + try: + from hydra import compose, initialize_config_dir + from hydra.core.global_hydra import GlobalHydra + + from almendra.eval import evaluate as eval_module + from almendra.paths import configs_dir + except ImportError as exc: + st.error(f"Missing dependency for evaluation: {exc}") + return None + + GlobalHydra.instance().clear() + with initialize_config_dir(version_base=None, config_dir=str(configs_dir())): + cfg = compose( + config_name="config", + overrides=[f"output_dir={ckpt_path.parent}"], + ) + return eval_module.run(cfg, checkpoint=str(ckpt_path), split=split) + + +def _collect_misclassified( + ckpt_path: Path, split: str, max_items: int = 24 +) -> list[dict[str, Any]]: + """Return up to ``max_items`` mis-classified bean records for the gallery.""" + try: + import torch + from torch.utils.data import DataLoader + + from almendra.datasets.manifest import filter_split, read_manifest + from almendra.datasets.multiview import MultiViewBeanDataset + from almendra.datasets.transforms import build_transforms + from almendra.models.classifier import build_model + from almendra.paths import processed_dir + from almendra.taxonomy import get_taxonomy + from almendra.train.loop import resolve_device + except ImportError: + return [] + + taxonomy = get_taxonomy() + device = resolve_device("auto") + ckpt = torch.load(ckpt_path, map_location=device, weights_only=False) + model_cfg = ckpt.get("config", {}).get("model", {}) + image_size = int(ckpt.get("image_size", 224)) + num_views = int(model_cfg.get("num_views", 1)) + + from types import SimpleNamespace + + cfg_obj = ( + SimpleNamespace(**model_cfg) + if model_cfg + else SimpleNamespace( + backbone="mobilenet_v3_small", + pretrained=False, + embedding_dim=576, + dropout=0.2, + num_views=1, + fusion="attention", + view_dropout=0.0, + ) + ) + model = build_model(cfg_obj, taxonomy.num_defect_classes).to(device) + model.load_state_dict(ckpt["model"]) + model.eval() + + records = filter_split(read_manifest(processed_dir() / "manifest.jsonl"), split) + if not records: + return [] + transform = build_transforms(image_size, None, train=False) + dataset = MultiViewBeanDataset(records, transform, num_views, 0.0) + loader = DataLoader(dataset, batch_size=32, shuffle=False) + + errors: list[dict[str, Any]] = [] + class_names = taxonomy.class_names() + record_iter = iter(records) + with torch.no_grad(): + for views, labels in loader: + preds = model(views.to(device)).argmax(1).cpu().tolist() + labels_list = labels.tolist() + for pred, true in zip(preds, labels_list, strict=True): + rec = next(record_iter) + if pred != true and len(errors) < max_items: + image_path = Path(rec.views[0]) + if not image_path.is_absolute(): + image_path = processed_dir() / image_path + errors.append( + { + "bean_id": rec.bean_id, + "image": image_path, + "true": class_names[true], + "pred": class_names[pred], + } + ) + if len(errors) >= max_items: + break + return errors + + +def render(lang: Lang) -> None: + st.title(t("evaluate.title", lang)) + runs = [r for r in list_runs() if r.checkpoint] + if not runs: + st.warning(t("evaluate.no_checkpoints", lang)) + return + + col_ckpt, col_split = st.columns([3, 1]) + with col_ckpt: + names = [r.name for r in runs] + pick = st.selectbox(t("evaluate.checkpoint", lang), names, index=0) + run = next(r for r in runs if r.name == pick) + with col_split: + split = st.selectbox(t("evaluate.split", lang), ["test", "val", "train"], index=0) + + if not st.button(t("common.run", lang), type="primary"): + return + + with st.spinner("…"): + metrics = _run_evaluation(run.checkpoint, split) + if not metrics: + return + + m1, m2, m3 = st.columns(3) + m1.metric(t("evaluate.headline_acc", lang), f"{metrics['accuracy']:.3f}") + m2.metric(t("evaluate.headline_f1", lang), f"{metrics['macro_f1']:.3f}") + m3.metric(t("evaluate.headline_mdr", lang), f"{metrics.get('missed_defect_rate', 0.0):.3f}") + + try: + from almendra.taxonomy import get_taxonomy + + taxonomy = get_taxonomy() + class_names = taxonomy.class_names() + except Exception: + class_names = [str(i) for i in range(len(metrics.get("per_class", {})))] + + st.subheader(t("evaluate.per_class", lang)) + per_class = metrics.get("per_class", {}) + rows = [ + { + "class": class_names[i] if i < len(class_names) else str(i), + "precision": v["precision"], + "recall": v["recall"], + "f1": v["f1"], + "support": v["support"], + } + for i, v in per_class.items() + if v["support"] > 0 + ] + st.dataframe(rows, hide_index=True, use_container_width=True) + + cm = metrics.get("confusion_matrix") + if cm: + st.subheader(t("evaluate.confusion", lang)) + st.plotly_chart( + confusion_heatmap(cm, class_names[: len(cm)]), + use_container_width=True, + ) + + st.subheader(t("evaluate.gallery", lang)) + try: + errors = _collect_misclassified(run.checkpoint, split) + except Exception as exc: + st.caption(f"gallery unavailable: {exc}") + errors = [] + if not errors: + st.caption("—") + else: + cols = st.columns(4) + for i, err in enumerate(errors): + col = cols[i % 4] + try: + col.image( + str(err["image"]), + caption=t("evaluate.gallery_caption", lang, pred=err["pred"], true=err["true"]), + use_container_width=True, + ) + except Exception: + col.caption(f"({err['bean_id']}) {err['pred']} ⟵ {err['true']}") + + +# Make project_root importable in tests/etc. +__all__ = ["render", "project_root"] diff --git a/src/almendra/ui/views/page_home.py b/src/almendra/ui/views/page_home.py new file mode 100644 index 0000000..4ed50a8 --- /dev/null +++ b/src/almendra/ui/views/page_home.py @@ -0,0 +1,115 @@ +"""Home / Status page — dataset stats, recent runs, health, inline wizard.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import streamlit as st + +from almendra.ui.components.i18n import Lang, t +from almendra.ui.components.state import set_state +from almendra.ui.discovery import list_runs, manifest_path + + +def _dataset_stats() -> tuple[int, dict[str, int]]: + path = manifest_path() + if not path.is_file(): + return 0, {} + try: + from almendra.datasets.manifest import class_distribution, read_manifest + + records = read_manifest(path) + return len(records), class_distribution(records) + except Exception: + return 0, {} + + +def _torch_version() -> str: + try: + import torch + + return torch.__version__ + except ImportError: + return t("common.not_found") + + +def _taxonomy_status() -> str: + try: + from almendra.taxonomy import get_taxonomy + + tax = get_taxonomy() + return f"v{tax.schema_version} ({tax.num_defect_classes})" + except Exception: + return t("common.not_found") + + +def render(lang: Lang) -> None: + st.title(t("home.title", lang)) + st.caption(t("app.tagline", lang)) + + left, right = st.columns([2, 1]) + + with left: + st.subheader(t("home.dataset_stats", lang)) + total, dist = _dataset_stats() + if total == 0: + st.info(t("home.no_manifest", lang)) + else: + st.metric(label="beans", value=total) + if dist: + rows = sorted(dist.items(), key=lambda kv: kv[1], reverse=True) + st.dataframe( + {"class": [k for k, _ in rows], "count": [v for _, v in rows]}, + hide_index=True, + use_container_width=True, + ) + + st.subheader(t("home.recent_runs", lang)) + runs = list_runs() + if not runs: + st.caption(t("common.no_runs", lang)) + else: + st.dataframe( + { + "run": [r.name for r in runs], + "checkpoint": [bool(r.checkpoint) for r in runs], + "onnx float": [bool(r.onnx_float) for r in runs], + "onnx int8": [bool(r.onnx_int8) for r in runs], + "metrics": [bool(r.metrics) for r in runs], + }, + hide_index=True, + use_container_width=True, + ) + + with right: + st.subheader(t("home.health", lang)) + st.markdown( + f"- **{t('home.health_python', lang)}**: " + f"{sys.version_info.major}.{sys.version_info.minor}\n" + f"- **{t('home.health_torch', lang)}**: {_torch_version()}\n" + f"- **{t('home.health_taxonomy', lang)}**: {_taxonomy_status()}\n" + f"- **{t('home.health_manifest', lang)}**: " + f"{'✅' if manifest_path().is_file() else '❌'}" + ) + st.caption(f"`{Path.cwd()}`") + + st.markdown("---") + with st.expander(t("home.wizard_header", lang), expanded=(total == 0)): + st.markdown(t("home.wizard_intro", lang)) + col1, col2, col3 = st.columns(3) + with col1: + st.markdown(f"**{t('home.wizard_step1', lang)}**") + if st.button(t("nav.tray", lang), key="wizard_tray", use_container_width=True): + set_state("almendra.page", "tray") + st.rerun() + with col2: + st.markdown(f"**{t('home.wizard_step2', lang)}**") + if st.button(t("nav.train", lang), key="wizard_train", use_container_width=True): + set_state("almendra.page", "train") + st.rerun() + with col3: + st.markdown(f"**{t('home.wizard_step3', lang)}**") + if st.button(t("nav.evaluate", lang), key="wizard_eval", use_container_width=True): + set_state("almendra.page", "evaluate") + st.rerun() diff --git a/src/almendra/ui/views/page_predict.py b/src/almendra/ui/views/page_predict.py new file mode 100644 index 0000000..a786419 --- /dev/null +++ b/src/almendra/ui/views/page_predict.py @@ -0,0 +1,111 @@ +"""Predict page — upload one bean photo and run the latest ONNX model on it.""" + +from __future__ import annotations + +from pathlib import Path + +import numpy as np +import streamlit as st +from PIL import Image + +from almendra.ui.components.i18n import Lang, t +from almendra.ui.discovery import best_onnx_for_prediction, list_runs + +_IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) +_IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) + + +def _preprocess(image: Image.Image, size: int) -> np.ndarray: + """Resize, normalise, and shape into the multi-view tensor the model expects.""" + rgb = image.convert("RGB").resize((size, size)) + array = np.asarray(rgb, dtype=np.float32) / 255.0 + array = (array - _IMAGENET_MEAN) / _IMAGENET_STD + chw = np.transpose(array, (2, 0, 1)) + # Model input shape: (batch=1, views=1, C, H, W). + return chw[None, None].astype(np.float32) + + +def _softmax(logits: np.ndarray) -> np.ndarray: + exp = np.exp(logits - logits.max(axis=-1, keepdims=True)) + return exp / exp.sum(axis=-1, keepdims=True) + + +def render(lang: Lang) -> None: + st.title(t("predict.title", lang)) + + runs = list_runs() + candidates: list[Path] = [] + for r in runs: + if r.onnx_int8: + candidates.append(r.onnx_int8) + if r.onnx_float: + candidates.append(r.onnx_float) + default = best_onnx_for_prediction() + if not candidates or default is None: + st.warning(t("predict.no_model", lang)) + return + + default_idx = candidates.index(default) if default in candidates else 0 + pick = st.selectbox( + "ONNX", + candidates, + index=default_idx, + format_func=lambda p: f"{p.parent.name}/{p.name}", + ) + + uploaded = st.file_uploader( + t("predict.upload", lang), type=["jpg", "jpeg", "png"], key="predict.upload" + ) + if uploaded is None: + return + + try: + import onnxruntime as ort # type: ignore + except ImportError: + st.error("onnxruntime is not installed. `uv sync --extra export`.") + return + + image = Image.open(uploaded) + st.image(image, width=256) + + try: + from almendra.taxonomy import get_taxonomy + + taxonomy = get_taxonomy() + class_names = taxonomy.class_names() + except Exception: + taxonomy = None + class_names = [] + + session = ort.InferenceSession(str(pick), providers=["CPUExecutionProvider"]) + input_name = session.get_inputs()[0].name + input_shape = session.get_inputs()[0].shape + # The model's H/W dims may be symbolic; use the run-time fallback of 224. + spatial = [d for d in input_shape if isinstance(d, int) and d > 1] + image_size = int(spatial[-1]) if spatial else 224 + + tensor = _preprocess(image, image_size) + logits = session.run(None, {input_name: tensor})[0][0] + probs = _softmax(logits) + top_idx = int(probs.argmax()) + top_name = class_names[top_idx] if top_idx < len(class_names) else str(top_idx) + + headline_col, verdict_col = st.columns([2, 1]) + headline_col.metric(t("predict.predicted", lang), top_name) + headline_col.metric(t("predict.confidence", lang), f"{probs[top_idx]:.1%}") + if taxonomy is not None: + accept = taxonomy.is_accept(top_name) + verdict_key = "predict.verdict_accept" if accept else "predict.verdict_reject" + verdict_col.markdown(f"### {t(verdict_key, lang)}") + + st.subheader(t("predict.top3", lang)) + order = np.argsort(probs)[::-1][:3] + rows = [ + { + "rank": rank + 1, + "class": class_names[i] if i < len(class_names) else str(i), + "probability": float(probs[i]), + } + for rank, i in enumerate(order) + ] + st.dataframe(rows, hide_index=True, use_container_width=True) diff --git a/src/almendra/ui/views/page_settings.py b/src/almendra/ui/views/page_settings.py new file mode 100644 index 0000000..cbc0b9c --- /dev/null +++ b/src/almendra/ui/views/page_settings.py @@ -0,0 +1,65 @@ +"""Settings page — taxonomy, data sources, current config, paths.""" + +from __future__ import annotations + +from pathlib import Path + +import streamlit as st + +from almendra.ui.components.i18n import Lang, t +from almendra.ui.discovery import manifest_path, outputs_root, project_root + + +def _read_yaml(path: Path) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError as exc: + return f"# could not read {path}: {exc}" + + +def render(lang: Lang) -> None: + st.title(t("settings.title", lang)) + + st.subheader(t("settings.taxonomy", lang)) + try: + from almendra.taxonomy import get_taxonomy + + tax = get_taxonomy() + rows = [ + { + "index": c.index, + "class": c.name, + "category": c.category_name, + "accept": c.accept, + "full_defect_equivalent": c.full_defect_equivalent, + } + for c in sorted(tax.defect_classes.values(), key=lambda c: c.index) + ] + st.dataframe(rows, hide_index=True, use_container_width=True) + st.caption( + f"schema v{tax.schema_version} · {'verified' if tax.verified else 'provisional'}" + ) + except Exception as exc: + st.error(f"taxonomy unavailable: {exc}") + + st.subheader(t("settings.sources", lang)) + sources_dir = project_root() / "data" / "sources" + if sources_dir.is_dir(): + for yaml_path in sorted(sources_dir.glob("*.yaml")): + with st.expander(yaml_path.stem): + st.code(_read_yaml(yaml_path), language="yaml") + else: + st.caption("no `data/sources/` directory") + + st.subheader(t("settings.config", lang)) + cfg_path = project_root() / "configs" / "config.yaml" + if cfg_path.is_file(): + st.code(_read_yaml(cfg_path), language="yaml") + + st.subheader(t("settings.paths", lang)) + st.markdown( + f"- **project**: `{project_root()}`\n" + f"- **outputs**: `{outputs_root()}`\n" + f"- **manifest**: `{manifest_path()}` " + f"({'✅' if manifest_path().is_file() else '❌'})" + ) diff --git a/src/almendra/ui/views/page_train.py b/src/almendra/ui/views/page_train.py new file mode 100644 index 0000000..7539d5a --- /dev/null +++ b/src/almendra/ui/views/page_train.py @@ -0,0 +1,156 @@ +"""Train page — form + Start/Stop + live Plotly chart tailing the metrics JSONL.""" + +from __future__ import annotations + +import time +from pathlib import Path + +import streamlit as st + +from almendra.ui.components.charts import training_curve +from almendra.ui.components.i18n import Lang, t +from almendra.ui.components.instructions import train_help +from almendra.ui.components.state import get_state, set_state +from almendra.ui.discovery import outputs_root +from almendra.ui.metrics_io import read_live_metrics +from almendra.ui.process import is_running, start_training, stop + +_BACKBONES = [ + "mobilenet_v3_small", + "mobilenet_v3_large", + "efficientnet_b0", +] +_FUSIONS = ["attention", "gated", "mean", "max"] + + +def _render_form(lang: Lang) -> dict[str, object]: + """Render the training-knob form; return a dict of Hydra-style values.""" + col1, col2, col3 = st.columns(3) + with col1: + backbone = st.selectbox(t("train.backbone", lang), _BACKBONES, index=0) + epochs = st.number_input(t("train.epochs", lang), min_value=1, max_value=300, value=30) + with col2: + lr = st.select_slider( + t("train.lr", lang), + options=[1e-4, 3e-4, 1e-3, 3e-3, 1e-2], + value=3e-4, + format_func=lambda v: f"{v:.0e}", + ) + image_size = st.selectbox(t("train.image_size", lang), [160, 192, 224, 256], index=2) + with col3: + batch_size = st.selectbox(t("train.batch_size", lang), [16, 32, 64, 128], index=2) + pseudo_views = st.checkbox(t("train.pseudo_views", lang), value=False) + + advanced: dict[str, object] = {} + with st.expander(t("common.advanced", lang), expanded=False): + fusion = st.selectbox(t("train.fusion", lang), _FUSIONS, index=0) + view_dropout = st.slider( + t("train.view_dropout", lang), min_value=0.0, max_value=0.7, value=0.0, step=0.05 + ) + augmentation = st.checkbox(t("train.augmentation", lang), value=True) + advanced["model.fusion"] = fusion + advanced["model.view_dropout"] = view_dropout + if not augmentation: + advanced["train.augmentation.hflip"] = False + advanced["train.augmentation.vflip"] = False + advanced["train.augmentation.color_jitter"] = 0.0 + + knobs: dict[str, object] = { + "model.backbone": backbone, + "model.name": backbone, + "train.epochs": int(epochs), + "train.optimizer.lr": float(lr), + "data.image_size": int(image_size), + "train.batch_size": int(batch_size), + "data.pseudo_views": pseudo_views, + } + knobs.update(advanced) + return knobs + + +def _overrides_from_knobs(knobs: dict[str, object]) -> list[str]: + overrides: list[str] = [] + for key, value in knobs.items(): + if isinstance(value, bool): + overrides.append(f"{key}={'true' if value else 'false'}") + elif isinstance(value, float): + overrides.append(f"{key}={value:.6g}") + else: + overrides.append(f"{key}={value}") + return overrides + + +def _resolve_run_dir() -> Path: + """A timestamped directory under ``outputs/`` so concurrent runs don't collide.""" + return outputs_root() / f"ui-{time.strftime('%Y%m%d-%H%M%S')}" + + +def render(lang: Lang) -> None: + st.title(t("train.title", lang)) + knobs = _render_form(lang) + + pid = int(get_state("almendra.train.pid", 0)) + metrics_path = get_state("almendra.train.metrics_path", "") + running = bool(pid) and is_running(pid) + + start_col, stop_col, status_col = st.columns([1, 1, 3]) + with start_col: + start_clicked = st.button( + t("train.start_btn", lang), + type="primary", + disabled=running, + use_container_width=True, + ) + with stop_col: + stop_clicked = st.button( + t("train.stop_btn", lang), + disabled=not running, + use_container_width=True, + ) + with status_col: + if running: + st.info(f"⏳ {t('train.running', lang)} (pid {pid})") + elif metrics_path: + st.success(f"✅ {t('train.done', lang)}") + + if start_clicked and not running: + run_dir = _resolve_run_dir() + handle = start_training( + run_dir, overrides=_overrides_from_knobs(knobs), cwd=outputs_root().parent + ) + set_state("almendra.train.pid", handle.pid) + set_state("almendra.train.metrics_path", str(handle.metrics_path)) + set_state("almendra.train.output_dir", str(handle.output_dir)) + st.rerun() + + if stop_clicked and running: + stop(pid) + set_state("almendra.train.pid", 0) + st.rerun() + + metrics_path = get_state("almendra.train.metrics_path", "") + if not metrics_path: + st.caption(t("common.no_runs", lang)) + return + + snapshot = read_live_metrics(metrics_path) + if snapshot.epochs_total: + st.progress(snapshot.progress, text=f"{snapshot.epochs_completed}/{snapshot.epochs_total}") + + if snapshot.val_macro_f1: + st.metric( + label=t("train.best_so_far", lang), + value=f"{max(snapshot.val_macro_f1):.4f}", + ) + st.subheader(t("train.chart_title", lang)) + st.plotly_chart( + training_curve(snapshot.epoch, snapshot.train_loss, snapshot.val_macro_f1), + use_container_width=True, + ) + with st.expander("ℹ️", expanded=False): + st.markdown(train_help(lang)) + + # Auto-refresh while training is alive — Streamlit reruns the whole script. + if running and not snapshot.done: + time.sleep(2.0) + st.rerun() diff --git a/src/almendra/ui/views/page_tray.py b/src/almendra/ui/views/page_tray.py new file mode 100644 index 0000000..d3ab110 --- /dev/null +++ b/src/almendra/ui/views/page_tray.py @@ -0,0 +1,186 @@ +"""Tray Capture page — upload tray photos, preview rectified+overlay, save crops. + +Calls into ``almendra.datasets.tray`` (which needs the ``capture`` extra). If +OpenCV is not installed, we degrade gracefully with a clear error. +""" + +from __future__ import annotations + +import json +import time +from pathlib import Path + +import numpy as np +import streamlit as st +from PIL import Image + +from almendra.ui.components.i18n import Lang, t +from almendra.ui.components.instructions import tray_help +from almendra.ui.discovery import project_root + +_FLIP_MODES = ["identity", "mirror_rows", "mirror_cols"] +_MARKER_DICTS = [ + "DICT_4X4_50", + "DICT_5X5_50", + "DICT_6X6_50", + "DICT_ARUCO_ORIGINAL", +] + + +def _read_uploaded_image(uploaded) -> np.ndarray | None: + if uploaded is None: + return None + import cv2 + + raw = np.frombuffer(uploaded.read(), dtype=np.uint8) + return cv2.imdecode(raw, cv2.IMREAD_COLOR) + + +def _bgr_to_rgb(image: np.ndarray) -> Image.Image: + return Image.fromarray(image[:, :, ::-1]) + + +def _save_session( + paired: dict[tuple[int, int], list[np.ndarray]], + session_id: str, + spec_dict: dict, +) -> Path: + import cv2 + + out_dir = project_root() / "data" / "raw" / "proprietary_tray" / "sessions" / session_id + crops_dir = out_dir / "crops" + crops_dir.mkdir(parents=True, exist_ok=True) + + saved: list[dict] = [] + for (row, col), views in paired.items(): + for i, crop in enumerate(views): + name = f"bean_r{row}c{col}_v{i}.png" + cv2.imwrite(str(crops_dir / name), crop) + saved.append({"row": row, "col": col, "view": i, "file": f"crops/{name}"}) + + (out_dir / "session.json").write_text( + json.dumps( + { + "session_id": session_id, + "created_at": time.time(), + "tray_spec": spec_dict, + "beans": saved, + }, + indent=2, + ) + ) + return out_dir + + +def render(lang: Lang) -> None: + st.title(t("tray.title", lang)) + st.info(t("tray.help_banner", lang)) + with st.expander("ℹ️", expanded=False): + st.markdown(tray_help(lang)) + + try: + import cv2 # noqa: F401 + + from almendra.datasets import tray + except ImportError: + st.error( + "OpenCV is not installed. Run `uv sync --extra capture` (or " + "`pip install almendra[capture]`) and reload." + ) + return + + col_uploads, col_spec = st.columns([2, 1]) + with col_uploads: + side_a_file = st.file_uploader( + t("tray.side_a", lang), + type=["jpg", "jpeg", "png"], + key="tray.side_a", + ) + side_b_file = st.file_uploader( + t("tray.side_b", lang), + type=["jpg", "jpeg", "png"], + key="tray.side_b", + ) + + with col_spec: + rows = st.number_input(t("tray.rows", lang), min_value=1, max_value=20, value=6) + cols = st.number_input(t("tray.cols", lang), min_value=1, max_value=20, value=8) + flip = st.selectbox(t("tray.flip", lang), _FLIP_MODES, index=2) + marker_dict = st.selectbox(t("tray.marker_dict", lang), _MARKER_DICTS, index=0) + margin_frac = st.slider( + t("tray.margin_frac", lang), min_value=0.0, max_value=0.5, value=0.10, step=0.01 + ) + well_frac = st.slider( + t("tray.well_frac", lang), min_value=0.5, max_value=1.0, value=0.85, step=0.01 + ) + + process = st.button(t("tray.process", lang), type="primary", use_container_width=True) + if not process: + return + + if side_a_file is None: + st.warning(f"{t('tray.side_a', lang)} — {t('common.required', lang)}") + return + + spec = tray.TraySpec( + rows=int(rows), + cols=int(cols), + flip=flip, + marker_dict=marker_dict, + margin_frac=float(margin_frac), + well_frac=float(well_frac), + ) + + side_a_img = _read_uploaded_image(side_a_file) + side_b_img = _read_uploaded_image(side_b_file) if side_b_file is not None else None + + try: + rect_a = tray.rectify(side_a_img, spec) + except tray.TrayError: + st.error(t("tray.error_markers", lang)) + return + + beans_a = tray.extract_from_rectified(rect_a, spec) + overlay_a = tray.draw_overlay(rect_a, spec, beans_a) + + st.subheader("A") + a_orig, a_rect = st.columns(2) + a_orig.image(_bgr_to_rgb(side_a_img), caption=t("tray.original", lang)) + a_rect.image(_bgr_to_rgb(overlay_a), caption=t("tray.rectified", lang)) + st.caption(t("tray.beans_found", lang, n=len(beans_a), total=spec.rows * spec.cols)) + + paired: dict[tuple[int, int], list[np.ndarray]] = { + well: [crop] for well, crop in beans_a.items() + } + if side_b_img is not None: + try: + rect_b = tray.rectify(side_b_img, spec) + except tray.TrayError: + st.error(t("tray.error_markers", lang)) + return + beans_b = tray.extract_from_rectified(rect_b, spec) + overlay_b = tray.draw_overlay(rect_b, spec, beans_b) + st.subheader("B") + b_orig, b_rect = st.columns(2) + b_orig.image(_bgr_to_rgb(side_b_img), caption=t("tray.original", lang)) + b_rect.image(_bgr_to_rgb(overlay_b), caption=t("tray.rectified", lang)) + st.caption(t("tray.beans_found", lang, n=len(beans_b), total=spec.rows * spec.cols)) + paired = tray.pair_sides(beans_a, beans_b, spec) + two_view = sum(1 for views in paired.values() if len(views) == 2) + single = len(paired) - two_view + st.success(t("tray.paired_summary", lang, two=two_view, one=single)) + + st.markdown("---") + default_id = time.strftime("%Y%m%d-%H%M%S") + session_id = st.text_input(t("tray.session_id", lang), value=default_id) + if st.button(t("tray.save_crops", lang), type="primary"): + spec_dict = { + "rows": spec.rows, + "cols": spec.cols, + "flip": spec.flip, + "marker_dict": spec.marker_dict, + "margin_frac": spec.margin_frac, + "well_frac": spec.well_frac, + } + out_dir = _save_session(paired, session_id, spec_dict) + st.success(f"{t('tray.saved_to', lang)}: `{out_dir}`") diff --git a/tests/test_ui_i18n.py b/tests/test_ui_i18n.py new file mode 100644 index 0000000..c5243d5 --- /dev/null +++ b/tests/test_ui_i18n.py @@ -0,0 +1,30 @@ +"""Tests for the bilingual ES/EN string table.""" + +from __future__ import annotations + +from almendra.ui.components.i18n import _STRINGS, available_languages, t + + +def test_default_lang_is_spanish() -> None: + assert t("app.title") == "almendra" + assert t("nav.home").startswith("🏠") + + +def test_every_key_has_both_languages() -> None: + missing: list[str] = [] + for key, entry in _STRINGS.items(): + for lang in available_languages(): + if lang not in entry or not entry[lang]: + missing.append(f"{key}:{lang}") + assert not missing, f"missing translations: {missing}" + + +def test_unknown_key_returns_key() -> None: + assert t("does.not.exist") == "does.not.exist" + + +def test_format_args() -> None: + es = t("tray.beans_found", lang="es", n=42, total=48) + en = t("tray.beans_found", lang="en", n=42, total=48) + assert "42" in es and "48" in es + assert "42" in en and "48" in en diff --git a/tests/test_ui_metrics_io.py b/tests/test_ui_metrics_io.py new file mode 100644 index 0000000..4645d34 --- /dev/null +++ b/tests/test_ui_metrics_io.py @@ -0,0 +1,82 @@ +"""Tests for the live-metrics JSONL file protocol shared by training and the UI.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from almendra.ui.metrics_io import read_live_metrics + + +def _write(path: Path, lines: list[dict]) -> None: + with path.open("w", encoding="utf-8") as fh: + for line in lines: + fh.write(json.dumps(line) + "\n") + + +def test_missing_file_returns_zeroed_snapshot(tmp_path: Path) -> None: + snap = read_live_metrics(tmp_path / "nope.jsonl") + assert snap.epochs_total == 0 + assert snap.epochs_completed == 0 + assert snap.progress == 0.0 + assert not snap.done + + +def test_start_then_epochs_then_done(tmp_path: Path) -> None: + path = tmp_path / "live_metrics.jsonl" + _write( + path, + [ + {"event": "start", "epochs": 3, "backbone": "mobilenet_v3_small"}, + { + "event": "epoch", + "epoch": 1, + "epochs": 3, + "train_loss": 1.0, + "val_macro_f1": 0.3, + "val_accuracy": 0.4, + }, + { + "event": "epoch", + "epoch": 2, + "epochs": 3, + "train_loss": 0.6, + "val_macro_f1": 0.6, + "val_accuracy": 0.7, + }, + {"event": "done", "best_val_macro_f1": 0.6}, + ], + ) + snap = read_live_metrics(path) + assert snap.backbone == "mobilenet_v3_small" + assert snap.epochs_total == 3 + assert snap.epochs_completed == 2 + assert snap.progress > 0.6 + assert snap.train_loss == [1.0, 0.6] + assert snap.val_macro_f1 == [0.3, 0.6] + assert snap.done + assert snap.best_val_macro_f1 == 0.6 + + +def test_malformed_lines_are_skipped(tmp_path: Path) -> None: + path = tmp_path / "x.jsonl" + path.write_text( + "\n" + "this is not json\n" + + json.dumps({"event": "start", "epochs": 1, "backbone": "x"}) + + "\n" + + json.dumps( + { + "event": "epoch", + "epoch": 1, + "epochs": 1, + "train_loss": 0.5, + "val_macro_f1": 0.5, + "val_accuracy": 0.5, + } + ) + + "\n" + ) + snap = read_live_metrics(path) + assert snap.epochs_completed == 1 + assert snap.val_macro_f1 == [0.5] diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py new file mode 100644 index 0000000..5e2a77c --- /dev/null +++ b/tests/test_ui_smoke.py @@ -0,0 +1,50 @@ +"""Streamlit smoke tests — each page renders without raising. + +Uses ``streamlit.testing.v1.AppTest`` to drive each page in-process. We invoke +each page's ``render(lang)`` from a tiny driver script so the test does not +depend on the sidebar navigation state. +""" + +from __future__ import annotations + +import inspect +import textwrap +from pathlib import Path + +import pytest + +streamlit_testing = pytest.importorskip("streamlit.testing.v1") +AppTest = streamlit_testing.AppTest + + +PAGES = ["home", "tray", "train", "evaluate", "predict", "settings"] + + +def _driver_script(page: str, lang: str) -> str: + """A standalone Streamlit script that imports and runs one page.""" + return textwrap.dedent( + f""" + from almendra.ui.views import page_{page} + page_{page}.render({lang!r}) + """ + ).strip() + + +@pytest.mark.parametrize("page", PAGES) +@pytest.mark.parametrize("lang", ["es", "en"]) +def test_page_renders_without_exception(tmp_path: Path, page: str, lang: str) -> None: + script_path = tmp_path / f"driver_{page}_{lang}.py" + script_path.write_text(_driver_script(page, lang)) + at = AppTest.from_file(str(script_path), default_timeout=30) + at.run() + assert not at.exception, f"{page}/{lang} raised: {[e.value for e in at.exception]}" + + +def test_render_signature_consistent() -> None: + """Every page module must expose ``render(lang)`` — keeps the dispatcher honest.""" + from almendra.ui import views + + for page in PAGES: + module = getattr(views, f"page_{page}") + sig = inspect.signature(module.render) + assert "lang" in sig.parameters, f"page_{page}.render must accept lang" diff --git a/uv.lock b/uv.lock index 6a1e758..7fa6080 100644 --- a/uv.lock +++ b/uv.lock @@ -207,6 +207,10 @@ train = [ { name = "torch" }, { name = "torchvision" }, ] +ui = [ + { name = "plotly" }, + { name = "streamlit" }, +] [package.metadata] requires-dist = [ @@ -222,16 +226,34 @@ requires-dist = [ { name = "onnxscript", marker = "extra == 'export'", specifier = ">=0.1" }, { name = "opencv-contrib-python-headless", marker = "extra == 'capture'", specifier = ">=4.9" }, { name = "pillow", specifier = ">=10.0" }, + { name = "plotly", marker = "extra == 'ui'", specifier = ">=5.20" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pyyaml", specifier = ">=6.0" }, { name = "roboflow", marker = "extra == 'data'", specifier = ">=1.1" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.5" }, { name = "scikit-learn", marker = "extra == 'train'", specifier = ">=1.4" }, + { name = "streamlit", marker = "extra == 'ui'", specifier = ">=1.40" }, { name = "torch", marker = "extra == 'train'", specifier = ">=2.2" }, { name = "torchvision", marker = "extra == 'train'", specifier = ">=0.17" }, { name = "tqdm", specifier = ">=4.66" }, ] -provides-extras = ["train", "export", "data", "capture", "dev"] +provides-extras = ["train", "export", "data", "capture", "ui", "dev"] + +[[package]] +name = "altair" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "jsonschema" }, + { name = "narwhals" }, + { name = "packaging" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/1e/365a9144db3254f86f1b974660b9ede1e9a38c9dc0730e4a9b1192eec5d6/altair-6.1.0.tar.gz", hash = "sha256:dda699216cf85b040d968ae5a569ad45957616811e38760a85e5118269daca67", size = 765519, upload-time = "2026-04-21T13:08:46.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/63/5dacc8d8306c715088b897a479e551bc0779fd2f0f26c97fec5e36542b4e/altair-6.1.0-py3-none-any.whl", hash = "sha256:fdf5fd939512e5b2fc4441c82dfd2635e706defbd037db0ac429ef5ddce66c3b", size = 796996, upload-time = "2026-04-21T13:08:48.549Z" }, +] [[package]] name = "amqp" @@ -1583,6 +1605,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + [[package]] name = "httpx" version = "0.28.1" @@ -1716,6 +1774,33 @@ 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 = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + [[package]] name = "kagglehub" version = "1.0.1" @@ -2285,6 +2370,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] +[[package]] +name = "narwhals" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/a0/6198c56d42ef2f3c6ed0c42ba30dbcefdc86a91262d7d449010770ae085b/narwhals-2.21.2.tar.gz", hash = "sha256:5c5b2d0b47aef7c73ea412cfcbcd467f2f2d5be73e3c2ab19d78f4a97718790a", size = 632176, upload-time = "2026-05-16T08:49:08.314Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/77/928ea2e70641ca177a11140062cc5840d421795f2e82749d408d0cce900a/narwhals-2.21.2-py3-none-any.whl", hash = "sha256:7bb57c3700486039215455b9bf2d64261915cc0fd845cc30272d631df696b251", size = 451201, upload-time = "2026-05-16T08:49:05.536Z" }, +] + [[package]] name = "networkx" version = "3.6.1" @@ -3079,6 +3173,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, ] +[[package]] +name = "plotly" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -3477,6 +3584,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" }, ] +[[package]] +name = "pydeck" +version = "0.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/df/4e9e7f20f8034a37c6571c93809f6d22388c39978c98d174d656c1a18fd2/pydeck-0.9.2.tar.gz", hash = "sha256:c10d9035e81ead6385264cac8d19402471f6866a15ca1f7df1400f52142bcf87", size = 5849672, upload-time = "2026-04-16T18:30:30.089Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/24/b30ee7d723100fd822de1bb4c0adea62f3419884a75a536f35f355d1e7c0/pydeck-0.9.2-py2.py3-none-any.whl", hash = "sha256:8213dfeacc5f6bfe6825f61c8ee34e3850e8a31fc43924379ec98edb34a75b25", size = 11305615, upload-time = "2026-04-16T18:30:28.133Z" }, +] + [[package]] name = "pydot" version = "4.0.1" @@ -3619,6 +3739,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, +] + [[package]] name = "pytz" version = "2026.2" @@ -3702,6 +3831,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -3773,6 +3916,114 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ee/84/3765db84f748be8e19047e050e117babaaee9738c74ea5348601e0c22767/roboflow-1.3.8-py3-none-any.whl", hash = "sha256:fe081e8d23196cd8c1afc7a07bcc2f6130784a7b78ed04a3ad987bb38bb5ba41", size = 207925, upload-time = "2026-05-06T14:21:21.27Z" }, ] +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/6e/f964e88b3d2abee2a82c1ac8366da848fce1c6d834dc2132c3fda3970290/rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425", size = 370157, upload-time = "2025-11-30T20:21:53.789Z" }, + { url = "https://files.pythonhosted.org/packages/94/ba/24e5ebb7c1c82e74c4e4f33b2112a5573ddc703915b13a073737b59b86e0/rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d", size = 359676, upload-time = "2025-11-30T20:21:55.475Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/04dbba1b087227747d64d80c3b74df946b986c57af0a9f0c98726d4d7a3b/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4", size = 389938, upload-time = "2025-11-30T20:21:57.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/bb/1463f0b1722b7f45431bdd468301991d1328b16cffe0b1c2918eba2c4eee/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f", size = 402932, upload-time = "2025-11-30T20:21:58.47Z" }, + { url = "https://files.pythonhosted.org/packages/99/ee/2520700a5c1f2d76631f948b0736cdf9b0acb25abd0ca8e889b5c62ac2e3/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4", size = 525830, upload-time = "2025-11-30T20:21:59.699Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ad/bd0331f740f5705cc555a5e17fdf334671262160270962e69a2bdef3bf76/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97", size = 412033, upload-time = "2025-11-30T20:22:00.991Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1e/372195d326549bb51f0ba0f2ecb9874579906b97e08880e7a65c3bef1a99/rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89", size = 390828, upload-time = "2025-11-30T20:22:02.723Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2b/d88bb33294e3e0c76bc8f351a3721212713629ffca1700fa94979cb3eae8/rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d", size = 404683, upload-time = "2025-11-30T20:22:04.367Z" }, + { url = "https://files.pythonhosted.org/packages/50/32/c759a8d42bcb5289c1fac697cd92f6fe01a018dd937e62ae77e0e7f15702/rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038", size = 421583, upload-time = "2025-11-30T20:22:05.814Z" }, + { url = "https://files.pythonhosted.org/packages/2b/81/e729761dbd55ddf5d84ec4ff1f47857f4374b0f19bdabfcf929164da3e24/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7", size = 572496, upload-time = "2025-11-30T20:22:07.713Z" }, + { url = "https://files.pythonhosted.org/packages/14/f6/69066a924c3557c9c30baa6ec3a0aa07526305684c6f86c696b08860726c/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed", size = 598669, upload-time = "2025-11-30T20:22:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/5f/48/905896b1eb8a05630d20333d1d8ffd162394127b74ce0b0784ae04498d32/rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85", size = 561011, upload-time = "2025-11-30T20:22:11.309Z" }, + { url = "https://files.pythonhosted.org/packages/22/16/cd3027c7e279d22e5eb431dd3c0fbc677bed58797fe7581e148f3f68818b/rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c", size = 221406, upload-time = "2025-11-30T20:22:13.101Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/e7b7aa136f28462b344e652ee010d4de26ee9fd16f1bfd5811f5153ccf89/rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825", size = 236024, upload-time = "2025-11-30T20:22:14.853Z" }, + { url = "https://files.pythonhosted.org/packages/14/a6/364bba985e4c13658edb156640608f2c9e1d3ea3c81b27aa9d889fff0e31/rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229", size = 229069, upload-time = "2025-11-30T20:22:16.577Z" }, + { url = "https://files.pythonhosted.org/packages/03/e7/98a2f4ac921d82f33e03f3835f5bf3a4a40aa1bfdc57975e74a97b2b4bdd/rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad", size = 375086, upload-time = "2025-11-30T20:22:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/4d/a1/bca7fd3d452b272e13335db8d6b0b3ecde0f90ad6f16f3328c6fb150c889/rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05", size = 359053, upload-time = "2025-11-30T20:22:19.297Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/ae157e83a6357eceff62ba7e52113e3ec4834a84cfe07fa4b0757a7d105f/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28", size = 390763, upload-time = "2025-11-30T20:22:21.661Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/eb2eb8515e2ad24c0bd43c3ee9cd74c33f7ca6430755ccdb240fd3144c44/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd", size = 408951, upload-time = "2025-11-30T20:22:23.408Z" }, + { url = "https://files.pythonhosted.org/packages/d6/65/ad8dc1784a331fabbd740ef6f71ce2198c7ed0890dab595adb9ea2d775a1/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f", size = 514622, upload-time = "2025-11-30T20:22:25.16Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/0cfa7ae158e15e143fe03993b5bcd743a59f541f5952e1546b1ac1b5fd45/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1", size = 414492, upload-time = "2025-11-30T20:22:26.505Z" }, + { url = "https://files.pythonhosted.org/packages/60/1b/6f8f29f3f995c7ffdde46a626ddccd7c63aefc0efae881dc13b6e5d5bb16/rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23", size = 394080, upload-time = "2025-11-30T20:22:27.934Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d5/a266341051a7a3ca2f4b750a3aa4abc986378431fc2da508c5034d081b70/rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6", size = 408680, upload-time = "2025-11-30T20:22:29.341Z" }, + { url = "https://files.pythonhosted.org/packages/10/3b/71b725851df9ab7a7a4e33cf36d241933da66040d195a84781f49c50490c/rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51", size = 423589, upload-time = "2025-11-30T20:22:31.469Z" }, + { url = "https://files.pythonhosted.org/packages/00/2b/e59e58c544dc9bd8bd8384ecdb8ea91f6727f0e37a7131baeff8d6f51661/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5", size = 573289, upload-time = "2025-11-30T20:22:32.997Z" }, + { url = "https://files.pythonhosted.org/packages/da/3e/a18e6f5b460893172a7d6a680e86d3b6bc87a54c1f0b03446a3c8c7b588f/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e", size = 599737, upload-time = "2025-11-30T20:22:34.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e2/714694e4b87b85a18e2c243614974413c60aa107fd815b8cbc42b873d1d7/rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394", size = 563120, upload-time = "2025-11-30T20:22:35.903Z" }, + { url = "https://files.pythonhosted.org/packages/6f/ab/d5d5e3bcedb0a77f4f613706b750e50a5a3ba1c15ccd3665ecc636c968fd/rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf", size = 223782, upload-time = "2025-11-30T20:22:37.271Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/f786af9957306fdc38a74cef405b7b93180f481fb48453a114bb6465744a/rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b", size = 240463, upload-time = "2025-11-30T20:22:39.021Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d2/b91dc748126c1559042cfe41990deb92c4ee3e2b415f6b5234969ffaf0cc/rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e", size = 230868, upload-time = "2025-11-30T20:22:40.493Z" }, + { url = "https://files.pythonhosted.org/packages/ed/dc/d61221eb88ff410de3c49143407f6f3147acf2538c86f2ab7ce65ae7d5f9/rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2", size = 374887, upload-time = "2025-11-30T20:22:41.812Z" }, + { url = "https://files.pythonhosted.org/packages/fd/32/55fb50ae104061dbc564ef15cc43c013dc4a9f4527a1f4d99baddf56fe5f/rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8", size = 358904, upload-time = "2025-11-30T20:22:43.479Z" }, + { url = "https://files.pythonhosted.org/packages/58/70/faed8186300e3b9bdd138d0273109784eea2396c68458ed580f885dfe7ad/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4", size = 389945, upload-time = "2025-11-30T20:22:44.819Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a8/073cac3ed2c6387df38f71296d002ab43496a96b92c823e76f46b8af0543/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136", size = 407783, upload-time = "2025-11-30T20:22:46.103Z" }, + { url = "https://files.pythonhosted.org/packages/77/57/5999eb8c58671f1c11eba084115e77a8899d6e694d2a18f69f0ba471ec8b/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7", size = 515021, upload-time = "2025-11-30T20:22:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/e0/af/5ab4833eadc36c0a8ed2bc5c0de0493c04f6c06de223170bd0798ff98ced/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2", size = 414589, upload-time = "2025-11-30T20:22:48.872Z" }, + { url = "https://files.pythonhosted.org/packages/b7/de/f7192e12b21b9e9a68a6d0f249b4af3fdcdff8418be0767a627564afa1f1/rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6", size = 394025, upload-time = "2025-11-30T20:22:50.196Z" }, + { url = "https://files.pythonhosted.org/packages/91/c4/fc70cd0249496493500e7cc2de87504f5aa6509de1e88623431fec76d4b6/rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e", size = 408895, upload-time = "2025-11-30T20:22:51.87Z" }, + { url = "https://files.pythonhosted.org/packages/58/95/d9275b05ab96556fefff73a385813eb66032e4c99f411d0795372d9abcea/rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d", size = 422799, upload-time = "2025-11-30T20:22:53.341Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/3088fc04b6624eb12a57eb814f0d4997a44b0d208d6cace713033ff1a6ba/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7", size = 572731, upload-time = "2025-11-30T20:22:54.778Z" }, + { url = "https://files.pythonhosted.org/packages/d8/42/c612a833183b39774e8ac8fecae81263a68b9583ee343db33ab571a7ce55/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31", size = 599027, upload-time = "2025-11-30T20:22:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/525a50f45b01d70005403ae0e25f43c0384369ad24ffe46e8d9068b50086/rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95", size = 563020, upload-time = "2025-11-30T20:22:58.2Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5d/47c4655e9bcd5ca907148535c10e7d489044243cc9941c16ed7cd53be91d/rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d", size = 223139, upload-time = "2025-11-30T20:23:00.209Z" }, + { url = "https://files.pythonhosted.org/packages/f2/e1/485132437d20aa4d3e1d8b3fb5a5e65aa8139f1e097080c2a8443201742c/rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15", size = 240224, upload-time = "2025-11-30T20:23:02.008Z" }, + { url = "https://files.pythonhosted.org/packages/24/95/ffd128ed1146a153d928617b0ef673960130be0009c77d8fbf0abe306713/rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1", size = 230645, upload-time = "2025-11-30T20:23:03.43Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1b/b10de890a0def2a319a2626334a7f0ae388215eb60914dbac8a3bae54435/rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a", size = 364443, upload-time = "2025-11-30T20:23:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/0d/bf/27e39f5971dc4f305a4fb9c672ca06f290f7c4e261c568f3dea16a410d47/rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e", size = 353375, upload-time = "2025-11-30T20:23:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/40/58/442ada3bba6e8e6615fc00483135c14a7538d2ffac30e2d933ccf6852232/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000", size = 383850, upload-time = "2025-11-30T20:23:07.825Z" }, + { url = "https://files.pythonhosted.org/packages/14/14/f59b0127409a33c6ef6f5c1ebd5ad8e32d7861c9c7adfa9a624fc3889f6c/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db", size = 392812, upload-time = "2025-11-30T20:23:09.228Z" }, + { url = "https://files.pythonhosted.org/packages/b3/66/e0be3e162ac299b3a22527e8913767d869e6cc75c46bd844aa43fb81ab62/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2", size = 517841, upload-time = "2025-11-30T20:23:11.186Z" }, + { url = "https://files.pythonhosted.org/packages/3d/55/fa3b9cf31d0c963ecf1ba777f7cf4b2a2c976795ac430d24a1f43d25a6ba/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa", size = 408149, upload-time = "2025-11-30T20:23:12.864Z" }, + { url = "https://files.pythonhosted.org/packages/60/ca/780cf3b1a32b18c0f05c441958d3758f02544f1d613abf9488cd78876378/rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083", size = 383843, upload-time = "2025-11-30T20:23:14.638Z" }, + { url = "https://files.pythonhosted.org/packages/82/86/d5f2e04f2aa6247c613da0c1dd87fcd08fa17107e858193566048a1e2f0a/rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9", size = 396507, upload-time = "2025-11-30T20:23:16.105Z" }, + { url = "https://files.pythonhosted.org/packages/4b/9a/453255d2f769fe44e07ea9785c8347edaf867f7026872e76c1ad9f7bed92/rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0", size = 414949, upload-time = "2025-11-30T20:23:17.539Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/622a86cdc0c45d6df0e9ccb6becdba5074735e7033c20e401a6d9d0e2ca0/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94", size = 565790, upload-time = "2025-11-30T20:23:19.029Z" }, + { url = "https://files.pythonhosted.org/packages/1c/5d/15bbf0fb4a3f58a3b1c67855ec1efcc4ceaef4e86644665fff03e1b66d8d/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08", size = 590217, upload-time = "2025-11-30T20:23:20.885Z" }, + { url = "https://files.pythonhosted.org/packages/6d/61/21b8c41f68e60c8cc3b2e25644f0e3681926020f11d06ab0b78e3c6bbff1/rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27", size = 555806, upload-time = "2025-11-30T20:23:22.488Z" }, + { url = "https://files.pythonhosted.org/packages/f9/39/7e067bb06c31de48de3eb200f9fc7c58982a4d3db44b07e73963e10d3be9/rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6", size = 211341, upload-time = "2025-11-30T20:23:24.449Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4d/222ef0b46443cf4cf46764d9c630f3fe4abaa7245be9417e56e9f52b8f65/rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d", size = 225768, upload-time = "2025-11-30T20:23:25.908Z" }, + { url = "https://files.pythonhosted.org/packages/86/81/dad16382ebbd3d0e0328776d8fd7ca94220e4fa0798d1dc5e7da48cb3201/rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0", size = 362099, upload-time = "2025-11-30T20:23:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/2b/60/19f7884db5d5603edf3c6bce35408f45ad3e97e10007df0e17dd57af18f8/rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be", size = 353192, upload-time = "2025-11-30T20:23:29.151Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c4/76eb0e1e72d1a9c4703c69607cec123c29028bff28ce41588792417098ac/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f", size = 384080, upload-time = "2025-11-30T20:23:30.785Z" }, + { url = "https://files.pythonhosted.org/packages/72/87/87ea665e92f3298d1b26d78814721dc39ed8d2c74b86e83348d6b48a6f31/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f", size = 394841, upload-time = "2025-11-30T20:23:32.209Z" }, + { url = "https://files.pythonhosted.org/packages/77/ad/7783a89ca0587c15dcbf139b4a8364a872a25f861bdb88ed99f9b0dec985/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87", size = 516670, upload-time = "2025-11-30T20:23:33.742Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/2882bdac942bd2172f3da574eab16f309ae10a3925644e969536553cb4ee/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18", size = 408005, upload-time = "2025-11-30T20:23:35.253Z" }, + { url = "https://files.pythonhosted.org/packages/ce/81/9a91c0111ce1758c92516a3e44776920b579d9a7c09b2b06b642d4de3f0f/rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad", size = 382112, upload-time = "2025-11-30T20:23:36.842Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8e/1da49d4a107027e5fbc64daeab96a0706361a2918da10cb41769244b805d/rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07", size = 399049, upload-time = "2025-11-30T20:23:38.343Z" }, + { url = "https://files.pythonhosted.org/packages/df/5a/7ee239b1aa48a127570ec03becbb29c9d5a9eb092febbd1699d567cae859/rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f", size = 415661, upload-time = "2025-11-30T20:23:40.263Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/caa143cf6b772f823bc7929a45da1fa83569ee49b11d18d0ada7f5ee6fd6/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65", size = 565606, upload-time = "2025-11-30T20:23:42.186Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/ac20ba2d69303f961ad8cf55bf7dbdb4763f627291ba3d0d7d67333cced9/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f", size = 591126, upload-time = "2025-11-30T20:23:44.086Z" }, + { url = "https://files.pythonhosted.org/packages/21/20/7ff5f3c8b00c8a95f75985128c26ba44503fb35b8e0259d812766ea966c7/rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53", size = 553371, upload-time = "2025-11-30T20:23:46.004Z" }, + { url = "https://files.pythonhosted.org/packages/72/c7/81dadd7b27c8ee391c132a6b192111ca58d866577ce2d9b0ca157552cce0/rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed", size = 215298, upload-time = "2025-11-30T20:23:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d2/1aaac33287e8cfb07aab2e6b8ac1deca62f6f65411344f1433c55e6f3eb8/rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950", size = 228604, upload-time = "2025-11-30T20:23:49.501Z" }, + { url = "https://files.pythonhosted.org/packages/e8/95/ab005315818cc519ad074cb7784dae60d939163108bd2b394e60dc7b5461/rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6", size = 222391, upload-time = "2025-11-30T20:23:50.96Z" }, + { url = "https://files.pythonhosted.org/packages/9e/68/154fe0194d83b973cdedcdcc88947a2752411165930182ae41d983dcefa6/rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb", size = 364868, upload-time = "2025-11-30T20:23:52.494Z" }, + { url = "https://files.pythonhosted.org/packages/83/69/8bbc8b07ec854d92a8b75668c24d2abcb1719ebf890f5604c61c9369a16f/rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8", size = 353747, upload-time = "2025-11-30T20:23:54.036Z" }, + { url = "https://files.pythonhosted.org/packages/ab/00/ba2e50183dbd9abcce9497fa5149c62b4ff3e22d338a30d690f9af970561/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7", size = 383795, upload-time = "2025-11-30T20:23:55.556Z" }, + { url = "https://files.pythonhosted.org/packages/05/6f/86f0272b84926bcb0e4c972262f54223e8ecc556b3224d281e6598fc9268/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898", size = 393330, upload-time = "2025-11-30T20:23:57.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e9/0e02bb2e6dc63d212641da45df2b0bf29699d01715913e0d0f017ee29438/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e", size = 518194, upload-time = "2025-11-30T20:23:58.637Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ca/be7bca14cf21513bdf9c0606aba17d1f389ea2b6987035eb4f62bd923f25/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419", size = 408340, upload-time = "2025-11-30T20:24:00.2Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c7/736e00ebf39ed81d75544c0da6ef7b0998f8201b369acf842f9a90dc8fce/rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551", size = 383765, upload-time = "2025-11-30T20:24:01.759Z" }, + { url = "https://files.pythonhosted.org/packages/4a/3f/da50dfde9956aaf365c4adc9533b100008ed31aea635f2b8d7b627e25b49/rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8", size = 396834, upload-time = "2025-11-30T20:24:03.687Z" }, + { url = "https://files.pythonhosted.org/packages/4e/00/34bcc2565b6020eab2623349efbdec810676ad571995911f1abdae62a3a0/rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5", size = 415470, upload-time = "2025-11-30T20:24:05.232Z" }, + { url = "https://files.pythonhosted.org/packages/8c/28/882e72b5b3e6f718d5453bd4d0d9cf8df36fddeb4ddbbab17869d5868616/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404", size = 565630, upload-time = "2025-11-30T20:24:06.878Z" }, + { url = "https://files.pythonhosted.org/packages/3b/97/04a65539c17692de5b85c6e293520fd01317fd878ea1995f0367d4532fb1/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856", size = 591148, upload-time = "2025-11-30T20:24:08.445Z" }, + { url = "https://files.pythonhosted.org/packages/85/70/92482ccffb96f5441aab93e26c4d66489eb599efdcf96fad90c14bbfb976/rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40", size = 556030, upload-time = "2025-11-30T20:24:10.956Z" }, + { url = "https://files.pythonhosted.org/packages/20/53/7c7e784abfa500a2b6b583b147ee4bb5a2b3747a9166bab52fec4b5b5e7d/rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0", size = 211570, upload-time = "2025-11-30T20:24:12.735Z" }, + { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, + { url = "https://files.pythonhosted.org/packages/69/71/3f34339ee70521864411f8b6992e7ab13ac30d8e4e3309e07c7361767d91/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58", size = 372292, upload-time = "2025-11-30T20:24:16.537Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/f183df9b8f2d66720d2ef71075c59f7e1b336bec7ee4c48f0a2b06857653/rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a", size = 362128, upload-time = "2025-11-30T20:24:18.086Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/5c2594e937253457342e078f0cc1ded3dd7b2ad59afdbf2d354869110a02/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb", size = 391542, upload-time = "2025-11-30T20:24:20.092Z" }, + { url = "https://files.pythonhosted.org/packages/49/5c/31ef1afd70b4b4fbdb2800249f34c57c64beb687495b10aec0365f53dfc4/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c", size = 404004, upload-time = "2025-11-30T20:24:22.231Z" }, + { url = "https://files.pythonhosted.org/packages/e3/63/0cfbea38d05756f3440ce6534d51a491d26176ac045e2707adc99bb6e60a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3", size = 527063, upload-time = "2025-11-30T20:24:24.302Z" }, + { url = "https://files.pythonhosted.org/packages/42/e6/01e1f72a2456678b0f618fc9a1a13f882061690893c192fcad9f2926553a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5", size = 413099, upload-time = "2025-11-30T20:24:25.916Z" }, + { url = "https://files.pythonhosted.org/packages/b8/25/8df56677f209003dcbb180765520c544525e3ef21ea72279c98b9aa7c7fb/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738", size = 392177, upload-time = "2025-11-30T20:24:27.834Z" }, + { url = "https://files.pythonhosted.org/packages/4a/b4/0a771378c5f16f8115f796d1f437950158679bcd2a7c68cf251cfb00ed5b/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f", size = 406015, upload-time = "2025-11-30T20:24:29.457Z" }, + { url = "https://files.pythonhosted.org/packages/36/d8/456dbba0af75049dc6f63ff295a2f92766b9d521fa00de67a2bd6427d57a/rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877", size = 423736, upload-time = "2025-11-30T20:24:31.22Z" }, + { url = "https://files.pythonhosted.org/packages/13/64/b4d76f227d5c45a7e0b796c674fd81b0a6c4fbd48dc29271857d8219571c/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a", size = 573981, upload-time = "2025-11-30T20:24:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/20/91/092bacadeda3edf92bf743cc96a7be133e13a39cdbfd7b5082e7ab638406/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4", size = 599782, upload-time = "2025-11-30T20:24:35.169Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, +] + [[package]] name = "ruamel-yaml" version = "0.19.1" @@ -4117,6 +4368,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, ] +[[package]] +name = "streamlit" +version = "1.57.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "altair" }, + { name = "anyio" }, + { name = "blinker" }, + { name = "cachetools" }, + { name = "click" }, + { name = "gitpython" }, + { name = "httptools" }, + { name = "itsdangerous" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "protobuf" }, + { name = "pyarrow" }, + { name = "pydeck" }, + { name = "python-multipart" }, + { name = "requests" }, + { name = "starlette" }, + { name = "tenacity" }, + { name = "toml" }, + { name = "typing-extensions" }, + { name = "uvicorn" }, + { name = "watchdog", marker = "sys_platform != 'darwin'" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/f8/b2daf7a5f8ae15527daf94406e771bb6075e958a01c3dde9eba79dc3c9a3/streamlit-1.57.0.tar.gz", hash = "sha256:0b028d305c1a1a757071b2c9504966787602842fc8af6e873795ca58d2b4d12f", size = 8678859, upload-time = "2026-04-28T22:13:32.238Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/1a/3ca2293d8552bacea3e67e9600d2d1df7df4a325059769ad83d91c279595/streamlit-1.57.0-py3-none-any.whl", hash = "sha256:0d1d41972aeade5637dbb0e7f0eefa5312272f85304923d240a1b1f0475249c8", size = 9194216, upload-time = "2026-04-28T22:13:29.624Z" }, +] + [[package]] name = "sympy" version = "1.14.0" @@ -4138,6 +4424,15 @@ 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 = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + [[package]] name = "threadpoolctl" version = "3.6.0" @@ -4147,6 +4442,15 @@ 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" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ba/1f744cdc819428fc6b5084ec34d9b30660f6f9daaf70eead706e3203ec3c/toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f", size = 22253, upload-time = "2020-11-01T01:40:22.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/6f/7120676b6d73228c96e17f1f794d8ab046fc910d781c8d151120c3f1569e/toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b", size = 16588, upload-time = "2020-11-01T01:40:20.672Z" }, +] + [[package]] name = "tomlkit" version = "0.15.0" @@ -4377,6 +4681,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8d/57/a27182528c90ef38d82b636a11f606b0cbb0e17588ed205435f8affe3368/waitress-3.0.2-py3-none-any.whl", hash = "sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e", size = 56232, upload-time = "2024-11-16T20:02:33.858Z" }, ] +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + [[package]] name = "wcwidth" version = "0.7.0" @@ -4386,6 +4708,65 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] + [[package]] name = "werkzeug" version = "3.1.8"