From e0abb66fbf80042c0b1778c52ede7b9919959bc0 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 16 Jun 2026 18:40:23 -0500 Subject: [PATCH 01/44] ci: add develop to push triggers + guard the release gate against a missing runner (#266) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prep for a long-lived `develop` integration branch (v1.1+ work lands there; main stays at the released state). - ci.yml: run the CI workflow on pushes to `develop` too (PRs already run on any base branch). - release-gate.yml: gate the self-hosted Tier-4 job behind `if: workflow_dispatch || vars.ENABLE_RELEASE_GATE == 'true'`. There is no self-hosted runner registered yet, so every push to main was queuing a job no runner could take, which GitHub auto-cancels after 24h — a permanent red ✗ on main. Now the automatic run is skipped (green/neutral) until a runner is wired up and ENABLE_RELEASE_GATE is set; a manual dispatch still runs. Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 2 +- .github/workflows/release-gate.yml | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a46996f..f550be6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main] + branches: [main, develop] pull_request: jobs: diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 421a2e7..874384f 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -35,6 +35,12 @@ concurrency: jobs: release-gate: name: Tier-4 live matrix (real nodes) + # Only run automatically once a self-hosted runner is actually wired up — set the repo variable + # ENABLE_RELEASE_GATE=true when one is registered. Without this guard, every push to main queues + # a job no runner can claim, and GitHub auto-cancels it after a 24h timeout — a permanent red ✗ + # on main. A skipped job is green/neutral instead. A manual workflow_dispatch ALWAYS runs, so a + # maintainer can still validate a reviewed ref on a registered runner on demand. + if: ${{ github.event_name == 'workflow_dispatch' || vars.ENABLE_RELEASE_GATE == 'true' }} # Register the server with these labels: `pithead-release` scopes the gate to the dedicated # box; prefer an ephemeral / just-in-time runner in its own runner group. runs-on: [self-hosted, pithead-release] From 6d24b5490544b1f6524b90e422cce7580d9f720e Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 16 Jun 2026 18:56:40 -0500 Subject: [PATCH 02/44] =?UTF-8?q?chore(v1.1):=20Wave=201=20cleanup=20?= =?UTF-8?q?=E2=80=94=20dead=20known=5Fworkers,=20tar=20xattrs,=20fail-clos?= =?UTF-8?q?ed=20e2e=20(#144,=20#252,=20#203)=20(#265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(v1.1): Wave 1 cleanup — dead known_workers, tar xattrs, fail-closed e2e (#144, #252, #203) Bundles the three zero-dependency v1.1 Wave-1 cleanup issues into one PR. #144 — Remove the dead known_workers persistence layer (orphaned by the proxy-sourced worker rewrite). Drops the state key, the workers table + its last_seen migration (replaced by a one-line DROP TABLE IF EXISTS to tidy old DBs), the load() block, and update/get_known_workers(). Two deviations from the issue text, both verified against the tree: * The issue named one test to delete; there were actually 4 across two classes (TestWorkers + a retention test) — all removed. * The issue said "keep WORKER_RETENTION_SEC, worker_presence.py uses it", but worker_presence.py doesn't exist here (it's in the unmerged #121 draft, now v1.2). Honored the acceptance criterion (kept the constant for #121) but corrected the false rationale in its comment; it has no live consumer in the current tree. Also simplifies the dead `worker_configs` unpack in data_service.py. #252 — Release bundle carried macOS xattrs (LIBARCHIVE.xattr.*) → GNU tar warns once per file on Linux extract. Adds `tar --no-xattrs` (portable no-op on GNU tar) + a post-bundle guard that greps the stream for xattr pax headers and fails the release if any survive. Validated on real macOS bsdtar: old bundle carries com.apple.provenance, new one is clean. #203 — Live e2e that the DEPLOY path refuses an unauthenticated proxy API with an empty PROXY_AUTH_TOKEN (#153), behind a new opt-in `--auth-fail-closed` phase. Tests `pithead up` (NOT apply — apply self-heals by regenerating the token; up doesn't re-render .env, so the compose `:?` guard fires). Restores the EXACT original token so the harness's end-of-run secret-fingerprint check still holds. Verification: dashboard pytest 528 passed, coverage 94% (gate 80%); shellcheck --severity=warning clean on release.sh + run.sh; integration selftest 97 passed; #252 fix/guard and #203 .env rewrite validated locally. Co-Authored-By: Claude Opus 4.8 (1M context) * test(wave-1): regen test-inventory + add #144/#252 regression tests Fixes the red CI on this PR: the first commit removed the 4 dead known_workers tests but left docs/test-inventory.md at the old count, tripping the `make test-inventory-check` drift guard (616 → 612). Also adds two regression tests for the Wave-1 changes: * #144 — pytest asserting the orphaned `workers` table is DROP'd in place when an older DB is opened, and the dead known_workers state key is gone, with history/shares/kv left intact. * #252 — a tests/stack/run.sh section asserting release.sh keeps `tar --no-xattrs` + the xattr-pax-header guard, and that this platform's tar actually honours --no-xattrs (reproduces the bug on macOS, no-op on GNU tar). Inventory regenerated to match (drift guard green). Co-Authored-By: Claude Opus 4.8 (1M context) * test(e2e): add e2e.sh gouda branch-runner (lean by default) + docs One-command Tier-4 wrapper that deploys a branch to the live gouda test bench, borrows a real miner, validates, and restores everything (miner pool config + canonical baseline stack) via an EXIT trap — even on failure/Ctrl-C. Design: * Dedicated /srv/code/pithead-e2e checkout; /srv/code/pithead stays the untouched baseline. Compose project is pinned to "pithead", so the two checkouts drive the SAME containers + the SAME shared synced chains — a code/image swap, never a re-sync. * Seeds the e2e checkout with the canonical config.json/.env (same wallet/ secrets/onions/data-dirs). * Borrows miner-0 (config-watch auto-reloads the repoint; restored from a timestamped backup), runs run.sh DETACHED on the box (survives SSH drops). Modes — default is LEAN per the "validate sync logic + dashboard against the node we already have, don't re-sync" guidance: * targeted (default) — dashboard check + sync gate via ONE controlled restart (lifecycle/failover) + --auth-fail-closed. No config sweep. * check — pure reads. * matrix — opt-in full destructive config sweep (pre-release gate). Also waits for monero/tari to re-confirm synced after a deploy (post-restart "loading" → "done") before the harness pre-check, so it doesn't flap — and that wait doubles as a direct sync-detection check. Validated live on gouda: check mode 36/36 live assertions, clean restore; the config sweep passed main/mini/nano sync+dashboard + node-down/unhealthy failover, all reusing the synced chains (no re-sync). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../mining_dashboard/config/config.py | 7 +- .../mining_dashboard/service/data_service.py | 4 +- .../service/storage_service.py | 76 +--- .../tests/service/test_storage_service.py | 50 ++- docs/integration-testing.md | 42 +++ docs/test-inventory.md | 26 +- scripts/release.sh | 14 +- tests/integration/e2e.sh | 351 ++++++++++++++++++ tests/integration/run.sh | 57 +++ tests/stack/run.sh | 23 ++ 10 files changed, 538 insertions(+), 112 deletions(-) create mode 100755 tests/integration/e2e.sh diff --git a/build/dashboard/mining_dashboard/config/config.py b/build/dashboard/mining_dashboard/config/config.py index ef7063c..e43ce65 100644 --- a/build/dashboard/mining_dashboard/config/config.py +++ b/build/dashboard/mining_dashboard/config/config.py @@ -220,10 +220,13 @@ # --- Data Retention Policies --- HISTORY_RETENTION_SEC = 30 * 24 * 3600 # 30 Days +# Retention for the known_workers persistence layer removed in #144. No live consumer in the current +# tree; kept for the deferred Telegram worker-presence monitor (#121), which reuses it as its +# retention default — consult that work before removing. WORKER_RETENTION_SEC = 7 * 24 * 3600 # 7 Days # How long an offline worker lingers in the live "Workers Alive" table before it falls off (#182). -# Operates on the live proxy-sourced list (NOT the dead known_workers path, #144). A reconnect -# re-adds the worker. 1h keeps a just-disconnected rig visible (shown as DOWN) but clears ghosts. +# Operates on the live proxy-sourced list. A reconnect re-adds the worker. 1h keeps a +# just-disconnected rig visible (shown as DOWN) but clears ghosts. WORKER_FALLOFF_SEC = 3600 # 1 Hour # --- Hashrate averaging windows (#168) --- diff --git a/build/dashboard/mining_dashboard/service/data_service.py b/build/dashboard/mining_dashboard/service/data_service.py index 159a4d4..d58ffe9 100644 --- a/build/dashboard/mining_dashboard/service/data_service.py +++ b/build/dashboard/mining_dashboard/service/data_service.py @@ -260,7 +260,7 @@ class WorkerLifecycle: (any positive value is left untouched). - ``last_active`` — the last time it was seen online. An offline worker falls off the table once it's been inactive longer than ``falloff_sec`` (#182); a reconnect re-adds it. Operates purely - on the live proxy-sourced list, never the dead ``known_workers`` path (#144). + on the live proxy-sourced worker list. Pure given (workers, now) plus its accumulated state, so it unit-tests without the data loop. Mutates each surviving online worker's ``uptime`` in place and returns the filtered list. @@ -485,7 +485,7 @@ async def run(self): while True: try: # 1. Collect Local Statistics (High Frequency Polling) - stratum_raw, worker_configs = get_stratum_stats() + stratum_raw, _ = get_stratum_stats() # 2. Fetch Worker Statistics from XMRig Proxy + normalize the payload. proxy_workers = [] diff --git a/build/dashboard/mining_dashboard/service/storage_service.py b/build/dashboard/mining_dashboard/service/storage_service.py index 1f0209a..12b4d63 100644 --- a/build/dashboard/mining_dashboard/service/storage_service.py +++ b/build/dashboard/mining_dashboard/service/storage_service.py @@ -8,7 +8,7 @@ from collections import deque from typing import Dict, List, Optional, Any from mining_dashboard.config.config import ( - DB_FILE_PATH, TIER_DEFAULTS, HISTORY_RETENTION_SEC, WORKER_RETENTION_SEC, + DB_FILE_PATH, TIER_DEFAULTS, HISTORY_RETENTION_SEC, HASHRATE_WINDOW_COLUMNS, ) @@ -40,7 +40,6 @@ def __init__(self, db_path: str = None): self.state = { "hashrate_history": deque(), "shares": [], - "known_workers": {}, # Persist worker IPs by name to prevent loss during XvB switching "xvb": { "total_donated_time": 0.0, "current_mode": "P2POOL", @@ -105,7 +104,6 @@ def _create_tables(self): # DBs get them via _migrate_db. Same source list (_WINDOW_EXTRA_COLUMNS) for both paths. extra = "".join(f", {c} REAL DEFAULT 0" for c in _WINDOW_EXTRA_COLUMNS) self._conn.execute(f"CREATE TABLE IF NOT EXISTS history (t TEXT, v REAL, v_p2pool REAL, v_xvb REAL, timestamp REAL{extra})") - self._conn.execute("CREATE TABLE IF NOT EXISTS workers (name TEXT PRIMARY KEY, ip TEXT, last_seen REAL)") self._conn.execute("CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT)") self._conn.execute("CREATE TABLE IF NOT EXISTS shares (ts REAL PRIMARY KEY, difficulty REAL)") @@ -144,13 +142,10 @@ def _migrate_db(self): self.logger.info(f"Migrating DB: Adding {col} column to history") self._conn.execute(f"ALTER TABLE history ADD COLUMN {col} REAL DEFAULT 0") - # Workers Table Migrations - cursor.execute("PRAGMA table_info(workers)") - w_columns = {info[1] for info in cursor.fetchall()} - if 'last_seen' not in w_columns: - self.logger.info("Migrating DB: Adding last_seen column to workers") - self._conn.execute("ALTER TABLE workers ADD COLUMN last_seen REAL") - self._conn.execute("UPDATE workers SET last_seen = ?", (time.time(),)) + # Drop the orphaned `workers` table (#144). It backed the known_workers persistence layer, + # which was dead code — the worker list is sourced live from the xmrig-proxy. Tidies old + # DBs; harmless no-op on fresh ones. + self._conn.execute("DROP TABLE IF EXISTS workers") def load(self): """ @@ -179,18 +174,7 @@ def load(self): history.append(item) self.state["hashrate_history"] = deque(history) - # 2. Load Workers - # Only load workers seen recently - worker_cutoff = time.time() - WORKER_RETENTION_SEC - cursor.execute("SELECT name, ip, last_seen FROM workers WHERE last_seen > ? OR last_seen IS NULL", (worker_cutoff,)) - self.state["known_workers"] = {} - for row in cursor.fetchall(): - self.state["known_workers"][row["name"]] = { - "ip": row["ip"], - "last_seen": row["last_seen"] if row["last_seen"] is not None else time.time() - } - - # 3. Load XVB Stats (KV Store) + # 2. Load XVB Stats (KV Store) cursor.execute("SELECT key, value FROM kv_store WHERE key LIKE 'xvb_%'") for row in cursor.fetchall(): key = row["key"] @@ -220,7 +204,7 @@ def load(self): except (ValueError, TypeError): self.logger.warning(f"Skipping corrupted KV pair: {key}={val}") - # 4. Load Shares + # 3. Load Shares cursor.execute("SELECT ts, difficulty FROM shares WHERE ts > ? ORDER BY ts ASC", (history_cutoff,)) self.state["shares"] = [dict(row) for row in cursor.fetchall()] @@ -420,47 +404,6 @@ def update_xvb_stats(self, mode: Optional[str] = None, avg_24h: Optional[float] except sqlite3.Error as e: self._db_error("XVB Update Error", e) - def update_known_workers(self, workers_list: List[Dict[str, str]]): - """ - Updates the list of known workers. - - Args: - workers_list (list): List of dicts [{'name': '...', 'ip': '...'}, ...] - """ - if workers_list is None: - workers_list = [] - ts = time.time() - to_upsert = [] - - with self._lock: - for w in workers_list: - name = w.get('name') - ip = w.get('ip') - if name and ip: - # Update memory - self.state["known_workers"][name] = {"ip": ip, "last_seen": ts} - - # Always update DB timestamp for active workers - to_upsert.append((name, ip, ts)) - - # Prune old workers from memory - cutoff = ts - WORKER_RETENTION_SEC - to_remove = [k for k, v in self.state["known_workers"].items() if v["last_seen"] < cutoff] - for k in to_remove: - del self.state["known_workers"][k] - - if to_upsert: - try: - with self._db_lock: - if not self._conn: - return - with self._conn: - self._conn.executemany("INSERT OR REPLACE INTO workers (name, ip, last_seen) VALUES (?, ?, ?)", to_upsert) - # Prune old workers from DB - self._conn.execute("DELETE FROM workers WHERE last_seen < ?", (ts - WORKER_RETENTION_SEC,)) - except sqlite3.Error as e: - self._db_error("Worker Update Error", e) - def save_snapshot(self, data: Dict[str, Any]): """Persists the full application state snapshot to the KV store.""" if not data: @@ -493,11 +436,6 @@ def load_snapshot(self) -> Optional[Dict[str, Any]]: self.logger.error(f"Snapshot Load Error: {e}") return None - def get_known_workers(self) -> List[Dict[str, str]]: - """Returns a list of worker dicts for the collector.""" - with self._lock: - return [{"name": k, "ip": v["ip"]} for k, v in self.state["known_workers"].items()] - def get_history(self) -> List[Dict[str, Any]]: """Returns a copy of the hashrate history.""" with self._lock: diff --git a/build/dashboard/tests/service/test_storage_service.py b/build/dashboard/tests/service/test_storage_service.py index 12b7a5b..c57fd55 100644 --- a/build/dashboard/tests/service/test_storage_service.py +++ b/build/dashboard/tests/service/test_storage_service.py @@ -4,7 +4,7 @@ import pytest from mining_dashboard.service.storage_service import StateManager -from mining_dashboard.config.config import TIER_DEFAULTS, HISTORY_RETENTION_SEC, WORKER_RETENTION_SEC +from mining_dashboard.config.config import TIER_DEFAULTS, HISTORY_RETENTION_SEC class TestDefaults: @@ -145,21 +145,6 @@ def test_unhealthy_after_write_error(self): sm.close() -class TestWorkers: - def test_update_and_get_known_workers(self, state_manager): - state_manager.update_known_workers([{"name": "rig1", "ip": "10.0.0.1"}]) - workers = state_manager.get_known_workers() - assert workers == [{"name": "rig1", "ip": "10.0.0.1"}] - - def test_worker_without_ip_skipped(self, state_manager): - state_manager.update_known_workers([{"name": "rig1"}, {"ip": "10.0.0.2"}]) - assert state_manager.get_known_workers() == [] - - def test_none_list_is_noop(self, state_manager): - state_manager.update_known_workers(None) - assert state_manager.get_known_workers() == [] - - class TestSnapshot: def test_roundtrip(self, state_manager): state_manager.save_snapshot({"a": 1, "b": [1, 2, 3]}) @@ -297,6 +282,29 @@ def test_per_window_columns_added_on_upgrade(self, tmp_path): finally: sm.close() + def test_orphaned_workers_table_dropped_on_upgrade(self, tmp_path): + # Intent (#144): the dead known_workers persistence layer was removed, so opening a DB + # that still has its orphaned `workers` table drops it in place — tidying old installs + # without touching history/shares/kv. The worker list is now sourced live from the + # xmrig-proxy, never from the DB. Also asserts the in-memory state key is gone. + db = str(tmp_path / "with_workers.db") + conn = sqlite3.connect(db) + conn.execute("CREATE TABLE history (t TEXT, v REAL, v_p2pool REAL, v_xvb REAL, timestamp REAL)") + conn.execute("CREATE TABLE workers (name TEXT PRIMARY KEY, ip TEXT, last_seen REAL)") + conn.execute("INSERT INTO workers VALUES (?, ?, ?)", ("rig1", "10.0.0.1", 123.0)) + conn.commit() + conn.close() + + sm = StateManager(db_path=db) # __init__ runs _migrate_db -> DROP TABLE IF EXISTS workers + try: + tables = {r[0] for r in sm._conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + assert "workers" not in tables, "orphaned workers table dropped (#144)" + assert "known_workers" not in sm.state, "dead known_workers state key removed (#144)" + assert {"history", "kv_store", "shares"} <= tables, "core tables left intact" + finally: + sm.close() + class TestRetention: """Long-running behavior: history/workers must not grow unbounded. Tests are white-box @@ -330,13 +338,3 @@ def test_old_history_pruned_from_db_when_cleanup_fires(self, state_manager, monk "SELECT COUNT(*) FROM history WHERE timestamp < ?", (time.time() - HISTORY_RETENTION_SEC,)).fetchone()[0] assert remaining == 0, "expired DB rows are pruned" - - def test_stale_workers_pruned_after_retention_window(self, state_manager): - # Intent: a worker not seen within WORKER_RETENTION_SEC (7d) is dropped when any worker - # next checks in — so stale name→IP mappings don't linger and leak memory. - state_manager.update_known_workers([{"name": "rig1", "ip": "10.0.0.1"}]) - # Backdate rig1 so it's now older than the retention window. - state_manager.state["known_workers"]["rig1"]["last_seen"] = time.time() - WORKER_RETENTION_SEC - 3600 - state_manager.update_known_workers([{"name": "rig2", "ip": "10.0.0.2"}]) # a fresh check-in - names = {w["name"] for w in state_manager.get_known_workers()} - assert "rig2" in names and "rig1" not in names diff --git a/docs/integration-testing.md b/docs/integration-testing.md index 05d7c73..08ae8d6 100644 --- a/docs/integration-testing.md +++ b/docs/integration-testing.md @@ -135,6 +135,7 @@ Useful flags (full list in `run.sh --help`): | `--pruned-data-dir` / `--full-data-dir` | Synced alt DB to enable the opposite prune mode. | | `--lifecycle` | Also run the lifecycle phase (restart, apply secret-preservation). | | `--fault-injection` | Also break monerod (stop / SIGSTOP / remove) and assert `status`' down/unhealthy/missing verdicts and the failover→recovery cycle. Destructive-then-restored; local mode only; slow. | +| `--auth-fail-closed` | Also empty `PROXY_AUTH_TOKEN` in `.env` and assert `pithead up` **refuses to start** (the live counterpart to the tier-1 compose-config check, [#153](https://github.com/p2pool-starter-stack/pithead/issues/153)/[#203](https://github.com/p2pool-starter-stack/pithead/issues/203)), then restore the exact token and recover. Destructive-then-restored; ssh **or** local mode. | | `--safety-backup` | Take a `pithead backup` before the destructive scenarios and **auto-roll-back** (down → restore → up) if anything fails; the archive is removed on success. Recommended for the destructive matrix on a precious box; also exercises backup/restore end-to-end. | | `--keep` | Don't restore the original config (leave the box on the last scenario). | | `--out ` | Where to write the manifest and failure artifacts. | @@ -144,6 +145,47 @@ The runner exits non-zero if any assertion failed. --- +## One-command branch e2e (`e2e.sh`) + +`run.sh` assumes a stack is already deployed on the box. [`tests/integration/e2e.sh`](../tests/integration/e2e.sh) +is the wrapper that does the whole thing for a **branch** against the live `gouda` test bench — deploy, +borrow a real miner, run the matrix, and **put everything back** — in one command: + +```bash +tests/integration/e2e.sh [--mode targeted|check|matrix] [--workers N] [--miner HOST] +tests/integration/e2e.sh claude/my-feature # default: LEAN — dashboard + sync logic +tests/integration/e2e.sh claude/my-feature --mode check # non-destructive smoke (pure reads) +tests/integration/e2e.sh claude/my-feature --mode matrix # full config sweep (opt-in, pre-release) +``` + +What it does, then reverses on exit (even on failure / Ctrl-C — an `EXIT` trap): + +1. **Dedicated checkout.** Provisions `/srv/code/pithead-e2e` (clone-once, then `git fetch`) and checks + out `` there. The canonical `/srv/code/pithead` is the **baseline** and is never git-touched. + Because the Compose project name is pinned to `pithead`, the two checkouts drive the **same** + containers + the **same shared chains** — they're two code copies of one stack, run one at a time, so + borrow→test→restore is a fast code/image swap, never a re-sync. +2. **Seeds** the e2e checkout with the canonical `config.json`/`.env` (same wallet, secrets, onion keys, + and shared `monero/tari/p2pool` data dirs), so only the branch's *code* differs. +3. **Safety backup** (`pithead backup`) as the rollback anchor. +4. **Borrows a miner** (default `miner-0`): backs up its xmrig config and repoints it at gouda so the + matrix has a real worker mining through this stack (1 worker → run with `--workers 1`). +5. **Deploys** the branch (`pithead apply` — builds the branch's images) and runs `run.sh` **detached** + on the box (survives an SSH drop on a long matrix), streaming a heartbeat and the full log at the end. +6. **Restores** the miner's original pool config and the canonical baseline stack. The synced chains are + never touched (asserted post-restore). + +`--mode`: **`targeted`** (default, **lean**) validates the **dashboard + the sync logic against the +already-synced node** — `check` + `--lifecycle` (one controlled restart exercises the sync gate / +node-down failover) + `--auth-fail-closed`. No full config sweep, and **never a re-sync** — container +restarts reload the existing chain and re-confirm the tip in seconds. `check` is pure reads only. +`matrix` is the opt-in full destructive config sweep (lifecycle + fault-injection + auth-fail-closed, +`--safety-backup` auto-rollback) for a pre-release tier-4 gate. `--keep` leaves it deployed for +inspection (skips the restore). Requires SSH access to the gouda box and the miner; see the +[gouda testbench README](../tests/integration/gouda-testbench-README.md). + +--- + ## The config matrix Every axis below changes a real runtime path. The matrix covers the realistic combinations and diff --git a/docs/test-inventory.md b/docs/test-inventory.md index d58ff3a..17e157c 100644 --- a/docs/test-inventory.md +++ b/docs/test-inventory.md @@ -4,8 +4,8 @@ _Generated by `make test-inventory` ([`tests/inventory.sh`](../tests/inventory.s edit by hand** — re-run the target to refresh. See [Testing Strategy](testing-strategy.md) for how the tiers fit together._ -**Totals:** 500 dashboard unit tests · 12 contract tests · 31 frontend -tests · 41 `pithead` shell sections · 17 harness self-test sections · +**Totals:** 497 dashboard unit tests · 12 contract tests · 31 frontend +tests · 42 `pithead` shell sections · 17 harness self-test sections · 9 live config scenarios (17 axis values) · 6 mini-stack scenarios. > Counts are **test functions / named cases** (parametrized pytest cases expand to more at @@ -14,9 +14,9 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · | Tier | Suite | Cases | |---|---|---| -| 1 — Unit | dashboard pytest | 500 | +| 1 — Unit | dashboard pytest | 497 | | 1 — Unit | frontend (node --test) | 31 | -| 1 — Unit | `pithead` shell suite | 41 sections | +| 1 — Unit | `pithead` shell suite | 42 sections | | 1 — Unit | compose interpolation + hardening (#90) | 1 | | 2 — Contract | fake-daemon clients | 12 | | 3 — Mini-stack | docker control-plane scenarios | 6 | @@ -27,7 +27,7 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · ## Tier 1 — Unit & component -### Dashboard (pytest) — 500 tests +### Dashboard (pytest) — 497 tests #### tests/client/test_docker_control.py — 6 - test_tcp_scheme_rewritten_to_http @@ -349,7 +349,7 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · - test_down_clears_only_after_recovery_window - test_healthy_requires_stable_window_from_unknown -#### tests/service/test_storage_service.py — 33 +#### tests/service/test_storage_service.py — 30 - test_get_tiers - test_default_xvb_stats - test_partial_updates @@ -368,9 +368,6 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · - test_per_window_splits_survive_reload - test_healthy_by_default - test_unhealthy_after_write_error -- test_update_and_get_known_workers -- test_worker_without_ip_skipped -- test_none_list_is_noop - test_roundtrip - test_empty_snapshot_not_saved - test_load_missing_snapshot_returns_none @@ -380,9 +377,9 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · - test_corrupted_kv_value_skipped - test_history_timestamp_backfilled_from_iso_on_upgrade - test_per_window_columns_added_on_upgrade +- test_orphaned_workers_table_dropped_on_upgrade - test_history_older_than_retention_pruned_from_memory - test_old_history_pruned_from_db_when_cleanup_fires -- test_stale_workers_pruned_after_retention_window #### tests/service/test_update_checker.py — 16 - test_accepts_plain_and_v_prefixed @@ -610,7 +607,7 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · - bandBorderWidth: zero-height segments get no border, real ones keep full width - uptimeCell: online shows uptime, offline shows DOWN -### `pithead` shell suite (tests/stack/run.sh) — 41 sections +### `pithead` shell suite (tests/stack/run.sh) — 42 sections - unit: resolve_default - unit: assert_safe_dir - unit: is_public_ip classifier (#113) @@ -652,6 +649,7 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · - black-box: backup -> restore round-trip (#140) - black-box: reset-dashboard targets .env dirs, not config.json (#139) - black-box: reset-dashboard refuses to guess without .env dirs (#139) +- release: install bundle is free of macOS xattr pax headers (#252) ### Compose validation + hardening (tests/stack/test_compose.sh) - docker-compose.yml `${VAR}` interpolation resolves against a representative .env @@ -731,6 +729,7 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · - backup/rollback prerequisites present (writable backups/, tar) - both prune modes exercisable (live=$baseline_mode + supplied $opp_label chain at $opp_dir) - check +- compose guard refuses the unauthenticated proxy API (#153) - container up: $svc - dashboard /api/state reachable - dashboard bound to localhost only (Caddy fronts it) @@ -748,6 +747,8 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · - monerod reports synced (RPC) - monerod running-but-unhealthy - monerod runs Tor-only in steady state — proxy present (#183/#234) +- original PROXY_AUTH_TOKEN restored verbatim +- pithead up fails closed (non-zero exit) on an empty PROXY_AUTH_TOKEN - pool actually changed - pool type - prune axis: live FS is snapshot-capable ($fstype) — the $opp_label variant can be built cheaply @@ -758,6 +759,7 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · - secrets intact after restore - secrets preserved across pool change - snapshot-isolated $baseline_mode chain on a CoW FS ($same_dir, $sfs) — destructive scenarios needn't touch the live chain +- stack healthy again after token restore - stack is healthy (pithead status) - status OK after monerod recovery - status OK after node recovery @@ -797,5 +799,5 @@ tests · 41 `pithead` shell sections · 17 harness self-test sections · --- -_Grand total: **616** enumerated cases/sections across the four tiers (plus the live +_Grand total: **614** enumerated cases/sections across the four tiers (plus the live lifecycle and fault-injection phases, which are exercised on a real server)._ diff --git a/scripts/release.sh b/scripts/release.sh index 587c057..ae3411f 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -452,7 +452,19 @@ make_bundle() { printf 'Pithead %s — pinned install bundle (images pulled from %s, no local build).\n\nQuick start:\n 1. cp config.json.template config.json # then set your Monero + Tari payout addresses\n (more options: config.advanced.example.json)\n 2. ./pithead setup\n\nThere are no build contexts here, so pithead pulls the published %s images instead of building.\n' \ "$TAG" "$REGISTRY" "$TAG" > "$d/README.txt" if [ "$DRY_RUN" -eq 1 ]; then printf ' %s[dry-run]%s would tar -> %s\n' "$C_YELLOW" "$C_RESET" "$out"; return 0; fi - tar -czf "$out" -C "$WORKDIR" "pithead" + # --no-xattrs: we cut releases on macOS, where tar is bsdtar and stores each file's extended + # attributes (incl. macOS's com.apple.provenance) as LIBARCHIVE.xattr.* pax headers. GNU tar on + # a user's Linux box doesn't know that keyword and warns once per file on extract (#252). Stripping + # xattrs makes the bundle clean; the flag is a portable no-op on GNU tar (xattrs aren't stored by + # default), so release.sh stays correct if a release is ever cut on Linux. + tar --no-xattrs -czf "$out" -C "$WORKDIR" "pithead" + # Guard the fix (#252): the bundle must carry no extended-attribute pax headers + # (LIBARCHIVE.xattr.* from bsdtar / SCHILY.xattr.* from GNU tar) — those make GNU tar warn once + # per file on a Linux extract. pax headers store the keyword as plain text in the tar stream, so + # grepping the decompressed bytes detects them regardless of which tar built the archive. + if gzip -dc "$out" 2>/dev/null | grep -qa -e 'LIBARCHIVE.xattr' -e 'SCHILY.xattr'; then + die "Bundle $out carries xattr pax headers (#252) — GNU tar will warn on extract. Does this tar honor --no-xattrs?" + fi log "Wrote install bundle: $out" } diff --git a/tests/integration/e2e.sh b/tests/integration/e2e.sh new file mode 100755 index 0000000..1a73c9c --- /dev/null +++ b/tests/integration/e2e.sh @@ -0,0 +1,351 @@ +#!/usr/bin/env bash +# +# e2e.sh — one-command Tier-4 end-to-end run of a branch against the live `gouda` test bench. +# +# tests/integration/e2e.sh [options] +# tests/integration/e2e.sh claude/my-feature --mode matrix +# +# What it does, end to end, then puts everything back the way it found it: +# 1. Provisions a DEDICATED checkout on gouda (/srv/code/pithead-e2e) — the canonical +# /srv/code/pithead is the baseline and is never git-touched. +# 2. Fetches + checks out there, and seeds it with the canonical config.json/.env so +# it has the same wallet / secrets / onion keys / shared chains (just the branch's code). +# 3. Takes a `pithead backup` of the live stack (the rollback anchor). +# 4. Borrows a miner (default miner-0): backs up its xmrig config and repoints it at gouda so +# the live matrix has a real worker mining through this stack. +# 5. Deploys the branch (`pithead apply` — builds the branch's images) and runs the live harness +# (tests/integration/run.sh) DETACHED on the box so an SSH drop can't kill a long matrix. +# 6. ALWAYS restores: the miner's original pool config, and the canonical baseline stack — even +# on failure or Ctrl-C (an EXIT trap). The synced chains are never touched. +# +# The Compose project name is pinned to "pithead", so the e2e checkout and the canonical checkout +# drive the SAME containers + the SAME shared chains — they are two code copies of one stack, run +# one at a time, not two stacks. That's why borrow→test→restore is a code/image swap, not a re-sync. +# +# Requires: SSH access to the gouda box and the miner (keys, LAN reachable), and `jq` on both. +# See tests/integration/gouda-testbench-README.md and docs/integration-testing.md. + +set -uo pipefail + +# --- Config (override via env or flags) ------------------------------------- +GOUDA_HOST="${GOUDA_HOST:-gouda}" +MINER_HOST="${MINER_HOST:-miner-0}" +CANONICAL_DIR="${CANONICAL_DIR:-/srv/code/pithead}" +E2E_DIR="${E2E_DIR:-/srv/code/pithead-e2e}" +MINER_XMRIG_CONFIG="${MINER_XMRIG_CONFIG:-/opt/rigforge/data/worker/xmrig/build/config.json}" +GIT_REMOTE_URL="${GIT_REMOTE_URL:-https://github.com/p2pool-starter-stack/pithead.git}" +MODE="targeted" # targeted (default, lean) | check | matrix (full sweep, opt-in) +WORKERS=1 +BORROW_MINER=1 +KEEP=0 +BRANCH="" + +# --- Output ----------------------------------------------------------------- +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_RESET='\033[0m'; C_GREEN='\033[1;32m'; C_YELLOW='\033[1;33m'; C_RED='\033[1;31m'; C_BLUE='\033[1;34m'; C_DIM='\033[2m' +else + C_RESET=''; C_GREEN=''; C_YELLOW=''; C_RED=''; C_BLUE=''; C_DIM='' +fi +log() { printf '%b==>%b %s\n' "$C_BLUE" "$C_RESET" "$*"; } +ok() { printf '%b ✓%b %s\n' "$C_GREEN" "$C_RESET" "$*"; } +warn() { printf '%b !%b %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; } +step() { printf '%b → %s%b\n' "$C_DIM" "$*" "$C_RESET"; } +die() { printf '%b ✗%b %s\n' "$C_RED" "$C_RESET" "$*" >&2; exit 1; } + +usage() { + cat < [options] + +OPTIONS: + --mode targeted | check | matrix (default: targeted) + targeted — LEAN (default): validate the dashboard + the sync logic against + the EXISTING synced node — no full config sweep, no node re-sync. + = check + lifecycle (one controlled restart exercises the sync + gate / node-down failover) + --auth-fail-closed. + check — non-destructive: readiness + current live state only (pure reads). + matrix — the full destructive config matrix + lifecycle + fault-injection + + auth-fail-closed, with --safety-backup auto-rollback. Opt-in — + a full pre-release sweep; recreates containers across many configs. + --workers workers expected mining through the stack (default: 1 — the borrowed miner) + --miner SSH host of the miner to borrow (default: $MINER_HOST) + --no-miner don't borrow a miner (mining assertions will be skipped/limited) + --keep don't restore at the end (leave the branch deployed + miner repointed — debugging) + -h, --help this help + +ENV OVERRIDES: GOUDA_HOST, MINER_HOST, CANONICAL_DIR, E2E_DIR, MINER_XMRIG_CONFIG, GIT_REMOTE_URL + +EXAMPLES: + tests/integration/e2e.sh claude/my-feature # full matrix, borrow miner-0 + tests/integration/e2e.sh claude/my-feature --mode check # safe, non-destructive first run + tests/integration/e2e.sh main --mode targeted --keep # quick, leave it deployed to inspect +EOF +} + +# --- Arg parsing ------------------------------------------------------------ +while [ $# -gt 0 ]; do + case "$1" in + --mode) MODE="$2"; shift 2 ;; + --workers) WORKERS="$2"; shift 2 ;; + --miner) MINER_HOST="$2"; shift 2 ;; + --no-miner) BORROW_MINER=0; shift ;; + --keep) KEEP=1; shift ;; + -h|--help) usage; exit 0 ;; + -*) die "Unknown option: $1 (try --help)" ;; + *) [ -z "$BRANCH" ] && BRANCH="$1" || die "Unexpected arg: $1"; shift ;; + esac +done +[ -n "$BRANCH" ] || { usage; die "A is required."; } +case "$MODE" in check|targeted|matrix) ;; *) die "--mode must be check|targeted|matrix (got '$MODE')." ;; esac + +# --- SSH helpers ------------------------------------------------------------ +# Keepalives so a quiet (but live) connection isn't dropped; BatchMode so we never hang on a prompt. +SSH_OPTS=(-o BatchMode=yes -o ConnectTimeout=10 -o ServerAliveInterval=30 -o ServerAliveCountMax=8 -o StrictHostKeyChecking=accept-new) +# NOTE (gouda README): avoid literal shell parens '()' in remote command strings — they break the +# non-interactive remote shell. jq filters (quoted) are fine; shell subshells are not. +on_gouda() { ssh "${SSH_OPTS[@]}" "$GOUDA_HOST" "$1"; } +on_miner() { ssh "${SSH_OPTS[@]}" "$MINER_HOST" "$1"; } + +# State captured for the restore trap. +SAFETY_ARCHIVE="" +MINER_CFG_BACKUP="" +RESTORED=0 + +# --- Restore (runs on EXIT, even on failure / Ctrl-C) ----------------------- +restore_all() { + local rc=$? + [ "$RESTORED" = "1" ] && return + RESTORED=1 + if [ "$KEEP" = "1" ]; then + warn "--keep set: leaving the branch deployed on $GOUDA_HOST and the miner repointed." + warn " Re-run without --keep, or restore by hand: canonical=$CANONICAL_DIR, miner cfg backup=$MINER_CFG_BACKUP" + return + fi + echo "" + log "Restoring everything to the pre-run state…" + + # 1. Miner: put its original pool config back and nudge xmrig to reconnect. + if [ -n "$MINER_CFG_BACKUP" ]; then + step "restoring $MINER_HOST xmrig config from $MINER_CFG_BACKUP" + if on_miner "cp -a '$MINER_CFG_BACKUP' '$MINER_XMRIG_CONFIG' && chmod 600 '$MINER_XMRIG_CONFIG'"; then + miner_reload && ok "$MINER_HOST repointed to its original pool(s)" || warn "$MINER_HOST config restored; verify xmrig reconnected" + else + warn "FAILED to restore $MINER_HOST config — backup kept at $MINER_CFG_BACKUP" + fi + fi + + # 2. Stack: stop the branch (e2e checkout) and bring the canonical baseline back up healthy. + step "bringing the canonical baseline stack ($CANONICAL_DIR) back up" + on_gouda "cd '$E2E_DIR' && ./pithead down >/dev/null 2>&1 || true" + if on_gouda "cd '$CANONICAL_DIR' && ./pithead apply -y >/dev/null 2>&1 && ./pithead up >/dev/null 2>&1"; then + wait_gouda_healthy 300 && ok "canonical baseline stack healthy again" || warn "canonical stack came up but isn't reporting healthy yet — check 'pithead status' on $GOUDA_HOST" + else + warn "canonical 'pithead apply/up' returned non-zero — check $GOUDA_HOST by hand." + warn " Safety backup to roll back to: $SAFETY_ARCHIVE" + fi + + # 3. Chains sanity: they must be untouched (the whole point). + local sync; sync="$(on_gouda "curl -fsS --max-time 8 http://127.0.0.1:8000/api/state 2>/dev/null | jq -r '\"\(.sync.monero.state)/\(.sync.tari.state)\"' 2>/dev/null" || true)" + [ -n "$sync" ] && step "post-restore sync state (monero/tari): $sync" + + if [ "$rc" -eq 0 ]; then ok "restore complete."; else warn "restore complete (the run itself failed — see above)."; fi +} +trap restore_all EXIT INT TERM + +# --- Small waiters / helpers ------------------------------------------------ +wait_gouda_healthy() { # + local deadline=$(( $(date +%s) + ${1:-300} )) + while :; do + on_gouda "cd '$CANONICAL_DIR' && ./pithead status >/dev/null 2>&1" && return 0 + [ "$(date +%s)" -ge "$deadline" ] && return 1 + sleep 10 + done +} + +# After a deploy recreates monerod/tari, they reload the EXISTING synced chain and re-confirm their +# tip (seconds — NOT a re-sync). Wait for the dashboard to report both back to "done" before running +# the harness, so the readiness pre-check doesn't flap on the brief post-restart "loading". Doubles as +# a direct check that the sync-detection logic settles correctly against the reused chains. +wait_synced() { # + local deadline=$(( $(date +%s) + ${1:-300} )) st + while :; do + st="$(on_gouda "curl -fsS --max-time 8 http://127.0.0.1:8000/api/state 2>/dev/null | jq -r '\"\(.sync.monero.state)/\(.sync.tari.state)\"' 2>/dev/null" || true)" + [ "$st" = "done/done" ] && { ok "monero + tari re-confirmed synced ($st) — existing chains reused, no re-sync"; return 0; } + [ "$(date +%s)" -ge "$deadline" ] && { warn "sync panels still '$st' after $(( ${1:-300} ))s — the harness will wait further on real sync signals"; return 1; } + sleep 8 + done +} + +# Nudge the miner's xmrig to reload its (rewritten) config. xmrig watches its config file and +# reloads on change; the systemctl/SIGHUP fallbacks cover builds that don't. Whichever works, we +# verify by polling gouda for the worker — so the exact mechanism doesn't matter. +miner_reload() { + on_miner "sudo -n systemctl restart xmrig >/dev/null 2>&1 || systemctl --user restart xmrig >/dev/null 2>&1 || pkill -HUP -x xmrig >/dev/null 2>&1 || true" + return 0 +} + +# Poll gouda's dashboard for at least workers connected. +wait_workers() { # + local want="$1" deadline=$(( $(date +%s) + ${2:-180} )) got + while :; do + got="$(on_gouda "curl -fsS --max-time 8 http://127.0.0.1:8000/api/state 2>/dev/null | jq -r '.proxy_workers // 0' 2>/dev/null" || echo 0)" + [ -n "$got" ] && [ "$got" -ge "$want" ] 2>/dev/null && { ok "$got worker(s) mining through gouda"; return 0; } + [ "$(date +%s)" -ge "$deadline" ] && { warn "only $got worker(s) connected after $(( ${2:-180} ))s (wanted $want)"; return 1; } + sleep 8 + done +} + +# --- Phase 0: preflight ----------------------------------------------------- +preflight() { + log "Preflight" + on_gouda 'echo ok >/dev/null' || die "Cannot SSH to gouda host '$GOUDA_HOST'." + ok "SSH to $GOUDA_HOST" + on_gouda "test -x '$CANONICAL_DIR/pithead'" || die "No pithead at $CANONICAL_DIR on $GOUDA_HOST." + on_gouda "cd '$CANONICAL_DIR' && ./pithead status >/dev/null 2>&1" \ + && ok "canonical stack is currently healthy" \ + || warn "canonical stack is NOT healthy right now — continuing, but check the box." + if [ "$BORROW_MINER" = "1" ]; then + on_miner 'echo ok >/dev/null' || die "Cannot SSH to miner '$MINER_HOST' (use --no-miner to skip)." + on_miner "test -f '$MINER_XMRIG_CONFIG'" || die "No xmrig config at $MINER_XMRIG_CONFIG on $MINER_HOST." + ok "SSH to $MINER_HOST + xmrig config found" + fi +} + +# --- Phase 1: provision the dedicated e2e checkout + check out the branch --- +provision() { + log "Provisioning the dedicated e2e checkout ($E2E_DIR) on $GOUDA_HOST" + # Clone from the local canonical checkout (fast, no network) the first time, then point origin + # at GitHub so we can fetch arbitrary branches. + on_gouda " + set -e + if [ ! -d '$E2E_DIR/.git' ]; then + git clone --quiet '$CANONICAL_DIR' '$E2E_DIR' + git -C '$E2E_DIR' remote set-url origin '$GIT_REMOTE_URL' + fi + git -C '$E2E_DIR' remote set-url origin '$GIT_REMOTE_URL' + git -C '$E2E_DIR' fetch --quiet origin '$BRANCH' + git -C '$E2E_DIR' checkout -q -B '$BRANCH' FETCH_HEAD + git -C '$E2E_DIR' reset -q --hard FETCH_HEAD + " || die "Failed to provision/checkout '$BRANCH' in $E2E_DIR." + local head; head="$(on_gouda "git -C '$E2E_DIR' rev-parse --short HEAD")" + ok "e2e checkout on $BRANCH @ $head" + + step "seeding the e2e checkout with the canonical config.json/.env (same wallet/secrets/chains)" + on_gouda "cp -a '$CANONICAL_DIR/config.json' '$E2E_DIR/config.json' && cp -a '$CANONICAL_DIR/.env' '$E2E_DIR/.env'" \ + || die "Failed to seed config.json/.env into $E2E_DIR." + ok "config seeded (data dirs point at the shared chains)" +} + +# --- Phase 2: safety backup of the live stack ------------------------------- +backup_stack() { + log "Taking a safety backup of the live stack (the rollback anchor)" + on_gouda "cd '$CANONICAL_DIR' && ./pithead backup -y >/dev/null 2>&1" || die "pithead backup failed." + SAFETY_ARCHIVE="$(on_gouda "ls -t '$CANONICAL_DIR'/backups/pithead-backup-*.tar.gz 2>/dev/null | head -n1")" + [ -n "$SAFETY_ARCHIVE" ] || die "Backup ran but produced no archive." + ok "safety backup: $SAFETY_ARCHIVE" +} + +# --- Phase 3: borrow the miner ---------------------------------------------- +borrow_miner() { + [ "$BORROW_MINER" = "1" ] || { warn "--no-miner: not borrowing a miner."; return 0; } + log "Borrowing $MINER_HOST → pointing it at $GOUDA_HOST" + MINER_CFG_BACKUP="$MINER_XMRIG_CONFIG.e2e-orig.$(on_miner 'date +%Y%m%d-%H%M%S')" + on_miner "cp -a '$MINER_XMRIG_CONFIG' '$MINER_CFG_BACKUP'" || die "Failed to back up the miner config." + step "miner config backed up → $MINER_CFG_BACKUP" + # Reorder pools so the gouda pool is primary (index 0); keep the rest as failover. Non-destructive + # and fully reversible from the backup above. Assumes a gouda pool already exists in the config. + on_miner " + jq '.pools |= ([.[] | select(.url | ascii_downcase | contains(\"$GOUDA_HOST\"))] + [.[] | select(.url | ascii_downcase | contains(\"$GOUDA_HOST\") | not)])' \ + '$MINER_XMRIG_CONFIG' > '$MINER_XMRIG_CONFIG.e2e.tmp' \ + && mv '$MINER_XMRIG_CONFIG.e2e.tmp' '$MINER_XMRIG_CONFIG' && chmod 600 '$MINER_XMRIG_CONFIG' + " || die "Failed to repoint the miner config." + local primary; primary="$(on_miner "jq -r '.pools[0].url' '$MINER_XMRIG_CONFIG'")" + [ -n "$primary" ] && step "miner primary pool is now: $primary" + case "$primary" in *"$GOUDA_HOST"*) ;; *) warn "primary pool ($primary) doesn't look like gouda — does the miner config have a gouda pool?";; esac + miner_reload + wait_workers "$WORKERS" 180 || warn "proceeding, but the matrix's mining assertions may not pass with too few workers" +} + +# --- Phase 4: deploy the branch --------------------------------------------- +deploy_branch() { + log "Deploying the branch on $GOUDA_HOST (pithead apply — builds the branch's images)" + on_gouda "cd '$E2E_DIR' && ./pithead apply -y" || die "pithead apply failed in $E2E_DIR — branch did not deploy." + wait_gouda_healthy 300 || warn "stack applied but not yet healthy; the harness will wait on real readiness signals" + wait_synced 300 || true # let the recreated monerod/tari re-confirm their tip before the harness pre-check + ok "branch deployed; stack reconciled" +} + +# --- Phase 5: run the live harness (detached on the box) -------------------- +run_harness() { + local phases + case "$MODE" in + check) phases="--check" ;; + targeted) phases="--readiness --auth-fail-closed --lifecycle" ;; # --readiness/--check run first below + matrix) phases="--safety-backup --lifecycle --fault-injection --auth-fail-closed" ;; + esac + log "Running the live harness on $GOUDA_HOST (mode=$MODE, detached so an SSH drop can't kill it)" + step "phases: $phases (workers=$WORKERS)" + + # Push a tiny runner that captures the harness exit code into a done-marker, then nohup it. + local runner; runner="$(mktemp)" + cat > "$runner" <<'RUNNER' +#!/usr/bin/env bash +set -uo pipefail +dir="$1"; workers="$2"; shift 2 +mkdir -p "$dir/results" +bash "$dir/tests/integration/run.sh" --local --dir "$dir" --workers "$workers" "$@" \ + > "$dir/results/e2e-harness.log" 2>&1 +echo $? > "$dir/results/e2e-harness.done" +RUNNER + on_gouda "cat > '$E2E_DIR/.e2e-run.sh' && chmod +x '$E2E_DIR/.e2e-run.sh'" < "$runner" + rm -f "$runner" + + # For non-check modes, run the safe readiness + current-state assertions inline first (fast, + # gives early signal), then the destructive phases detached. + if [ "$MODE" != "check" ]; then + on_gouda "cd '$E2E_DIR' && bash tests/integration/run.sh --local --dir '$E2E_DIR' --readiness --check" \ + || warn "readiness/check reported issues (see above) — continuing to the destructive phases" + fi + + on_gouda "rm -f '$E2E_DIR/results/e2e-harness.done'; cd '$E2E_DIR' && nohup ./.e2e-run.sh '$E2E_DIR' '$WORKERS' $phases >/dev/null 2>&1 & echo launched" \ + || die "Failed to launch the harness." + + # Poll the done-marker, printing a heartbeat tail of the log. + local rc="" waited=0 + while :; do + if on_gouda "test -f '$E2E_DIR/results/e2e-harness.done'"; then + rc="$(on_gouda "cat '$E2E_DIR/results/e2e-harness.done'")"; break + fi + sleep 20; waited=$(( waited + 20 )) + step "harness running… ${waited}s — latest:" + on_gouda "tail -n 2 '$E2E_DIR/results/e2e-harness.log' 2>/dev/null" | sed 's/^/ /' || true + done + + echo "" + log "Harness finished (exit $rc). Full log:" + on_gouda "cat '$E2E_DIR/results/e2e-harness.log' 2>/dev/null" | sed 's/^/ /' + return "${rc:-1}" +} + +# --- Main ------------------------------------------------------------------- +main() { + log "Pithead e2e — branch '$BRANCH' → $GOUDA_HOST (mode=$MODE)$([ "$KEEP" = 1 ] && echo ' [--keep: no restore]')" + preflight + provision + backup_stack + borrow_miner + deploy_branch + local hrc=0 + run_harness || hrc=$? + # restore_all runs via the EXIT trap. + echo "" + if [ "$hrc" -eq 0 ]; then + ok "E2E PASSED for '$BRANCH' (mode=$MODE)." + else + die "E2E FAILED for '$BRANCH' (harness exit $hrc). Artifacts under $E2E_DIR/results on $GOUDA_HOST." + fi +} + +main diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 5d85c81..8aa1597 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -37,6 +37,7 @@ CHECK_ONLY=0 READINESS=0 RUN_LIFECYCLE=0 RUN_FAULTS=0 +RUN_AUTH_FAIL_CLOSED=0 SAFETY_BACKUP=0 SAFETY_ARCHIVE="" KEEP_STATE=0 @@ -92,6 +93,10 @@ MATRIX: (down / unhealthy / missing) and the failover→recovery cycle. DESTRUCTIVE-then-restored; local mode only. Slow (healthcheck + node-health debounce). + --auth-fail-closed also run the fail-closed auth phase (#153/#203): empty PROXY_AUTH_TOKEN + in .env and assert `pithead up` REFUSES to start (the live counterpart + to the tier-1 compose-config check), then restore the exact token and + recover. DESTRUCTIVE-then-restored; works in both ssh and local mode. --keep do NOT restore the original config.json at the end (leaves the box on the last scenario — useful for debugging) @@ -125,6 +130,7 @@ parse_args() { --full-data-dir) FULL_DATA_DIR="$2"; shift 2 ;; --lifecycle) RUN_LIFECYCLE=1; shift ;; --fault-injection) RUN_FAULTS=1; shift ;; + --auth-fail-closed) RUN_AUTH_FAIL_CLOSED=1; shift ;; --safety-backup) SAFETY_BACKUP=1; shift ;; --keep) KEEP_STATE=1; shift ;; --out) OUT_DIR="$2"; shift 2 ;; @@ -749,6 +755,56 @@ run_fault_injection() { wait_status_ok 240 || true } +# --- Fail-closed auth phase (--auth-fail-closed) ---------------------------- +# Live counterpart to the tier-1 compose-config assertion (tests/stack/test_compose.sh): prove the +# DEPLOY path — not just `docker compose config` — refuses to start an unauthenticated xmrig-proxy +# control API when PROXY_AUTH_TOKEN is empty (#153/#203). We empty the token in .env and run +# `pithead up`, which does NOT re-render .env (only setup/apply do — and apply would self-heal by +# regenerating the token), so the compose `:?` guard fires and the stack refuses to start. The +# `:?` error aborts `docker compose up` before it touches any container, so a running stack is left +# intact. We then restore the EXACT original token (a fresh one would break the run's end-of-run +# secret-fingerprint check) and bring the stack back healthy. DESTRUCTIVE-then-restored. + +# Rewrite PROXY_AUTH_TOKEN in .env in place, preserving line order. quote_arg makes the value safe +# for the remote shell; awk leaves every other line untouched. +_set_env_token() { # _set_env_token + rx "awk -v t=$(quote_arg "$1") '/^PROXY_AUTH_TOKEN=/{print \"PROXY_AUTH_TOKEN=\" t; next} {print}' .env > .env.itest && mv .env.itest .env" +} + +run_auth_fail_closed() { + # shellcheck disable=SC2034 # read by lib.sh:it_fail to label captured failures + IT_CURRENT_SCENARIO="auth-fail-closed" + echo "" + it_log "── fail-closed auth phase (#153/#203) ──────────────" + + local orig; orig="$(env_on_box PROXY_AUTH_TOKEN)" + if [ -z "$orig" ]; then + it_warn "skipping: PROXY_AUTH_TOKEN already empty on the box (run 'pithead setup'/'apply' first)" + return 0 + fi + + local fails_before="$IT_FAIL" + + # 1. Empty the token; `pithead up` must refuse to start AND name the documented fix. + it_step "emptying PROXY_AUTH_TOKEN in .env and running 'pithead up'…" + _set_env_token "" + local out rc + out="$(pithead up 2>&1)"; rc=$? + assert_ne "pithead up fails closed (non-zero exit) on an empty PROXY_AUTH_TOKEN" "$rc" "0" + assert_contains "compose guard refuses the unauthenticated proxy API (#153)" \ + "$out" "refusing to start an unauthenticated xmrig-proxy control API" + + # 2. Restore the EXACT original token (apply would mint a new one) and recover. + it_step "restoring the original PROXY_AUTH_TOKEN and recovering…" + _set_env_token "$orig" + assert_eq "original PROXY_AUTH_TOKEN restored verbatim" "$(env_on_box PROXY_AUTH_TOKEN)" "$orig" + pithead up >/dev/null 2>&1 || it_warn "recovery 'pithead up' returned non-zero; check the box." + wait_status_ok 240 || true + pithead status >/dev/null 2>&1; assert_rc "stack healthy again after token restore" "$?" "0" + + [ "$IT_FAIL" -gt "$fails_before" ] && capture_artifacts "auth-fail-closed" "$OUT_DIR" +} + # --- Safety backup / rollback (--safety-backup) ----------------------------- # Take a real `pithead backup` before the destructive scenarios so a failed run can be rolled # all the way back (config, .env, Caddyfile, Tor onion keys, dashboard DB). This both protects @@ -865,6 +921,7 @@ main() { [ "$RUN_LIFECYCLE" = "1" ] && run_lifecycle [ "$RUN_FAULTS" = "1" ] && run_fault_injection + [ "$RUN_AUTH_FAIL_CLOSED" = "1" ] && run_auth_fail_closed # Failure → roll the box back to the safety backup; success → leave it (restore_baseline # just puts config.json back to where we found it). Then drop the generated archive. diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 56def02..6dc9eeb 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -1244,6 +1244,29 @@ out="$(cd "$R" && SUDO_LOG=/dev/null PATH="$R/bin:$PATH" ./pithead reset-dashboa assert_rc "reset refuses with no data dirs in .env" "$rc" "1" assert_contains "reset refuse message" "$out" "refusing to guess" +echo "== release: install bundle is free of macOS xattr pax headers (#252) ==" +# Static guard: make_bundle must keep `--no-xattrs` AND the post-bundle xattr assertion, so the +# fix can't be silently reverted in a future edit. +REL="$ROOT/scripts/release.sh" +assert_contains "release.sh tars the bundle with --no-xattrs" \ + "$(grep -E '^[[:space:]]*tar .*--no-xattrs' "$REL" || true)" "--no-xattrs" +assert_contains "release.sh guards the bundle against xattr pax headers" \ + "$(cat "$REL")" "LIBARCHIVE.xattr" +# Functional: this platform's tar must actually honour --no-xattrs (the guard's whole premise). +# Tar a file that carries an xattr where we can set one (macOS: xattr -w / Linux: setfattr; a +# no-op elsewhere), and assert no LIBARCHIVE.xattr/SCHILY.xattr pax header survives — the exact +# check release.sh runs. Reproduces #252 on macOS; a clean no-op on GNU tar. +RELTMP="$(mktemp -d)"; mkdir -p "$RELTMP/pithead"; echo hi > "$RELTMP/pithead/f" +xattr -w com.test val "$RELTMP/pithead/f" 2>/dev/null \ + || setfattr -n user.test -v val "$RELTMP/pithead/f" 2>/dev/null || true +tar --no-xattrs -czf "$RELTMP/b.tar.gz" -C "$RELTMP" pithead 2>/dev/null +if gzip -dc "$RELTMP/b.tar.gz" 2>/dev/null | grep -qa -e 'LIBARCHIVE.xattr' -e 'SCHILY.xattr'; then + bad "tar --no-xattrs yields an xattr-free bundle" "xattr pax headers present despite --no-xattrs" +else + ok "tar --no-xattrs yields an xattr-free bundle" +fi +rm -rf "$RELTMP" + # --------------------------------------------------------------------------- echo "" printf 'pithead tests: \033[1;32m%d passed\033[0m, ' "$PASS" From fec2831017995d3343fd4d91f03c4bea23341cb2 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 16 Jun 2026 21:01:07 -0500 Subject: [PATCH 03/44] feat(p2pool): route outbound sidechain P2P through Tor by default (#165) (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit p2pool's --onion-address only ADVERTISES an onion for inbound peers; without a SOCKS proxy it dials outbound sidechain peers over clearnet, exposing the home IP (upstream docs/TOR.MD) — contradicting the stack's privacy default. Route those dials through the bundled Tor proxy by default. - New `p2pool.clearnet` config knob (default false ⇒ Tor). When false, pithead appends `--socks5 ${NETWORK_PREFIX}.25:9050 --socks5-proxy-type tor` to P2POOL_FLAGS via a small pure helper `p2pool_outbound_flags` (subnet-aware, honours a custom network.subnet / #180). `true` = direct clearnet for max yield (lower stale/uncle rate, larger peer set — worse on --mini/--nano). - Fix a latent compose bug surfaced by this change: `- ${P2POOL_FLAGS}` is a SINGLE command item, so Compose passes a multi-flag value as one mangled arg (verified via `docker compose config`). Move P2POOL_FLAGS to the p2pool container environment and word-split it in the entrypoint (`exec p2pool "$@" ${P2POOL_FLAGS:-}`) — also drops the stray empty arg for the main-pool case. Tests (tier 1, all green; full live regression on develop after Wave 2): - unit: p2pool_outbound_flags (Tor default / clearnet opt-out / custom prefix) - entrypoint word-splits P2POOL_FLAGS into distinct args (+ empty → no stray arg) - black-box: a full `pithead apply` renders Tor SOCKS flags by default - compose: P2POOL_FLAGS reaches the p2pool env intact (test_compose green) Docs: privacy.md egress table + hardening section, configuration.md reference. The Tor default is subject to the #256 benchmark (orphan/uncle-rate impact) confirming it before the v1.1 release; the opt-out is the release valve. Co-authored-by: Claude Opus 4.8 (1M context) --- build/p2pool/entrypoint.sh | 8 +++++++- config.advanced.example.json | 1 + docker-compose.yml | 6 +++++- docs/configuration.md | 1 + docs/privacy.md | 40 ++++++++++++++++-------------------- docs/test-inventory.md | 10 +++++---- pithead | 16 +++++++++++++++ tests/stack/run.sh | 33 ++++++++++++++++++++++++++++- 8 files changed, 86 insertions(+), 29 deletions(-) diff --git a/build/p2pool/entrypoint.sh b/build/p2pool/entrypoint.sh index f8376b6..bb738a9 100644 --- a/build/p2pool/entrypoint.sh +++ b/build/p2pool/entrypoint.sh @@ -3,4 +3,10 @@ set -euo pipefail # P2Pool launcher. (mDNS/.local resolution was removed — point p2pool at an IP or a # DNS-resolvable hostname; on a home LAN, use a DHCP reservation or static IP.) -exec p2pool "$@" +# +# Extra flags arrive via $P2POOL_FLAGS (rendered by pithead: the pool-type flag + the #165 Tor SOCKS +# routing — e.g. "--mini --socks5 172.28.0.25:9050 --socks5-proxy-type tor"). We word-split it HERE +# because Docker Compose passes a `- ${VAR}` command item as ONE argument (no word-splitting), which +# would hand p2pool a single mangled flag. An empty value expands to nothing (no stray empty arg). +# shellcheck disable=SC2086 # intentional word-splitting of the space-separated flag string +exec p2pool "$@" ${P2POOL_FLAGS:-} diff --git a/config.advanced.example.json b/config.advanced.example.json index 7a1d32c..df5a606 100644 --- a/config.advanced.example.json +++ b/config.advanced.example.json @@ -32,6 +32,7 @@ "pool": "main", "stratum_bind": "0.0.0.0", "stratum_password": "", + "clearnet": false, "data_dir": "auto" }, diff --git a/docker-compose.yml b/docker-compose.yml index e98b715..fe5c554 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -214,7 +214,11 @@ services: - 0.0.0.0:${P2POOL_PORT} - --data-api - /stats - - ${P2POOL_FLAGS} + # P2POOL_FLAGS (pool type + the #165 Tor SOCKS routing) is passed via the environment, not as a + # `- ${P2POOL_FLAGS}` command item: Compose passes such an item as a SINGLE argument (no + # word-splitting), which mangles a multi-flag value. The entrypoint word-splits this env var. + environment: + - P2POOL_FLAGS=${P2POOL_FLAGS:-} networks: mining_net: ipv4_address: ${NETWORK_PREFIX:-172.28.0}.28 diff --git a/docs/configuration.md b/docs/configuration.md index d9aad64..2d8d1b3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -85,6 +85,7 @@ plain HTTP, edit `config.json` and run `./pithead apply`. | `p2pool.pool` | `main` | Which P2Pool sidechain to mine: `main`, `mini`, or `nano`. **Lower hashrate? Pick `mini` (or `nano`).** They have a lower share difficulty, so you find shares far more often — smoother, more frequent PPLNS payouts instead of long dry spells. `main` suits high hashrate; `mini`/`nano` suit typical home rigs. | | `p2pool.stratum_bind` | `0.0.0.0` | Host **bind address** for the stratum port (`3333`) your rigs connect to — a single IPv4 address (maps to Docker's port-publish host IP), **not** a subnet/CIDR. Default `0.0.0.0` reaches the whole LAN out of the box; set a specific LAN IP (e.g. `192.168.1.10`) to limit it to one interface, or `127.0.0.1` to disable LAN access entirely. To restrict *which source subnet* may connect, use a firewall — see [Connecting Miners › Firewall](workers.md#firewall). | | `p2pool.stratum_password` | `""` _(off)_ | Optional **password every rig must send** to mine through the proxy — turns the otherwise-open `3333` port into authenticated stratum. `""` (default) = no password, any rig may connect. `"auto"` = generate a random secret once and keep it stable (shown after `setup`/`apply` and stored in `.env`); set it as each rig's stratum `pass`. Any literal string = use exactly that password. Only devices that know the secret can mine — which also shrinks the worker-name [SSRF](workers.md#authentication) surface. The password is sent **in cleartext** over stratum, so this is access control ("who may mine"), **not** encryption — pair it with `stratum_bind`/a firewall. See [Connecting Miners › Authentication](workers.md#authentication). | +| `p2pool.clearnet` | `false` | **Privacy-relevant, default off (Tor).** P2Pool's `--onion-address` only advertises an onion for *inbound* peers; its *outbound* sidechain dials need a SOCKS proxy or they go over **clearnet, exposing your home IP**. Default (`false`) routes those dials through the bundled Tor proxy (`--socks5 :9050 --socks5-proxy-type tor`). Set `true` to dial peers **directly over clearnet** for maximum yield — Tor latency raises the stale/uncle-share rate and onion-only shrinks the peer set, both worse on `--mini`/`--nano`, so a high-variance small rig may prefer clearnet (at the cost of IP exposure). Full threat model: [Privacy › P2Pool outbound peers](privacy.md#p2pool-outbound-peers-165--tor-by-default). | | `p2pool.data_dir` | `auto` | Where P2Pool data lives on the host. `auto` = `./data/p2pool`. | | `proxy.donate_level` | `0` | xmrig-proxy's built-in **dev-fee donation** to the xmrig developers, as a percentage of submitted hashrate. Defaults to **`0` — no donation** (xmrig-proxy's own compiled-in default, which the stack now renders explicitly so it's visible rather than invisible). Set an integer `1`–`99` to donate that share to the xmrig devs if you want to support them. **This is not the XvB donation** — that's the separate `xvb.*` mechanism the optimizer steers, never this dev fee. | | `xvb.enabled` | `true` | Enable XMRvsBeast bonus-round hashrate switching. | diff --git a/docs/privacy.md b/docs/privacy.md index 9814eba..33d0e4f 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -7,11 +7,11 @@ goes, whether it's routed over Tor, whether it's on by default, and how to lock possible (or trades away yield), the connection must be **off or Tor-routed by default**, have a **documented toggle**, and be **listed here**. -Honesty first: the stack is **Tor-first, not yet Tor-only**. Monero and Tari — including the DNS -lookups they used to leak — are fully Tor-routed. But as of v1.0 **two outbound yield paths still -use clearnet** (P2Pool's outbound peers and XvB donation mining), and **install/build reveals your -IP once**. Those are called out below with how to mitigate each today, and both clearnet yield paths -are slated to move to Tor-by-default (with an opt-out) in v1.1. +Honesty first: the stack is **Tor-first**. Monero and Tari — including the DNS lookups they used to +leak — are fully Tor-routed, and as of v1.1 **P2Pool's outbound sidechain peers route over Tor by +default** too (#165, with a `p2pool.clearnet` opt-out). The remaining clearnet yield path is **XvB +donation mining** (Tor-by-default tracked in #166), plus **install/build reveals your IP once**. +Those are called out below with how to mitigate each. There is also one **opt-in** that deliberately moves a node onto clearnet: an [optional clearnet initial sync](#optional-clearnet-initial-sync-off-by-default) that lets Monero @@ -49,7 +49,7 @@ What the running stack sends to the internet, connection by connection. | **Tari** P2P | Tari network | — | ✅ Tor (`type = "tor"`) | on | Tor by default; can opt into clearnet (TCP) for the initial sync only ([#183](#optional-clearnet-initial-sync-off-by-default)) | | **Tari** DNS seeds + Pulse (`seeds.tari.com`, `checkpoints.tari.com`) | DNS resolvers | "this IP runs Tari" | ✅ **closed** — `dns_seeds = []`, onion `peer_seeds`, resolver pointed at a dead address (#162) | n/a | clearnet sync ([#183](#optional-clearnet-initial-sync-off-by-default)) re-enables the `seeds.tari.com` DNS seed for the sync window | | **P2Pool** inbound peers | reach you via onion | — | ✅ onion hidden service | on | — | -| **P2Pool** outbound sidechain peers | clearnet P2Pool peers | **your real home IP** | ❌ **clearnet** | **on** | ⏳ Tor-by-default in v1.1 (#165). Harden now → [below](#hardening-the-clearnet-paths) | +| **P2Pool** outbound sidechain peers | P2Pool sidechain peers, via Tor | — | ✅ **Tor** (`--socks5`, proxy-type `tor`) by default (#165) | on | opt out with `p2pool.clearnet: true` (exposes your IP for max yield) → [below](#hardening-the-clearnet-paths) | | Dashboard **XvB stats** fetch | `xmrvsbeast.com` | your Monero **wallet** (no longer your IP) | ✅ Tor (`socks5h`, #163) | on, only if XvB enabled | `XVB_ENABLED=false` stops it | | **XvB donation mining** (only while donating) | `na.xmrvsbeast.com:4247` | **your real home IP** | ❌ **clearnet** | on while donating | ⏳ Tor-by-default in v1.1 (#166). Disable XvB to stop it | | Dashboard **update check** (#224) | `api.github.com` | nothing about you — GitHub sees a **Tor exit**, not your IP | ✅ Tor (`socks5h`) | **on** | `dashboard.check_for_updates: false` to opt out; cached, fails silently offline | @@ -83,25 +83,21 @@ the risk is the **one-time IP disclosure**, not tampering. ## Hardening the clearnet paths -Two outbound yield paths use clearnet in v1.0. Here's how to close each one **today**; v1.1 will make -the Tor routing the default. +The **XvB donation** path still uses clearnet by default (Tor-by-default tracked in #166) — close it +today by disabling XvB, or see below. **P2Pool's outbound peers now route over Tor by default** as of +v1.1 (#165), documented here for completeness. -### P2Pool outbound peers (#165) +### P2Pool outbound peers (#165) — ✅ Tor by default -P2Pool advertises its onion for *inbound* peers but dials *outbound* sidechain peers over clearnet, -exposing your IP to the P2Pool network. **v1.0 has no config knob for this yet** (a Tor-by-default -toggle is tracked in #165); to route those dials through Tor today, hand-edit P2Pool's `command:` in -`docker-compose.yml`, adding the SOCKS flags just before `${P2POOL_FLAGS}`: +P2Pool advertises its onion for *inbound* peers but, without a SOCKS proxy, would dial *outbound* +sidechain peers over clearnet, exposing your IP to the P2Pool network. As of v1.1 `pithead` injects +`--socks5 :9050 --socks5-proxy-type tor` into P2Pool's `command:` **by default**, so outbound +dials go through Tor — no action needed. -```yaml -# docker-compose.yml — the p2pool service `command:` - - --socks5 - - 172.28.0.25:9050 - - --socks5-proxy-type - - tor - # add '--no-clearnet-p2p' (its own line) to refuse clearnet peers entirely (onion-only) - - ${P2POOL_FLAGS} -``` +**Opt out** for maximum yield (lower stale/uncle rate + a larger peer set, at the cost of exposing +your IP — worse on `--mini`/`--nano`): set `p2pool.clearnet: true` in `config.json` and re-run +`pithead apply`. For the strictest posture — refuse clearnet peers entirely (onion-only) — P2Pool +also has a `--no-clearnet-p2p` flag, not yet wired to its own config knob. Then `docker compose up -d p2pool`. **Trade-off:** Tor adds latency to share propagation, which can raise your orphan/uncle rate (most noticeable on mini/nano), and `--no-clearnet-p2p` shrinks your diff --git a/docs/test-inventory.md b/docs/test-inventory.md index 17e157c..8e8fb7d 100644 --- a/docs/test-inventory.md +++ b/docs/test-inventory.md @@ -5,7 +5,7 @@ edit by hand** — re-run the target to refresh. See [Testing Strategy](testing- how the tiers fit together._ **Totals:** 497 dashboard unit tests · 12 contract tests · 31 frontend -tests · 42 `pithead` shell sections · 17 harness self-test sections · +tests · 44 `pithead` shell sections · 17 harness self-test sections · 9 live config scenarios (17 axis values) · 6 mini-stack scenarios. > Counts are **test functions / named cases** (parametrized pytest cases expand to more at @@ -16,7 +16,7 @@ tests · 42 `pithead` shell sections · 17 harness self-test sections · |---|---|---| | 1 — Unit | dashboard pytest | 497 | | 1 — Unit | frontend (node --test) | 31 | -| 1 — Unit | `pithead` shell suite | 42 sections | +| 1 — Unit | `pithead` shell suite | 44 sections | | 1 — Unit | compose interpolation + hardening (#90) | 1 | | 2 — Contract | fake-daemon clients | 12 | | 3 — Mini-stack | docker control-plane scenarios | 6 | @@ -607,7 +607,7 @@ tests · 42 `pithead` shell sections · 17 harness self-test sections · - bandBorderWidth: zero-height segments get no border, real ones keep full width - uptimeCell: online shows uptime, offline shows DOWN -### `pithead` shell suite (tests/stack/run.sh) — 42 sections +### `pithead` shell suite (tests/stack/run.sh) — 44 sections - unit: resolve_default - unit: assert_safe_dir - unit: is_public_ip classifier (#113) @@ -616,6 +616,8 @@ tests · 42 `pithead` shell sections · 17 harness self-test sections · - unit: docker_boot_enabled (#137) - unit: is_valid_host (#130) - unit: describe_change +- unit: p2pool_outbound_flags — Tor-by-default for outbound P2P (#165) +- p2pool entrypoint word-splits P2POOL_FLAGS into separate args (#165) - unit: clearnet initial sync helpers (#183) - unit: clock_sync_status (mining is time-sensitive) - unit: monero_address_type — p2pool needs a PRIMARY address (#250) @@ -799,5 +801,5 @@ tests · 42 `pithead` shell sections · 17 harness self-test sections · --- -_Grand total: **614** enumerated cases/sections across the four tiers (plus the live +_Grand total: **616** enumerated cases/sections across the four tiers (plus the live lifecycle and fault-injection phases, which are exercised on a real server)._ diff --git a/pithead b/pithead index 0d5712d..d54bce7 100755 --- a/pithead +++ b/pithead @@ -933,6 +933,15 @@ normalize_bool() { esac } +# p2pool outbound SOCKS flags (#165). p2pool's --onion-address only advertises an onion for INBOUND; +# without --socks5 it dials outbound sidechain peers over clearnet, exposing the home IP. Returns the +# Tor SOCKS flags by default; empty when the operator opts into clearnet (p2pool.clearnet=true) for +# max yield. Pure (args only: ) so it unit-tests in isolation. +p2pool_outbound_flags() { + [ "$(normalize_bool "${1:-}")" = "true" ] && return 0 + printf -- '--socks5 %s.25:9050 --socks5-proxy-type tor' "${2:-172.28.0}" +} + # Keys whose value differs between two env files (added, removed, or changed), one per line. env_changed_keys() { comm -3 <(sort "$1") <(sort "$2") 2>/dev/null \ @@ -1756,6 +1765,13 @@ render_env() { p2pool_flags="--nano"; p2pool_port="37890" fi + # Route outbound sidechain P2P through Tor by default (#165); p2pool.clearnet opts out for yield. + # See p2pool_outbound_flags + docs/privacy.md. Uses the configured subnet so a custom NETWORK_PREFIX + # (#180) still points at the Tor container (.25). + local p2pool_socks + p2pool_socks=$(p2pool_outbound_flags "$(jq -r '.p2pool.clearnet // false' "$CONFIG_FILE")" "$NETWORK_PREFIX") + [ -n "$p2pool_socks" ] && p2pool_flags="${p2pool_flags:+$p2pool_flags }$p2pool_socks" + # XvB config (accepts legacy xmrig_proxy.* keys) local xvb_enabled xvb_url xvb_donor xvb_donation_level xvb_enabled=$(jq -r 'if .xvb.enabled != null then .xvb.enabled elif .xmrig_proxy.enabled != null then .xmrig_proxy.enabled else "true" end' "$CONFIG_FILE") diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 6dc9eeb..a6a7a13 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -158,6 +158,34 @@ assert_contains "monero clearnet disable is DEST" "$(run_sourced "$SANDBOX" desc assert_contains "tari clearnet enable is DEST" "$(run_sourced "$SANDBOX" describe_change TARI_CLEARNET_SYNC false true)" "DEST" assert_contains "tari clearnet enable warns exposure" "$(run_sourced "$SANDBOX" describe_change TARI_CLEARNET_SYNC false true)" "CLEARNET" +echo "== unit: p2pool_outbound_flags — Tor-by-default for outbound P2P (#165) ==" +# Default (clearnet absent/false) routes outbound sidechain dials through the bundled Tor SOCKS proxy. +assert_eq "default → Tor SOCKS flags" "$(run_sourced "$SANDBOX" p2pool_outbound_flags false 172.28.0)" "--socks5 172.28.0.25:9050 --socks5-proxy-type tor" +assert_eq "empty arg → Tor (default off)" "$(run_sourced "$SANDBOX" p2pool_outbound_flags '' 172.28.0)" "--socks5 172.28.0.25:9050 --socks5-proxy-type tor" +# clearnet opt-out → no SOCKS flags (p2pool dials peers directly, IP exposed). +assert_eq "clearnet=true → no SOCKS flags" "$(run_sourced "$SANDBOX" p2pool_outbound_flags true 172.28.0)" "" +assert_eq "clearnet=yes (any truthy) → no SOCKS flags" "$(run_sourced "$SANDBOX" p2pool_outbound_flags yes 172.28.0)" "" +# Honours a custom bridge subnet (#180) — the Tor container is always .25 of the configured /24. +assert_contains "custom NETWORK_PREFIX points at its Tor (.25)" "$(run_sourced "$SANDBOX" p2pool_outbound_flags false 172.30.5)" "172.30.5.25:9050" + +echo "== p2pool entrypoint word-splits P2POOL_FLAGS into separate args (#165) ==" +# Compose passes P2POOL_FLAGS as ONE env var (a `- ${VAR}` command item is a single arg, unsplit); +# the entrypoint must word-split it so a multi-flag value reaches p2pool as distinct args. A stub +# p2pool on PATH captures what it's exec'd with. +PE="$SANDBOX/p2pool-ep/bin"; mkdir -p "$PE" +cat > "$PE/p2pool" <<'STUB' +#!/usr/bin/env bash +printf 'ARGC=%s\n' "$#"; for a in "$@"; do printf 'ARG=[%s]\n' "$a"; done +STUB +chmod +x "$PE/p2pool" +ep_out=$(PATH="$PE:$PATH" P2POOL_FLAGS="--mini --socks5 172.28.0.25:9050 --socks5-proxy-type tor" bash "$ROOT/build/p2pool/entrypoint.sh" --stratum 0.0.0.0:3333 2>&1) +assert_contains "--socks5 is its own arg" "$ep_out" "ARG=[--socks5]" +assert_contains "socks address is its own arg" "$ep_out" "ARG=[172.28.0.25:9050]" +assert_contains "proxy-type value split out" "$ep_out" "ARG=[tor]" +assert_contains "2 fixed + 5 flag tokens = ARGC 7" "$ep_out" "ARGC=7" +ep_empty=$(PATH="$PE:$PATH" P2POOL_FLAGS="" bash "$ROOT/build/p2pool/entrypoint.sh" --stratum 0.0.0.0:3333 2>&1) +assert_contains "empty P2POOL_FLAGS → no stray empty arg (ARGC=2)" "$ep_empty" "ARGC=2" + echo "== unit: clearnet initial sync helpers (#183) ==" # normalize_bool: 1/true/yes/on (any case) => true; everything else (incl. empty) => false, matching # the dashboard's MONERO_PRUNE truthiness so a config bool reads the same on both sides. @@ -774,7 +802,10 @@ seed_env printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","node_password":"p"}, "tari":{"wallet_address":"T"}, "p2pool":{"pool":"mini"}, "dashboard":{"secure":false,"host":"box.lan"} }\n' "$WALLET" > "$V/config.json" DOCKER_LOG="$V/docker.log"; : > "$DOCKER_LOG" out="$(cd "$V" && DOCKER_LOG="$DOCKER_LOG" PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" -assert_eq "pool flag propagated" "$(run_sourced "$V" env_get_file "$V/.env" P2POOL_FLAGS)" "--mini" +assert_contains "pool flag propagated" "$(run_sourced "$V" env_get_file "$V/.env" P2POOL_FLAGS)" "--mini" +# Default routes outbound sidechain P2P through Tor (#165): the rendered P2POOL_FLAGS carries the +# pool flag AND the Tor SOCKS flags (no p2pool.clearnet set in this config). +assert_contains "outbound P2P via Tor by default (#165)" "$(run_sourced "$V" env_get_file "$V/.env" P2POOL_FLAGS)" "--socks5 172.28.0.25:9050 --socks5-proxy-type tor" assert_eq "stratum_bind default" "$(run_sourced "$V" env_get_file "$V/.env" STRATUM_BIND)" "0.0.0.0" assert_eq "token preserved" "$(run_sourced "$V" env_get_file "$V/.env" PROXY_AUTH_TOKEN)" "ORIGINALTOKEN" assert_eq "onion preserved" "$(run_sourced "$V" env_get_file "$V/.env" P2POOL_ONION_ADDRESS)" "p2pa.onion" From 851e8a97198466c1736c3a049bbe5d8d2c4d38c3 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 16 Jun 2026 21:31:46 -0500 Subject: [PATCH 04/44] feat(xvb): route XvB donation mining through Tor by default (#166) (#269) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While donating, algo_service points xmrig-proxy at na.xmrvsbeast.com:4247 — which would otherwise expose the home IP to XvB. Route that connection through Tor by default, mirroring the #163 stats-fetch fix for the mining path. - algo_service.switch_miners now sets a per-pool "socks5" on the ENABLED XvB pool object (xmrig-proxy resolves that pool's DNS proxy-side, like socks5h). The local p2pool pool NEVER gets socks5 — it dials direct on the bridge. - New xvb.tor knob (default true ⇒ Tor). false dials the XvB pool direct for max yield (stratum-over-Tor latency raises rejects, scales with hashrate). - XVB_TOR_SOCKS5 is derived from the existing XVB_TOR_PROXY (scheme stripped → bare host:port for xmrig), so a custom subnet (#180) carries through. - xmrig-proxy dev-fee --donate-level is already pinned 0 by default (#173); documented that >0 would bypass the XvB socks5 → clearnet. Tests: test_algo_service — XvB pool carries socks5 by default while the local pool doesn't (both XVB and P2POOL modes), and xvb.tor:false → no socks5 anywhere. Dashboard pytest 94% coverage; pithead suite + test_compose green; shellcheck clean. Docs: privacy.md (now "Tor-by-default for all runtime egress"), configuration.md. Default subject to the #256 benchmark before the v1.1 release; closes #160's flips. Co-authored-by: Claude Opus 4.8 (1M context) --- .../mining_dashboard/config/config.py | 9 ++++ .../mining_dashboard/service/algo_service.py | 21 +++++---- .../tests/service/test_algo_service.py | 30 ++++++++++++ config.advanced.example.json | 3 +- docker-compose.yml | 2 + docs/configuration.md | 1 + docs/privacy.md | 46 ++++++++++--------- docs/test-inventory.md | 13 ++++-- pithead | 5 +- 9 files changed, 94 insertions(+), 36 deletions(-) diff --git a/build/dashboard/mining_dashboard/config/config.py b/build/dashboard/mining_dashboard/config/config.py index e43ce65..6c549bb 100644 --- a/build/dashboard/mining_dashboard/config/config.py +++ b/build/dashboard/mining_dashboard/config/config.py @@ -67,6 +67,15 @@ # host-networked dashboard reaches the bridge container's Tor SOCKS at 172.28.0.25:9050. XVB_TOR_PROXY = os.environ.get("XVB_TOR_PROXY", "socks5h://172.28.0.25:9050") +# Route XvB DONATION MINING through Tor by default too (#166), not just the stats fetch (#163): while +# donating, the proxy connects to na.xmrvsbeast.com, which would otherwise expose the host IP. The +# algo controller sets this host:port as the XvB pool's per-pool `socks5` field (xmrig-proxy resolves +# that pool's DNS proxy-side). `xvb.tor: false` opts out (direct, for max yield). The host:port is +# derived from XVB_TOR_PROXY (so a custom subnet, #180, carries through) with the scheme stripped — +# xmrig wants a bare host:port. +XVB_TOR_ENABLED = os.environ.get("XVB_TOR_ENABLED", "true").lower() == "true" +XVB_TOR_SOCKS5 = XVB_TOR_PROXY.split("://", 1)[-1] + # New-release check (#224, config.json: dashboard.check_for_updates). Default ON — the dashboard asks # GitHub for the latest release and shows a header badge linking to it if it's newer than the running # version. Notify-only (no upgrade — that's #59). The check is routed over the same bridge Tor SOCKS diff --git a/build/dashboard/mining_dashboard/service/algo_service.py b/build/dashboard/mining_dashboard/service/algo_service.py index 15640e6..81551f4 100644 --- a/build/dashboard/mining_dashboard/service/algo_service.py +++ b/build/dashboard/mining_dashboard/service/algo_service.py @@ -8,6 +8,8 @@ XVB_DONOR_ID, P2POOL_URL, XVB_POOL_URL, + XVB_TOR_ENABLED, + XVB_TOR_SOCKS5, XVB_MIN_TIME_SEND_MS, ENABLE_XVB, XVB_DONATION_LEVEL, @@ -44,16 +46,19 @@ async def switch_miners(self, mode, state_label=None): """ Configures the upstream pool priority for the XMRig Proxy. """ + # The local p2pool pool dials direct (it's on the bridge). The XvB pool routes through Tor by + # default (#166) — its per-pool `socks5` makes the proxy reach na.xmrvsbeast.com via Tor + # (DNS resolved proxy-side), so donation mining doesn't expose the home IP. `xvb.tor: false` + # opts out. Only the XvB pool gets `socks5`; the local pool never does. + p2pool_pool = {"url": P2POOL_URL, "user": MONERO_WALLET_ADDRESS, "pass": "x", "coin": "monero"} + xvb_pool = {"url": XVB_POOL_URL, "user": XVB_DONOR_ID, "pass": "x", "coin": "monero"} + if XVB_TOR_ENABLED: + xvb_pool["socks5"] = XVB_TOR_SOCKS5 + if mode == "P2POOL": - pools = [ - {"url": P2POOL_URL, "user": MONERO_WALLET_ADDRESS, "pass": "x", "enabled": True, "coin": "monero"}, - {"url": XVB_POOL_URL, "user": XVB_DONOR_ID, "pass": "x", "enabled": False, "coin": "monero"} - ] + pools = [{**p2pool_pool, "enabled": True}, {**xvb_pool, "enabled": False}] else: - pools = [ - {"url": XVB_POOL_URL, "user": XVB_DONOR_ID, "pass": "x", "enabled": True, "coin": "monero"}, - {"url": P2POOL_URL, "user": MONERO_WALLET_ADDRESS, "pass": "x", "enabled": False, "coin": "monero"} - ] + pools = [{**xvb_pool, "enabled": True}, {**p2pool_pool, "enabled": False}] try: # Fetch current full configuration to preserve other settings diff --git a/build/dashboard/tests/service/test_algo_service.py b/build/dashboard/tests/service/test_algo_service.py index 408990d..eada5f4 100644 --- a/build/dashboard/tests/service/test_algo_service.py +++ b/build/dashboard/tests/service/test_algo_service.py @@ -194,6 +194,36 @@ async def test_switch_aborts_on_bad_config(self, algo): await algo.switch_miners("P2POOL") algo.proxy_client.update_config.assert_not_called() + async def test_xvb_pool_routed_over_tor_by_default(self, algo): + # #166: the XvB pool carries a per-pool socks5 (Tor); the local p2pool pool never does. + algo.proxy_client.get_config.return_value = {"pools": []} + with patch("mining_dashboard.service.algo_service.XVB_TOR_ENABLED", True), \ + patch("mining_dashboard.service.algo_service.XVB_TOR_SOCKS5", "172.28.0.25:9050"): + await algo.switch_miners("XVB") + pools = algo.proxy_client.update_config.call_args[0][0]["pools"] + xvb, local = pools[0], pools[1] # enabled XvB pool first in XVB mode + assert xvb["enabled"] is True and xvb["socks5"] == "172.28.0.25:9050" + assert "socks5" not in local # local p2pool dials direct, never via Tor + + async def test_local_pool_never_routed_over_tor(self, algo): + # Even in P2POOL mode (XvB pool present but disabled), only the XvB pool carries socks5. + algo.proxy_client.get_config.return_value = {"pools": []} + with patch("mining_dashboard.service.algo_service.XVB_TOR_ENABLED", True), \ + patch("mining_dashboard.service.algo_service.XVB_TOR_SOCKS5", "172.28.0.25:9050"): + await algo.switch_miners("P2POOL") + pools = algo.proxy_client.update_config.call_args[0][0]["pools"] + local, xvb = pools[0], pools[1] # enabled local pool first in P2POOL mode + assert "socks5" not in local + assert xvb["socks5"] == "172.28.0.25:9050" + + async def test_xvb_tor_opt_out_no_socks5(self, algo): + # xvb.tor:false → XVB_TOR_ENABLED False → no pool carries socks5 (direct clearnet, max yield). + algo.proxy_client.get_config.return_value = {"pools": []} + with patch("mining_dashboard.service.algo_service.XVB_TOR_ENABLED", False): + await algo.switch_miners("XVB") + pools = algo.proxy_client.update_config.call_args[0][0]["pools"] + assert all("socks5" not in p for p in pools) + class TestSmartSleep: LATEST = {"total_live_h15": 15_000, "total_live_h10": 15_000, "pool": {}, "shares": []} diff --git a/config.advanced.example.json b/config.advanced.example.json index df5a606..76708b2 100644 --- a/config.advanced.example.json +++ b/config.advanced.example.json @@ -44,7 +44,8 @@ "enabled": true, "url": "na.xmrvsbeast.com:4247", "donor_id": "auto", - "donation_level": "auto" + "donation_level": "auto", + "tor": true }, "tor": { diff --git a/docker-compose.yml b/docker-compose.yml index fe5c554..e9cf0fb 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -367,6 +367,8 @@ services: - XVB_ENABLED=${XVB_ENABLED} - XVB_POOL_URL=${XVB_POOL_URL} - XVB_DONOR_ID=${XVB_DONOR_ID} + # Route XvB donation mining over Tor by default (#166); xvb.tor:false opts out. + - XVB_TOR_ENABLED=${XVB_TOR_ENABLED:-true} - XVB_DONATION_LEVEL=${XVB_DONATION_LEVEL:-auto} - PROXY_HOST=${NETWORK_PREFIX:-172.28.0}.29 - PROXY_API_PORT=${PROXY_API_PORT} diff --git a/docs/configuration.md b/docs/configuration.md index 2d8d1b3..f74ddf2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -92,6 +92,7 @@ plain HTTP, edit `config.json` and run `./pithead apply`. | `xvb.url` | `na.xmrvsbeast.com:4247` | XMRvsBeast pool endpoint. | | `xvb.donor_id` | `auto` | XvB donor id. `auto` = the first 8 characters of your Monero address. | | `xvb.donation_level` | `auto` | Donation tier to target: `auto` (the highest tier your hashrate can sustain) or a specific tier — **`donor` (1 kH/s) / `vip` (10 kH/s) / `whale` (100 kH/s) / `mega` (1 MH/s)** — where the figure is the donation hashrate you must hold on both your 1h and 24h averages. A specific tier is honored even if your hashrate can't hold it — the dashboard shows a warning badge in that case. (The `vip` tier is a donation level, not the dashboard's separate **Raffle Eligible** status; see [Architecture › Algorithmic switching](architecture.md#algorithmic-switching).) | +| `xvb.tor` | `true` | **Privacy-relevant, default on (Tor).** While donating, the proxy connects to `na.xmrvsbeast.com` — which would otherwise **expose your home IP** to XvB. Default (`true`) routes that connection through the bundled Tor proxy (a per-pool `socks5` on the XvB pool; DNS resolved proxy-side). Set `false` to dial **direct over clearnet** for maximum yield — stratum-over-Tor adds latency that can raise rejected shares (scales with hashrate). Only the XvB pool is affected; your local p2pool stratum is unaffected. Full threat model: [Privacy › XvB donation mining](privacy.md#xvb-donation-mining-166--tor-by-default). | | `tor.data_dir` | `auto` | Where Tor's state (including onion keys) lives. `auto` = `./data/tor`. | | `dashboard.secure` | `true` | `true` serves the dashboard over HTTPS (Caddy `tls internal`); `false` uses plain HTTP. | | `dashboard.host` | `auto` | Hostname you use to reach the dashboard. `auto` = this machine's hostname. | diff --git a/docs/privacy.md b/docs/privacy.md index 33d0e4f..059c9f2 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -7,11 +7,12 @@ goes, whether it's routed over Tor, whether it's on by default, and how to lock possible (or trades away yield), the connection must be **off or Tor-routed by default**, have a **documented toggle**, and be **listed here**. -Honesty first: the stack is **Tor-first**. Monero and Tari — including the DNS lookups they used to -leak — are fully Tor-routed, and as of v1.1 **P2Pool's outbound sidechain peers route over Tor by -default** too (#165, with a `p2pool.clearnet` opt-out). The remaining clearnet yield path is **XvB -donation mining** (Tor-by-default tracked in #166), plus **install/build reveals your IP once**. -Those are called out below with how to mitigate each. +Honesty first: as of v1.1 the stack is **Tor-by-default for all runtime egress**. Monero and Tari +(including the DNS lookups they used to leak), **P2Pool's outbound sidechain peers** (#165), and +**XvB donation mining** (#166) are all Tor-routed by default — each with a documented opt-out for +operators who trade privacy for yield. What's left is **inherent**: the one-time **install/build +reveals your IP** to the download host, and **remote-node mode** (`monero.mode: remote`, off by +default) talks to the node you choose. Those are called out below. There is also one **opt-in** that deliberately moves a node onto clearnet: an [optional clearnet initial sync](#optional-clearnet-initial-sync-off-by-default) that lets Monero @@ -49,9 +50,9 @@ What the running stack sends to the internet, connection by connection. | **Tari** P2P | Tari network | — | ✅ Tor (`type = "tor"`) | on | Tor by default; can opt into clearnet (TCP) for the initial sync only ([#183](#optional-clearnet-initial-sync-off-by-default)) | | **Tari** DNS seeds + Pulse (`seeds.tari.com`, `checkpoints.tari.com`) | DNS resolvers | "this IP runs Tari" | ✅ **closed** — `dns_seeds = []`, onion `peer_seeds`, resolver pointed at a dead address (#162) | n/a | clearnet sync ([#183](#optional-clearnet-initial-sync-off-by-default)) re-enables the `seeds.tari.com` DNS seed for the sync window | | **P2Pool** inbound peers | reach you via onion | — | ✅ onion hidden service | on | — | -| **P2Pool** outbound sidechain peers | P2Pool sidechain peers, via Tor | — | ✅ **Tor** (`--socks5`, proxy-type `tor`) by default (#165) | on | opt out with `p2pool.clearnet: true` (exposes your IP for max yield) → [below](#hardening-the-clearnet-paths) | +| **P2Pool** outbound sidechain peers | P2Pool sidechain peers, via Tor | — | ✅ **Tor** (`--socks5`, proxy-type `tor`) by default (#165) | on | opt out with `p2pool.clearnet: true` (exposes your IP for max yield) → [below](#p2pool-outbound-peers-165--tor-by-default) | | Dashboard **XvB stats** fetch | `xmrvsbeast.com` | your Monero **wallet** (no longer your IP) | ✅ Tor (`socks5h`, #163) | on, only if XvB enabled | `XVB_ENABLED=false` stops it | -| **XvB donation mining** (only while donating) | `na.xmrvsbeast.com:4247` | **your real home IP** | ❌ **clearnet** | on while donating | ⏳ Tor-by-default in v1.1 (#166). Disable XvB to stop it | +| **XvB donation mining** (only while donating) | `na.xmrvsbeast.com:4247` via Tor | — | ✅ **Tor** (per-pool `socks5`, DNS proxy-side) by default (#166) | on while donating | opt out with `xvb.tor: false` (exposes IP for max yield); `xvb.enabled: false` stops it entirely | | Dashboard **update check** (#224) | `api.github.com` | nothing about you — GitHub sees a **Tor exit**, not your IP | ✅ Tor (`socks5h`) | **on** | `dashboard.check_for_updates: false` to opt out; cached, fails silently offline | | **Caddy** TLS (dashboard HTTPS) | local only | — | n/a — `tls internal`, **no ACME / no external CA** | on | clean (no egress) | | **Telegram** alerts (#121) | Telegram API | your IP | ❌ | **off** | opt-in only | @@ -81,11 +82,12 @@ the risk is the **one-time IP disclosure**, not tampering. --- -## Hardening the clearnet paths +## The Tor-by-default yield paths (and their opt-outs) -The **XvB donation** path still uses clearnet by default (Tor-by-default tracked in #166) — close it -today by disabling XvB, or see below. **P2Pool's outbound peers now route over Tor by default** as of -v1.1 (#165), documented here for completeness. +As of v1.1 both former clearnet yield paths — **P2Pool outbound peers** (#165) and **XvB donation +mining** (#166) — route over Tor **by default**. Each has an opt-out for operators who'd trade the IP +exposure for maximum yield (Tor latency can raise stale/uncle shares and rejects). The two sections +below document the routing and how to opt out. ### P2Pool outbound peers (#165) — ✅ Tor by default @@ -105,19 +107,21 @@ peer set to onion-only. Measure the effect on your earnings before keeping it the Tor-by-default flip is a benchmarked **v1.1** change (#165), not a v1.0 default. (This hand-edit lives in `docker-compose.yml`, so re-apply it after a stack update until #165 lands.) -### XvB donation mining (#166) +### XvB donation mining (#166) — ✅ Tor by default -When the optimizer donates to XMRvsBeast (XvB), it points the proxy at `na.xmrvsbeast.com:4247` -over clearnet, exposing your IP to XvB. Pool stratum over Tor is high-latency and hurts share -acceptance, so there's no clean Tor fix yet. To stop the egress entirely, **disable XvB**: +When the optimizer donates to XMRvsBeast (XvB), it points the proxy at `na.xmrvsbeast.com:4247`. As +of v1.1 that connection routes through Tor **by default**: `algo_service` sets a per-pool `socks5` on +the XvB pool object it pushes to xmrig-proxy (the pool's DNS is resolved proxy-side, like the stats +fetch in #163), so donation mining no longer exposes your home IP. No action needed. -```jsonc -// config.json -"xvb": { "enabled": false } -``` +**Opt out** for maximum yield — stratum-over-Tor adds latency that can raise rejected shares (scales +with hashrate) — set `xvb.tor: false` and re-run `pithead apply`; the donation connection then dials +direct. To stop the egress entirely instead, **disable XvB** (`"xvb": { "enabled": false }`), which +also stops the (already Tor-routed, #163) stats fetch. -This also stops the (already Tor-routed) stats fetch. v1.1 adds an `xvb.tor` opt-in to route donation -mining through Tor and pins `--donate-level 0`. +> The xmrig-proxy **dev-fee** `--donate-level` is pinned to **`0`** by default (`proxy.donate_level`, +> rendered explicitly per #173) and does **not** ride the XvB pool's `socks5` — so if you set it +> above 0, that dev-fee traffic goes **clearnet**. Keep it at `0` for a fully Tor runtime. --- diff --git a/docs/test-inventory.md b/docs/test-inventory.md index 8e8fb7d..df48263 100644 --- a/docs/test-inventory.md +++ b/docs/test-inventory.md @@ -4,7 +4,7 @@ _Generated by `make test-inventory` ([`tests/inventory.sh`](../tests/inventory.s edit by hand** — re-run the target to refresh. See [Testing Strategy](testing-strategy.md) for how the tiers fit together._ -**Totals:** 497 dashboard unit tests · 12 contract tests · 31 frontend +**Totals:** 500 dashboard unit tests · 12 contract tests · 31 frontend tests · 44 `pithead` shell sections · 17 harness self-test sections · 9 live config scenarios (17 axis values) · 6 mini-stack scenarios. @@ -14,7 +14,7 @@ tests · 44 `pithead` shell sections · 17 harness self-test sections · | Tier | Suite | Cases | |---|---|---| -| 1 — Unit | dashboard pytest | 497 | +| 1 — Unit | dashboard pytest | 500 | | 1 — Unit | frontend (node --test) | 31 | | 1 — Unit | `pithead` shell suite | 44 sections | | 1 — Unit | compose interpolation + hardening (#90) | 1 | @@ -27,7 +27,7 @@ tests · 44 `pithead` shell sections · 17 harness self-test sections · ## Tier 1 — Unit & component -### Dashboard (pytest) — 497 tests +### Dashboard (pytest) — 500 tests #### tests/client/test_docker_control.py — 6 - test_tcp_scheme_rewritten_to_http @@ -180,7 +180,7 @@ tests · 44 `pithead` shell sections · 17 harness self-test sections · - test_none_when_no_route - test_socket_is_closed_even_on_error -#### tests/service/test_algo_service.py — 28 +#### tests/service/test_algo_service.py — 31 - test_xvb_disabled_forces_p2pool - test_zero_shares_forces_p2pool - test_excessive_failures_forces_p2pool @@ -204,6 +204,9 @@ tests · 44 `pithead` shell sections · 17 harness self-test sections · - test_explicit_tier_not_downgraded - test_switch_updates_proxy_and_state - test_switch_aborts_on_bad_config +- test_xvb_pool_routed_over_tor_by_default +- test_local_pool_never_routed_over_tor +- test_xvb_tor_opt_out_no_socks5 - test_aborts_early_when_decision_flips_to_donate - test_aborts_early_when_below_tier - test_sleeps_full_duration_when_in_tier_on_p2pool @@ -801,5 +804,5 @@ tests · 44 `pithead` shell sections · 17 harness self-test sections · --- -_Grand total: **616** enumerated cases/sections across the four tiers (plus the live +_Grand total: **619** enumerated cases/sections across the four tiers (plus the live lifecycle and fault-injection phases, which are exercised on a real server)._ diff --git a/pithead b/pithead index d54bce7..dd44d78 100755 --- a/pithead +++ b/pithead @@ -1773,8 +1773,10 @@ render_env() { [ -n "$p2pool_socks" ] && p2pool_flags="${p2pool_flags:+$p2pool_flags }$p2pool_socks" # XvB config (accepts legacy xmrig_proxy.* keys) - local xvb_enabled xvb_url xvb_donor xvb_donation_level + local xvb_enabled xvb_url xvb_donor xvb_donation_level xvb_tor xvb_enabled=$(jq -r 'if .xvb.enabled != null then .xvb.enabled elif .xmrig_proxy.enabled != null then .xmrig_proxy.enabled else "true" end' "$CONFIG_FILE") + # Route XvB donation mining through Tor by default (#166); xvb.tor:false opts out for max yield. + xvb_tor=$(normalize_bool "$(jq -r '.xvb.tor // true' "$CONFIG_FILE")") xvb_url=$(jq -r '.xvb.url // .xmrig_proxy.url // empty' "$CONFIG_FILE") [ -z "$xvb_url" ] && xvb_url="na.xmrvsbeast.com:4247" xvb_donor=$(jq -r '.xvb.donor_id // .xmrig_proxy.donor_id // empty' "$CONFIG_FILE") @@ -1870,6 +1872,7 @@ PROXY_STRATUM_PASSWORD=$STRATUM_PASSWORD XVB_POOL_URL=$xvb_url XVB_DONOR_ID=$xvb_donor XVB_ENABLED=$xvb_enabled +XVB_TOR_ENABLED=$xvb_tor XVB_DONATION_LEVEL=$xvb_donation_level TARI_REQUIRED=$tari_required DASHBOARD_CHECK_UPDATES=$check_for_updates From 3244625a4ddcf6f13e520ae8ba46393610a77b81 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 16 Jun 2026 22:29:39 -0500 Subject: [PATCH 05/44] feat(privacy): enforce Tor-only egress fail-closed via a host firewall (#270) (#275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(privacy): enforce Tor-only egress fail-closed via a host firewall (#270) Per-app Tor config is fragile — p2pool leaked on a stale image, and Tari dials some peers over clearnet despite type=tor (#271). Make "behind Tor" structural: a misconfigured/buggy bridge daemon now CANNOT reach clearnet. pithead installs rules in Docker's DOCKER-USER chain at up/apply (removed at down): the mining bridge (monerod/p2pool/tari/xmrig-proxy) may reach the LAN, the other containers, and the Tor SOCKS, but any DIRECT clearnet dial is DROPPED — only the tor container reaches the internet. Fail-closed. - Keeps the topology + published ports intact (the internal-network approach was ruled out on gouda: it blocks published ports too). Rules are source/dest-based; ESTABLISHED/RELATED accepted so published-port replies + Tor return traffic work; the Tor container (.25) is exempt. - `network.tor_egress_firewall` knob (default true) → rendered to .env so the `up` path can read it; opt out to fall back to per-app config. - Subnet-aware (#180); needs root (sudo) like the GRUB/HugePages steps; IPv4 (mining_net is IPv4); degrades gracefully if iptables/root unavailable (warns, doesn't fail the stack). Tests (tier 1): tor_egress_rules ruleset (accepts first, DROP last, custom subnet); black-box apply/remove via stubbed iptables (installs tagged rules; opt-out installs nothing). 377 pithead-suite tests green; shellcheck clean. Docs: privacy.md (the structural guarantee) + configuration.md. LIVE VALIDATION (needs root on the box): `pithead up` installs the rules, then `bench-verify-egress.sh tor` confirms 0 app public connections even with a deliberately-misconfigured app. Contains #271 structurally. Part of #160. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(pithead): remove_tor_egress_firewall must not trip `set -e` on a no-match grep (#270) CI (Linux, where iptables exists) failed 3 existing apply/up black-box tests with rc 1. Root cause: pithead runs `set -Eeuo pipefail`, and remove_tor_egress_firewall's cleanup pipeline (`iptables-save | grep | grep | while`) returns non-zero when no tagged rules exist (grep finds nothing) → errexit aborts the whole `pithead apply`/`up`. It passed on macOS only because `command -v iptables` fails there and the function returns early. Capture the matching rules with `|| true` and iterate via a here-string, so a no-match is a clean 0. The firewall removal stays best-effort + idempotent. 377 pithead-suite tests pass both with iptables present (Linux-simulated) and absent (macOS); shellcheck clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- config.advanced.example.json | 3 +- docs/configuration.md | 1 + docs/privacy.md | 18 +++++++++ docs/test-inventory.md | 10 +++-- pithead | 75 ++++++++++++++++++++++++++++++++++++ tests/stack/run.sh | 26 +++++++++++++ 6 files changed, 128 insertions(+), 5 deletions(-) diff --git a/config.advanced.example.json b/config.advanced.example.json index 76708b2..319158b 100644 --- a/config.advanced.example.json +++ b/config.advanced.example.json @@ -66,6 +66,7 @@ }, "network": { - "subnet": "172.28.0.0/24" + "subnet": "172.28.0.0/24", + "tor_egress_firewall": true } } diff --git a/docs/configuration.md b/docs/configuration.md index f74ddf2..3fcd0e5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -103,6 +103,7 @@ plain HTTP, edit `config.json` and run `./pithead apply`. | `dashboard.check_for_updates` | `true` _(on)_ | The dashboard periodically asks GitHub whether a **newer Pithead release** exists and, if so, shows a header badge linking to it (e.g. "New release v1.4.0 available"). **Notify-only** — it never updates anything; you upgrade with `./pithead upgrade` on your own terms. **On by default** because the check is **routed over Tor** (the same bridge SOCKS as the XvB fetch, `socks5h` so the DNS lookup goes through Tor too) — GitHub sees a Tor exit, not your IP, so it's a privacy-safe default. It's cached (hourly) and fails silently offline. Set to `false` to opt out entirely. See [Privacy › Runtime egress](privacy.md#runtime-egress). | | `dashboard.tari_required` | `true` | How much a Tari problem holds up the rest of the stack. Monero is **required** to mine, so its behavior isn't configurable: a monerod outage always rejects workers (stops `xmrig-proxy` so miners **fail over to their backup pools**), and the miner is always held until monerod finishes syncing. Tari is **only needed for merge mining**, so this one flag decides how much it blocks. **`true` (default):** a Tari outage also rejects workers, the miner waits for Tari's initial sync too, and a Tari-only (re)sync shows the full-screen Sync view. **`false` (non-blocking):** keep mining Monero through a Tari outage, start mining as soon as Monero is synced (Tari finishes in the background), and keep the normal dashboard — with a `Tari syncing` indicator — instead of the takeover screen. | | `network.subnet` | `172.28.0.0/24` | The private Docker bridge the stack's containers run on. Change it **only** if install fails with `Pool overlaps with other one on this address space` — i.e. your host already uses `172.28.0.0/24` for another Docker network or interface. Must be a free **`X.Y.Z.0/24`** block (e.g. `"172.30.0.0/24"`); the services keep their fixed host octets (`.25`–`.31`) within it, so the structured addressing the dashboard and the worker SSRF guard rely on is preserved. | +| `network.tor_egress_firewall` | `true` _(on)_ | **Privacy-relevant, default on.** Enforces "behind Tor" **fail-closed**: at `up`/`apply`, `pithead` installs host firewall rules (Docker's `DOCKER-USER` chain) that **drop any direct clearnet dial** from the mining containers (monerod/p2pool/tari/xmrig-proxy) — only the Tor container reaches the internet, so a misconfigured or buggy daemon can't leak your IP. Needs root (like the GRUB/HugePages steps); removed at `down`. Set `false` to skip it and rely on per-app Tor config only (e.g. a host where you manage egress yourself, or where `iptables` isn't available). Full detail: [Privacy › Enforced fail-closed](privacy.md#enforced-fail-closed-not-just-configured-270). | --- diff --git a/docs/privacy.md b/docs/privacy.md index 059c9f2..b010f42 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -22,6 +22,24 @@ below. --- +## Enforced fail-closed, not just configured (#270) + +Every per-app Tor setting below is **backed by a host firewall**, so "behind Tor" is a property the +stack *enforces*, not one it merely *hopes* each daemon is configured for. At `up`/`apply`, `pithead` +installs rules in Docker's `DOCKER-USER` chain: the mining bridge (monerod, p2pool, tari, xmrig-proxy) +may reach the LAN, the other containers and the **Tor SOCKS** — but any **direct dial to the public +internet is DROPPED**. Only the `tor` container reaches the internet. So if a daemon is misconfigured, +buggy, or learns a clearnet peer address (as Tari's comms layer does), the connection **fails closed +instead of leaking your IP**. + +- Needs root (the firewall rules), like the GRUB/HugePages steps; removed at `pithead down`. +- Opt out with `network.tor_egress_firewall: false` (then routing falls back to per-app config only). +- The host-networked **dashboard** and **caddy** aren't on the bridge; the dashboard's only external + calls already go over the Tor SOCKS (`socks5h`, [#163](#runtime-egress)/#224). +- Verify it live with [`tests/integration/benchmarks/bench-verify-egress.sh`](../tests/integration/benchmarks/bench-verify-egress.sh) — it confirms 0 app-container public connections. + +--- + ## Inbound — no port forwarding **Your home IP is never advertised to a single inbound peer, and you never touch your router's diff --git a/docs/test-inventory.md b/docs/test-inventory.md index df48263..5004da6 100644 --- a/docs/test-inventory.md +++ b/docs/test-inventory.md @@ -5,7 +5,7 @@ edit by hand** — re-run the target to refresh. See [Testing Strategy](testing- how the tiers fit together._ **Totals:** 500 dashboard unit tests · 12 contract tests · 31 frontend -tests · 44 `pithead` shell sections · 17 harness self-test sections · +tests · 46 `pithead` shell sections · 17 harness self-test sections · 9 live config scenarios (17 axis values) · 6 mini-stack scenarios. > Counts are **test functions / named cases** (parametrized pytest cases expand to more at @@ -16,7 +16,7 @@ tests · 44 `pithead` shell sections · 17 harness self-test sections · |---|---|---| | 1 — Unit | dashboard pytest | 500 | | 1 — Unit | frontend (node --test) | 31 | -| 1 — Unit | `pithead` shell suite | 44 sections | +| 1 — Unit | `pithead` shell suite | 46 sections | | 1 — Unit | compose interpolation + hardening (#90) | 1 | | 2 — Contract | fake-daemon clients | 12 | | 3 — Mini-stack | docker control-plane scenarios | 6 | @@ -610,7 +610,7 @@ tests · 44 `pithead` shell sections · 17 harness self-test sections · - bandBorderWidth: zero-height segments get no border, real ones keep full width - uptimeCell: online shows uptime, offline shows DOWN -### `pithead` shell suite (tests/stack/run.sh) — 44 sections +### `pithead` shell suite (tests/stack/run.sh) — 46 sections - unit: resolve_default - unit: assert_safe_dir - unit: is_public_ip classifier (#113) @@ -621,6 +621,8 @@ tests · 44 `pithead` shell sections · 17 harness self-test sections · - unit: describe_change - unit: p2pool_outbound_flags — Tor-by-default for outbound P2P (#165) - p2pool entrypoint word-splits P2POOL_FLAGS into separate args (#165) +- unit: tor_egress_rules — fail-closed Tor-only egress ruleset (#270) +- black-box: apply/remove_tor_egress_firewall via stubbed iptables (#270) - unit: clearnet initial sync helpers (#183) - unit: clock_sync_status (mining is time-sensitive) - unit: monero_address_type — p2pool needs a PRIMARY address (#250) @@ -804,5 +806,5 @@ tests · 44 `pithead` shell sections · 17 harness self-test sections · --- -_Grand total: **619** enumerated cases/sections across the four tiers (plus the live +_Grand total: **621** enumerated cases/sections across the four tiers (plus the live lifecycle and fault-injection phases, which are exercised on a real server)._ diff --git a/pithead b/pithead index dd44d78..b4f97d9 100755 --- a/pithead +++ b/pithead @@ -149,6 +149,7 @@ stack_up() { error "Stack failed to start — see the error above." fi log "Stack started successfully!" + apply_tor_egress_firewall # Remind the operator at start-time if a node is coming up on clearnet (#183). print_clearnet_banner announce_dashboard_url @@ -156,6 +157,7 @@ stack_up() { stack_down() { log "Stopping stack..." + remove_tor_egress_firewall docker compose down log "Stack stopped." } @@ -166,6 +168,75 @@ stack_restart() { log "Stack restarted." } +# --- Tor-only egress enforcement (#270) --------------------------------------------------------- +# Fail-closed host firewall so a misconfigured/buggy bridge daemon (monerod/p2pool/tari/xmrig-proxy) +# CAN'T leak the home IP: each may reach the LAN, the other containers and the Tor SOCKS, but any +# DIRECT clearnet dial is DROPPED — only the `tor` container reaches the internet. Rules live in +# Docker's DOCKER-USER chain (preserved across Docker restarts), installed at up/apply and removed at +# down. Needs root (sudo), like the GRUB/HugePages steps; IPv4-only (mining_net is IPv4). Opt out with +# network.tor_egress_firewall=false. Proven by tests/integration/benchmarks/bench-verify-egress.sh. +# See docs/privacy.md. +TOR_EGRESS_TAG="pithead-tor-egress" + +# Ordered iptables rule bodies (no chain/comment) for . Pure (args only) so it +# unit-tests; ACCEPTs first, DROP last — the order is load-bearing. +tor_egress_rules() { # + local subnet="$1" tor_ip="$2" + printf '%s\n' \ + "-m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT" \ + "-s $tor_ip -j ACCEPT" \ + "-s $subnet -d 10.0.0.0/8 -j ACCEPT" \ + "-s $subnet -d 172.16.0.0/12 -j ACCEPT" \ + "-s $subnet -d 192.168.0.0/16 -j ACCEPT" \ + "-s $subnet -d 100.64.0.0/10 -j ACCEPT" \ + "-s $subnet -j DROP" +} + +# Remove every rule we previously installed (matched by the comment tag) — idempotent, config-agnostic. +remove_tor_egress_firewall() { + command -v iptables >/dev/null 2>&1 || return 0 + local saved match line + saved=$(sudo iptables-save 2>/dev/null) || return 0 + # Our tagged DOCKER-USER rules (empty if none). `|| true` so a no-match grep (rc 1, under + # `set -e`/pipefail) doesn't abort — removal is best-effort + idempotent. + match=$(printf '%s\n' "$saved" | grep -- '^-A DOCKER-USER' | grep -F -- "$TOR_EGRESS_TAG") || true + [ -n "$match" ] || return 0 + while IFS= read -r line; do + [ -n "$line" ] || continue + # shellcheck disable=SC2086 # intentional word-splitting of the saved rule spec + sudo iptables -D DOCKER-USER ${line#-A DOCKER-USER } 2>/dev/null || true + done <<< "$match" + return 0 +} + +# Install (or re-install, idempotently) the fail-closed Tor-only egress rules. Reads the toggle + +# subnet from .env so it works in the `up` path (where config.json isn't re-parsed). +apply_tor_egress_firewall() { + local enabled subnet tor_ip pos=1 rule + enabled=$(env_get TOR_EGRESS_FIREWALL 2>/dev/null); [ -n "$enabled" ] || enabled=true + remove_tor_egress_firewall # clear stale rules so a re-apply is idempotent + if [ "$(normalize_bool "$enabled")" != "true" ]; then + warn "Tor-only egress firewall is OFF (network.tor_egress_firewall=false) — a misconfigured app could reach clearnet (#270)." + return 0 + fi + if ! command -v iptables >/dev/null 2>&1; then + warn "iptables not found — cannot enforce Tor-only egress (#270). The stack runs, but clearnet egress is NOT fail-closed." + return 0 + fi + subnet=$(env_get NETWORK_SUBNET 2>/dev/null); [ -n "$subnet" ] || subnet="172.28.0.0/24" + tor_ip=$(env_get NETWORK_PREFIX 2>/dev/null); [ -n "$tor_ip" ] || tor_ip="172.28.0"; tor_ip="${tor_ip}.25" + while IFS= read -r rule; do + # shellcheck disable=SC2086 # intentional word-splitting of the rule body + if ! sudo iptables -I DOCKER-USER "$pos" -m comment --comment "$TOR_EGRESS_TAG" $rule 2>/dev/null; then + warn "Could not install the Tor-egress firewall (needs root + iptables). Stack runs, but clearnet egress is NOT fail-closed (#270)." + remove_tor_egress_firewall + return 0 + fi + pos=$((pos + 1)) + done < <(tor_egress_rules "$subnet" "$tor_ip") + log "Tor-only egress enforced (#270): clearnet dials from $subnet dropped except via Tor ($tor_ip)." +} + stack_upgrade() { log "Upgrading stack (rebuilding containers)..." # A release (git pull) may change config templates, add/rename .env vars, or restructure the @@ -1434,6 +1505,8 @@ parse_and_validate_config() { *) error "network.subnet must be an X.Y.Z.0/24 block (got \"$NETWORK_SUBNET\")." ;; esac is_ipv4 "${NETWORK_PREFIX}.0" || error "network.subnet is not a valid IPv4 /24 (got \"$NETWORK_SUBNET\")." + # Fail-closed Tor-only egress firewall (#270); default on. Renders to .env so `up` can read it. + TOR_EGRESS_FIREWALL=$(normalize_bool "$(jq -r '.network.tor_egress_firewall // true' "$CONFIG_FILE")") if [ "$MONERO_MODE" == "remote" ]; then local remote_host remote_host=$(jq -r '.monero.remote.host // empty' "$CONFIG_FILE") @@ -1881,6 +1954,7 @@ MONERO_MEM_LIMIT=$monero_mem_limit P2POOL_URL=${NETWORK_PREFIX}.28:3333 NETWORK_SUBNET=$NETWORK_SUBNET NETWORK_PREFIX=$NETWORK_PREFIX +TOR_EGRESS_FIREWALL=$TOR_EGRESS_FIREWALL PROXY_API_PORT=3344 PROXY_AUTH_TOKEN=$PROXY_AUTH_TOKEN PROXY_DONATE_LEVEL=$DONATE_LEVEL @@ -2454,6 +2528,7 @@ apply() { docker compose restart caddy fi rm -f "$apply_marker" + apply_tor_egress_firewall # (re)install the fail-closed Tor-only egress rules (#270) log "Configuration applied." announce_dashboard_url } diff --git a/tests/stack/run.sh b/tests/stack/run.sh index a6a7a13..913c865 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -186,6 +186,32 @@ assert_contains "2 fixed + 5 flag tokens = ARGC 7" "$ep_out" "ARGC=7" ep_empty=$(PATH="$PE:$PATH" P2POOL_FLAGS="" bash "$ROOT/build/p2pool/entrypoint.sh" --stratum 0.0.0.0:3333 2>&1) assert_contains "empty P2POOL_FLAGS → no stray empty arg (ARGC=2)" "$ep_empty" "ARGC=2" +echo "== unit: tor_egress_rules — fail-closed Tor-only egress ruleset (#270) ==" +TER=$(run_sourced "$SANDBOX" tor_egress_rules 172.28.0.0/24 172.28.0.25) +assert_contains "ESTABLISHED/RELATED accepted (published-port replies, ongoing flows)" "$TER" "conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT" +assert_contains "only Tor (.25) may egress to the internet" "$TER" "-s 172.28.0.25 -j ACCEPT" +assert_contains "inter-container + 172.16/12 LAN allowed" "$TER" "-s 172.28.0.0/24 -d 172.16.0.0/12 -j ACCEPT" +assert_contains "10/8 LAN allowed" "$TER" "-s 172.28.0.0/24 -d 10.0.0.0/8 -j ACCEPT" +assert_contains "192.168/16 LAN allowed" "$TER" "-s 172.28.0.0/24 -d 192.168.0.0/16 -j ACCEPT" +assert_eq "the clearnet DROP is the FINAL rule (fail-closed)" "$(printf '%s\n' "$TER" | tail -1)" "-s 172.28.0.0/24 -j DROP" +assert_contains "honours a custom subnet/prefix (#180)" "$(run_sourced "$SANDBOX" tor_egress_rules 172.30.5.0/24 172.30.5.25)" "-s 172.30.5.0/24 -j DROP" + +echo "== black-box: apply/remove_tor_egress_firewall via stubbed iptables (#270) ==" +FW="$SANDBOX/fw"; mkdir -p "$FW/bin" +printf '#!/usr/bin/env bash\nexec "$@"\n' > "$FW/bin/sudo" +printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$*" >> "%s/ipt.log"\n' "$FW" > "$FW/bin/iptables" +printf '#!/usr/bin/env bash\nexit 0\n' > "$FW/bin/iptables-save" # no pre-existing rules +chmod +x "$FW/bin/sudo" "$FW/bin/iptables" "$FW/bin/iptables-save" +printf 'NETWORK_SUBNET=172.28.0.0/24\nNETWORK_PREFIX=172.28.0\nTOR_EGRESS_FIREWALL=true\n' > "$FW/.env" +: > "$FW/ipt.log"; PATH="$FW/bin:$PATH" run_sourced "$FW" apply_tor_egress_firewall >/dev/null 2>&1 +iptlog="$(cat "$FW/ipt.log" 2>/dev/null)" +assert_contains "installs the fail-closed clearnet DROP, tagged" "$iptlog" "-I DOCKER-USER 7 -m comment --comment pithead-tor-egress -s 172.28.0.0/24 -j DROP" +assert_contains "exempts the Tor container" "$iptlog" "-m comment --comment pithead-tor-egress -s 172.28.0.25 -j ACCEPT" +# opt-out: TOR_EGRESS_FIREWALL=false installs nothing +printf 'NETWORK_SUBNET=172.28.0.0/24\nNETWORK_PREFIX=172.28.0\nTOR_EGRESS_FIREWALL=false\n' > "$FW/.env" +: > "$FW/ipt.log"; PATH="$FW/bin:$PATH" run_sourced "$FW" apply_tor_egress_firewall >/dev/null 2>&1 +assert_eq "opt-out (network.tor_egress_firewall=false) installs no DROP" "$(grep -c 'DROP' "$FW/ipt.log" 2>/dev/null)" "0" + echo "== unit: clearnet initial sync helpers (#183) ==" # normalize_bool: 1/true/yes/on (any case) => true; everything else (incl. empty) => false, matching # the dashboard's MONERO_PRUNE truthiness so a config bool reads the same on both sides. From 87ee875bf674ec77babe6ef1132388112cdcb92d Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 16 Jun 2026 23:12:29 -0500 Subject: [PATCH 06/44] =?UTF-8?q?fix(privacy):=20install=20Tor-egress=20fi?= =?UTF-8?q?rewall=20before=20containers=20start=20=E2=80=94=20close=20star?= =?UTF-8?q?tup=20window=20(#276)=20(#277)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#270): install Tor-egress firewall before containers start (close startup window) The firewall was installed *after* `docker compose up`, leaving a brief startup window in which a clearnet-only app (Tari, #271) opens connections that the ESTABLISHED,RELATED rule then grandfathers past the DROP — observed live as Tari holding 2-3 public peers after a fresh `up`. Move the install ahead of compose so no container ever dials before the rules are in place, and pre-create DOCKER-USER (idempotent) so the pre-compose install also works on a first-ever `up`. DOCKER-USER is a static chain Docker preserves across network (re)creation, so rules referencing the fixed subnet/Tor IP can be installed before the network exists; Docker adds the FORWARD jump when it creates the network. Co-Authored-By: Claude Opus 4.8 (1M context) * test(#276): assert apply pre-creates the DOCKER-USER chain Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- pithead | 16 +++++++++++++--- tests/stack/run.sh | 3 +++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/pithead b/pithead index b4f97d9..b633ece 100755 --- a/pithead +++ b/pithead @@ -144,12 +144,17 @@ stack_up() { log "Starting stack..." warn_missing_data_dirs migrate_compose_project + # Install the Tor-only egress firewall BEFORE the containers start (#270). DOCKER-USER is a static + # chain whose rules reference the fixed subnet/Tor IP, so they can go in before the network exists; + # Docker preserves DOCKER-USER and (re)adds the FORWARD jump when it creates the network. Doing this + # first closes the startup window in which a clearnet app (e.g. Tari) could open a connection that + # the ESTABLISHED rule would then grandfather past the DROP. + apply_tor_egress_firewall # Docker Compose automatically picks up COMPOSE_PROFILES from .env if ! compose_up_checked -d; then error "Stack failed to start — see the error above." fi log "Stack started successfully!" - apply_tor_egress_firewall # Remind the operator at start-time if a node is coming up on clearnet (#183). print_clearnet_banner announce_dashboard_url @@ -172,8 +177,9 @@ stack_restart() { # Fail-closed host firewall so a misconfigured/buggy bridge daemon (monerod/p2pool/tari/xmrig-proxy) # CAN'T leak the home IP: each may reach the LAN, the other containers and the Tor SOCKS, but any # DIRECT clearnet dial is DROPPED — only the `tor` container reaches the internet. Rules live in -# Docker's DOCKER-USER chain (preserved across Docker restarts), installed at up/apply and removed at -# down. Needs root (sudo), like the GRUB/HugePages steps; IPv4-only (mining_net is IPv4). Opt out with +# Docker's DOCKER-USER chain (preserved across Docker restarts), installed BEFORE containers start at +# `up` (so there is no startup window to grandfather), re-asserted at `apply`, removed at `down`. Needs +# root (sudo), like the GRUB/HugePages steps; IPv4-only (mining_net is IPv4). Opt out with # network.tor_egress_firewall=false. Proven by tests/integration/benchmarks/bench-verify-egress.sh. # See docs/privacy.md. TOR_EGRESS_TAG="pithead-tor-egress" @@ -225,6 +231,10 @@ apply_tor_egress_firewall() { fi subnet=$(env_get NETWORK_SUBNET 2>/dev/null); [ -n "$subnet" ] || subnet="172.28.0.0/24" tor_ip=$(env_get NETWORK_PREFIX 2>/dev/null); [ -n "$tor_ip" ] || tor_ip="172.28.0"; tor_ip="${tor_ip}.25" + # DOCKER-USER may not exist yet on a first-ever `up` (Docker creates it with its first network). + # Pre-create it so installing here — before compose runs — succeeds; Docker adopts the existing + # chain and adds the FORWARD jump. Harmless (-N fails with rc 1) once the chain is already there. + sudo iptables -N DOCKER-USER 2>/dev/null || true while IFS= read -r rule; do # shellcheck disable=SC2086 # intentional word-splitting of the rule body if ! sudo iptables -I DOCKER-USER "$pos" -m comment --comment "$TOR_EGRESS_TAG" $rule 2>/dev/null; then diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 913c865..0c081c8 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -207,6 +207,9 @@ printf 'NETWORK_SUBNET=172.28.0.0/24\nNETWORK_PREFIX=172.28.0\nTOR_EGRESS_FIREWA iptlog="$(cat "$FW/ipt.log" 2>/dev/null)" assert_contains "installs the fail-closed clearnet DROP, tagged" "$iptlog" "-I DOCKER-USER 7 -m comment --comment pithead-tor-egress -s 172.28.0.0/24 -j DROP" assert_contains "exempts the Tor container" "$iptlog" "-m comment --comment pithead-tor-egress -s 172.28.0.25 -j ACCEPT" +# Pre-creates DOCKER-USER so the BEFORE-compose install at `up` can't miss on a first-ever start where +# Docker hasn't created the chain yet — closes the startup window that grandfathered leaks (#276). +assert_contains "pre-creates the DOCKER-USER chain (idempotently)" "$iptlog" "-N DOCKER-USER" # opt-out: TOR_EGRESS_FIREWALL=false installs nothing printf 'NETWORK_SUBNET=172.28.0.0/24\nNETWORK_PREFIX=172.28.0\nTOR_EGRESS_FIREWALL=false\n' > "$FW/.env" : > "$FW/ipt.log"; PATH="$FW/bin:$PATH" run_sourced "$FW" apply_tor_egress_firewall >/dev/null 2>&1 From 5509c78dcc755653378789514e501469fc5a244e Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Tue, 16 Jun 2026 23:58:45 -0500 Subject: [PATCH 07/44] =?UTF-8?q?fix(privacy):=20route=20Tari=20clearnet?= =?UTF-8?q?=20peer=20dials=20through=20Tor=20SOCKS=20=E2=80=94=20genuinely?= =?UTF-8?q?=20behind=20Tor=20(#271)=20(#285)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(#271): route Tari clearnet peer dials through Tor SOCKS (proxy_bypass_for_outbound_tcp=false) minotari's tor transport defaults proxy_bypass_for_outbound_tcp=true, so SocksTransport::dial() direct-dials any peer that advertises a bare /ip4|/ip6 .../tcp address (the seed list ships clearnet variants) — bypassing Tor and leaking the home IP, observed live as sustained connections to public IPs despite type="tor". Setting it false routes every non-onion dial through the SOCKS proxy, so Tari reaches those peers via Tor exit nodes: fully functional AND never touching clearnet directly. Keep proxy_bypass_addresses empty (no per-address exemptions; we run no local Tari wallet). Verified against tari v5.3.0 source (transport.rs:160-212, socks.rs:119-128) and the tari-launchpad docker_rig config. Residual (tracked separately): Tari Pulse does a checkpoint DNS lookup with no upstream off-switch; the node tolerates it being blocked. Merge-mining is unaffected (base node ↔ MM proxy over local gRPC). Co-Authored-By: Claude Opus 4.8 (1M context) * test(#271): assert tari config routes outbound TCP via Tor SOCKS (proxy_bypass_for_outbound_tcp=false) Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- build/tari/config.toml.template | 8 ++++++++ tests/stack/run.sh | 4 ++++ 2 files changed, 12 insertions(+) diff --git a/build/tari/config.toml.template b/build/tari/config.toml.template index 0d9f3e1..5997eae 100644 --- a/build/tari/config.toml.template +++ b/build/tari/config.toml.template @@ -112,5 +112,13 @@ control_address = "/ip4/172.28.0.25/tcp/9051" socks_address_override = "/ip4/172.28.0.25/tcp/9050" control_auth = "auto" +# Route EVERY outbound dial through the Tor SOCKS proxy — including peers learned via gossip that +# advertise a bare /ip4 (clearnet) address. With this true (an upstream default that favours speed), +# minotari direct-dials those clearnet peers and leaks the home IP (#271, observed as sustained +# connections to public IPs). false = clearnet peers are reached via Tor exit nodes, so Tari stays +# fully functional AND never touches clearnet directly. No per-address exemptions (no local wallet). +proxy_bypass_for_outbound_tcp = false +proxy_bypass_addresses = [] + # Port mapping for the hidden service onion_port = 18189 diff --git a/tests/stack/run.sh b/tests/stack/run.sh index 0c081c8..d096329 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -672,6 +672,10 @@ assert_contains "monerod: DNS checkpoints disabled (#161)" "$(cat "$MONC")" "dis assert_contains "monerod: update check disabled (#161)" "$(cat "$MONC")" "check-updates=disabled" # tari (#162): no DNS seeds; peer_seeds onion-only; the inert check_for_updates gRPC method dropped. assert_contains "tari: DNS seeds disabled (#162)" "$(cat "$TARC")" "dns_seeds = []" +# #271: minotari defaults proxy_bypass_for_outbound_tcp=true → it direct-dials peers advertising a bare +# /ip4 (clearnet) address, bypassing Tor. false routes every dial through the SOCKS proxy (reach those +# peers via Tor exits) — so Tari is functional AND never touches clearnet directly. +assert_contains "tari: outbound TCP dials routed via Tor SOCKS, not direct (#271)" "$(cat "$TARC")" "proxy_bypass_for_outbound_tcp = false" case "$(grep -E '::/ip4/|::/ip6/' "$TARC" || true)" in "") ok "tari: peer_seeds are onion-only (#162)" ;; *) bad "tari: peer_seeds are onion-only (#162)" "clearnet /ip4//ip6/ peer seeds present" ;; From 7d3cae6577f55705ad9bc38bcfca896bb2d7a1bf Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Wed, 17 Jun 2026 09:48:13 -0500 Subject: [PATCH 08/44] fix(#272): e2e deploys via `pithead upgrade` so it rebuilds the branch's images (#287) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `e2e.sh deploy_branch` used `pithead apply -y`, which runs `compose up --pull` (never `--build`) — so it tested whatever first-party images were last built on the box, not the branch under test. That masked #165's p2pool clearnet leak (the entrypoint word-split only lives in a rebuilt image). Switch deploy to `pithead upgrade`, which re-renders the generated configs (inject_service_configs) AND rebuilds the first-party images from build/ (--build) before recreating, so a Dockerfile/entrypoint/template change is actually under test. Also log the built image IDs so "what we tested" is unambiguous. Plus: `stack_upgrade` now reasserts the Tor-egress firewall (#270), consistent with up/apply — previously the upgrade path left it to persist from a prior up. Co-authored-by: Claude Opus 4.8 (1M context) --- pithead | 1 + tests/integration/e2e.sh | 15 +++++++++++---- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/pithead b/pithead index b633ece..d738473 100755 --- a/pithead +++ b/pithead @@ -283,6 +283,7 @@ stack_upgrade() { else PITHEAD_PULL=always compose_up_checked -d || error "Upgrade failed during 'docker compose up' — see the error above." fi + apply_tor_egress_firewall # reassert the Tor-only egress firewall (#270), consistent with up/apply log "Stack upgraded." } diff --git a/tests/integration/e2e.sh b/tests/integration/e2e.sh index 1a73c9c..716895e 100755 --- a/tests/integration/e2e.sh +++ b/tests/integration/e2e.sh @@ -13,8 +13,9 @@ # 3. Takes a `pithead backup` of the live stack (the rollback anchor). # 4. Borrows a miner (default miner-0): backs up its xmrig config and repoints it at gouda so # the live matrix has a real worker mining through this stack. -# 5. Deploys the branch (`pithead apply` — builds the branch's images) and runs the live harness -# (tests/integration/run.sh) DETACHED on the box so an SSH drop can't kill a long matrix. +# 5. Deploys the branch (`pithead upgrade` — re-renders configs AND rebuilds the branch's first-party +# images from build/, so a Dockerfile/entrypoint change is actually tested #272) and runs the live +# harness (tests/integration/run.sh) DETACHED on the box so an SSH drop can't kill a long matrix. # 6. ALWAYS restores: the miner's original pool config, and the canonical baseline stack — even # on failure or Ctrl-C (an EXIT trap). The synced chains are never touched. # @@ -270,8 +271,14 @@ borrow_miner() { # --- Phase 4: deploy the branch --------------------------------------------- deploy_branch() { - log "Deploying the branch on $GOUDA_HOST (pithead apply — builds the branch's images)" - on_gouda "cd '$E2E_DIR' && ./pithead apply -y" || die "pithead apply failed in $E2E_DIR — branch did not deploy." + # #272: `pithead apply` runs `compose up --pull` (never --build), so it would test whatever images + # were last built on the box, not this branch. `pithead upgrade` re-renders the generated configs + # (inject_service_configs) AND rebuilds the first-party images from build/ (--build) before + # recreating — so a Dockerfile/entrypoint change in the branch is actually under test. + log "Deploying the branch on $GOUDA_HOST (pithead upgrade — re-render configs + rebuild first-party images)" + on_gouda "cd '$E2E_DIR' && ./pithead upgrade" || die "pithead upgrade failed in $E2E_DIR — branch did not deploy." + # Record what was actually built, so "what did we test" is unambiguous in the run log (#272). + on_gouda "cd '$E2E_DIR' && docker compose images --format '{{.Service}} {{.Repository}}:{{.Tag}} {{.ID}}' 2>/dev/null | grep -E 'p2pool|dashboard|monero|tor|xmrig' || true" | while IFS= read -r l; do step "image: $l"; done wait_gouda_healthy 300 || warn "stack applied but not yet healthy; the harness will wait on real readiness signals" wait_synced 300 || true # let the recreated monerod/tari re-confirm their tip before the harness pre-check ok "branch deployed; stack reconciled" From 0f495abe5a39bb164947416719feddd859faf44b Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Wed, 17 Jun 2026 10:00:10 -0500 Subject: [PATCH 09/44] feat(#274): standing no-clearnet-leak egress gate in the harness (#288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(#274): standing no-clearnet-leak egress gate in the harness Promote the #256 egress verifier into the live harness as a standing privacy assertion (the runtime proof of #270): the --check phase now FAILs if any app container holds a persistent direct public connection — what config-level checks miss (it caught the #165 stale-image p2pool leak and the #271 Tari direct-dial). Refine bench-verify-egress.sh: poll N times (default 4×10s) and flag only IPs seen in >= --min-hits polls, so post-restart startup transients (a brief direct dial before Tor circuits build) don't false-positive — only sustained leaks fail. Validated on gouda: caught a grandfathered Tari leak (2 persistent IPs → FAIL), then a clean PASS (all 4 apps via Tor) after a restart cleared it. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(#274): regenerate test inventory for the egress-gate assertion Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- docs/test-inventory.md | 1 + .../benchmarks/bench-verify-egress.sh | 101 ++++++++++++++++++ tests/integration/run.sh | 22 ++++ 3 files changed, 124 insertions(+) create mode 100755 tests/integration/benchmarks/bench-verify-egress.sh diff --git a/docs/test-inventory.md b/docs/test-inventory.md index 5004da6..1e71478 100644 --- a/docs/test-inventory.md +++ b/docs/test-inventory.md @@ -754,6 +754,7 @@ tests · 46 `pithead` shell sections · 17 harness self-test sections · - monerod reports synced (RPC) - monerod running-but-unhealthy - monerod runs Tor-only in steady state — proxy present (#183/#234) +- no clearnet egress — every app dials via Tor (#274/#270) - original PROXY_AUTH_TOKEN restored verbatim - pithead up fails closed (non-zero exit) on an empty PROXY_AUTH_TOKEN - pool actually changed diff --git a/tests/integration/benchmarks/bench-verify-egress.sh b/tests/integration/benchmarks/bench-verify-egress.sh new file mode 100755 index 0000000..09d58c7 --- /dev/null +++ b/tests/integration/benchmarks/bench-verify-egress.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# +# bench-verify-egress.sh — prove each container's ACTUAL egress posture: a live, runtime privacy-leak +# check (and the gate for the #256 benchmark arms). It reads `/proc/net/tcp` from inside each container +# (root there, so no host sudo) and reports every ESTABLISHED connection to a **public** IP — i.e. one +# that bypasses the Tor SOCKS at .25:9050 (a private 172.x address). Run ON the mining host. +# +# tests/integration/benchmarks/bench-verify-egress.sh \ +# [--dir STACK_DIR] [--prefix 172.28.0] [--polls N] [--interval S] [--min-hits K] +# +# It POLLS `--polls` times (default 4) `--interval` seconds apart (default 10) and only flags a +# foreign IP seen in `--min-hits` or more polls (default 2) — so a post-restart STARTUP TRANSIENT +# (e.g. a brief direct dial before Tor circuits build / before p2pool's `--socks5` connects) clears +# within a poll or two and does NOT false-positive. A real leak (a sustained peer connection) persists +# across polls and is reported. `--polls 1` gives a single instantaneous snapshot. (#274) +# +# Interpretation: +# - `tor` arm → EVERY app container must show 0 PERSISTENT public connections (all egress via Tor). +# Only the `tor` container should reach public IPs (Tor relays). A persistent app +# connection = LEAK → exit 1. +# - `clearnet` → the mining-path containers (p2pool, xmrig-proxy while donating) SHOULD show direct +# public connections; monerod/tari staying at 0 confirms node-sync is still Tor +# (the benchmark holds those constant — see docs/benchmarks/tor-vs-clearnet.md). +# +# Ground-truth backstop (needs root, so run by hand): a WAN-interface capture should show NO mining +# traffic to non-Tor IPs in the tor arm — +# sudo tcpdump -ni 'tcp and (port 18080 or portrange 37888-37890 or port 4247) and not host ' + +set -uo pipefail + +ARM="${1:-}"; case "$ARM" in tor|clearnet) shift ;; *) echo "usage: bench-verify-egress.sh [--dir DIR] [--prefix P] [--polls N] [--interval S] [--min-hits K]" >&2; exit 2 ;; esac +DIR="/srv/code/pithead"; PREFIX="172.28.0"; POLLS=4; INTERVAL=10; MIN_HITS=2 +while [ $# -gt 0 ]; do + case "$1" in + --dir) DIR="$2"; shift 2 ;; + --prefix) PREFIX="$2"; shift 2 ;; + --polls) POLLS="$2"; shift 2 ;; + --interval) INTERVAL="$2"; shift 2 ;; + --min-hits) MIN_HITS="$2"; shift 2 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done +[ "$POLLS" -ge 1 ] 2>/dev/null || { echo "--polls must be >= 1" >&2; exit 2; } +[ "$MIN_HITS" -le "$POLLS" ] 2>/dev/null || MIN_HITS="$POLLS" # can't need more hits than polls +APPS="monerod p2pool tari xmrig-proxy" + +# Established (st=01) foreign IPv4s for a container that are PUBLIC (skip loopback/private/bridge/ +# link-local — the Tor SOCKS lives in the private 172.16/12 range, so SOCKS-routed traffic is skipped). +# /proc/net/tcp `rem_address` is little-endian hex "IIIIIIII:PPPP"; decode with bash arithmetic so we +# don't depend on gawk/strtonum inside minimal images (only `cat` runs in the container). IPv4-only by +# design — mining_net is IPv4 (matches the #270 firewall scope). +public_conns() { # → one "ip:port" per established public connection + docker exec "$1" sh -c 'cat /proc/net/tcp 2>/dev/null' | while read -r _sl _local rem st _rest; do + [ "$st" = "01" ] || continue + local hip="${rem%:*}" hport="${rem#*:}" o1 o2 o3 o4 + o1=$((16#${hip:6:2})); o2=$((16#${hip:4:2})); o3=$((16#${hip:2:2})); o4=$((16#${hip:0:2})) + case "$o1.$o2" in 10.*|127.*|0.*|169.254|192.168) continue ;; esac + { [ "$o1" = 172 ] && [ "$o2" -ge 16 ] && [ "$o2" -le 31 ]; } && continue + printf '%d.%d.%d.%d:%d\n' "$o1" "$o2" "$o3" "$o4" "$((16#$hport))" + done +} + +cid_of() { ( cd "$DIR" && docker compose ps -q "$1" 2>/dev/null | head -n1 ); } + +echo "[verify-egress] arm=$ARM stack=$DIR tor-socks=${PREFIX}.25:9050 (polls=$POLLS interval=${INTERVAL}s, persistent>=$MIN_HITS)" + +# Poll POLLS times; per poll record each app's UNIQUE public foreign IPs (drop the churning port). An +# (app, ip) pair seen in >= MIN_HITS distinct polls is a SUSTAINED connection, not a startup transient. +samples="$(mktemp)"; trap 'rm -f "$samples"' EXIT +p=1 +while [ "$p" -le "$POLLS" ]; do + for c in $APPS; do + cid=$(cid_of "$c"); [ -n "$cid" ] || continue + public_conns "$cid" | sed 's/:.*//' | sort -u | sed "s/^/$c /" >> "$samples" + done + [ "$p" -lt "$POLLS" ] && sleep "$INTERVAL" + p=$((p + 1)) +done +# uniq -c over the per-poll-unique lines = #polls each (app,ip) appeared in; keep the persistent ones. +persistent=$(sort "$samples" | uniq -c | awk -v m="$MIN_HITS" '$1>=m {print $2" "$3" "$1}') # "app ip hits" + +fail=0 +for c in $APPS; do + cid=$(cid_of "$c"); [ -n "$cid" ] || { echo " - $c: not running (skip)"; continue; } + rows=$(printf '%s\n' "$persistent" | awk -v a="$c" -v P="$POLLS" '$1==a {print $2" ("$3"/"P" polls)"}') + n=$(printf '%s' "$rows" | grep -c . || true) + if [ "$ARM" = "tor" ]; then + if [ "$n" -eq 0 ]; then echo " ✓ $c: no persistent public connections — all egress via Tor" + else echo " ✗ $c: $n PERSISTENT PUBLIC connection(s) — CLEARNET LEAK:"; printf '%s\n' "$rows" | sed 's/^/ /'; fail=1; fi + else + if [ "$n" -gt 0 ]; then echo " ✓ $c: $n persistent public connection(s) — clearnet, as expected for this arm" + else echo " · $c: no persistent public connections (still Tor / idle — expected for monerod & tari)"; fi + fi +done +tcid=$(cid_of tor); tn=$(public_conns "$tcid" | sed 's/:.*//' | sort -u | grep -c . || true) +echo " · tor: $tn external relay connection(s) (expected > 0 — this is the only container that should reach the internet)" + +if [ "$ARM" = "tor" ] && [ "$fail" -ne 0 ]; then + echo "[verify-egress] FAIL — persistent clearnet leak(s) above; the 'all-Tor' arm is not clean." >&2; exit 1 +fi +echo "[verify-egress] OK" diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 8aa1597..7f4a55c 100755 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -491,6 +491,27 @@ assert_scenario() { assert_contains "re-apply is a no-op" "$again" "No configuration changes detected" } +# Runtime egress posture (#274) — the structural proof of #270, beyond config: poll each app +# container's LIVE connections and FAIL if any holds a PERSISTENT direct public connection (i.e. it +# isn't dialing through the Tor SOCKS). Config-level checks miss this — it's what caught the #165 +# stale-image p2pool leak and the #271 Tari direct-dial. Reuses bench-verify-egress.sh (the #256 +# verifier) in its persistent-only mode so post-restart startup transients don't false-positive. +# Skipped when a clearnet initial sync is active (#183): a node is then intentionally on clearnet. +assert_egress_posture() { + local mc tc prefix out + mc="$(env_on_box MONERO_CLEARNET_SYNC)"; tc="$(env_on_box TARI_CLEARNET_SYNC)" + if [ "$mc" = "true" ] || [ "$tc" = "true" ]; then + it_log " egress: clearnet initial sync active (#183) — skipping the all-Tor egress gate" + return 0 + fi + prefix="$(env_on_box NETWORK_PREFIX)"; [ -n "$prefix" ] || prefix="172.28.0" + out="$(rx "bash tests/integration/benchmarks/bench-verify-egress.sh tor --dir . --prefix '$prefix' --polls 3 --interval 8 2>&1")" + case "$out" in + *"[verify-egress] OK"*) it_pass "no clearnet egress — every app dials via Tor (#274/#270)" ;; + *) it_fail "no clearnet egress — every app dials via Tor (#274/#270)" "$(printf '%s' "$out" | grep -E 'LEAK|✗' | head -4)" ;; + esac +} + # Non-destructive --check: assert the box's CURRENT live state (its own config), no apply. assert_current_state() { IT_CURRENT_SCENARIO="check" @@ -498,6 +519,7 @@ assert_current_state() { it_log "── read-only check against the live stack ──────────" local fails_before="$IT_FAIL" assert_running_state "check" "$BASELINE_CONFIG" + assert_egress_posture [ "$IT_FAIL" -gt "$fails_before" ] && capture_artifacts "check" "$OUT_DIR" } From cb172e88d49c6b2eaee54c1904f15d6e3da45b1e Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Wed, 17 Jun 2026 10:01:37 -0500 Subject: [PATCH 10/44] =?UTF-8?q?fix(#273):=20fail-loud=20when=20a=20stale?= =?UTF-8?q?=20p2pool=20image=20drops=20the=20Tor=20flags=20(compose?= =?UTF-8?q?=E2=86=94image=20coupling)=20(#289)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #165 moved P2POOL_FLAGS to the container env, word-split by a new entrypoint. A 'new compose + old image' partial update leaves the old entrypoint ignoring the env → p2pool launches with no --socks5 and dials the sidechain over CLEARNET, silently. #270 now DROPs that dial (so a stale p2pool can't peer rather than leaking) and #272 rebuilds on deploy — but make the CAUSE obvious instead of a mysterious can't-peer stall: - pithead doctor: when clearnet is off (P2POOL_FLAGS carries --socks5), read the RUNNING p2pool argv from /proc/1/cmdline (the env flags are word-split in the entrypoint, so they never show in the container Args) and FAIL with a rebuild hint if --socks5 is absent. Image-independent (pithead is always current). - p2pool entrypoint: log the final launch command, so the applied flags are auditable in `docker logs p2pool`. Tests: doctor FAILs on a stale (no-socks5) p2pool argv, OK when --socks5 present. Live: confirmed gouda's p2pool exposes --socks5 in /proc/1/cmdline. Co-authored-by: Claude Opus 4.8 (1M context) --- build/p2pool/entrypoint.sh | 4 ++++ pithead | 17 +++++++++++++++++ tests/stack/run.sh | 11 +++++++++++ 3 files changed, 32 insertions(+) diff --git a/build/p2pool/entrypoint.sh b/build/p2pool/entrypoint.sh index bb738a9..f5a677a 100644 --- a/build/p2pool/entrypoint.sh +++ b/build/p2pool/entrypoint.sh @@ -8,5 +8,9 @@ set -euo pipefail # routing — e.g. "--mini --socks5 172.28.0.25:9050 --socks5-proxy-type tor"). We word-split it HERE # because Docker Compose passes a `- ${VAR}` command item as ONE argument (no word-splitting), which # would hand p2pool a single mangled flag. An empty value expands to nothing (no stray empty arg). +# Log the FINAL launch command (#273): makes the applied flags — notably the #165 `--socks5` Tor +# routing — auditable in `docker logs p2pool`, so a stale image silently dropping P2POOL_FLAGS shows +# up here (and `pithead doctor` fails on it) rather than leaking quietly. +echo "[p2pool-entrypoint] launching: p2pool $* ${P2POOL_FLAGS:-}" # shellcheck disable=SC2086 # intentional word-splitting of the space-separated flag string exec p2pool "$@" ${P2POOL_FLAGS:-} diff --git a/pithead b/pithead index d738473..3a1a968 100755 --- a/pithead +++ b/pithead @@ -594,6 +594,23 @@ doctor() { dr_ok "No clearnet initial sync active — all node P2P is Tor-only (#183)." fi + # p2pool Tor-routing fail-safe (#273): with clearnet off, P2POOL_FLAGS carries the #165 --socks5; + # the RUNNING p2pool must actually carry it. A STALE p2pool image (pre-#165 entrypoint) ignores the + # env var and dials sidechain peers directly — over CLEARNET, exposing the home IP. The #270 firewall + # now DROPs that dial (so a stale p2pool just can't peer rather than leaking), but surface the cause + # loudly so the fix — rebuild with 'pithead upgrade' — is obvious instead of a silent leak/stall. + # Read the EXEC'd args from /proc/1/cmdline (the env flags are word-split in the entrypoint, so they + # never appear in the container's Args). Empty = p2pool not running (often an intentional sync hold + # #31/#35) — don't flag. + if printf '%s' "$(env_get P2POOL_FLAGS 2>/dev/null)" | grep -q -- '--socks5'; then + local _p2args; _p2args=$(docker exec p2pool cat /proc/1/cmdline 2>/dev/null | tr '\0' ' ') + case "$_p2args" in + "") : ;; + *--socks5*) dr_ok "p2pool routes outbound sidechain P2P via Tor (#165)." ;; + *) dr_fail "p2pool is NOT routing over Tor despite p2pool.clearnet=false — its sidechain P2P would hit CLEARNET (exposing your IP). Almost certainly a STALE p2pool image not applying P2POOL_FLAGS (#165/#273): run 'pithead upgrade' to rebuild it. (#270's egress firewall blocks the dial meanwhile, so p2pool can't peer until you do.)" ;; + esac + fi + # --- Disk: free space per underlying filesystem --- echo "" echo "Disk:" diff --git a/tests/stack/run.sh b/tests/stack/run.sh index d096329..466467c 100755 --- a/tests/stack/run.sh +++ b/tests/stack/run.sh @@ -43,6 +43,7 @@ case "$*" in "exec tor cat /var/lib/tor/monero/hostname") echo "mona.onion" ;; "exec tor cat /var/lib/tor/tari/hostname") echo "taria.onion" ;; "exec tor cat /var/lib/tor/p2pool/hostname") echo "p2pa.onion" ;; + "exec p2pool cat /proc/1/cmdline") printf '%s' "${P2POOL_PROC1:-}" ;; # #273: tests set the running p2pool argv *hash-password*) # Fake `caddy hash-password` (#8): a per-password digest so enable/change paths differ, and it # never echoes the plaintext back (real bcrypt doesn't either) — keeps the leak checks honest. @@ -956,6 +957,16 @@ printf '{ "monero": {"mode":"local","wallet_address":"%s","node_username":"u","n out="$(cd "$V" && DOCKER_LOG="$DOCKER_LOG" PATH="$V/bin:$PATH" ./pithead apply -y 2>&1)" assert_contains "doctor: OK when Tor-only (#183)" "$(cd "$V" && PATH="$V/bin:$PATH" ./pithead doctor 2>&1)" "Tor-only" +# p2pool compose↔image coupling fail-safe (#273): clearnet is off, so apply renders P2POOL_FLAGS with +# the #165 --socks5. doctor reads the RUNNING p2pool argv (/proc/1/cmdline, stubbed via P2POOL_PROC1) +# and must FAIL loudly if --socks5 is absent (a stale pre-#165 image silently dropping the env flags), +# and pass when it IS present. The config above (p2pool.pool=mini, clearnet default off) is reused. +dr273() { cd "$V" && P2POOL_PROC1="$1" PATH="$V/bin:$PATH" ./pithead doctor 2>&1; } +assert_contains "doctor FAILs when p2pool isn't on Tor — stale image (#273)" \ + "$(dr273 'p2pool --host 172.28.0.26 --rpc-port 18081 --mini')" "STALE p2pool image" +assert_contains "doctor OK when p2pool IS routed over Tor (#273)" \ + "$(dr273 'p2pool --host 172.28.0.26 --mini --socks5 172.28.0.25:9050 --socks5-proxy-type tor')" "routes outbound sidechain P2P via Tor" + echo "== black-box: local node creds auto-generated + persisted (#50) ==" # A local node with BLANK creds: apply must generate them, write them into .env AND back into # config.json, and keep them stable on a second apply (don't regenerate every run). From ed8482308198d8c3eac852b0dea2c1f8d7084101 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Wed, 17 Jun 2026 10:07:07 -0500 Subject: [PATCH 11/44] fix(#278): keep p2pool's monerod RPC/ZMQ direct under Tor (loopback bridge) (#290) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit p2pool's --socks5 (#165 Tor sidechain routing) ALSO proxies its monerod RPC/ZMQ connection — and p2pool exempts ONLY loopback (127.0.0.1/::1/localhost) from the proxy (p2pool v4.16 json_rpc_request.cpp / util.cpp is_localhost). So with the default p2pool.clearnet=false, p2pool dialled the local monerod's private Docker IP THROUGH Tor → "get_info ... empty response" → no template → no mining. There's no per-host proxy-bypass flag. Fix: when --socks5 is on, the entrypoint bridges 127.0.0.1 -> the real node with socat and repoints --host at loopback, so the node RPC/ZMQ stay DIRECT while the sidechain P2P still rides --socks5 over Tor. Reads the node/ports from the args (no compose coupling); no-op when clearnet (no --socks5) or node already loopback. This was masked until #272 (e2e now rebuilds) — prior e2e ran the pre-#165 p2pool with no --socks5. Without it, the v1.1 Tor default produces zero shares. Co-authored-by: Claude Opus 4.8 (1M context) --- build/p2pool/Dockerfile | 6 ++++-- build/p2pool/entrypoint.sh | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/build/p2pool/Dockerfile b/build/p2pool/Dockerfile index f4b99f4..804e514 100644 --- a/build/p2pool/Dockerfile +++ b/build/p2pool/Dockerfile @@ -4,9 +4,11 @@ FROM ubuntu:24.04@sha256:786a8b558f7be160c6c8c4a54f9a57274f3b4fb1491cf65146521ae ARG P2POOL_VERSION=v4.16 ARG P2POOL_HASH=1b03b2e4d4adfe488b2867eb85b57366fbaf8594a7f84cdbf13a3c8519159280 -# Install runtime dependencies +# Install runtime dependencies. socat (#278): the entrypoint bridges 127.0.0.1 -> the real monerod so +# p2pool's node RPC/ZMQ stay DIRECT while --socks5 routes only the sidechain over Tor (p2pool proxies +# everything but loopback when --socks5 is set). RUN apt-get update && apt-get install -y \ - wget ca-certificates libgomp1 \ + wget ca-certificates libgomp1 socat \ && rm -rf /var/lib/apt/lists/* RUN wget -O p2pool.tar.gz https://github.com/SChernykh/p2pool/releases/download/${P2POOL_VERSION}/p2pool-${P2POOL_VERSION}-linux-x64.tar.gz \ diff --git a/build/p2pool/entrypoint.sh b/build/p2pool/entrypoint.sh index f5a677a..0fad300 100644 --- a/build/p2pool/entrypoint.sh +++ b/build/p2pool/entrypoint.sh @@ -8,9 +8,41 @@ set -euo pipefail # routing — e.g. "--mini --socks5 172.28.0.25:9050 --socks5-proxy-type tor"). We word-split it HERE # because Docker Compose passes a `- ${VAR}` command item as ONE argument (no word-splitting), which # would hand p2pool a single mangled flag. An empty value expands to nothing (no stray empty arg). +# #278: p2pool's --socks5 (#165 Tor sidechain routing) ALSO proxies the monerod RPC/ZMQ connection — +# unless the node address is LOOPBACK. p2pool exempts only 127.0.0.1/::1/localhost from the SOCKS5 +# proxy (verified vs p2pool v4.16: json_rpc_request.cpp / util.cpp is_localhost), so a private Docker +# node IP (e.g. 172.28.0.26) gets dialled THROUGH Tor — which can't reach a private IP → "get_info ... +# empty response", no block template, no mining. There is no per-host proxy-bypass flag. So when the +# Tor proxy is on, bridge 127.0.0.1 -> the real node with socat and repoint --host at loopback: the +# node RPC/ZMQ then stay DIRECT (socat is a plain TCP forward, not p2pool's proxy) while the sidechain +# P2P still rides --socks5 over Tor. The socat hops (loopback -> node) are intra-stack, allowed by the +# #270 firewall (subnet -> 172.16/12). +if printf '%s' "${P2POOL_FLAGS:-}" | grep -q -- '--socks5'; then + _node="" _rpc="18081" _zmq="18083" _prev="" + for _a in "$@"; do + case "$_prev" in --host) _node="$_a" ;; --rpc-port) _rpc="$_a" ;; --zmq-port) _zmq="$_a" ;; esac + _prev="$_a" + done + case "$_node" in + ""|127.0.0.1|::1|localhost) : ;; # already loopback (or p2pool's 127.0.0.1 default) — nothing to bridge + *) + echo "[p2pool-entrypoint] Tor on (#278): bridging 127.0.0.1 -> $_node for monerod RPC($_rpc)/ZMQ($_zmq) so the node stays DIRECT (p2pool only exempts loopback from --socks5)." + socat "TCP-LISTEN:$_rpc,bind=127.0.0.1,fork,reuseaddr" "TCP:$_node:$_rpc" & + socat "TCP-LISTEN:$_zmq,bind=127.0.0.1,fork,reuseaddr" "TCP:$_node:$_zmq" & + _args=(); _skip=0 + for _a in "$@"; do + if [ "$_skip" = 1 ]; then _args+=("127.0.0.1"); _skip=0; continue; fi + _args+=("$_a"); [ "$_a" = "--host" ] && _skip=1 + done + set -- "${_args[@]}" + ;; + esac +fi + # Log the FINAL launch command (#273): makes the applied flags — notably the #165 `--socks5` Tor # routing — auditable in `docker logs p2pool`, so a stale image silently dropping P2POOL_FLAGS shows -# up here (and `pithead doctor` fails on it) rather than leaking quietly. +# up here (and `pithead doctor` fails on it) rather than leaking quietly. After the #278 block above so +# it reflects the rewritten --host. echo "[p2pool-entrypoint] launching: p2pool $* ${P2POOL_FLAGS:-}" # shellcheck disable=SC2086 # intentional word-splitting of the space-separated flag string exec p2pool "$@" ${P2POOL_FLAGS:-} From 9f79215075ed8853d80147e3e4bf4be6415317d3 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Wed, 17 Jun 2026 21:45:36 -0500 Subject: [PATCH 12/44] tooling(#280): ruff lint + format for all repo Python (+ .editorconfig, pre-commit) (#296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style(#280): apply ruff format to the dashboard (format-only) Wave 7 tooling. Adds the [tool.ruff] format config (target py311, line-length 100, generated Tari gRPC stubs excluded) plus a repo-root .editorconfig, then runs `ruff format` across build/dashboard. Pure formatting, no behaviour change: the dashboard pytest suite (524 tests) is green before and after. The lint gate lands in a separate commit so this large, zero-logic diff stays reviewable (per #280). Co-Authored-By: Claude Opus 4.8 (1M context) * build(#280): enable the ruff lint gate for the dashboard Wave 7 tooling. Turns on `ruff check` (E/F/W/I/B/UP/ASYNC/S) alongside the formatter from the previous commit, wired into `make lint-py` (folded into `make lint`) and a new `python-lint` CI job. ruff is pinned via a new `dev` extra so local, pre-commit, and CI all run the same version. Findings resolved: - auto-fixes: import sorting (I), unused imports (F401), pyupgrade to PEP 585/604 (UP) — safe under the py311 target + python:3.11 runtime. - real fixes: chain the re-raised HTTPException (B904); explicit zip(strict=False) (B905); drop a dead tari_status_str/tari_active block (F841 — likely latent UI-status bug, flagged for follow-up). - documented `# noqa` for verified-safe security hits: demo creds in a __main__ example + CSS palette tokens (S105), constant-built SQL with ?-bound values (S608), probabilistic-pruning random (S311), best-effort disk stat (S110). Sentinel "0.0.0.0" strings ignored globally (S104); E501 is owned by the formatter; tests ignore S101/S106/B017/ASYNC240. Dashboard suite green on 3.11: 532 passed, 94% coverage. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(#280): add pre-commit config + document the dev tooling Wave 7 tooling. Adds .pre-commit-config.yaml — ruff-check + ruff-format pinned to v0.15.17 (matching the dev extra), plus detect-private-key / check-added-large-files / end-of-file-fixer / trailing-whitespace, with generated gRPC stubs and vendored/minified assets excluded — and documents the one-time `pip install -e "build/dashboard[dev]" && pre-commit install` setup in CONTRIBUTING.md. Co-Authored-By: Claude Opus 4.8 (1M context) * tooling(#280): lint all repo Python (root ruff config + integration fakes) Extends ruff coverage beyond the dashboard package to the rest of the repo's Python — the tests/integration/fakes/* contract-test helpers were unlinted. Adds a root ruff.toml that `extend`s the dashboard's canonical rules, so `ruff`/pre-commit discover a config from anywhere (dashboard files -> package pyproject; repo-level files -> root config) and Make/CI/pre-commit all lint the same set. `make lint-py` now runs from the repo root; pre-commit's ruff hooks drop their build/dashboard scoping. Fakes: import-sort + format only (no logic change); their contract test still passes (12). Also gitignores .venv/.ruff_cache. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .editorconfig | 24 + .github/workflows/ci.yml | 25 +- .gitignore | 4 +- .pre-commit-config.yaml | 28 + CONTRIBUTING.md | 20 +- Makefile | 9 +- .../client/docker/docker_control.py | 34 +- .../client/monero/monero_client.py | 23 +- .../client/tari/tari_client.py | 26 +- .../mining_dashboard/client/xmrig_client.py | 37 +- .../client/xmrig_proxy_client.py | 23 +- .../mining_dashboard/client/xvb_client.py | 31 +- .../mining_dashboard/collector/logs.py | 75 +-- .../mining_dashboard/collector/pools.py | 70 ++- .../mining_dashboard/collector/system.py | 64 +- .../mining_dashboard/config/config.py | 58 +- .../mining_dashboard/helper/utils.py | 59 +- build/dashboard/mining_dashboard/main.py | 36 +- .../mining_dashboard/service/algo_service.py | 97 +-- .../mining_dashboard/service/clearnet_sync.py | 29 +- .../mining_dashboard/service/data_service.py | 275 +++++---- .../mining_dashboard/service/metrics.py | 122 ++-- .../mining_dashboard/service/node_health.py | 14 +- .../service/storage_service.py | 185 +++--- .../service/update_checker.py | 10 +- .../mining_dashboard/sim/donation_model.py | 114 ++-- build/dashboard/mining_dashboard/version.py | 6 +- .../dashboard/mining_dashboard/web/server.py | 47 +- build/dashboard/mining_dashboard/web/views.py | 420 ++++++++----- build/dashboard/pyproject.toml | 36 ++ .../tests/client/test_docker_control.py | 2 +- .../tests/client/test_monero_client.py | 34 +- .../tests/client/test_tari_client.py | 33 +- .../tests/client/test_xmrig_client.py | 56 +- .../dashboard/tests/client/test_xvb_client.py | 5 +- build/dashboard/tests/collector/test_logs.py | 70 ++- build/dashboard/tests/collector/test_pools.py | 51 +- .../dashboard/tests/collector/test_system.py | 4 +- build/dashboard/tests/config/test_config.py | 6 +- build/dashboard/tests/conftest.py | 1 + build/dashboard/tests/helper/test_utils.py | 16 +- .../tests/service/test_algo_service.py | 177 ++++-- .../tests/service/test_clearnet_sync.py | 28 +- .../tests/service/test_data_service.py | 447 +++++++++----- .../dashboard/tests/service/test_earnings.py | 26 +- build/dashboard/tests/service/test_metrics.py | 53 +- .../tests/service/test_node_health.py | 29 +- .../tests/service/test_storage_service.py | 103 +++- .../tests/service/test_update_checker.py | 51 +- .../tests/sim/test_donation_model.py | 105 +++- build/dashboard/tests/test_version.py | 45 +- build/dashboard/tests/web/test_server.py | 33 +- build/dashboard/tests/web/test_views.py | 557 +++++++++++++----- ruff.toml | 14 + tests/integration/fakes/fake_monerod.py | 15 +- tests/integration/fakes/fake_tari.py | 9 +- tests/integration/fakes/test_contract.py | 14 +- 57 files changed, 2668 insertions(+), 1317 deletions(-) create mode 100644 .editorconfig create mode 100644 .pre-commit-config.yaml create mode 100644 ruff.toml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..630c92d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,24 @@ +# EditorConfig — whitespace/EOL baseline across every file surface (Wave 7 tooling, #280). +# https://editorconfig.org · Mirrors the formatters: ruff (Python), and the per-surface +# tools landing in #281 (shfmt -i 4 for shell, Biome 2-space for JS/CSS/JSON). +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +# Web/config surfaces conventionally use 2-space indent. +[*.{js,mjs,cjs,css,json,jsonc,yml,yaml,toml,proto}] +indent_size = 2 + +# Markdown uses two trailing spaces for a hard line break — don't strip them. +[*.md] +trim_trailing_whitespace = false + +# Makefiles require real tabs. +[Makefile] +indent_style = tab diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f550be6..8471d47 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,6 +76,22 @@ jobs: - name: Lint all build/* Dockerfiles (config in .hadolint.yaml) run: hadolint build/*/Dockerfile + python-lint: + name: Python lint + format (ruff) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - name: Install ruff (pinned via the dashboard's dev extra) + # Single version source: the `dev` extra in build/dashboard/pyproject.toml, so CI, + # pre-commit, and local devs can't drift to different ruff versions (lint is version-sensitive). + run: pip install -e "build/dashboard[dev]" + - name: ruff check + format --check (config in build/dashboard/pyproject.toml) + # Single source of truth: the Makefile `lint-py` target. + run: make lint-py + shell: name: Shell tests (shellcheck + pithead suite) runs-on: ubuntu-latest @@ -86,11 +102,12 @@ jobs: # unrelated third-party mirrors like dl.google.com) and intermittently fails # the job when one is briefly out of sync — see issue #64. - name: Lint pithead, build/* container scripts, and test scripts - # Single source of truth: the Makefile `lint` target (so the file list can't drift between - # here and `make lint`). Now also covers build/*/*.sh — the entrypoints + healthchecks that + # Single source of truth: the Makefile `lint-sh` target (so the file list can't drift + # between here and the Makefile). Covers build/*/*.sh — the entrypoints + healthchecks that # run in every container (#124). Gate on warnings+errors; info-level style nits vary by - # shellcheck version. - run: make lint + # shellcheck version. Python lint runs in its own `python-lint` job (ruff isn't on this + # runner; `make lint` runs both for local devs). + run: make lint-sh - name: Run pithead test suite run: bash tests/stack/run.sh - name: Run integration harness self-test diff --git a/.gitignore b/.gitignore index 9c1ee47..de7c509 100644 --- a/.gitignore +++ b/.gitignore @@ -21,9 +21,11 @@ __pycache__/ htmlcov/ *.egg-info/ .eggs/ +.venv/ +.ruff_cache/ # Integration test artifacts (manifest, per-scenario logs, captured state) /tests/integration/results/ # OS -.DS_Store \ No newline at end of file +.DS_Store diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..f443f36 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +# Pre-commit hooks (Wave 7 tooling, #280). Local == CI: these mirror `make lint` + the CI lint +# jobs, so issues are caught before they reach a PR. Set up once with: +# pip install -e "build/dashboard[dev]" && pre-commit install +# Later Wave 7 children add more hooks here (#281 shfmt/Biome/yamllint/markdownlint; +# #282 gitleaks). Keep the ruff `rev` in lockstep with the `dev` extra's ruff pin in +# build/dashboard/pyproject.toml. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.17 + hooks: + # Lints all repo Python; ruff discovers the right config per file (the dashboard package's + # pyproject.toml, or the root ruff.toml for the integration-test fakes). + - id: ruff-check + args: [--fix] + - id: ruff-format + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + # Cheap, high-value guards across every surface. + - id: detect-private-key # never commit an onion/wallet/SSH private key + - id: check-added-large-files + # Skip generated gRPC stubs and vendored/minified third-party assets — not ours to reformat. + - id: end-of-file-fixer + exclude: '^build/dashboard/mining_dashboard/(client/tari/generated/|web/static/vendor/)' + - id: trailing-whitespace + # Also skip Markdown — two trailing spaces are a hard line break (see .editorconfig). + exclude: '(^build/dashboard/mining_dashboard/(client/tari/generated/|web/static/vendor/)|\.md$)' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b801517..9cca473 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,19 @@ whole new feature, contributions are very welcome. This guide covers the workflo - Check the [open issues](https://github.com/p2pool-starter-stack/pithead/issues) to see if someone's already on it. +## Dev environment + +The dashboard's Python tooling — [`ruff`](https://docs.astral.sh/ruff/) (lint + format) and +[`pre-commit`](https://pre-commit.com/) — is pinned in the `dev` extra. Install it once and +enable the git hooks, so the same checks that run in CI run on every commit (local == CI): + +```bash +pip install -e "build/dashboard[dev]" +pre-commit install +``` + +`pre-commit` then runs `ruff` (plus a few hygiene hooks) on your changed files automatically. + ## Development workflow 1. Fork the repo and create a branch off `main`. @@ -24,7 +37,8 @@ whole new feature, contributions are very welcome. This guide covers the workflo This runs everything CI does without a server or Docker: - **lint** — `shellcheck` over `pithead` and the test scripts (keep them - `--severity=warning` clean). + `--severity=warning` clean), plus `ruff` lint + format check over all the repo's Python. + Run one surface on its own with `make lint-sh` or `make lint-py`. - **test-dashboard** — the dashboard `pytest` suite (must stay ≥ the **80% coverage gate**). - **test-stack** — the `pithead` shell test suite. - **test-compose** — `docker-compose.yml` interpolation validation. @@ -52,7 +66,9 @@ whole new feature, contributions are very welcome. This guide covers the workflo ## Style -- Match the surrounding code. Shell scripts should pass `shellcheck --severity=warning`. +- Match the surrounding code. Shell scripts should pass `shellcheck --severity=warning`; + Python is linted and formatted by `ruff` (config in `build/dashboard/pyproject.toml`) — + run `make lint-py`, or `cd build/dashboard && ruff format` to apply it. - Keep commits tidy and messages descriptive. By contributing, you agree that your contributions are licensed under the project's diff --git a/Makefile b/Makefile index 705cc48..bf10af4 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Local test entry points (mirror the GitHub Actions CI jobs). -.PHONY: test test-dashboard test-stack test-compose test-integration test-integration-selftest test-fakes test-mini-stack lint release +.PHONY: test test-dashboard test-stack test-compose test-integration test-integration-selftest test-fakes test-mini-stack lint lint-sh lint-py release test: lint test-dashboard test-stack test-compose test-integration-selftest test-fakes ## Run everything that doesn't need a server/docker @@ -37,10 +37,15 @@ test-inventory-check: ## Fail if docs/test-inventory.md is stale (CI drift guard test-integration: ## Run the live config-matrix integration suite (requires a test box; pass ARGS=...) bash tests/integration/run.sh $(ARGS) -lint: ## shellcheck the CLI, the build/* container scripts, the release script, and the test scripts +lint: lint-sh lint-py ## Lint every surface (shell + Python) + +lint-sh: ## shellcheck the CLI, the build/* container scripts, the release script, and the test scripts shellcheck --severity=warning pithead scripts/*.sh build/*/*.sh tests/stack/run.sh tests/stack/test_compose.sh \ tests/inventory.sh tests/integration/*.sh tests/integration/mini-stack/*.sh +lint-py: ## ruff lint + format check on all repo Python (install ruff: pip install -e "build/dashboard[dev]") + ruff check . && ruff format --check . + # Cut a release from the private build/test server (gouda) — GHCR publish, gated on the test suite + # the #54 integration matrix (issue #44). Pass options through ARGS, e.g. a safe plan-only preview: # make release ARGS="--dry-run" diff --git a/build/dashboard/mining_dashboard/client/docker/docker_control.py b/build/dashboard/mining_dashboard/client/docker/docker_control.py index 693ecce..28e9513 100644 --- a/build/dashboard/mining_dashboard/client/docker/docker_control.py +++ b/build/dashboard/mining_dashboard/client/docker/docker_control.py @@ -1,6 +1,7 @@ -import aiohttp import logging +import aiohttp + from mining_dashboard.config.config import DOCKER_CONTROL_URL, DOCKER_TIMEOUT logger = logging.getLogger("DockerControl") @@ -39,22 +40,33 @@ async def stop(self, container, stop_timeout=10, quiet=False, request_timeout=No daemon needs an HTTP timeout that OUTLASTS `stop_timeout` or the call gives up early and wrongly reports failure (#234: Tari took >5s to stop, so the default timeout aborted it). """ - return await self._post(f"/containers/{container}/stop", params={"t": stop_timeout}, - action="stop", container=container, quiet=quiet, - request_timeout=request_timeout) + return await self._post( + f"/containers/{container}/stop", + params={"t": stop_timeout}, + action="stop", + container=container, + quiet=quiet, + request_timeout=request_timeout, + ) async def start(self, container, quiet=False, request_timeout=None): """Start a container. Returns True on success (incl. already-running).""" - return await self._post(f"/containers/{container}/start", params=None, - action="start", container=container, quiet=quiet, - request_timeout=request_timeout) + return await self._post( + f"/containers/{container}/start", + params=None, + action="start", + container=container, + quiet=quiet, + request_timeout=request_timeout, + ) async def _post(self, path, params, action, container, quiet=False, request_timeout=None): url = f"{self.base_url}{path}" try: async with aiohttp.ClientSession() as session: - async with session.post(url, params=params, - timeout=request_timeout or self.timeout) as resp: + async with session.post( + url, params=params, timeout=request_timeout or self.timeout + ) as resp: # 204 No Content = done; 304 Not Modified = already in that state # (Docker's idempotent response) — both are success for us. if resp.status in (204, 304): @@ -62,7 +74,9 @@ async def _post(self, path, params, action, container, quiet=False, request_time log(f"Container {container} {action}: ok (HTTP {resp.status})") return True body = await resp.text() - logger.error(f"Container {container} {action} failed: HTTP {resp.status} {body[:200]}") + logger.error( + f"Container {container} {action} failed: HTTP {resp.status} {body[:200]}" + ) return False except Exception as e: logger.error(f"Container {container} {action} error via {self.base_url}: {e}") diff --git a/build/dashboard/mining_dashboard/client/monero/monero_client.py b/build/dashboard/mining_dashboard/client/monero/monero_client.py index 3c05f79..c4ead28 100644 --- a/build/dashboard/mining_dashboard/client/monero/monero_client.py +++ b/build/dashboard/mining_dashboard/client/monero/monero_client.py @@ -1,11 +1,12 @@ import logging + import requests from requests.auth import HTTPDigestAuth from mining_dashboard.config.config import ( - MONERO_RPC_URL, - MONERO_NODE_USERNAME, MONERO_NODE_PASSWORD, + MONERO_NODE_USERNAME, + MONERO_RPC_URL, ) logger = logging.getLogger("MoneroClient") @@ -25,8 +26,13 @@ class MoneroClient: via `asyncio.to_thread`, mirroring how XvbClient / proxy_client are used. """ - def __init__(self, url=MONERO_RPC_URL, username=MONERO_NODE_USERNAME, - password=MONERO_NODE_PASSWORD, timeout=5): + def __init__( + self, + url=MONERO_RPC_URL, + username=MONERO_NODE_USERNAME, + password=MONERO_NODE_PASSWORD, + timeout=5, + ): self.url = url.rstrip("/") + "/get_info" # No creds (e.g. a remote node deployment) → send unauthenticated; the request # will simply fail and the caller falls back to log scraping. @@ -88,5 +94,10 @@ def get_sync_status(self): return {"is_syncing": False, "db_size": db_size} percent = int((height / target) * 100) - return {"is_syncing": True, "current": height, "target": target, - "percent": percent, "db_size": db_size} + return { + "is_syncing": True, + "current": height, + "target": target, + "percent": percent, + "db_size": db_size, + } diff --git a/build/dashboard/mining_dashboard/client/tari/tari_client.py b/build/dashboard/mining_dashboard/client/tari/tari_client.py index 04316dc..fd109e2 100644 --- a/build/dashboard/mining_dashboard/client/tari/tari_client.py +++ b/build/dashboard/mining_dashboard/client/tari/tari_client.py @@ -1,19 +1,20 @@ -import aiohttp import logging -import grpc -import os import time +import aiohttp +import grpc + from mining_dashboard.config.config import TARI_GRPC_ADDRESS logger = logging.getLogger("TariClient") # Attempt to import generated protobuf modules # See README.md for generation instructions (requires grpcio-tools) -from .generated import base_node_pb2 -from .generated import base_node_pb2_grpc from google.protobuf import empty_pb2 +from .generated import base_node_pb2_grpc + + class TariClient: # When the base node is briefly overloaded mid-sync (it logs "BaseNodeService failed # to send reply ... ChainMetadata" and its own `status` command times out), gRPC calls @@ -63,7 +64,10 @@ async def get_sync_status(self): # gRPC unreachable this cycle. Serve the last good state briefly (node is likely # just busy), but stop once it's clearly stale so a down node isn't masked forever. - if self._last_sync_status and (time.monotonic() - self._last_sync_ts) <= self._MAX_STALE_SECONDS: + if ( + self._last_sync_status + and (time.monotonic() - self._last_sync_ts) <= self._MAX_STALE_SECONDS + ): return {**self._last_sync_status, "reachable": False} return {"is_syncing": False, "reachable": False} @@ -88,8 +92,12 @@ async def _fetch_sync_status(self): # The node reports initial sync complete — trust it over any height heuristic. if tip.initial_sync_achieved: - return {"is_syncing": False, "current": local_height, - "target": local_height, "percent": 100} + return { + "is_syncing": False, + "current": local_height, + "target": local_height, + "percent": 100, + } # Still syncing: ask the node what height it is syncing toward. target = 0 @@ -113,4 +121,4 @@ async def _fetch_sync_status(self): async def close(self): if self._channel: await self._channel.close() - self._channel = None \ No newline at end of file + self._channel = None diff --git a/build/dashboard/mining_dashboard/client/xmrig_client.py b/build/dashboard/mining_dashboard/client/xmrig_client.py index 0f8f964..1309f06 100644 --- a/build/dashboard/mining_dashboard/client/xmrig_client.py +++ b/build/dashboard/mining_dashboard/client/xmrig_client.py @@ -2,11 +2,11 @@ import logging from mining_dashboard.config.config import ( - XMRIG_API_PORT, - PROXY_API_PORT, - PROXY_AUTH_TOKEN, API_TIMEOUT, MINING_NET_CIDR, + PROXY_API_PORT, + PROXY_AUTH_TOKEN, + XMRIG_API_PORT, ) # Longest worker-name we'll ever echo back as a Bearer token (#122). xmrig names/tokens are short; @@ -42,8 +42,13 @@ def _safe_probe_host(ip): addr = ipaddress.ip_address(host) except ValueError: return None # not a bare IP — never treat a worker name/hostname as a request host - if (addr.is_loopback or addr.is_link_local or addr.is_multicast - or addr.is_unspecified or addr.is_reserved): + if ( + addr.is_loopback + or addr.is_link_local + or addr.is_multicast + or addr.is_unspecified + or addr.is_reserved + ): return None if addr.version == _INTERNAL_NET.version and addr in _INTERNAL_NET: return None @@ -81,7 +86,7 @@ async def get_stats(self, ip, name): # miner-controlled name as a host — that is the SSRF this guard exists to prevent (#122). return {} - name_token = name.split('+')[0].strip()[:_MAX_NAME_TOKEN] if name else "" + name_token = name.split("+")[0].strip()[:_MAX_NAME_TOKEN] if name else "" attempts = [ # 1. Open proxy — no auth header at all @@ -89,16 +94,20 @@ async def get_stats(self, ip, name): ] # 2. Secured proxy on a custom port (only if distinct from XMRIG_API_PORT) if PROXY_AUTH_TOKEN and PROXY_API_PORT != XMRIG_API_PORT: - attempts.append(( - f"http://{host}:{PROXY_API_PORT}/1/summary", - {"Authorization": f"Bearer {PROXY_AUTH_TOKEN}"}, - )) + attempts.append( + ( + f"http://{host}:{PROXY_API_PORT}/1/summary", + {"Authorization": f"Bearer {PROXY_AUTH_TOKEN}"}, + ) + ) # 3. Direct XMRig miner: the name doubles as the access token, sent only to its own IP. if name_token: - attempts.append(( - f"http://{host}:{XMRIG_API_PORT}/1/summary", - {"Authorization": f"Bearer {name_token}"}, - )) + attempts.append( + ( + f"http://{host}:{XMRIG_API_PORT}/1/summary", + {"Authorization": f"Bearer {name_token}"}, + ) + ) for url, headers in attempts: try: diff --git a/build/dashboard/mining_dashboard/client/xmrig_proxy_client.py b/build/dashboard/mining_dashboard/client/xmrig_proxy_client.py index 7c4e4df..2da5923 100644 --- a/build/dashboard/mining_dashboard/client/xmrig_proxy_client.py +++ b/build/dashboard/mining_dashboard/client/xmrig_proxy_client.py @@ -1,32 +1,34 @@ -import requests import json import logging + +import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry + class XMRigProxyClient: def __init__(self, host="127.0.0.1", port=8080, access_token=None): """ Initialize the XMRig Proxy Client. - + :param host: The hostname or IP address of the xmrig-proxy. :param port: The HTTP API port (configured via --http-port). :param access_token: The access token (configured via --http-access-token). """ self.logger = logging.getLogger("ProxyClient") self.base_url = f"http://{host}:{port}" - + # Configure Session with Retries self.session = requests.Session() retry_strategy = Retry( total=3, - backoff_factor=1, # Wait 1s, 2s, 4s between retries + backoff_factor=1, # Wait 1s, 2s, 4s between retries status_forcelist=[429, 500, 502, 503, 504], - allowed_methods=["HEAD", "GET", "PUT", "OPTIONS"] + allowed_methods=["HEAD", "GET", "PUT", "OPTIONS"], ) adapter = HTTPAdapter(max_retries=retry_strategy) self.session.mount("http://", adapter) - + if access_token: self.session.headers.update({"Authorization": f"Bearer {access_token}"}) @@ -165,14 +167,15 @@ def update_config(self, config_data): return {} return response.json() + if __name__ == "__main__": # Configuration # Ensure xmrig-proxy is running with API enabled: # ./xmrig-proxy --http-port=8080 --http-access-token=SECRET - + HOST = "127.0.0.1" - PORT = 8080 - TOKEN = "SECRET" + PORT = 8080 + TOKEN = "SECRET" # noqa: S105 — placeholder for this __main__ usage example, not a real secret client = XMRigProxyClient(HOST, PORT, TOKEN) @@ -200,4 +203,4 @@ def update_config(self, config_data): except requests.exceptions.RequestException as e: print(f"HTTP Request failed: {e}") except Exception as e: - print(f"An error occurred: {e}") \ No newline at end of file + print(f"An error occurred: {e}") diff --git a/build/dashboard/mining_dashboard/client/xvb_client.py b/build/dashboard/mining_dashboard/client/xvb_client.py index 5fafb35..99fd647 100644 --- a/build/dashboard/mining_dashboard/client/xvb_client.py +++ b/build/dashboard/mining_dashboard/client/xvb_client.py @@ -1,8 +1,11 @@ -import requests import logging import re -from mining_dashboard.helper.utils import parse_hashrate + +import requests + from mining_dashboard.config.config import XVB_TOR_PROXY +from mining_dashboard.helper.utils import parse_hashrate + class XvbClient: def __init__(self, wallet_address, tor_proxy=None): @@ -17,7 +20,7 @@ def __init__(self, wallet_address, tor_proxy=None): self.wallet_address = wallet_address self.url = "https://xmrvsbeast.com/cgi-bin/p2pool_bonus_history.cgi" self.tor_proxy = tor_proxy if tor_proxy is not None else XVB_TOR_PROXY - + # Pre-compile regex patterns self.REGEX_FAIL_COUNT = re.compile(r"Fail Count:\s*(\d+)", re.IGNORECASE) self.REGEX_HR_1H = re.compile(r"1hr avg:\s*([\d\.]+)\s*([kKmMgG]?H/s)?", re.IGNORECASE) @@ -26,9 +29,9 @@ def __init__(self, wallet_address, tor_proxy=None): def get_stats(self): """ Retrieves bonus history statistics from the XMRvsBeast service. - + Returns: - dict or None: A dictionary containing 'fail_count', 'avg_1h', and 'avg_24h' + dict or None: A dictionary containing 'fail_count', 'avg_1h', and 'avg_24h' if successful, otherwise None. """ if not self.wallet_address or self.wallet_address == "placeholder": @@ -45,7 +48,9 @@ def get_stats(self): if response.status_code == 200: return self._parse_html(response.text) else: - self.logger.error(f"XvB API request failed with status code: {response.status_code}") + self.logger.error( + f"XvB API request failed with status code: {response.status_code}" + ) return None except requests.RequestException as e: self.logger.error(f"Network error while fetching XvB stats: {e}") @@ -59,11 +64,7 @@ def _parse_html(self, html_text): Parses raw HTML content to extract mining statistics. """ try: - stats = { - "fail_count": 0, - "avg_1h": 0.0, - "avg_24h": 0.0 - } + stats = {"fail_count": 0, "avg_1h": 0.0, "avg_24h": 0.0} # Extract Fail Count fail_match = self.REGEX_FAIL_COUNT.search(html_text) @@ -76,16 +77,18 @@ def _parse_html(self, html_text): if hr1_match: stats["avg_1h"] = parse_hashrate(hr1_match.group(1), hr1_match.group(2)) - + if hr24_match: stats["avg_24h"] = parse_hashrate(hr24_match.group(1), hr24_match.group(2)) if not fail_match and not hr1_match: - self.logger.warning("Parsing Warning: Critical stats not found in XvB response. HTML structure may have changed.") + self.logger.warning( + "Parsing Warning: Critical stats not found in XvB response. HTML structure may have changed." + ) return None return stats except Exception as e: self.logger.error(f"Parsing Error: Failed to process XvB HTML: {e}") - return None \ No newline at end of file + return None diff --git a/build/dashboard/mining_dashboard/collector/logs.py b/build/dashboard/mining_dashboard/collector/logs.py index 1720538..141dbb0 100644 --- a/build/dashboard/mining_dashboard/collector/logs.py +++ b/build/dashboard/mining_dashboard/collector/logs.py @@ -1,19 +1,28 @@ -import aiohttp import asyncio +import json import logging import re import struct -import json + import aiofiles +import aiohttp -from mining_dashboard.config.config import DOCKER_PROXY_URL, LOG_TAIL_LINES, DOCKER_TIMEOUT, NETWORK_STATS_PATH, MONERO_NODE_HOST, LOCAL_MONERO_HOST from mining_dashboard.client.monero.monero_client import MoneroClient +from mining_dashboard.config.config import ( + DOCKER_PROXY_URL, + DOCKER_TIMEOUT, + LOCAL_MONERO_HOST, + LOG_TAIL_LINES, + MONERO_NODE_HOST, + NETWORK_STATS_PATH, +) logger = logging.getLogger("LogCollector") # Stateless client reused across cycles; reads monerod's get_info RPC (Issue #29). _monero_client = MoneroClient() + async def fetch_docker_logs(container_name, tail=None): """ Fetches logs from a container via the Docker Socket Proxy. @@ -26,14 +35,10 @@ async def fetch_docker_logs(container_name, tail=None): base_url = DOCKER_PROXY_URL if base_url.startswith("tcp://"): base_url = base_url.replace("tcp://", "http://") - + # Docker Engine API: /containers/{id}/logs url = f"{base_url}/containers/{container_name}/logs" - params = { - "stdout": 1, - "stderr": 1, - "tail": tail - } + params = {"stdout": 1, "stderr": 1, "tail": tail} try: async with aiohttp.ClientSession() as session: @@ -42,11 +47,14 @@ async def fetch_docker_logs(container_name, tail=None): raw_data = await response.read() return _parse_docker_stream(raw_data) else: - logger.error(f"Failed to fetch logs for {container_name}. Status: {response.status}") + logger.error( + f"Failed to fetch logs for {container_name}. Status: {response.status}" + ) return [f"Error: Could not retrieve logs (Status {response.status})"] except Exception as e: logger.error(f"Error connecting to Docker Proxy at {base_url}: {e}") - return [f"Error: Connection to Docker Proxy failed."] + return ["Error: Connection to Docker Proxy failed."] + def _parse_docker_stream(data): """ @@ -56,54 +64,56 @@ def _parse_docker_stream(data): logs = [] i = 0 n = len(data) - + while i < n: if i + 8 > n: break - - payload_size = struct.unpack('>I', data[i+4:i+8])[0] - + + payload_size = struct.unpack(">I", data[i + 4 : i + 8])[0] + i += 8 if i + payload_size > n: break - - line = data[i:i+payload_size].decode('utf-8', errors='replace').strip() + + line = data[i : i + payload_size].decode("utf-8", errors="replace").strip() if line: logs.append(line) - + i += payload_size - + return logs + async def get_monero_logs(tail=None): return await fetch_docker_logs("monerod", tail=tail) + async def _get_remote_monero_sync_status(): """ Reads monerod sync status from the local stats file generated by p2pool. """ try: - async with aiofiles.open(NETWORK_STATS_PATH, mode='r') as f: + async with aiofiles.open(NETWORK_STATS_PATH) as f: contents = await f.read() if not contents: - return {"is_syncing": False} - + return {"is_syncing": False} + stats = json.loads(contents) - + current_height = stats.get("height", 0) target_height = stats.get("target_height", 0) - + if target_height > 0 and current_height < target_height: percent = int((current_height / target_height) * 100) return { "is_syncing": True, "current": current_height, "target": target_height, - "percent": percent + "percent": percent, } else: - return {"is_syncing": False} - + return {"is_syncing": False} + except FileNotFoundError: logger.warning(f"Network stats file not found at {NETWORK_STATS_PATH}") return {"is_syncing": False} @@ -114,6 +124,7 @@ async def _get_remote_monero_sync_status(): logger.error(f"Error reading monero sync status: {e}") return {"is_syncing": False} + async def _get_local_monero_sync_status(): """ Sync status for a local monerod. @@ -174,15 +185,11 @@ async def _get_monero_sync_status_from_logs(): elif target > 0: percent = int((current / target) * 100) - return { - "is_syncing": True, - "current": current, - "target": target, - "percent": percent - } + return {"is_syncing": True, "current": current, "target": target, "percent": percent} return {"is_syncing": False} + async def get_monero_sync_status(): """ Determines whether to check local docker logs or P2Pool's network stats @@ -194,4 +201,4 @@ async def get_monero_sync_status(): # feature (Issue #31) deliberately no-ops for remote nodes (p2pool manages those). status = await _get_remote_monero_sync_status() status.setdefault("reachable", True) - return status \ No newline at end of file + return status diff --git a/build/dashboard/mining_dashboard/collector/pools.py b/build/dashboard/mining_dashboard/collector/pools.py index 23c71da..0680acd 100644 --- a/build/dashboard/mining_dashboard/collector/pools.py +++ b/build/dashboard/mining_dashboard/collector/pools.py @@ -1,13 +1,16 @@ import json import os -import time + from mining_dashboard.config.config import ( - P2P_STATS_PATH, POOL_STATS_PATH, NETWORK_STATS_PATH, - STRATUM_STATS_PATH, TARI_STATS_PATH, SECOND_PER_BLOCK_MAIN, - BLOCK_PPLNS_WINDOW_MAIN, BLOCK_PPLNS_WINDOW_MINI, BLOCK_PPLNS_WINDOW_NANO, - SECOND_PER_BLOCK_P2POOL_MAIN, SECOND_PER_BLOCK_P2POOL_MINI, SECOND_PER_BLOCK_P2POOL_NANO + NETWORK_STATS_PATH, + P2P_STATS_PATH, + POOL_STATS_PATH, + SECOND_PER_BLOCK_MAIN, + STRATUM_STATS_PATH, + TARI_STATS_PATH, ) + def _read_json(path): """ Safely loads a JSON file, returning an empty dictionary on failure. @@ -15,7 +18,7 @@ def _read_json(path): """ if os.path.exists(path): try: - with open(path, 'r') as f: + with open(path) as f: return json.load(f) except (json.JSONDecodeError, OSError): # Fail silently to allow the dashboard to continue running @@ -23,17 +26,18 @@ def _read_json(path): pass return {} + def detect_pool_type(peers): """ Heuristically detects the P2Pool network type (Main, Mini, Nano) based on peer ports. - + Args: peers (list): List of peer connection strings (e.g., "1.2.3.4:37889"). """ counts = {"Main": 0, "Mini": 0, "Nano": 0} - if not peers: + if not peers: return "Unknown" - + # Match the port exactly (the last colon-segment), not as a substring of the whole peer # string — an IP that merely contains the port digits (e.g. "37.88.9.1:18080") would # otherwise be miscounted, and pool type drives block_time / the PPLNS-window duration the @@ -47,13 +51,14 @@ def detect_pool_type(peers): winner = max(counts, key=counts.get) return winner if counts[winner] > 0 else "Unknown" + def get_p2pool_stats(): """Aggregates P2Pool local statistics and P2P network health data.""" raw_p2p = _read_json(P2P_STATS_PATH) raw_pool = _read_json(POOL_STATS_PATH) raw_stratum = _read_json(STRATUM_STATS_PATH) pool_stats = raw_pool.get("pool_statistics", {}) - + pool_type = detect_pool_type(raw_p2p.get("peers", [])) last_share_time = raw_stratum.get("last_share_found_time", 0) @@ -66,7 +71,7 @@ def get_p2pool_stats(): "in_peers": raw_p2p.get("incoming_connections", 0), "peers_count": raw_p2p.get("peer_list_size", 0), "uptime": raw_p2p.get("uptime", 0), - "zmq_active": raw_p2p.get("zmq_last_active", 0) + "zmq_active": raw_p2p.get("zmq_last_active", 0), }, "pool": { "hashrate": pool_stats.get("hashRate", 0), @@ -81,51 +86,54 @@ def get_p2pool_stats(): "total_hashes": pool_stats.get("totalHashes", 0), "shares_found": shares_total, "last_share_time": last_share_time, - } + }, } return stats + def get_network_stats(): """Retrieves Monero network statistics (Difficulty, Height, Reward).""" raw = _read_json(NETWORK_STATS_PATH) - - diff = raw.get('difficulty', 0) - hashrate = raw.get('hash', 'N/A') - + + diff = raw.get("difficulty", 0) + hashrate = raw.get("hash", "N/A") + # Calculate hashrate if missing (Difficulty / Target Time) - if (hashrate == 'N/A' or hashrate == 0) and diff > 0: + if (hashrate == "N/A" or hashrate == 0) and diff > 0: hashrate = diff / SECOND_PER_BLOCK_MAIN - + return { "difficulty": diff, - "height": raw.get('height', 0), - "reward": raw.get('reward', 0), + "height": raw.get("height", 0), + "reward": raw.get("reward", 0), "hash": hashrate, - "timestamp": raw.get('timestamp', 0) + "timestamp": raw.get("timestamp", 0), } + def get_stratum_stats(): """ Parses local stratum statistics to extract worker configurations. - + Returns: tuple: (Raw JSON dict, List of worker config dicts) """ raw = _read_json(STRATUM_STATS_PATH) - + worker_configs = [] # Iterate through worker entries (Format: "IP, ..., ..., ..., Name, ...") for w_entry in raw.get("workers", []): if isinstance(w_entry, str): - parts = w_entry.split(',') + parts = w_entry.split(",") if len(parts) >= 1: - ip = parts[0].split(':')[0].strip() + ip = parts[0].split(":")[0].strip() # Default to "miner" if name field (index 4) is missing name = parts[4].strip() if len(parts) >= 5 else "miner" worker_configs.append({"ip": ip, "name": name, "parts": parts}) return raw, worker_configs + def get_tari_stats(): """Retrieves Tari merge mining status and rewards.""" raw = _read_json(TARI_STATS_PATH) @@ -134,10 +142,10 @@ def get_tari_stats(): t = chains[0] return { "active": True, - "status": t.get('channel_state', 'UNKNOWN'), - "address": t.get('wallet', 'Unknown'), - "height": t.get('height', 0), - "reward": t.get('reward', 0) / 1_000_000, # Convert uTari to Tari - "difficulty": t.get('difficulty', 0) + "status": t.get("channel_state", "UNKNOWN"), + "address": t.get("wallet", "Unknown"), + "height": t.get("height", 0), + "reward": t.get("reward", 0) / 1_000_000, # Convert uTari to Tari + "difficulty": t.get("difficulty", 0), } - return {"active": False} \ No newline at end of file + return {"active": False} diff --git a/build/dashboard/mining_dashboard/collector/system.py b/build/dashboard/mining_dashboard/collector/system.py index e527645..bac77d1 100644 --- a/build/dashboard/mining_dashboard/collector/system.py +++ b/build/dashboard/mining_dashboard/collector/system.py @@ -1,11 +1,13 @@ -import shutil import os +import shutil + from mining_dashboard.config.config import DISK_PATH -BYTES_IN_GB = 1024 ** 3 +BYTES_IN_GB = 1024**3 _last_cpu_times = None + def get_disk_usage(): """ Calculates storage utilization for the configured data directory. @@ -21,14 +23,12 @@ def get_disk_usage(): "total_gb": usage.total / BYTES_IN_GB, "used_gb": usage.used / BYTES_IN_GB, "percent": percent, - "percent_str": f"{percent:.1f}%" + "percent_str": f"{percent:.1f}%", } except Exception: # Return zeroed metrics if the path is inaccessible - return { - "total_gb": 0, "used_gb": 0, - "percent": 0, "percent_str": "0%" - } + return {"total_gb": 0, "used_gb": 0, "percent": 0, "percent_str": "0%"} + def get_memory_usage(): """ @@ -38,13 +38,13 @@ def get_memory_usage(): try: mem_total = 0 mem_available = 0 - with open('/proc/meminfo', 'r') as f: + with open("/proc/meminfo") as f: for line in f: - if line.startswith('MemTotal:'): - mem_total = int(line.split()[1]) * 1024 # kB to bytes - elif line.startswith('MemAvailable:'): - mem_available = int(line.split()[1]) * 1024 # kB to bytes - + if line.startswith("MemTotal:"): + mem_total = int(line.split()[1]) * 1024 # kB to bytes + elif line.startswith("MemAvailable:"): + mem_available = int(line.split()[1]) * 1024 # kB to bytes + if mem_total > 0: used = mem_total - mem_available percent = (used / mem_total) * 100 @@ -52,12 +52,13 @@ def get_memory_usage(): "total_gb": mem_total / BYTES_IN_GB, "used_gb": used / BYTES_IN_GB, "percent": percent, - "percent_str": f"{percent:.1f}%" + "percent_str": f"{percent:.1f}%", } - except Exception: + except Exception: # noqa: S110 — best-effort disk stat; any failure falls through to the zeroed default below pass return {"total_gb": 0, "used_gb": 0, "percent": 0, "percent_str": "0%"} + def get_load_average(): """ Returns system load average (1m, 5m, 15m) as a string. @@ -68,6 +69,7 @@ def get_load_average(): except Exception: return "0.00 0.00 0.00" + def get_cpu_usage(): """ Calculates CPU usage percentage using /proc/stat. @@ -75,19 +77,20 @@ def get_cpu_usage(): """ global _last_cpu_times try: - with open('/proc/stat', 'r') as f: + with open("/proc/stat") as f: line = f.readline() - + parts = line.split() # cpu user nice system idle iowait irq softirq steal - if len(parts) < 5: return "0.0%" - + if len(parts) < 5: + return "0.0%" + # Sum all fields for total time values = [int(x) for x in parts[1:]] total = sum(values) # Idle is idle + iowait idle = values[3] + (values[4] if len(values) > 4 else 0) - + usage = 0.0 if _last_cpu_times: prev_total, prev_idle = _last_cpu_times @@ -95,16 +98,17 @@ def get_cpu_usage(): delta_idle = idle - prev_idle if delta_total > 0: usage = ((delta_total - delta_idle) / delta_total) * 100 - + _last_cpu_times = (total, idle) return f"{usage:.1f}%" except Exception: return "0.0%" + def get_hugepages_status(): """ Analyzes system memory configuration to determine HugePage availability. - + Parses /proc/meminfo to check if HugePages are allocated and actively used by the mining process (RandomX optimization). @@ -113,7 +117,7 @@ def get_hugepages_status(): """ try: mem_stats = {} - with open("/proc/meminfo", "r") as f: + with open("/proc/meminfo") as f: for line in f: if line.startswith("HugePages_Total"): mem_stats["total"] = int(line.split()[1]) @@ -125,27 +129,27 @@ def get_hugepages_status(): hp_total = mem_stats["total"] hp_free = mem_stats["free"] hp_used = hp_total - hp_free - + val_str = f"{hp_used} / {hp_total}" - + # Status Logic: # 1. Total == 0: Feature not enabled in kernel/GRUB. if hp_total == 0: return "Disabled", "status-bad", val_str - + # 2. Used > 0: Feature enabled and actively utilized by miner. elif hp_used > 0: return "Enabled", "status-ok", val_str - + # 3. Total > 0 but Used == 0: reserved but the miner isn't consuming them yet — the # normal startup / sync-hold state, NOT an error, so render it green (#175). Keep the # "Allocated" label (distinct from actively-"Enabled") but with the ok class. (The # genuinely-bad case is hp_total == 0 / "Disabled"; "Unknown" below stays warn.) else: return "Allocated", "status-ok", val_str - + except (FileNotFoundError, ValueError, IndexError): # Gracefully handle non-Linux systems or parsing errors pass - - return "Unknown", "status-warn", "0/0" \ No newline at end of file + + return "Unknown", "status-warn", "0/0" diff --git a/build/dashboard/mining_dashboard/config/config.py b/build/dashboard/mining_dashboard/config/config.py index 6c549bb..ae9d83b 100644 --- a/build/dashboard/mining_dashboard/config/config.py +++ b/build/dashboard/mining_dashboard/config/config.py @@ -1,12 +1,12 @@ -import os import json +import os # --- System Configuration & Paths --- # Base directory for shared statistics generated by the P2Pool sidecar BASE_STATS_DIR = "/app/stats" # Persistent storage path for application state database -DISK_PATH = '/data' +DISK_PATH = "/data" DB_FILE_PATH = os.path.join(DISK_PATH, "mining_data.db") # Low-disk warning thresholds (% used of the data filesystem), surfaced as a top-bar badge (#138): @@ -30,7 +30,7 @@ # XMRig Worker API Configuration XMRIG_API_PORT = 8080 -API_TIMEOUT = 1 # Connection timeout (seconds) for worker API calls +API_TIMEOUT = 1 # Connection timeout (seconds) for worker API calls # The stack's internal docker bridge subnet. The dashboard runs network_mode: host and a connecting # miner fully controls its worker name/ip via stratum, yet per-worker stats are fetched at a host @@ -51,10 +51,10 @@ XVB_TIME_ALGO_MS = 600000 # Minimum dwell time on a pool to ensure valid share submission (15 seconds) -XVB_MIN_TIME_SEND_MS = 15000 +XVB_MIN_TIME_SEND_MS = 15000 # Wallet address used for fetching XvB bonus history and pool identification -MONERO_WALLET_ADDRESS = os.environ.get("MONERO_WALLET_ADDRESS", "") +MONERO_WALLET_ADDRESS = os.environ.get("MONERO_WALLET_ADDRESS", "") # Unique Donor ID for the XMRvsBeast pool XVB_DONOR_ID = os.environ.get("XVB_DONOR_ID", "") @@ -83,7 +83,9 @@ # GitHub — which is why it's safe to default on; it fails silently offline. Opt out with `false`. CHECK_FOR_UPDATES = os.environ.get("DASHBOARD_CHECK_UPDATES", "true").strip().lower() == "true" GITHUB_RELEASES_API = os.environ.get( - "GITHUB_RELEASES_API", "https://api.github.com/repos/p2pool-starter-stack/pithead/releases/latest") + "GITHUB_RELEASES_API", + "https://api.github.com/repos/p2pool-starter-stack/pithead/releases/latest", +) UPDATE_CHECK_INTERVAL = int(float(os.environ.get("UPDATE_CHECK_INTERVAL", "3600"))) # Donation tier to target (config.json: xvb.donation_level). The XvB raffle picks @@ -140,7 +142,9 @@ # #31's job — it stops only xmrig-proxy so p2pool keeps its sidechain position), and the latch # is persisted across dashboard restarts so a restart mid-sync doesn't prematurely release. SYNC_GATE_CONTAINERS = [ - c.strip() for c in os.environ.get("SYNC_GATE_CONTAINERS", "p2pool,xmrig-proxy").split(",") if c.strip() + c.strip() + for c in os.environ.get("SYNC_GATE_CONTAINERS", "p2pool,xmrig-proxy").split(",") + if c.strip() ] # Debounce: a node must be unreachable this long before it's declared DOWN, and reachable @@ -179,8 +183,18 @@ # signal the data loop already computes and, the first time a clearnet node reports synced, drops a # persistent marker in CLEARNET_STATE_DIR and restarts the container — whose entrypoint, seeing the # marker, comes back up Tor-only. Default off. Truthy parsing matches MONERO_PRUNE. -MONERO_CLEARNET_SYNC = os.environ.get("MONERO_CLEARNET_SYNC", "false").strip().lower() in ("true", "1", "yes", "on") -TARI_CLEARNET_SYNC = os.environ.get("TARI_CLEARNET_SYNC", "false").strip().lower() in ("true", "1", "yes", "on") +MONERO_CLEARNET_SYNC = os.environ.get("MONERO_CLEARNET_SYNC", "false").strip().lower() in ( + "true", + "1", + "yes", + "on", +) +TARI_CLEARNET_SYNC = os.environ.get("TARI_CLEARNET_SYNC", "false").strip().lower() in ( + "true", + "1", + "yes", + "on", +) # Shared, dashboard-writable dir holding the per-chain ".synced" transition markers. Mounted # read-only into monerod/tari at the same path. Container default matches the compose mount. CLEARNET_STATE_DIR = os.environ.get("CLEARNET_STATE_DIR", "/clearnet-state") @@ -232,11 +246,11 @@ # Retention for the known_workers persistence layer removed in #144. No live consumer in the current # tree; kept for the deferred Telegram worker-presence monitor (#121), which reuses it as its # retention default — consult that work before removing. -WORKER_RETENTION_SEC = 7 * 24 * 3600 # 7 Days +WORKER_RETENTION_SEC = 7 * 24 * 3600 # 7 Days # How long an offline worker lingers in the live "Workers Alive" table before it falls off (#182). # Operates on the live proxy-sourced list. A reconnect re-adds the worker. 1h keeps a # just-disconnected rig visible (shown as DOWN) but clears ghosts. -WORKER_FALLOFF_SEC = 3600 # 1 Hour +WORKER_FALLOFF_SEC = 3600 # 1 Hour # --- Hashrate averaging windows (#168) --- # xmrig-proxy's /workers rows expose five native per-worker averaging windows. The chart lets the @@ -252,9 +266,9 @@ # window -> (p2pool column, xvb column) in the history table. 10m maps to the original pair so the # default view and all existing rows stay intact; the rest are additive columns (see _migrate_db). HASHRATE_WINDOW_COLUMNS = { - "1m": ("v_p2pool_1m", "v_xvb_1m"), - "10m": ("v_p2pool", "v_xvb"), - "1h": ("v_p2pool_1h", "v_xvb_1h"), + "1m": ("v_p2pool_1m", "v_xvb_1m"), + "10m": ("v_p2pool", "v_xvb"), + "1h": ("v_p2pool_1h", "v_xvb_1h"), "12h": ("v_p2pool_12h", "v_xvb_12h"), "24h": ("v_p2pool_24h", "v_xvb_24h"), } @@ -263,10 +277,10 @@ # Hashrate thresholds (H/s) for XMRvsBeast donation tiers. # Reference: Official XvB rules (Mega=1M, Whale=100k, VIP=10k, Donor=1k) TIER_DEFAULTS = { - "donor_mega": 1_000_000, - "donor_whale": 100_000, - "donor_vip": 10_000, - "donor": 1_000 + "donor_mega": 1_000_000, + "donor_whale": 100_000, + "donor_vip": 10_000, + "donor": 1_000, } # Allow runtime configuration override via environment variable (injected via .env) @@ -285,7 +299,7 @@ BLOCK_PPLNS_WINDOW_MINI = 2160 BLOCK_PPLNS_WINDOW_NANO = 2160 -SECOND_PER_BLOCK_MAIN = 120 # Monero Target block time in seconds -SECOND_PER_BLOCK_P2POOL_MAIN = 10 # P2Pool Main Block Time -SECOND_PER_BLOCK_P2POOL_MINI = 10 # P2Pool Mini Block Time -SECOND_PER_BLOCK_P2POOL_NANO = 30 # P2Pool Nano Block Time \ No newline at end of file +SECOND_PER_BLOCK_MAIN = 120 # Monero Target block time in seconds +SECOND_PER_BLOCK_P2POOL_MAIN = 10 # P2Pool Main Block Time +SECOND_PER_BLOCK_P2POOL_MINI = 10 # P2Pool Mini Block Time +SECOND_PER_BLOCK_P2POOL_NANO = 30 # P2Pool Nano Block Time diff --git a/build/dashboard/mining_dashboard/helper/utils.py b/build/dashboard/mining_dashboard/helper/utils.py index 64b8f04..18a1d53 100644 --- a/build/dashboard/mining_dashboard/helper/utils.py +++ b/build/dashboard/mining_dashboard/helper/utils.py @@ -1,16 +1,18 @@ -import time -import socket import ipaddress +import socket +import time + from mining_dashboard.config.config import TIER_DEFAULTS + def parse_hashrate(val_str, unit_str=None): """ Converts a numeric string and an optional unit suffix into raw hashes per second (H/s). - + Args: val_str (str|float): The numeric value (e.g., "1.5"). unit_str (str, optional): The unit suffix (e.g., "MH/s", "kH/s"). - + Returns: float: The standardized hashrate in H/s. Returns 0.0 on parsing failure. """ @@ -18,31 +20,35 @@ def parse_hashrate(val_str, unit_str=None): val = float(val_str) if not unit_str: return val - + # Normalize unit string for case-insensitive comparison unit = unit_str.lower() - - if "gh" in unit: return val * 1_000_000_000 - if "mh" in unit: return val * 1_000_000 - if "kh" in unit: return val * 1_000 - + + if "gh" in unit: + return val * 1_000_000_000 + if "mh" in unit: + return val * 1_000_000 + if "kh" in unit: + return val * 1_000 + return val except (ValueError, TypeError): return 0.0 + def format_hashrate(hashrate): """ Formats a raw hashrate value into a human-readable string with appropriate units. - + Args: hashrate (float): The raw hashrate in H/s. - + Returns: str: Formatted string (e.g., "1.25 MH/s"). """ try: val = float(hashrate) - + if val >= 1_000_000_000: return f"{val / 1_000_000_000:.2f} GH/s" elif val >= 1_000_000: @@ -51,22 +57,23 @@ def format_hashrate(hashrate): return f"{val / 1_000:.2f} kH/s" else: return f"{val:.2f} H/s" - + except (ValueError, TypeError): return "0 H/s" + def format_duration(seconds): """ Formats a duration in seconds into a concise human-readable string. - + Format logic: - > 1 day: "Xd Xh Xm" - > 1 hour: "Xh Xm" - < 1 hour: "Xm Xs" - + Args: seconds (int|float): Duration in seconds. - + Returns: str: Formatted duration string. """ @@ -76,35 +83,37 @@ def format_duration(seconds): hours = (seconds // 3600) % 24 minutes = (seconds // 60) % 60 secs = seconds % 60 - + if days > 0: return f"{days}d {hours}h {minutes}m" if hours > 0: return f"{hours}h {minutes}m" - + return f"{minutes}m {secs}s" - + except (ValueError, TypeError): return "0s" + def format_time_abs(timestamp): """ Converts a Unix timestamp into a localized time string (HH:MM:SS). - + Args: timestamp (float): Unix timestamp. - + Returns: str: Formatted time string or error placeholder. """ if not timestamp: return "Never" - + try: - return time.strftime('%H:%M:%S', time.localtime(timestamp)) + return time.strftime("%H:%M:%S", time.localtime(timestamp)) except (ValueError, OSError, TypeError): return "Invalid Time" + def get_tier_info(hashrate, tiers=None): """ Determines the donation tier based on hashrate. @@ -124,6 +133,7 @@ def get_tier_info(hashrate, tiers=None): return "None", 0.0 + def _configured_tier_threshold(tiers, donation_level): """ Maps a configured donation level to a tier threshold (H/s). @@ -154,6 +164,7 @@ def _configured_tier_threshold(tiers, donation_level): except (ValueError, TypeError): return float(positive[0]) + def resolve_target_threshold(tiers, stable_hr, donation_level, max_fraction): """ Resolves the donation tier to aim for. Returns ``(threshold_hs, sustainable)``. diff --git a/build/dashboard/mining_dashboard/main.py b/build/dashboard/mining_dashboard/main.py index 7d4b403..e1956a4 100644 --- a/build/dashboard/mining_dashboard/main.py +++ b/build/dashboard/mining_dashboard/main.py @@ -3,20 +3,20 @@ from aiohttp import web +from mining_dashboard.client.xmrig_proxy_client import XMRigProxyClient +from mining_dashboard.client.xvb_client import XvbClient from mining_dashboard.config.config import ( + MONERO_WALLET_ADDRESS, + PROXY_API_PORT, PROXY_AUTH_TOKEN, PROXY_HOST, - PROXY_API_PORT, - MONERO_WALLET_ADDRESS, ) +from mining_dashboard.service.algo_service import AlgoService +from mining_dashboard.service.data_service import DataService from mining_dashboard.service.storage_service import StateManager from mining_dashboard.web.server import create_app -from mining_dashboard.client.xmrig_proxy_client import XMRigProxyClient -from mining_dashboard.client.xvb_client import XvbClient -from mining_dashboard.service.data_service import DataService -from mining_dashboard.service.algo_service import AlgoService -logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("Main") @@ -27,26 +27,28 @@ def build_app() -> web.Application: side effects — nothing opens the database or a network client until the app is built. """ state_manager = StateManager() - proxy_client = XMRigProxyClient(host=PROXY_HOST, port=PROXY_API_PORT, access_token=PROXY_AUTH_TOKEN) + proxy_client = XMRigProxyClient( + host=PROXY_HOST, port=PROXY_API_PORT, access_token=PROXY_AUTH_TOKEN + ) xvb_client = XvbClient(wallet_address=MONERO_WALLET_ADDRESS) data_service = DataService(state_manager, proxy_client, xvb_client) algo_service = AlgoService(state_manager, proxy_client, data_service) async def start_background_tasks(app): """Initializes background services upon web application startup.""" - app['data_task'] = asyncio.create_task(data_service.run()) - app['algo_task'] = asyncio.create_task(algo_service.run()) + app["data_task"] = asyncio.create_task(data_service.run()) + app["algo_task"] = asyncio.create_task(algo_service.run()) async def cleanup_background_tasks(app): """Stops background tasks and closes resources on shutdown.""" - app['data_task'].cancel() - app['algo_task'].cancel() - await asyncio.gather(app['data_task'], app['algo_task'], return_exceptions=True) - if 'state_manager' in app: - app['state_manager'].close() + app["data_task"].cancel() + app["algo_task"].cancel() + await asyncio.gather(app["data_task"], app["algo_task"], return_exceptions=True) + if "state_manager" in app: + app["state_manager"].close() app = create_app(state_manager, data_service.latest_data) - app['state_manager'] = state_manager + app["state_manager"] = state_manager app.on_startup.append(start_background_tasks) app.on_cleanup.append(cleanup_background_tasks) return app @@ -57,7 +59,7 @@ def main() -> None: logger.info("Initializing Dashboard Web Server securely on 127.0.0.1:8000") # Bound to localhost (127.0.0.1) so it is inaccessible from the local network directly; # traffic is securely routed through the Caddy proxy. - web.run_app(app, host='127.0.0.1', port=8000, print=None) + web.run_app(app, host="127.0.0.1", port=8000, print=None) if __name__ == "__main__": diff --git a/build/dashboard/mining_dashboard/service/algo_service.py b/build/dashboard/mining_dashboard/service/algo_service.py index 81551f4..757039b 100644 --- a/build/dashboard/mining_dashboard/service/algo_service.py +++ b/build/dashboard/mining_dashboard/service/algo_service.py @@ -1,30 +1,32 @@ import asyncio import logging -import time import math +import time + from mining_dashboard.config.config import ( - XVB_TIME_ALGO_MS, + ENABLE_XVB, MONERO_WALLET_ADDRESS, - XVB_DONOR_ID, P2POOL_URL, - XVB_POOL_URL, - XVB_TOR_ENABLED, - XVB_TOR_SOCKS5, - XVB_MIN_TIME_SEND_MS, - ENABLE_XVB, + UPDATE_INTERVAL, + XVB_CONTROL_GAIN, XVB_DONATION_LEVEL, - XVB_MAX_DONATION_FRACTION, - XVB_MAINT_MARGIN_PCT, + XVB_DONOR_ID, XVB_MAINT_MARGIN_ABS_CAP, - XVB_CONTROL_GAIN, + XVB_MAINT_MARGIN_PCT, + XVB_MAX_DONATION_FRACTION, + XVB_MIN_TIME_SEND_MS, XVB_P2POOL_RESERVE_FACTOR, + XVB_POOL_URL, XVB_SWITCH_OVERHEAD_MS, - UPDATE_INTERVAL + XVB_TIME_ALGO_MS, + XVB_TOR_ENABLED, + XVB_TOR_SOCKS5, ) from mining_dashboard.helper.utils import resolve_target_threshold logger = logging.getLogger("AlgoService") + class AlgoService: def __init__(self, state_manager, proxy_client, data_service): self.state_manager = state_manager @@ -50,7 +52,12 @@ async def switch_miners(self, mode, state_label=None): # default (#166) — its per-pool `socks5` makes the proxy reach na.xmrvsbeast.com via Tor # (DNS resolved proxy-side), so donation mining doesn't expose the home IP. `xvb.tor: false` # opts out. Only the XvB pool gets `socks5`; the local pool never does. - p2pool_pool = {"url": P2POOL_URL, "user": MONERO_WALLET_ADDRESS, "pass": "x", "coin": "monero"} + p2pool_pool = { + "url": P2POOL_URL, + "user": MONERO_WALLET_ADDRESS, + "pass": "x", + "coin": "monero", + } xvb_pool = {"url": XVB_POOL_URL, "user": XVB_DONOR_ID, "pass": "x", "coin": "monero"} if XVB_TOR_ENABLED: xvb_pool["socks5"] = XVB_TOR_SOCKS5 @@ -71,7 +78,7 @@ async def switch_miners(self, mode, state_label=None): # Execute update via Proxy Client with the full configuration await asyncio.to_thread(self.proxy_client.update_config, current_config) - + # Update state manager with the new active mode final_label = state_label if state_label else mode await asyncio.to_thread(self.state_manager.update_xvb_stats, mode=final_label) @@ -79,8 +86,9 @@ async def switch_miners(self, mode, state_label=None): except Exception as e: logger.error(f"Failed to switch proxy mode: {e}") - def get_decision(self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats, - shares, advance=True): + def get_decision( + self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats, shares, advance=True + ): """ Evaluates the current mining state to determine the next operation mode. @@ -108,8 +116,8 @@ def get_decision(self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats # Constraint: Enforce P2Pool mode if no shares have been found recently. # This uses the same logic as the dashboard UI to count shares within the PPLNS window. - pool_type = p2p_stats.get('type', 'Main') - pplns_window = p2pool_stats.get('pplns_window', 2160) + pool_type = p2p_stats.get("type", "Main") + pplns_window = p2pool_stats.get("pplns_window", 2160) block_time = 10 # Default for Main/Mini if pool_type == "Nano": @@ -117,16 +125,20 @@ def get_decision(self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats window_duration = pplns_window * block_time cutoff = time.time() - window_duration - shares_in_window_count = sum(1 for s in shares if s.get('ts', 0) >= cutoff) + shares_in_window_count = sum(1 for s in shares if s.get("ts", 0) >= cutoff) if shares_in_window_count == 0: - logger.info(f"Decision Strategy: Force P2POOL (Zero shares in PPLNS window of {window_duration}s)") + logger.info( + f"Decision Strategy: Force P2POOL (Zero shares in PPLNS window of {window_duration}s)" + ) return "P2POOL", 0 # Constraint: Fallback to P2Pool if XvB endpoint failures exceed threshold. - fail_count = xvb_stats.get('fail_count', 0) + fail_count = xvb_stats.get("fail_count", 0) if fail_count >= 3: - logger.warning(f"Decision Strategy: Force P2POOL (Excessive XvB failures: {fail_count})") + logger.warning( + f"Decision Strategy: Force P2POOL (Excessive XvB failures: {fail_count})" + ) return "P2POOL", 0 # Highest tier we can sustain, capped by the configured donation level. @@ -139,8 +151,8 @@ def get_decision(self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats # Cap the donated fraction so p2pool keeps finding shares (VIP status). max_fraction = self._max_donation_fraction(current_hr, window_duration, p2pool_stats) - avg_1h = xvb_stats.get('avg_1h', 0) - avg_24h = xvb_stats.get('avg_24h', 0) + avg_1h = xvb_stats.get("avg_1h", 0) + avg_24h = xvb_stats.get("avg_24h", 0) # Advance the calibration loop once per real cycle (not during _smart_sleep). if advance: @@ -161,11 +173,15 @@ def get_decision(self, current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats needed_time_ms = XVB_TIME_ALGO_MS if needed_time_ms >= XVB_TIME_ALGO_MS: - logger.info(f"Decision: Full XVB cycle (target {target_hr:.0f}; 1h {avg_1h:.0f} / 24h {avg_24h:.0f})") + logger.info( + f"Decision: Full XVB cycle (target {target_hr:.0f}; 1h {avg_1h:.0f} / 24h {avg_24h:.0f})" + ) return "XVB", XVB_TIME_ALGO_MS - logger.info(f"Decision: Split ({needed_time_ms}ms to XvB; frac {fraction:.3f}; " - f"target {target_hr:.0f}; 1h {avg_1h:.0f} / 24h {avg_24h:.0f})") + logger.info( + f"Decision: Split ({needed_time_ms}ms to XvB; frac {fraction:.3f}; " + f"target {target_hr:.0f}; 1h {avg_1h:.0f} / 24h {avg_24h:.0f})" + ) return "SPLIT", int(needed_time_ms) def _get_target_donation_hr(self, stable_hr): @@ -199,7 +215,7 @@ def _max_donation_fraction(self, current_hr, window_duration, p2pool_stats): headroom against variance. When difficulty is unknown (stats not ready), fall back to the flat hard cap. """ - difficulty = p2pool_stats.get('difficulty', 0) or 0 + difficulty = p2pool_stats.get("difficulty", 0) or 0 if current_hr <= 0 or difficulty <= 0 or window_duration <= 0: return self.max_donation_fraction @@ -280,13 +296,20 @@ async def _smart_sleep(self, duration_sec, check_interval_sec=None): # XvB's 1h average has slipped below the tier and we need to catch # up — so the next cycle reacts in seconds, not after the full dwell. decision, _ = self.get_decision( - current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats, shares, + current_hr, + stable_hr, + p2pool_stats, + p2p_stats, + xvb_stats, + shares, advance=False, ) target_hr = self._get_target_donation_hr(stable_hr) - under_tier = target_hr > 0 and xvb_stats.get('avg_1h', 0) < target_hr + under_tier = target_hr > 0 and xvb_stats.get("avg_1h", 0) < target_hr if decision in ("XVB", "SPLIT") or under_tier: - logger.info("Smart-sleep: donation target needs attention — ending P2Pool dwell early.") + logger.info( + "Smart-sleep: donation target needs attention — ending P2Pool dwell early." + ) return except Exception as e: logger.debug(f"Smart-sleep check error: {e}") @@ -297,8 +320,8 @@ async def run(self): Determines the optimal mining mode and manages worker switching cycles. """ logger.info("Service Started: Algorithm Control Loop") - await asyncio.sleep(5) - + await asyncio.sleep(5) + while True: try: # While workers are rejected (a node is down, Issue #31) the proxy is @@ -325,9 +348,11 @@ async def run(self): p2p_stats = p2pool_data.get("p2p", {}) xvb_stats = self.state_manager.get_xvb_stats() shares = latest_data.get("shares", []) - + # Execute decision logic - decision, xvb_duration = self.get_decision(current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats, shares) + decision, xvb_duration = self.get_decision( + current_hr, stable_hr, p2pool_stats, p2p_stats, xvb_stats, shares + ) # Record the fraction of this cycle actually routed to XvB so the # dashboard can show routed-vs-credited (the live credit factor). @@ -358,4 +383,4 @@ async def run(self): except Exception as e: logger.error(f"Algorithm Error: {e}") - await asyncio.sleep(10) \ No newline at end of file + await asyncio.sleep(10) diff --git a/build/dashboard/mining_dashboard/service/clearnet_sync.py b/build/dashboard/mining_dashboard/service/clearnet_sync.py index c887dc1..fc666b1 100644 --- a/build/dashboard/mining_dashboard/service/clearnet_sync.py +++ b/build/dashboard/mining_dashboard/service/clearnet_sync.py @@ -7,8 +7,10 @@ # and was SIGKILL'd), and Docker holds the stop request open until the container is down — so the # HTTP timeout MUST exceed the stop deadline, or the stop call aborts early, reports failure, and # (with the old `stop and start`) skipped the start entirely, leaving the daemon down. -_RESTART_STOP_TIMEOUT = 30 # seconds Docker waits (SIGTERM → SIGKILL) for the daemon to stop -_RESTART_HTTP_TIMEOUT = 60 # HTTP timeout for the stop/start calls; must exceed _RESTART_STOP_TIMEOUT +_RESTART_STOP_TIMEOUT = 30 # seconds Docker waits (SIGTERM → SIGKILL) for the daemon to stop +_RESTART_HTTP_TIMEOUT = ( + 60 # HTTP timeout for the stop/start calls; must exceed _RESTART_STOP_TIMEOUT +) class ClearnetSyncSupervisor: @@ -60,8 +62,9 @@ def _write_marker(self, name): fh.write("clearnet initial sync complete; node returned to Tor (#234)\n") return True except OSError as exc: - logger.error("%s: could not write Tor-resync marker at %s: %s", - name, self.marker_path(name), exc) + logger.error( + "%s: could not write Tor-resync marker at %s: %s", name, self.marker_path(name), exc + ) return False async def maybe_transition(self, name, container, flag_on, synced): @@ -84,15 +87,17 @@ async def maybe_transition(self, name, container, flag_on, synced): # and a reboot would re-expose the node. Stay exposed and retry next cycle instead. if not self._write_marker(name): return True - logger.warning("%s: CLEARNET initial sync complete — switching %s back to Tor (#234).", - name, container) + logger.warning( + "%s: CLEARNET initial sync complete — switching %s back to Tor (#234).", name, container + ) # Stop with a generous window + an HTTP timeout that OUTLASTS it, then ALWAYS start. The old # `stop and start` left a slow-stopping daemon down: when stop's HTTP call timed out before # the container finished stopping, `and` short-circuited and start was never called (#234: # Tari took >5s to stop and never came back). `ok` is now the START result — the container # must end up running; a stop hiccup can no longer skip the start. - await self.docker_control.stop(container, stop_timeout=_RESTART_STOP_TIMEOUT, - request_timeout=_RESTART_HTTP_TIMEOUT) + await self.docker_control.stop( + container, stop_timeout=_RESTART_STOP_TIMEOUT, request_timeout=_RESTART_HTTP_TIMEOUT + ) ok = await self.docker_control.start(container, request_timeout=_RESTART_HTTP_TIMEOUT) if ok: self._flipped.add(name) @@ -100,8 +105,12 @@ async def maybe_transition(self, name, container, flag_on, synced): else: # Restart failed: do NOT mark flipped, so we retry next cycle. The marker is already on # disk, so any start (this retry, a manual restart, a reboot) brings the node up on Tor. - logger.error("%s: restart of %s onto Tor failed — will retry next cycle (the marker is " - "set, so any restart comes up Tor-only).", name, container) + logger.error( + "%s: restart of %s onto Tor failed — will retry next cycle (the marker is " + "set, so any restart comes up Tor-only).", + name, + container, + ) if self.on_transition is not None: try: self.on_transition(name, ok) diff --git a/build/dashboard/mining_dashboard/service/data_service.py b/build/dashboard/mining_dashboard/service/data_service.py index d58ffe9..a02a126 100644 --- a/build/dashboard/mining_dashboard/service/data_service.py +++ b/build/dashboard/mining_dashboard/service/data_service.py @@ -2,32 +2,44 @@ import logging import os import time + from aiohttp import ClientSession +from mining_dashboard.client.docker.docker_control import DockerControl +from mining_dashboard.client.tari.tari_client import TariClient +from mining_dashboard.client.xmrig_client import XMRigWorkerClient +from mining_dashboard.collector.logs import get_monero_sync_status +from mining_dashboard.collector.pools import ( + get_network_stats, + get_p2pool_stats, + get_stratum_stats, + get_tari_stats, +) +from mining_dashboard.collector.system import ( + get_cpu_usage, + get_disk_usage, + get_hugepages_status, + get_load_average, + get_memory_usage, +) from mining_dashboard.config.config import ( - UPDATE_INTERVAL, - TARI_REQUIRED, - REJECT_WORKERS_CONTAINER, - SYNC_GATE_CONTAINERS, - ENABLE_XVB, - WORKER_FALLOFF_SEC, CHECK_FOR_UPDATES, + CLEARNET_STATE_DIR, + ENABLE_XVB, GITHUB_RELEASES_API, - UPDATE_CHECK_INTERVAL, - XVB_TOR_PROXY, MONERO_CLEARNET_SYNC, + REJECT_WORKERS_CONTAINER, + SYNC_GATE_CONTAINERS, TARI_CLEARNET_SYNC, - CLEARNET_STATE_DIR, + TARI_REQUIRED, + UPDATE_CHECK_INTERVAL, + UPDATE_INTERVAL, + WORKER_FALLOFF_SEC, + XVB_TOR_PROXY, ) -from mining_dashboard.service.update_checker import GitHubReleaseClient, UpdateChecker -from mining_dashboard.client.xmrig_client import XMRigWorkerClient -from mining_dashboard.client.tari.tari_client import TariClient -from mining_dashboard.client.docker.docker_control import DockerControl from mining_dashboard.service.clearnet_sync import ClearnetSyncSupervisor -from mining_dashboard.collector.pools import get_p2pool_stats, get_network_stats, get_stratum_stats, get_tari_stats -from mining_dashboard.collector.logs import get_monero_sync_status -from mining_dashboard.collector.system import get_disk_usage, get_hugepages_status, get_memory_usage, get_load_average, get_cpu_usage from mining_dashboard.service.node_health import NodeHealthMonitor +from mining_dashboard.service.update_checker import GitHubReleaseClient, UpdateChecker logger = logging.getLogger("DataService") @@ -35,16 +47,16 @@ # normalization below isn't a wall of magic indices. A row has >= _PX_MIN_FIELDS entries. _PX_NAME = 0 _PX_IP = 1 -_PX_CONNECTIONS = 2 # active connections; 0 means a stale/disconnected worker -_PX_ACCEPTED = 3 # accepted shares (cumulative) -_PX_REJECTED = 4 # rejected shares (cumulative) -_PX_INVALID = 5 # invalid shares (cumulative) -_PX_LAST_SHARE_MS = 7 # epoch ms of the last accepted share -_PX_HR_1M = 8 # 1-minute hashrate, kH/s -_PX_HR_10M = 9 # 10-minute hashrate, kH/s -_PX_HR_1H = 10 # 1-hour hashrate, kH/s (#168) -_PX_HR_12H = 11 # 12-hour hashrate, kH/s (#168) -_PX_HR_24H = 12 # 24-hour hashrate, kH/s (#168) +_PX_CONNECTIONS = 2 # active connections; 0 means a stale/disconnected worker +_PX_ACCEPTED = 3 # accepted shares (cumulative) +_PX_REJECTED = 4 # rejected shares (cumulative) +_PX_INVALID = 5 # invalid shares (cumulative) +_PX_LAST_SHARE_MS = 7 # epoch ms of the last accepted share +_PX_HR_1M = 8 # 1-minute hashrate, kH/s +_PX_HR_10M = 9 # 10-minute hashrate, kH/s +_PX_HR_1H = 10 # 1-hour hashrate, kH/s (#168) +_PX_HR_12H = 11 # 12-hour hashrate, kH/s (#168) +_PX_HR_24H = 12 # 24-hour hashrate, kH/s (#168) _PX_MIN_FIELDS = 13 # xmrig-proxy reports hashrate in kH/s; the dashboard works in H/s. @@ -174,20 +186,20 @@ def _merge_direct_stats(workers, results, active_pool_port): worker is tagged with ``active_pool`` for the UI badge. """ final_workers = [] - for w, extra_stats in zip(workers, results): + for w, extra_stats in zip(workers, results, strict=False): if extra_stats: - w['uptime'] = extra_stats.get('uptime', w['uptime']) + w["uptime"] = extra_stats.get("uptime", w["uptime"]) - is_proxy = extra_stats.get('kind') == 'proxy' + is_proxy = extra_stats.get("kind") == "proxy" hr_scale = _KHS_TO_HS if is_proxy else 1 - hr_total = extra_stats.get('hashrate', {}).get('total', []) + hr_total = extra_stats.get("hashrate", {}).get("total", []) if isinstance(hr_total, list) and len(hr_total) >= 3: - w['h10'] = (hr_total[0] or 0) * hr_scale - w['h60'] = (hr_total[1] or 0) * hr_scale - w['h15'] = (hr_total[2] or 0) * hr_scale + w["h10"] = (hr_total[0] or 0) * hr_scale + w["h60"] = (hr_total[1] or 0) * hr_scale + w["h15"] = (hr_total[2] or 0) * hr_scale - w['active_pool'] = active_pool_port + w["active_pool"] = active_pool_port final_workers.append(w) return final_workers @@ -202,14 +214,14 @@ def _aggregate_hashrate(workers): total_hr = 0 total_h10 = 0 for w in workers: - if w.get('status') == 'online': - w_hr = w.get('h15', 0) + if w.get("status") == "online": + w_hr = w.get("h15", 0) if w_hr == 0: - w_hr = w.get('h60', 0) + w_hr = w.get("h60", 0) if w_hr == 0: - w_hr = w.get('h10', 0) + w_hr = w.get("h10", 0) total_hr += w_hr - total_h10 += w.get('h10', 0) + total_h10 += w.get("h10", 0) return total_hr, total_h10 @@ -227,7 +239,7 @@ def _aggregate_window_hashrates(workers): """ totals = {win: 0 for win in _WINDOW_WORKER_KEYS} for w in workers: - if w.get('status') == 'online': + if w.get("status") == "online": for win, src in _WINDOW_WORKER_KEYS.items(): totals[win] += w.get(src, 0) or 0 return totals @@ -268,7 +280,7 @@ class WorkerLifecycle: def __init__(self, falloff_sec): self.falloff_sec = falloff_sec - self._state = {} # name -> {"connected_since": float | None, "last_active": float} + self._state = {} # name -> {"connected_since": float | None, "last_active": float} def update(self, workers, now): live = [] @@ -278,18 +290,18 @@ def update(self, workers, now): seen.add(name) st = self._state.setdefault(name, {"connected_since": None, "last_active": 0.0}) if w.get("status") == "online": - if st["connected_since"] is None: # new connection or a reconnect + if st["connected_since"] is None: # new connection or a reconnect st["connected_since"] = now st["last_active"] = now - if not w.get("uptime"): # no real (direct-API) uptime → track it + if not w.get("uptime"): # no real (direct-API) uptime → track it w["uptime"] = int(now - st["connected_since"]) live.append(w) else: - st["connected_since"] = None # disconnected — uptime restarts on reconnect + st["connected_since"] = None # disconnected — uptime restarts on reconnect if st["last_active"] == 0.0: - st["last_active"] = now # first seen already offline + st["last_active"] = now # first seen already offline if now - st["last_active"] <= self.falloff_sec: - live.append(w) # recently-offline rows stay (shown as DOWN) + live.append(w) # recently-offline rows stay (shown as DOWN) # else: fall off — drop the ghost row # Forget ONLY workers the proxy no longer reports at all. A worker that has aged out of the # live table but is STILL reported (offline) must be KEPT in state so its `last_active` @@ -307,6 +319,7 @@ class DataService: Core service responsible for aggregating mining statistics from various sources (Local collectors, XMRig Proxy, Tari Node, etc.) and maintaining the application state. """ + def __init__(self, state_manager, proxy_client, xvb_client): self.state_manager = state_manager self.proxy_client = proxy_client @@ -339,7 +352,7 @@ def __init__(self, state_manager, proxy_client, xvb_client): "workers_rejected": False, "miner_released": False, "miner_held": False, - "timestamp": 0 + "timestamp": 0, } # Node-down detection + optional worker rejection (Issue #31). @@ -350,7 +363,8 @@ def __init__(self, state_manager, proxy_client, xvb_client): # the same docker control proxy as the #31 failover (start/stop only). on_transition surfaces # the event into the snapshot so the UI/status can reflect "switched back to Tor". self.clearnet_supervisor = ClearnetSyncSupervisor( - CLEARNET_STATE_DIR, self.docker_control, + CLEARNET_STATE_DIR, + self.docker_control, on_transition=self._on_clearnet_transition, ) # Per-chain "currently exposed on clearnet" flags, surfaced in the snapshot for the UI/banner. @@ -401,8 +415,7 @@ async def _apply_worker_rejection(self, monero_down, tari_down): # Readmit only once every node we reject on is confirmed healthy (not merely 'not # down'), so a dashboard restart mid-outage doesn't bring workers back to a still-down # stack. Tari's health is ignored when it's non-blocking. - recovered = self.monero_health.healthy and \ - ((not TARI_REQUIRED) or self.tari_health.healthy) + recovered = self.monero_health.healthy and ((not TARI_REQUIRED) or self.tari_health.healthy) if self.workers_rejected and recovered: logger.info( f"Required nodes recovered — starting {REJECT_WORKERS_CONTAINER} to readmit workers." @@ -462,7 +475,9 @@ def _on_clearnet_transition(self, name, ok): if ok: logger.info("%s returned to Tor after its clearnet initial sync (#234).", name) else: - logger.warning("%s clearnet→Tor switch did not complete this cycle — will retry (#234).", name) + logger.warning( + "%s clearnet→Tor switch did not complete this cycle — will retry (#234).", name + ) async def run(self): """ @@ -470,13 +485,13 @@ async def run(self): Updates the `latest_data` state and persists historical metrics to the database. """ logger.info("Service Started: Data Collection Loop") - - iteration_count = 0 - + + iteration_count = 0 + async with ClientSession() as session: worker_client = XMRigWorkerClient(session) tari_client = TariClient(session) - + # P2Pool shares are recorded from the cumulative shares_found counter (#129); None until # the first poll baselines it, so we never backfill the whole historical count on startup # or re-record what the DB already loaded. @@ -486,7 +501,7 @@ async def run(self): try: # 1. Collect Local Statistics (High Frequency Polling) stratum_raw, _ = get_stratum_stats() - + # 2. Fetch Worker Statistics from XMRig Proxy + normalize the payload. proxy_workers = [] try: @@ -507,13 +522,15 @@ async def run(self): logger.error(f"Proxy Summary Fetch Error: {e}") # 3. Augment with Direct Worker Stats (Uptime, Hashrate) via Local API - tasks = [worker_client.get_stats(w['ip'], w['name']) for w in proxy_workers] + tasks = [worker_client.get_stats(w["ip"], w["name"]) for w in proxy_workers] worker_results = await asyncio.gather(*tasks) current_mode = self.state_manager.get_xvb_stats().get("current_mode", "P2POOL") # Determine active pool port for UI badges based on current Algo mode active_pool_port = "3344" if "XVB" in current_mode else "3333" - final_workers = _merge_direct_stats(proxy_workers, worker_results, active_pool_port) + final_workers = _merge_direct_stats( + proxy_workers, worker_results, active_pool_port + ) # 3b. Track per-worker connection lifecycle: fill true uptime for online workers # (#169) and drop stale offline rows past the fall-off window (#182). final_workers = self._lifecycle.update(final_workers, time.time()) @@ -532,10 +549,13 @@ async def run(self): current_share_ts = p2pool_stats["pool"].get("last_share_time", 0) current_shares_total = p2pool_stats["pool"].get("shares_found", 0) new_shares, last_known_shares_total = _shares_to_record( - last_known_shares_total, current_shares_total) + last_known_shares_total, current_shares_total + ) if new_shares > 0 and current_share_ts > 0: difficulty = p2pool_stats["pool"].get("difficulty", 0) - await asyncio.to_thread(self.state_manager.add_shares, new_shares, current_share_ts, difficulty) + await asyncio.to_thread( + self.state_manager.add_shares, new_shares, current_share_ts, difficulty + ) monero_sync = await get_monero_sync_status() tari_sync = await tari_client.get_sync_status() @@ -547,101 +567,107 @@ async def run(self): # (that's what #31's node-down handling is for). Reading the raw signal # also avoids a deadlock: the height override is fed by p2pool's stats # file, which reads 0 while p2pool is held — falsely "syncing" forever. - monero_synced = monero_sync.get('reachable', True) and not monero_sync.get('is_syncing', False) - tari_synced = tari_sync.get('reachable', True) and not tari_sync.get('is_syncing', False) + monero_synced = monero_sync.get("reachable", True) and not monero_sync.get( + "is_syncing", False + ) + tari_synced = tari_sync.get("reachable", True) and not tari_sync.get( + "is_syncing", False + ) # Auto-transition a clearnet initial-sync node back to Tor once it's synced # (#234). Reuses the synced signals above; the supervisor writes a persistent # marker + restarts the daemon (which then comes up Tor-only). Returns whether # each chain is still EXPOSED on clearnet, for the UI banner. monero_clearnet_exposed = await self.clearnet_supervisor.maybe_transition( - "monero", "monerod", MONERO_CLEARNET_SYNC, monero_synced) + "monero", "monerod", MONERO_CLEARNET_SYNC, monero_synced + ) tari_clearnet_exposed = await self.clearnet_supervisor.maybe_transition( - "tari", "tari", TARI_CLEARNET_SYNC, tari_synced) + "tari", "tari", TARI_CLEARNET_SYNC, tari_synced + ) self.clearnet_sync_state = { "monero": monero_clearnet_exposed, "tari": tari_clearnet_exposed, "active": monero_clearnet_exposed or tari_clearnet_exposed, } - # Determine effective Tari status for UI display - tari_active = tari_stats.get('active', False) - tari_status_str = tari_stats.get('status', 'Waiting...') if tari_active else 'Waiting...' - # Apply Sync Logic Overrides # 1. Monero Sync Check - if network_stats.get('height', 0) == 0: - monero_sync['is_syncing'] = True - if 'percent' not in monero_sync: - monero_sync.update({'percent': 0, 'current': 0, 'target': 1}) - + if network_stats.get("height", 0) == 0: + monero_sync["is_syncing"] = True + if "percent" not in monero_sync: + monero_sync.update({"percent": 0, "current": 0, "target": 1}) + # 2. Global Sync Logic. monerod always drives the full-screen Sync Mode; # Tari does so only when it's required (Issue #51). A non-blocking Tari # (dashboard.tari_required:false) keeps the operational view and surfaces # its progress in the Tari panel instead of hijacking the whole dashboard. - is_monero_syncing = monero_sync.get('is_syncing', False) - is_tari_syncing = tari_sync.get('is_syncing', False) + is_monero_syncing = monero_sync.get("is_syncing", False) + is_tari_syncing = tari_sync.get("is_syncing", False) global_sync = is_monero_syncing or (is_tari_syncing and TARI_REQUIRED) # True when Tari is syncing but we're staying in the operational view — the # UI shows a "Tari syncing" indicator rather than the takeover screen. tari_syncing_passive = is_tari_syncing and not global_sync if global_sync: - if not is_monero_syncing and 'percent' not in monero_sync: - h = network_stats.get('height', 1) - monero_sync.update({'percent': 100, 'current': h, 'target': h}) - if not is_tari_syncing and 'percent' not in tari_sync: - h = tari_stats.get('height', 0) - tari_sync.update({'percent': 100, 'current': h, 'target': h}) + if not is_monero_syncing and "percent" not in monero_sync: + h = network_stats.get("height", 1) + monero_sync.update({"percent": 100, "current": h, "target": h}) + if not is_tari_syncing and "percent" not in tari_sync: + h = tari_stats.get("height", 0) + tari_sync.update({"percent": 100, "current": h, "target": h}) # 3. Node-down detection + worker rejection (Issue #31). Debounce each # node's live reachability into a stable DOWN flag; monerod-down always # rejects, Tari-down rejects only when required (handled in the helper). - monero_down = self.monero_health.update(monero_sync.get('reachable', True)) - tari_down = self.tari_health.update(tari_sync.get('reachable', True)) - monero_sync['down'] = monero_down - tari_sync['down'] = tari_down + monero_down = self.monero_health.update(monero_sync.get("reachable", True)) + tari_down = self.tari_health.update(tari_sync.get("reachable", True)) + monero_sync["down"] = monero_down + tari_sync["down"] = tari_down # 4. Sync gate (Issue #35): hold p2pool + xmrig-proxy until the required # chain(s) first sync, then release. monerod must be synced; Tari must be # synced too unless it's non-blocking. #31's runtime failover only applies # once released — before that there are no workers to fail over, and it # keeps the two features from both driving xmrig-proxy. - await self._apply_sync_gate(monero_synced and (tari_synced or not TARI_REQUIRED)) + await self._apply_sync_gate( + monero_synced and (tari_synced or not TARI_REQUIRED) + ) if self.miner_released: await self._apply_worker_rejection(monero_down, tari_down) # Fetch fresh shares list to populate UI shares_list = await asyncio.to_thread(self.state_manager.get_shares) - self.latest_data.update({ - "workers": final_workers, - "proxy_summary": proxy_summary, - "shares": shares_list, - "total_live_h15": total_hr, - "total_live_h10": total_h10, - "pool": p2pool_stats, - "network": network_stats, - "tari": tari_stats, - "monero_sync": monero_sync, - "tari_sync": tari_sync, - "global_sync": global_sync, - "tari_syncing_passive": tari_syncing_passive, - "workers_rejected": self.workers_rejected, - "miner_released": self.miner_released, - "miner_held": self.miner_held, - "clearnet_sync": self.clearnet_sync_state, - "system": { - "disk": get_disk_usage(), - "hugepages": get_hugepages_status(), - "memory": get_memory_usage(), - "load": get_load_average(), - "cpu_percent": get_cpu_usage() - }, - "stratum": stratum_raw, - "timestamp": time.time() - }) - + self.latest_data.update( + { + "workers": final_workers, + "proxy_summary": proxy_summary, + "shares": shares_list, + "total_live_h15": total_hr, + "total_live_h10": total_h10, + "pool": p2pool_stats, + "network": network_stats, + "tari": tari_stats, + "monero_sync": monero_sync, + "tari_sync": tari_sync, + "global_sync": global_sync, + "tari_syncing_passive": tari_syncing_passive, + "workers_rejected": self.workers_rejected, + "miner_released": self.miner_released, + "miner_held": self.miner_held, + "clearnet_sync": self.clearnet_sync_state, + "system": { + "disk": get_disk_usage(), + "hugepages": get_hugepages_status(), + "memory": get_memory_usage(), + "load": get_load_average(), + "cpu_percent": get_cpu_usage(), + }, + "stratum": stratum_raw, + "timestamp": time.time(), + } + ) + # 6. Persist Historical Data is_xvb = "XVB" in current_mode p2pool_hr = 0 if is_xvb else total_hr @@ -657,9 +683,13 @@ async def run(self): } await asyncio.to_thread( - self.state_manager.update_history, total_hr, p2pool_hr, xvb_hr, window_splits + self.state_manager.update_history, + total_hr, + p2pool_hr, + xvb_hr, + window_splits, ) - + # Create a lightweight snapshot (exclude shares entirely as they are safely in DB) snapshot_data = self.latest_data.copy() snapshot_data.pop("shares", None) @@ -670,17 +700,22 @@ async def run(self): if ENABLE_XVB and iteration_count % 10 == 0: real_xvb_stats = await asyncio.to_thread(self.xvb_client.get_stats) if real_xvb_stats: - await asyncio.to_thread(self.state_manager.update_xvb_stats, **real_xvb_stats) - logger.info(f"External Sync: XvB Stats Updated (1h={real_xvb_stats['avg_1h']:.0f} H/s)") + await asyncio.to_thread( + self.state_manager.update_xvb_stats, **real_xvb_stats + ) + logger.info( + f"External Sync: XvB Stats Updated (1h={real_xvb_stats['avg_1h']:.0f} H/s)" + ) # 8. New-release check over Tor (#224) — ONLY when explicitly enabled (default off, # so the appliance never phones GitHub unbidden). The checker self-throttles to # hourly and returns the cached result; surfaced as state.update for the header badge. if self.update_checker.enabled: self.latest_data["update"] = await asyncio.to_thread( - self.update_checker.maybe_check, time.time()) + self.update_checker.maybe_check, time.time() + ) iteration_count += 1 except Exception as e: logger.error(f"Data Collection Error: {e}") - await asyncio.sleep(UPDATE_INTERVAL) \ No newline at end of file + await asyncio.sleep(UPDATE_INTERVAL) diff --git a/build/dashboard/mining_dashboard/service/metrics.py b/build/dashboard/mining_dashboard/service/metrics.py index c293bb9..5b84012 100644 --- a/build/dashboard/mining_dashboard/service/metrics.py +++ b/build/dashboard/mining_dashboard/service/metrics.py @@ -10,12 +10,17 @@ (#45) and XvB/P2Pool calculator (#12) — read the same ``Metrics`` instead of re-deriving from the raw dict or scraping rendered HTML. Nothing here formats or emits markup. """ + import time from dataclasses import dataclass from mining_dashboard.config.config import ( - ENABLE_XVB, XVB_DONATION_LEVEL, XVB_MAX_DONATION_FRACTION, - MONERO_PRUNE, MONERO_NODE_HOST, LOCAL_MONERO_HOST, + ENABLE_XVB, + LOCAL_MONERO_HOST, + MONERO_NODE_HOST, + MONERO_PRUNE, + XVB_DONATION_LEVEL, + XVB_MAX_DONATION_FRACTION, ) from mining_dashboard.helper.utils import get_tier_info, resolve_target_threshold @@ -31,26 +36,28 @@ @dataclass(frozen=True) class SyncMetric: """One chain's sync/health state (Issues #31, #51).""" + percent: int current: int target: int remaining: int - has_target: bool # a real target height is known (vs. still discovering it) - done: bool # fully synced - down: bool # debounced unreachable (node-health monitor) + has_target: bool # a real target height is known (vs. still discovering it) + done: bool # fully synced + down: bool # debounced unreachable (node-health monitor) @dataclass(frozen=True) class Metrics: """Computed dashboard domain values. All hashrates are raw H/s; no display formatting.""" + # Effective hashrate (H/s). total_h15: float p2pool_1h: float p2pool_24h: float - xvb_1h: float # credited (XvB API avg_1h) — controller input + Advanced card only - xvb_24h: float # credited (XvB API avg_24h) - xvb_routed_1h: float # routed (proxy v_xvb, time-weighted 1h) — header / Simple / chart (#156) - xvb_routed_24h: float # routed (proxy v_xvb, time-weighted 24h) + xvb_1h: float # credited (XvB API avg_1h) — controller input + Advanced card only + xvb_24h: float # credited (XvB API avg_24h) + xvb_routed_1h: float # routed (proxy v_xvb, time-weighted 1h) — header / Simple / chart (#156) + xvb_routed_24h: float # routed (proxy v_xvb, time-weighted 24h) stratum_h15: float stratum_h1h: float stratum_h24h: float @@ -61,16 +68,16 @@ class Metrics: target_tier: str target_threshold: float target_sustainable: bool - low_hr_warning: bool # an explicit tier was chosen that the hashrate can't sustain + low_hr_warning: bool # an explicit tier was chosen that the hashrate can't sustain xvb_fail_count: int - xvb_last_update: float # epoch seconds of the last XvB stats fetch + xvb_last_update: float # epoch seconds of the last XvB stats fetch # Workers. workers_online: int workers_total: int # Shares / PPLNS. shares_in_window: int - pplns_window: int # blocks - block_time: int # seconds per sidechain block + pplns_window: int # blocks + block_time: int # seconds per sidechain block # Pool / network (raw figures; e.g. payout-calculator inputs, #12). pool_type: str pool_hashrate: float @@ -81,8 +88,8 @@ class Metrics: global_syncing: bool monero: SyncMetric tari: SyncMetric - monero_mode: str # "Pruned" / "Full" / "Unknown" - tari_mining: bool # Tari merge-mining active + monero_mode: str # "Pruned" / "Full" / "Unknown" + tari_mining: bool # Tari merge-mining active def build_metrics(latest_data, state_mgr, history=None): @@ -98,15 +105,15 @@ def build_metrics(latest_data, state_mgr, history=None): xvb_stats = state_mgr.get_xvb_stats() or {} tiers = state_mgr.get_tiers() - mode = xvb_stats.get('current_mode', 'P2POOL') + mode = xvb_stats.get("current_mode", "P2POOL") if not ENABLE_XVB: mode = "P2POOL (XvB Disabled)" - total_h15 = data.get('total_live_h15', 0) or 0 + total_h15 = data.get("total_live_h15", 0) or 0 # Credited — XvB's own verdict (avg_1h/24h). The controller steers off this (#9/#70) and the # Advanced card shows it next to routed so the credit factor is visible; nowhere else (#156). - xvb_1h = xvb_stats.get('avg_1h', 0) or 0 - xvb_24h = xvb_stats.get('avg_24h', 0) or 0 + xvb_1h = xvb_stats.get("avg_1h", 0) or 0 + xvb_24h = xvb_stats.get("avg_24h", 0) or 0 # Routed — what the proxy ACTUALLY sent to XvB, time-weighted from our own DB history (v_xvb), # mirroring P2Pool's v_p2pool averaging so the two sum to total. This is the at-a-glance display # figure (header / Simple / chart), NOT the controller's intended donation_fraction (#156). @@ -129,28 +136,30 @@ def build_metrics(latest_data, state_mgr, history=None): ) target_tier, _ = get_tier_info(target_threshold, tiers) low_hr_warning = bool( - ENABLE_XVB and XVB_DONATION_LEVEL not in ("auto", "highest") - and target_threshold > 0 and not sustainable + ENABLE_XVB + and XVB_DONATION_LEVEL not in ("auto", "highest") + and target_threshold > 0 + and not sustainable ) if not ENABLE_XVB: current_tier = "Disabled" target_tier = "Disabled" low_hr_warning = False - stratum = data.get('stratum', {}) - pool_stats = data.get('pool', {}) - p2p = pool_stats.get('p2p', {}) - local_pool = pool_stats.get('pool', {}) - network = data.get('network', {}) + stratum = data.get("stratum", {}) + pool_stats = data.get("pool", {}) + p2p = pool_stats.get("p2p", {}) + local_pool = pool_stats.get("pool", {}) + network = data.get("network", {}) - pool_type = p2p.get('type', 'Main') - block_time = _BLOCK_TIME_NANO if pool_type == 'Nano' else _BLOCK_TIME_DEFAULT - pplns_window = local_pool.get('pplns_window', 2160) + pool_type = p2p.get("type", "Main") + block_time = _BLOCK_TIME_NANO if pool_type == "Nano" else _BLOCK_TIME_DEFAULT + pplns_window = local_pool.get("pplns_window", 2160) cutoff = time.time() - pplns_window * block_time - shares_in_window = sum(1 for s in data.get('shares', []) if s.get('ts', 0) >= cutoff) + shares_in_window = sum(1 for s in data.get("shares", []) if s.get("ts", 0) >= cutoff) - workers = data.get('workers', []) - workers_online = sum(1 for w in workers if w.get('status') == 'online') + workers = data.get("workers", []) + workers_online = sum(1 for w in workers if w.get("status") == "online") return Metrics( total_h15=total_h15, @@ -160,9 +169,9 @@ def build_metrics(latest_data, state_mgr, history=None): xvb_24h=xvb_24h, xvb_routed_1h=xvb_routed_1h, xvb_routed_24h=xvb_routed_24h, - stratum_h15=stratum.get('hashrate_15m', 0) or 0, - stratum_h1h=stratum.get('hashrate_1h', 0) or 0, - stratum_h24h=stratum.get('hashrate_24h', 0) or 0, + stratum_h15=stratum.get("hashrate_15m", 0) or 0, + stratum_h1h=stratum.get("hashrate_1h", 0) or 0, + stratum_h24h=stratum.get("hashrate_24h", 0) or 0, mode=mode, xvb_enabled=bool(ENABLE_XVB), current_tier=current_tier, @@ -170,23 +179,23 @@ def build_metrics(latest_data, state_mgr, history=None): target_threshold=target_threshold, target_sustainable=sustainable, low_hr_warning=low_hr_warning, - xvb_fail_count=xvb_stats.get('fail_count', 0) or 0, - xvb_last_update=xvb_stats.get('last_update', 0) or 0, + xvb_fail_count=xvb_stats.get("fail_count", 0) or 0, + xvb_last_update=xvb_stats.get("last_update", 0) or 0, workers_online=workers_online, workers_total=len(workers), shares_in_window=shares_in_window, pplns_window=pplns_window, block_time=block_time, pool_type=pool_type, - pool_hashrate=local_pool.get('hashrate', 0) or 0, - pool_difficulty=local_pool.get('difficulty', 0) or 0, - network_difficulty=network.get('difficulty', 0) or 0, - network_height=network.get('height', 0) or 0, - global_syncing=bool(data.get('global_sync', False)), - monero=_sync_metric(data.get('monero_sync', {})), - tari=_sync_metric(data.get('tari_sync', {})), + pool_hashrate=local_pool.get("hashrate", 0) or 0, + pool_difficulty=local_pool.get("difficulty", 0) or 0, + network_difficulty=network.get("difficulty", 0) or 0, + network_height=network.get("height", 0) or 0, + global_syncing=bool(data.get("global_sync", False)), + monero=_sync_metric(data.get("monero_sync", {})), + tari=_sync_metric(data.get("tari_sync", {})), monero_mode=_monero_mode(), - tari_mining=bool(data.get('tari', {}).get('active', False)), + tari_mining=bool(data.get("tari", {}).get("active", False)), ) @@ -205,11 +214,11 @@ def _avg_p2pool_over_window(history, window_seconds): total = 0.0 count = 0 for x in history: - if x.get('timestamp', 0) < cutoff: + if x.get("timestamp", 0) < cutoff: continue - vp = x.get('v_p2pool', 0) or 0 - vx = x.get('v_xvb', 0) or 0 - v = x.get('v', 0) or 0 + vp = x.get("v_p2pool", 0) or 0 + vx = x.get("v_xvb", 0) or 0 + v = x.get("v", 0) or 0 if vp == 0 and vx == 0 and v > 0: vp = v total += vp @@ -233,9 +242,9 @@ def _avg_xvb_over_window(history, window_seconds): total = 0.0 count = 0 for x in history: - if x.get('timestamp', 0) < cutoff: + if x.get("timestamp", 0) < cutoff: continue - total += x.get('v_xvb', 0) or 0 + total += x.get("v_xvb", 0) or 0 count += 1 return total / count if count else 0.0 @@ -243,9 +252,9 @@ def _avg_xvb_over_window(history, window_seconds): def _sync_metric(sync): """Build a :class:`SyncMetric` from a chain's raw ``*_sync`` dict.""" - percent = sync.get('percent', 0) or 0 - current = sync.get('current', 0) or 0 - target = sync.get('target', 0) or 0 + percent = sync.get("percent", 0) or 0 + current = sync.get("current", 0) or 0 + target = sync.get("target", 0) or 0 has_target = target > 0 return SyncMetric( percent=percent, @@ -257,8 +266,9 @@ def _sync_metric(sync): # the percent>=100 path never fires for it — which left a fully-synced node stuck at "loading" # (found in the #180 gouda validation). Trust the authoritative reachable + not-is_syncing # signal too; Tari already reports current==target/percent=100, so it's unaffected. - done=(sync.get('reachable', False) and not sync.get('is_syncing', True)) or (has_target and percent >= 100), - down=bool(sync.get('down', False)), + done=(sync.get("reachable", False) and not sync.get("is_syncing", True)) + or (has_target and percent >= 100), + down=bool(sync.get("down", False)), ) diff --git a/build/dashboard/mining_dashboard/service/node_health.py b/build/dashboard/mining_dashboard/service/node_health.py index 8027e3a..c658f10 100644 --- a/build/dashboard/mining_dashboard/service/node_health.py +++ b/build/dashboard/mining_dashboard/service/node_health.py @@ -29,8 +29,12 @@ class NodeHealthMonitor: Clock is injectable for tests; defaults to `time.monotonic`. """ - def __init__(self, down_after=NODE_DOWN_AFTER_SEC, recovery_after=NODE_RECOVERY_AFTER_SEC, - clock=time.monotonic): + def __init__( + self, + down_after=NODE_DOWN_AFTER_SEC, + recovery_after=NODE_RECOVERY_AFTER_SEC, + clock=time.monotonic, + ): self.down_after = down_after self.recovery_after = recovery_after self._clock = clock @@ -60,7 +64,11 @@ def update(self, reachable): if self._unreachable_since is None: self._unreachable_since = now # Only a node that has actually been up can fall DOWN. - if self.ever_up and not self.down and (now - self._unreachable_since) >= self.down_after: + if ( + self.ever_up + and not self.down + and (now - self._unreachable_since) >= self.down_after + ): self.down = True return self.down diff --git a/build/dashboard/mining_dashboard/service/storage_service.py b/build/dashboard/mining_dashboard/service/storage_service.py index 12b4d63..e4d067b 100644 --- a/build/dashboard/mining_dashboard/service/storage_service.py +++ b/build/dashboard/mining_dashboard/service/storage_service.py @@ -1,15 +1,17 @@ +import json +import logging +import random import sqlite3 import threading -import logging -import json -import os import time -import random from collections import deque -from typing import Dict, List, Optional, Any +from typing import Any + from mining_dashboard.config.config import ( - DB_FILE_PATH, TIER_DEFAULTS, HISTORY_RETENTION_SEC, + DB_FILE_PATH, HASHRATE_WINDOW_COLUMNS, + HISTORY_RETENTION_SEC, + TIER_DEFAULTS, ) # The 10m window reuses the original v_p2pool/v_xvb pair; every other window in @@ -27,10 +29,11 @@ class StateManager: """ Manages persistent application state including hashrate history and mining mode statistics. - + Handles atomic file I/O to prevent data corruption and ensures state consistency across application restarts. """ + def __init__(self, db_path: str = None): self.logger = logging.getLogger("StateManager") # Default to the configured path; tests inject a temp file or ":memory:". @@ -51,12 +54,12 @@ def __init__(self, db_path: str = None): # controller each cycle. Lets the dashboard show what we *send* # (routed) next to what XvB *credits* (avg_1h/24h) — the live # credit-factor signal (Issue #70). - "donation_fraction": 0.0 + "donation_fraction": 0.0, }, # Initialize state with default values from configuration - "tiers": TIER_DEFAULTS.copy() + "tiers": TIER_DEFAULTS.copy(), } - + # Initialize persistent DB connection # check_same_thread=False allows the connection to be used by multiple threads # (serialized via self._db_lock) @@ -77,7 +80,7 @@ def _init_db(self): # Enable WAL mode for better concurrency self._conn.execute("PRAGMA journal_mode=WAL") self._conn.execute("PRAGMA synchronous=NORMAL") - + with self._conn: self._create_tables() self._migrate_db() @@ -103,9 +106,13 @@ def _create_tables(self): # Per-window hashrate columns (#168) are appended so a fresh DB starts with them; existing # DBs get them via _migrate_db. Same source list (_WINDOW_EXTRA_COLUMNS) for both paths. extra = "".join(f", {c} REAL DEFAULT 0" for c in _WINDOW_EXTRA_COLUMNS) - self._conn.execute(f"CREATE TABLE IF NOT EXISTS history (t TEXT, v REAL, v_p2pool REAL, v_xvb REAL, timestamp REAL{extra})") + self._conn.execute( + f"CREATE TABLE IF NOT EXISTS history (t TEXT, v REAL, v_p2pool REAL, v_xvb REAL, timestamp REAL{extra})" + ) self._conn.execute("CREATE TABLE IF NOT EXISTS kv_store (key TEXT PRIMARY KEY, value TEXT)") - self._conn.execute("CREATE TABLE IF NOT EXISTS shares (ts REAL PRIMARY KEY, difficulty REAL)") + self._conn.execute( + "CREATE TABLE IF NOT EXISTS shares (ts REAL PRIMARY KEY, difficulty REAL)" + ) def _create_indexes(self): """Creates indexes. Called after migrations so the indexed columns are guaranteed to @@ -116,23 +123,25 @@ def _create_indexes(self): def _migrate_db(self): """Handles schema migrations for existing databases.""" cursor = self._conn.cursor() - + # History Table Migrations cursor.execute("PRAGMA table_info(history)") columns = {info[1] for info in cursor.fetchall()} - - if 'v_p2pool' not in columns: + + if "v_p2pool" not in columns: self.logger.info("Migrating DB: Adding v_p2pool column to history") self._conn.execute("ALTER TABLE history ADD COLUMN v_p2pool REAL DEFAULT 0") - if 'v_xvb' not in columns: + if "v_xvb" not in columns: self.logger.info("Migrating DB: Adding v_xvb column to history") self._conn.execute("ALTER TABLE history ADD COLUMN v_xvb REAL DEFAULT 0") - if 'timestamp' not in columns: + if "timestamp" not in columns: self.logger.info("Migrating DB: Adding timestamp column to history") self._conn.execute("ALTER TABLE history ADD COLUMN timestamp REAL") - self._conn.execute("UPDATE history SET timestamp = CAST(strftime('%s', t) AS REAL) WHERE timestamp IS NULL") + self._conn.execute( + "UPDATE history SET timestamp = CAST(strftime('%s', t) AS REAL) WHERE timestamp IS NULL" + ) self._conn.execute("UPDATE history SET timestamp = 0 WHERE timestamp IS NULL") # Per-window hashrate columns (#168) — additive, forward-only. Pre-existing rows keep DEFAULT @@ -153,15 +162,22 @@ def load(self): """ try: with self._db_lock: - if not self._conn: return + if not self._conn: + return cursor = self._conn.cursor() - + with self._lock: # 1. Load History # Limit to retention period to prevent memory bloat history_cutoff = time.time() - HISTORY_RETENTION_SEC - hist_cols = ", ".join(["t", "v", "v_p2pool", "v_xvb", "timestamp"] + _WINDOW_EXTRA_COLUMNS) - cursor.execute(f"SELECT {hist_cols} FROM history WHERE timestamp > ? ORDER BY timestamp ASC", (history_cutoff,)) + hist_cols = ", ".join( + ["t", "v", "v_p2pool", "v_xvb", "timestamp"] + _WINDOW_EXTRA_COLUMNS + ) + cursor.execute( + # Column list is literals + a module constant, never user input; value is ?-bound. + f"SELECT {hist_cols} FROM history WHERE timestamp > ? ORDER BY timestamp ASC", # noqa: S608 + (history_cutoff,), + ) history = [] for row in cursor.fetchall(): item = dict(row) @@ -180,12 +196,14 @@ def load(self): key = row["key"] if key.startswith("xvb_"): key = key[4:] - + val = row["value"] - + # Migration: Handle legacy keys from previous versions - if key == "1h_avg": key = "avg_1h" - if key == "24h_avg": key = "avg_24h" + if key == "1h_avg": + key = "avg_1h" + if key == "24h_avg": + key = "avg_24h" # Enforce schema: Ignore keys not present in the default state if key not in self.state["xvb"]: @@ -205,14 +223,19 @@ def load(self): self.logger.warning(f"Skipping corrupted KV pair: {key}={val}") # 3. Load Shares - cursor.execute("SELECT ts, difficulty FROM shares WHERE ts > ? ORDER BY ts ASC", (history_cutoff,)) + cursor.execute( + "SELECT ts, difficulty FROM shares WHERE ts > ? ORDER BY ts ASC", + (history_cutoff,), + ) self.state["shares"] = [dict(row) for row in cursor.fetchall()] - + self.logger.info(f"State successfully loaded from {self.db_path}") except sqlite3.Error as e: self.logger.error(f"DB Load Error: {e}") - def update_history(self, hashrate: float, p2pool_hr: float = 0, xvb_hr: float = 0, windows=None): + def update_history( + self, hashrate: float, p2pool_hr: float = 0, xvb_hr: float = 0, windows=None + ): """Appends a new hashrate data point to the history buffer. ``windows`` (Issue #168) is an optional ``{window: (p2pool_hr, xvb_hr)}`` mapping of the @@ -220,7 +243,7 @@ def update_history(self, hashrate: float, p2pool_hr: float = 0, xvb_hr: float = ``p2pool_hr``/``xvb_hr`` pair above). Each is stored in its own column so the chart's window toggle can plot a true average per window; an omitted/unknown window defaults to 0. """ - t_str = time.strftime('%Y-%m-%d %H:%M:%S') + t_str = time.strftime("%Y-%m-%d %H:%M:%S") ts = time.time() try: @@ -248,18 +271,23 @@ def update_history(self, hashrate: float, p2pool_hr: float = 0, xvb_hr: float = with self._lock: # 1. Update In-Memory State - self.state["hashrate_history"].append({ - "t": t_str, - "v": v_val, - "v_p2pool": v_p2p, - "v_xvb": v_xvb, - "timestamp": ts, - **extra, - }) + self.state["hashrate_history"].append( + { + "t": t_str, + "v": v_val, + "v_p2pool": v_p2p, + "v_xvb": v_xvb, + "timestamp": ts, + **extra, + } + ) # Prune in-memory history to enforce retention policy cutoff = ts - HISTORY_RETENTION_SEC - while self.state["hashrate_history"] and self.state["hashrate_history"][0]["timestamp"] < cutoff: + while ( + self.state["hashrate_history"] + and self.state["hashrate_history"][0]["timestamp"] < cutoff + ): self.state["hashrate_history"].popleft() # 2. Persist to DB @@ -270,14 +298,19 @@ def update_history(self, hashrate: float, p2pool_hr: float = 0, xvb_hr: float = with self._conn: cols = ["t", "v", "v_p2pool", "v_xvb", "timestamp"] + _WINDOW_EXTRA_COLUMNS placeholders = ", ".join("?" * len(cols)) - values = (t_str, v_val, v_p2p, v_xvb, ts) + tuple(extra[c] for c in _WINDOW_EXTRA_COLUMNS) + values = (t_str, v_val, v_p2p, v_xvb, ts) + tuple( + extra[c] for c in _WINDOW_EXTRA_COLUMNS + ) self._conn.execute( - f"INSERT INTO history ({', '.join(cols)}) VALUES ({placeholders})", - values + # Column/placeholder lists are literals + a module constant, not user input. + f"INSERT INTO history ({', '.join(cols)}) VALUES ({placeholders})", # noqa: S608 + values, ) # Prune old history from DB to prevent unbounded growth (Probabilistic pruning to save I/O) - if random.random() < 0.05: - self._conn.execute("DELETE FROM history WHERE timestamp < ?", (ts - HISTORY_RETENTION_SEC,)) + if random.random() < 0.05: # noqa: S311 — pruning sampler, not a security context + self._conn.execute( + "DELETE FROM history WHERE timestamp < ?", (ts - HISTORY_RETENTION_SEC,) + ) except sqlite3.Error as e: self._db_error("History Update Error", e) @@ -285,9 +318,9 @@ def add_share(self, ts: float, difficulty: float): """Appends a new share to history and persists it to the DB.""" with self._lock: # Check if share already exists to prevent duplicate in-memory appends - if not any(s['ts'] == ts for s in self.state.get("shares", [])): + if not any(s["ts"] == ts for s in self.state.get("shares", [])): self.state["shares"].append({"ts": ts, "difficulty": difficulty}) - + # Prune in-memory state based on the 30-day config cutoff = time.time() - HISTORY_RETENTION_SEC self.state["shares"] = [s for s in self.state["shares"] if s["ts"] >= cutoff] @@ -295,12 +328,19 @@ def add_share(self, ts: float, difficulty: float): # Persist to DB try: with self._db_lock: - if not self._conn: return + if not self._conn: + return with self._conn: - self._conn.execute("INSERT OR IGNORE INTO shares (ts, difficulty) VALUES (?, ?)", (ts, difficulty)) - - if random.random() < 0.05: - self._conn.execute("DELETE FROM shares WHERE ts < ?", (time.time() - HISTORY_RETENTION_SEC,)) + self._conn.execute( + "INSERT OR IGNORE INTO shares (ts, difficulty) VALUES (?, ?)", + (ts, difficulty), + ) + + if random.random() < 0.05: # noqa: S311 — pruning sampler, not a security context + self._conn.execute( + "DELETE FROM shares WHERE ts < ?", + (time.time() - HISTORY_RETENTION_SEC,), + ) except sqlite3.Error as e: self._db_error("Share Insert Error", e) @@ -316,22 +356,29 @@ def add_shares(self, count: int, latest_ts: float, difficulty: float): # Distinct timestamps ending at latest_ts (1 ms steps back) so the ts PRIMARY KEY keeps all. self.add_share(round(latest_ts - 0.001 * (count - 1 - i), 3), difficulty) - def get_shares(self) -> List[Dict[str, Any]]: + def get_shares(self) -> list[dict[str, Any]]: """Returns a copy of the shares history.""" with self._lock: return list(self.state.get("shares", [])) - def get_xvb_stats(self) -> Dict[str, Any]: + def get_xvb_stats(self) -> dict[str, Any]: """Returns the current XvB mining statistics dictionary.""" with self._lock: return self.state["xvb"].copy() - def update_xvb_stats(self, mode: Optional[str] = None, avg_24h: Optional[float] = None, avg_1h: Optional[float] = None, fail_count: Optional[int] = None, **kwargs): + def update_xvb_stats( + self, + mode: str | None = None, + avg_24h: float | None = None, + avg_1h: float | None = None, + fail_count: int | None = None, + **kwargs, + ): """ Updates specific fields within the XvB statistics state. - + Allows partial updates to decouple mode switching from statistical updates. - + Args: mode (str, optional): The current mining mode (e.g., "P2POOL", "XVB"). avg_24h (float, optional): 24-hour average hashrate on XvB. @@ -391,7 +438,7 @@ def update_xvb_stats(self, mode: Optional[str] = None, avg_24h: Optional[float] ts = time.time() self.state["xvb"]["last_update"] = ts updates["xvb_last_update"] = ts - + # Persist to DB if updates: try: @@ -399,12 +446,14 @@ def update_xvb_stats(self, mode: Optional[str] = None, avg_24h: Optional[float] if not self._conn: return with self._conn: - self._conn.executemany("INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)", - [(k, str(v)) for k, v in updates.items()]) + self._conn.executemany( + "INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)", + [(k, str(v)) for k, v in updates.items()], + ) except sqlite3.Error as e: self._db_error("XVB Update Error", e) - def save_snapshot(self, data: Dict[str, Any]): + def save_snapshot(self, data: dict[str, Any]): """Persists the full application state snapshot to the KV store.""" if not data: return @@ -414,14 +463,16 @@ def save_snapshot(self, data: Dict[str, Any]): if not self._conn: return with self._conn: - self._conn.execute("INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)", - ("snapshot_latest_data", json_str)) + self._conn.execute( + "INSERT OR REPLACE INTO kv_store (key, value) VALUES (?, ?)", + ("snapshot_latest_data", json_str), + ) except sqlite3.Error as e: self._db_error("Snapshot Save Error", e) except TypeError as e: self.logger.error(f"Snapshot serialization error: {e}") - def load_snapshot(self) -> Optional[Dict[str, Any]]: + def load_snapshot(self) -> dict[str, Any] | None: """Loads the last persisted application state snapshot.""" try: with self._db_lock: @@ -436,12 +487,12 @@ def load_snapshot(self) -> Optional[Dict[str, Any]]: self.logger.error(f"Snapshot Load Error: {e}") return None - def get_history(self) -> List[Dict[str, Any]]: + def get_history(self) -> list[dict[str, Any]]: """Returns a copy of the hashrate history.""" with self._lock: return list(self.state["hashrate_history"]) - def get_tiers(self) -> Dict[str, Any]: + def get_tiers(self) -> dict[str, Any]: """Returns a copy of the donation tiers configuration.""" with self._lock: return self.state["tiers"].copy() @@ -456,4 +507,4 @@ def close(self): except sqlite3.Error as e: self.logger.error(f"Error closing database: {e}") finally: - self._conn = None \ No newline at end of file + self._conn = None diff --git a/build/dashboard/mining_dashboard/service/update_checker.py b/build/dashboard/mining_dashboard/service/update_checker.py index 61b95e2..111a342 100644 --- a/build/dashboard/mining_dashboard/service/update_checker.py +++ b/build/dashboard/mining_dashboard/service/update_checker.py @@ -9,6 +9,7 @@ `XVB_TOR_PROXY`, like the XvB stats fetch #163), so enabling it doesn't reveal the host IP to GitHub. Every failure path is silent (returns ``None``) so an offline / Tor-only stack just shows no badge. """ + import logging import requests @@ -53,8 +54,13 @@ def latest_release(self): proxies = {"http": self.tor_proxy, "https": self.tor_proxy} if self.tor_proxy else None try: resp = requests.get( - self.api_url, timeout=20, proxies=proxies, - headers={"Accept": "application/vnd.github+json", "User-Agent": "pithead-dashboard"}, + self.api_url, + timeout=20, + proxies=proxies, + headers={ + "Accept": "application/vnd.github+json", + "User-Agent": "pithead-dashboard", + }, ) if resp.status_code != 200: return None diff --git a/build/dashboard/mining_dashboard/sim/donation_model.py b/build/dashboard/mining_dashboard/sim/donation_model.py index 6e65c8c..fd02c1e 100644 --- a/build/dashboard/mining_dashboard/sim/donation_model.py +++ b/build/dashboard/mining_dashboard/sim/donation_model.py @@ -28,14 +28,14 @@ Run ``python -m mining_dashboard.sim.donation_model`` for a before/after report. """ -from dataclasses import dataclass, field, replace -from typing import Callable, Optional import math +from collections.abc import Callable +from dataclasses import dataclass, field, replace from mining_dashboard.config.config import ( - XVB_TIME_ALGO_MS, - XVB_SWITCH_OVERHEAD_MS, TIER_DEFAULTS, + XVB_SWITCH_OVERHEAD_MS, + XVB_TIME_ALGO_MS, ) # A 10-minute switching cycle is the simulation's time step. @@ -44,7 +44,7 @@ # Constraint inputs that keep AlgoService.get_decision past its guard clauses so we # exercise the donation math (a recent share, a healthy endpoint, a Main pool). -_RECENT_SHARES = [{"ts": 10 ** 12}] # far-future ts -> always inside the PPLNS window +_RECENT_SHARES = [{"ts": 10**12}] # far-future ts -> always inside the PPLNS window _P2P_MAIN = {"type": "Main"} _PPLNS_WINDOW = 2160 @@ -113,7 +113,7 @@ def __init__(self, span_cycles: int, semantics: str = "fixed"): def push(self, credited_hr: float) -> None: self._samples.append(credited_hr) if len(self._samples) > self.span: - self._samples = self._samples[-self.span:] + self._samples = self._samples[-self.span :] def average(self) -> float: if not self._samples: @@ -129,12 +129,12 @@ class Scenario: """A simulation setup. Times are in 10-minute cycles.""" name: str - target_hr: float # tier threshold to hold (H/s) - current_hr: float # steady rig hashrate (H/s) + target_hr: float # tier threshold to hold (H/s) + current_hr: float # steady rig hashrate (H/s) cycles: int = 3 * CYCLES_PER_DAY measurement: str = "fixed" # "fixed" | "connected" (see _Window) - warm_avg: float = 0.0 # initial avg_1h/avg_24h (0 = cold start; >0 = warm) - hr_noise: float = 0.0 # deterministic +/- fractional jitter on current_hr + warm_avg: float = 0.0 # initial avg_1h/avg_24h (0 = cold start; >0 = warm) + hr_noise: float = 0.0 # deterministic +/- fractional jitter on current_hr # Per-switch reconnect ramp: XvB credits ~0 for this long at the start of each # donation slice while miners reconnect to the new pool. This is exactly what # the controller's XVB_SWITCH_OVERHEAD_MS compensates for, so modelling it lets @@ -159,19 +159,19 @@ class Scenario: # actually keeps a share each cycle (VIP status) in `vip_held`. p2pool_difficulty: float = 0.0 # Optional sustained hashrate drop (worker disconnect) to test tier recovery. - drop_at: Optional[int] = None - drop_until: Optional[int] = None - drop_factor: float = 1.0 # current_hr multiplier while dropped + drop_at: int | None = None + drop_until: int | None = None + drop_factor: float = 1.0 # current_hr multiplier while dropped @dataclass class SimResult: scenario: Scenario - credited: list[float] = field(default_factory=list) # XvB-credited rate/cycle - credited_1h: list[float] = field(default_factory=list) # XvB-reported 1h avg + credited: list[float] = field(default_factory=list) # XvB-credited rate/cycle + credited_1h: list[float] = field(default_factory=list) # XvB-reported 1h avg credited_24h: list[float] = field(default_factory=list) # XvB-reported 24h avg - fraction: list[float] = field(default_factory=list) # donated fraction/cycle - current_hr: list[float] = field(default_factory=list) # rig hashrate seen + fraction: list[float] = field(default_factory=list) # donated fraction/cycle + current_hr: list[float] = field(default_factory=list) # rig hashrate seen # --- metrics ------------------------------------------------------------ @property @@ -223,10 +223,12 @@ def tier_held(self, tol: float = 0.02) -> bool: noise around the threshold. """ t = self.scenario.target_hr * (1 - tol) - return (min(self.credited_1h[self._tail], default=0.0) >= t - and min(self.credited_24h[self._tail], default=0.0) >= t) + return ( + min(self.credited_1h[self._tail], default=0.0) >= t + and min(self.credited_24h[self._tail], default=0.0) >= t + ) - def cycles_to_tier_24h(self) -> Optional[int]: + def cycles_to_tier_24h(self) -> int | None: """First cycle at which the 24h average reaches the threshold (or None).""" for i, v in enumerate(self.credited_24h): if v >= self.scenario.target_hr: @@ -247,7 +249,9 @@ def min_expected_shares(self, window_seconds: float = _PPLNS_WINDOW * 10) -> flo tail_hr = self.current_hr[self._tail] if not tail_frac: return float("inf") - return min((1 - f) * hr * window_seconds / diff for f, hr in zip(tail_frac, tail_hr)) + return min( + (1 - f) * hr * window_seconds / diff for f, hr in zip(tail_frac, tail_hr, strict=False) + ) def vip_held(self, min_shares: float = 1.0) -> bool: """True if p2pool keeps at least ``min_shares`` expected shares in the window @@ -284,8 +288,10 @@ def run_simulation(controller: Controller, scenario: Scenario) -> SimResult: # Effective rig hashrate this cycle: deterministic jitter + optional drop. base = scenario.current_hr - if scenario.drop_at is not None and scenario.drop_at <= cycle and ( - scenario.drop_until is None or cycle < scenario.drop_until + if ( + scenario.drop_at is not None + and scenario.drop_at <= cycle + and (scenario.drop_until is None or cycle < scenario.drop_until) ): base *= scenario.drop_factor # Deterministic pseudo-jitter (no RNG: reproducible across runs/resumes). @@ -345,31 +351,55 @@ def run_algo(scenario: Scenario, donation_level="vip", **tuning) -> SimResult: # VIP reserve is exercised rather than the flat fallback. _DIFFICULTY = 120_000_000 DEFAULT_SCENARIOS = [ - Scenario(name="field (VIP 10k on 46k rig, cold start)", - target_hr=10_000, current_hr=46_300, p2pool_difficulty=_DIFFICULTY), - Scenario(name="high headroom (VIP 10k on 200k rig)", - target_hr=10_000, current_hr=200_000, p2pool_difficulty=_DIFFICULTY), - Scenario(name="stale/laggy API reads (~2h reporting lag)", - target_hr=10_000, current_hr=46_300, report_lag_cycles=12, - p2pool_difficulty=_DIFFICULTY), - Scenario(name="XvB over-credits 3x (calibration must back off)", - target_hr=10_000, current_hr=46_300, credit_factor=3.0, - p2pool_difficulty=_DIFFICULTY), - Scenario(name="worker drop below tier mid-run, then recovery", - target_hr=10_000, current_hr=46_300, warm_avg=10_300, - drop_at=CYCLES_PER_DAY, drop_until=CYCLES_PER_DAY + 18, drop_factor=0.2, - p2pool_difficulty=_DIFFICULTY), + Scenario( + name="field (VIP 10k on 46k rig, cold start)", + target_hr=10_000, + current_hr=46_300, + p2pool_difficulty=_DIFFICULTY, + ), + Scenario( + name="high headroom (VIP 10k on 200k rig)", + target_hr=10_000, + current_hr=200_000, + p2pool_difficulty=_DIFFICULTY, + ), + Scenario( + name="stale/laggy API reads (~2h reporting lag)", + target_hr=10_000, + current_hr=46_300, + report_lag_cycles=12, + p2pool_difficulty=_DIFFICULTY, + ), + Scenario( + name="XvB over-credits 3x (calibration must back off)", + target_hr=10_000, + current_hr=46_300, + credit_factor=3.0, + p2pool_difficulty=_DIFFICULTY, + ), + Scenario( + name="worker drop below tier mid-run, then recovery", + target_hr=10_000, + current_hr=46_300, + warm_avg=10_300, + drop_at=CYCLES_PER_DAY, + drop_until=CYCLES_PER_DAY + 18, + drop_factor=0.2, + p2pool_difficulty=_DIFFICULTY, + ), ] def _fmt(r: SimResult) -> str: shares = r.min_expected_shares() vip = "inf" if shares == float("inf") else f"{shares:.1f}" - return (f"day1 {r.first_day_overshoot:4.2f}x " - f"peak {r.peak_overshoot:4.2f}x " - f"steady {r.steady_overshoot_1h:4.2f}x " - f"p2pool {r.p2pool_efficiency*100:5.1f}% " - f"tier {str(r.tier_held()):5} vip_shares {vip}") + return ( + f"day1 {r.first_day_overshoot:4.2f}x " + f"peak {r.peak_overshoot:4.2f}x " + f"steady {r.steady_overshoot_1h:4.2f}x " + f"p2pool {r.p2pool_efficiency * 100:5.1f}% " + f"tier {str(r.tier_held()):5} vip_shares {vip}" + ) def main(): # pragma: no cover - human-facing report, not a test path diff --git a/build/dashboard/mining_dashboard/version.py b/build/dashboard/mining_dashboard/version.py index bf3ad5a..af61afc 100644 --- a/build/dashboard/mining_dashboard/version.py +++ b/build/dashboard/mining_dashboard/version.py @@ -9,6 +9,7 @@ — the single source of truth — and ``git``). Reading only the environment keeps the container self-describing and this resolver a pure, unit-testable function of its inputs. """ + import os @@ -50,7 +51,8 @@ def resolve_version(env=None): else: text = "dev build" - detail = ", ".join(p for p in (f"branch {branch}" if branch else "", - f"commit {commit}" if commit else "") if p) + detail = ", ".join( + p for p in (f"branch {branch}" if branch else "", f"commit {commit}" if commit else "") if p + ) title = f"Development build ({detail})" if detail else "Development build" return {"text": text, "title": title, "dev": True} diff --git a/build/dashboard/mining_dashboard/web/server.py b/build/dashboard/mining_dashboard/web/server.py index 8e3e074..793b905 100644 --- a/build/dashboard/mining_dashboard/web/server.py +++ b/build/dashboard/mining_dashboard/web/server.py @@ -1,9 +1,10 @@ -import os import logging import mimetypes +import os + from aiohttp import web -from mining_dashboard.web.views import build_state, get_shell_html, parse_window, canonical_window +from mining_dashboard.web.views import build_state, canonical_window, get_shell_html, parse_window logger = logging.getLogger("WebServer") @@ -18,20 +19,20 @@ async def handle_index(request): """Serve the static HTML shell. It carries no data — the client fetches ``/api/state`` and renders the dashboard. Pure transport.""" - return web.Response(text=get_shell_html(), content_type='text/html') + return web.Response(text=get_shell_html(), content_type="text/html") async def handle_state(request): """The dashboard's data API. Pull shared state, delegate to the view layer, and return the assembled state object as JSON (or a sanitized 500 on failure).""" app = request.app - data = app['latest_data'] - state_mgr = app['state_manager'] - range_arg = request.query.get('range', 'all') + data = app["latest_data"] + state_mgr = app["state_manager"] + range_arg = request.query.get("range", "all") # Optional manual-zoom window (Issue #47); malformed from/to falls back to the preset range. - window = parse_window(request.query.get('from'), request.query.get('to')) + window = parse_window(request.query.get("from"), request.query.get("to")) # Hashrate-averaging window for the chart (#168); unknown/missing falls back to the default. - avg_window = canonical_window(request.query.get('avg')) + avg_window = canonical_window(request.query.get("avg")) try: return web.json_response(build_state(data, state_mgr, range_arg, window, avg_window)) @@ -47,10 +48,10 @@ def _apply_security_headers(response): so no 'unsafe-inline' or 'unsafe-eval' is needed (Issue #60). The frontend libraries are eval-free ES modules; dynamic styling is applied via the CSSOM, which style-src doesn't govern.""" - response.headers['X-Content-Type-Options'] = 'nosniff' - response.headers['X-Frame-Options'] = 'DENY' - response.headers['Referrer-Policy'] = 'no-referrer' - response.headers['Content-Security-Policy'] = ( + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["Referrer-Policy"] = "no-referrer" + response.headers["Content-Security-Policy"] = ( "default-src 'self'; img-src 'self' data:; style-src 'self'; " "script-src 'self'; connect-src 'self'; frame-ancestors 'none'; " "base-uri 'self'; form-action 'self'" @@ -60,7 +61,7 @@ def _apply_security_headers(response): # without this, a browser (notably iOS Safari) can keep serving the pre-upgrade dashboard.css # for an unpredictable while (Issue #83). 'no-cache' still allows a conditional request, so an # unchanged asset costs only a 304 — no re-download of the vendored libs on each page load. - response.headers['Cache-Control'] = 'no-cache' + response.headers["Cache-Control"] = "no-cache" return response @@ -70,22 +71,24 @@ async def security_headers_middleware(request, handler): try: return _apply_security_headers(await handler(request)) except web.HTTPException as exc: - raise _apply_security_headers(exc) + raise _apply_security_headers(exc) from exc def create_app(state_manager, latest_data_ref): """Factory to create the web app instance.""" app = web.Application(middlewares=[security_headers_middleware]) # Pass shared state objects to the app context - app['state_manager'] = state_manager - app['latest_data'] = latest_data_ref - - app.add_routes([ - web.get('/', handle_index), - web.get('/api/state', handle_state), - ]) + app["state_manager"] = state_manager + app["latest_data"] = latest_data_ref + + app.add_routes( + [ + web.get("/", handle_index), + web.get("/api/state", handle_state), + ] + ) static_path = os.path.join(os.path.dirname(__file__), "static") - app.router.add_static('/static', static_path) + app.router.add_static("/static", static_path) return app diff --git a/build/dashboard/mining_dashboard/web/views.py b/build/dashboard/mining_dashboard/web/views.py index eb53911..4463292 100644 --- a/build/dashboard/mining_dashboard/web/views.py +++ b/build/dashboard/mining_dashboard/web/views.py @@ -9,27 +9,37 @@ ``build_state`` is the single assembly point and the contract the ``/api/state`` endpoint and the client share; ``server.py`` stays pure transport. """ -import os -import math -import time + import bisect import logging +import math +import os +import time from mining_dashboard.config.config import ( - HOST_IP, UPDATE_INTERVAL, DISK_WARN_PERCENT, DISK_CRITICAL_PERCENT, - HASHRATE_WINDOWS, DEFAULT_HASHRATE_WINDOW, HASHRATE_WINDOW_COLUMNS, + DEFAULT_HASHRATE_WINDOW, + DISK_CRITICAL_PERCENT, + DISK_WARN_PERCENT, + HASHRATE_WINDOW_COLUMNS, + HASHRATE_WINDOWS, + HOST_IP, + UPDATE_INTERVAL, ) from mining_dashboard.helper.utils import ( - format_hashrate, format_duration, format_time_abs, is_ip_address, detect_host_ipv4, + detect_host_ipv4, + format_duration, + format_hashrate, + format_time_abs, + is_ip_address, ) -from mining_dashboard.service.metrics import build_metrics from mining_dashboard.service.earnings import xmr_per_hs_day +from mining_dashboard.service.metrics import build_metrics from mining_dashboard.version import resolve_version logger = logging.getLogger("WebViews") # Preset range -> window length in seconds. 'all'/unknown -> use the data's own extent. -_RANGE_SECONDS = {'1h': 3600, '24h': 86400, '1w': 604800, '1m': 2592000} +_RANGE_SECONDS = {"1h": 3600, "24h": 86400, "1w": 604800, "1m": 2592000} # Adaptive chart resolution (Issue #47). Point count and line smoothing are chosen from the # *visible window duration*: a wide span is a smooth, high-level overview (fewer points, more @@ -37,11 +47,11 @@ # is capped near the canvas pixel width — more points than pixels just slows hit-testing. # (limit_seconds, target_points); 0 target = send native resolution (no downsampling). _POINT_TIERS = ((3600, 0), (21600, 360), (86400, 480), (604800, 600)) -_MAX_CHART_POINTS = 700 # > 1w, and the hard ceiling (~1 point per canvas pixel) +_MAX_CHART_POINTS = 700 # > 1w, and the hard ceiling (~1 point per canvas pixel) # (limit_seconds, tension). Chart.js line tension = curve smoothing. _TENSION_TIERS = ((3600, 0.0), (86400, 0.2), (604800, 0.35)) -_MAX_TENSION = 0.4 # > 1w: smooth / high-level +_MAX_TENSION = 0.4 # > 1w: smooth / high-level def _target_points(duration_s): @@ -74,6 +84,7 @@ def parse_window(from_arg, to_arg): return None return (lo, hi) + # A run of missing samples longer than this is drawn as a real break in the line rather than a # segment spanning the outage (Issue #65). Adaptive: a multiple of the series' own median # spacing (so it works for both live 30s samples and heavily downsampled long ranges), floored @@ -82,18 +93,21 @@ def parse_window(from_arg, to_arg): # Pool/mode palette *tokens* -> CSS colour classes on the client (``.c-``/``.bg-``). # The active pool is coloured, the inactive one muted (Issue #27). -_TOKEN_GREEN = "ok" # P2Pool active -_TOKEN_PURPLE = "purple" # XvB active -_TOKEN_BLUE = "accent" # split / neutral mode -_TOKEN_MUTED = "muted" # inactive pool +_TOKEN_GREEN = "ok" # P2Pool active # noqa: S105 — CSS palette token, not a secret +_TOKEN_PURPLE = "purple" # XvB active # noqa: S105 +_TOKEN_BLUE = "accent" # split / neutral mode # noqa: S105 +_TOKEN_MUTED = "muted" # inactive pool # noqa: S105 -_LOW_HR_TITLE = ("Your hashrate can't sustain the selected XvB donation tier; " - "donation will fall short of it.") +_LOW_HR_TITLE = ( + "Your hashrate can't sustain the selected XvB donation tier; donation will fall short of it." +) -_NOT_ELIGIBLE_TITLE = ("You have no share in the P2Pool PPLNS window, so you're not eligible to collect " - "an XvB raffle win (what XvB calls being a \"VIP\"). If you win a round you're " - "skipped and take a fail (removed from the raffle after the 3rd) — regardless of " - "your donation tier. Keep enough hashrate on P2Pool to keep landing shares.") +_NOT_ELIGIBLE_TITLE = ( + "You have no share in the P2Pool PPLNS window, so you're not eligible to collect " + 'an XvB raffle win (what XvB calls being a "VIP"). If you win a round you\'re ' + "skipped and take a fail (removed from the raffle after the 3rd) — regardless of " + "your donation tier. Keep enough hashrate on P2Pool to keep landing shares." +) def build_raffle_eligibility(metrics): @@ -121,6 +135,7 @@ def build_raffle_eligibility(metrics): # Chart series (Issue #65: positioned by real time, with outage gaps as breaks). # -------------------------------------------------------------------------------------- + def build_chart(history, shares, range_arg, window=None, avg_window=DEFAULT_HASHRATE_WINDOW): """Build the Chart.js datasets from history. Each point carries its real timestamp as the x value (epoch ms) so a linear time axis spaces points to scale; runs of missing samples @@ -139,7 +154,7 @@ def build_chart(history, shares, range_arg, window=None, avg_window=DEFAULT_HASH duration_s = _window_duration(filtered_history, range_arg, window) filtered_history = _downsample_history(filtered_history, duration_s) - timestamps = [x['timestamp'] for x in filtered_history] + timestamps = [x["timestamp"] for x in filtered_history] gap_after = _gap_after_indices(timestamps) p2pool = [] @@ -154,9 +169,12 @@ def build_chart(history, shares, range_arg, window=None, avg_window=DEFAULT_HASH p2pool.append({"x": mid_ms, "y": None}) xvb.append({"x": mid_ms, "y": None}) - return {"p2pool": p2pool, "xvb": xvb, - "shares": _share_points(filtered_history, filtered_shares), - "tension": _chart_tension(duration_s)} + return { + "p2pool": p2pool, + "xvb": xvb, + "shares": _share_points(filtered_history, filtered_shares), + "tension": _chart_tension(duration_s), + } def _filter_range(history, shares, range_arg, window=None): @@ -165,16 +183,20 @@ def _filter_range(history, shares, range_arg, window=None): keeps everything).""" if window is not None: lo, hi = window - return ([x for x in history if lo <= x['timestamp'] <= hi], - [s for s in shares if lo <= s['ts'] <= hi]) - if range_arg == 'all': + return ( + [x for x in history if lo <= x["timestamp"] <= hi], + [s for s in shares if lo <= s["ts"] <= hi], + ) + if range_arg == "all": return history, shares target_seconds = _RANGE_SECONDS.get(range_arg, 0) if target_seconds <= 0: return history, shares cutoff = time.time() - target_seconds - return ([x for x in history if x['timestamp'] >= cutoff], - [s for s in shares if s['ts'] >= cutoff]) + return ( + [x for x in history if x["timestamp"] >= cutoff], + [s for s in shares if s["ts"] >= cutoff], + ) def _window_duration(filtered_history, range_arg, window): @@ -186,7 +208,7 @@ def _window_duration(filtered_history, range_arg, window): if secs > 0: return secs if len(filtered_history) >= 2: - return filtered_history[-1]['timestamp'] - filtered_history[0]['timestamp'] + return filtered_history[-1]["timestamp"] - filtered_history[0]["timestamp"] return 0 @@ -196,12 +218,14 @@ def _split_values(x, avg_window=DEFAULT_HASHRATE_WINDOW): Defaults to 10m — the original headline series — which also keeps the legacy-data fallback (older rows stored only the un-split total ``v``). The other windows read their own columns, which are 0 on pre-#168 rows (per-window capture is forward-only).""" - p_col, x_col = HASHRATE_WINDOW_COLUMNS.get(avg_window, HASHRATE_WINDOW_COLUMNS[DEFAULT_HASHRATE_WINDOW]) + p_col, x_col = HASHRATE_WINDOW_COLUMNS.get( + avg_window, HASHRATE_WINDOW_COLUMNS[DEFAULT_HASHRATE_WINDOW] + ) vp = x.get(p_col, 0) or 0 vx = x.get(x_col, 0) or 0 # The fallback only makes sense for the default window, where ``v`` is that window's total. if avg_window == DEFAULT_HASHRATE_WINDOW and vp == 0 and vx == 0: - v = x.get('v', 0) + v = x.get("v", 0) if v > 0: vp = v return vp, vx @@ -228,9 +252,9 @@ def _gap_after_indices(timestamps): # bucket-averaged automatically instead of being silently dropped. (Bug: the old downsampler kept # only v/v_p2pool/v_xvb, so the 1m/1h/12h/24h Avg series went flat at 0 on any range wide enough to # downsample — e.g. the 24h/1w/1mo ranges — while the default 10m window happened to survive.) -_DOWNSAMPLE_VALUE_COLUMNS = tuple(dict.fromkeys( - ("v",) + tuple(col for cols in HASHRATE_WINDOW_COLUMNS.values() for col in cols) -)) +_DOWNSAMPLE_VALUE_COLUMNS = tuple( + dict.fromkeys(("v",) + tuple(col for cols in HASHRATE_WINDOW_COLUMNS.values() for col in cols)) +) def _downsample_history(filtered_history, duration_s): @@ -245,11 +269,11 @@ def _downsample_history(filtered_history, duration_s): chunk_size = len(filtered_history) / target downsampled = [] for i in range(target): - chunk = filtered_history[int(i * chunk_size):int((i + 1) * chunk_size)] + chunk = filtered_history[int(i * chunk_size) : int((i + 1) * chunk_size)] if not chunk: continue mid = chunk[len(chunk) // 2] - row = {'t': mid['t'], 'timestamp': mid['timestamp']} + row = {"t": mid["t"], "timestamp": mid["timestamp"]} for col in _DOWNSAMPLE_VALUE_COLUMNS: row[col] = round(sum(x.get(col, 0) or 0 for x in chunk) / len(chunk), 2) downsampled.append(row) @@ -270,14 +294,16 @@ def _share_points(filtered_history, filtered_shares): if not (filtered_history and filtered_shares): return [] - hist_ts = [x['timestamp'] for x in filtered_history] + hist_ts = [x["timestamp"] for x in filtered_history] counts = {} for s in filtered_shares: - s_ts = s['ts'] + s_ts = s["ts"] idx = bisect.bisect_left(hist_ts, s_ts) candidates = [] - if idx < len(hist_ts): candidates.append(idx) - if idx > 0: candidates.append(idx - 1) + if idx < len(hist_ts): + candidates.append(idx) + if idx > 0: + candidates.append(idx - 1) if candidates: closest_idx = min(candidates, key=lambda i: abs(hist_ts[i] - s_ts)) counts[closest_idx] = counts.get(closest_idx, 0) + 1 @@ -287,12 +313,14 @@ def _share_points(filtered_history, filtered_shares): count = counts[idx] # y is fixed (a fraction of the dedicated 0–1 share axis): the marker no longer tracks the # hashrate, so it never stretches the y-range. Radius still scales with the share count. - points.append({ - "x": int(hist_ts[idx] * 1000), - "y": _SHARE_MARKER_Y, - "r": min(6 + (count * 3), 15), - "c": count, - }) + points.append( + { + "x": int(hist_ts[idx] * 1000), + "y": _SHARE_MARKER_Y, + "r": min(6 + (count * 3), 15), + "c": count, + } + ) return points @@ -300,14 +328,15 @@ def _share_points(filtered_history, filtered_shares): # Section builders: Metrics (+ passthrough) -> display data. # -------------------------------------------------------------------------------------- + def _mode_palette(current_mode): """(mode, p2p, xvb) palette tokens for the algo mode. Checked most-specific first: "XVB (Split)" contains both "Split" and "XVB".""" if "Split" in current_mode: - return _TOKEN_BLUE, _TOKEN_GREEN, _TOKEN_PURPLE # both pools active + return _TOKEN_BLUE, _TOKEN_GREEN, _TOKEN_PURPLE # both pools active if "XVB" in current_mode: return _TOKEN_PURPLE, _TOKEN_MUTED, _TOKEN_PURPLE - return _TOKEN_GREEN, _TOKEN_GREEN, _TOKEN_MUTED # P2POOL + return _TOKEN_GREEN, _TOKEN_GREEN, _TOKEN_MUTED # P2POOL def build_hashrate(metrics, mode_tok, p2p_tok, xvb_tok): @@ -330,62 +359,67 @@ def build_hashrate(metrics, mode_tok, p2p_tok, xvb_tok): "target_tier": metrics.target_tier, "xvb_fail_count": metrics.xvb_fail_count, "xvb_updated": format_time_abs(metrics.xvb_last_update), - "low_hr": {"text": "⚠ Hashrate low for tier", "title": _LOW_HR_TITLE} if metrics.low_hr_warning else None, + "low_hr": {"text": "⚠ Hashrate low for tier", "title": _LOW_HR_TITLE} + if metrics.low_hr_warning + else None, } def build_pool_network(data, metrics): """P2Pool / Stratum / Monero-network display values (computed bits come from Metrics).""" - stratum = data.get('stratum', {}) - local_pool = data.get('pool', {}).get('pool', {}) - p2p = data.get('pool', {}).get('p2p', {}) - network = data.get('network', {}) - s_addr = stratum.get('wallet', 'Unknown') + stratum = data.get("stratum", {}) + local_pool = data.get("pool", {}).get("pool", {}) + p2p = data.get("pool", {}).get("p2p", {}) + network = data.get("network", {}) + s_addr = stratum.get("wallet", "Unknown") return { "stratum": { "h15": format_hashrate(metrics.stratum_h15), "h1h": format_hashrate(metrics.stratum_h1h), "h24h": format_hashrate(metrics.stratum_h24h), - "shares": f"{stratum.get('shares_found',0)} / {stratum.get('shares_failed',0)}", + "shares": f"{stratum.get('shares_found', 0)} / {stratum.get('shares_failed', 0)}", "effort": f"{stratum.get('current_effort', 0):.1f}%", - "total_shares": stratum.get('total_stratum_shares', 0), + "total_shares": stratum.get("total_stratum_shares", 0), "reward_pct": f"{stratum.get('block_reward_share_percent', 0):.4f}%", - "conns": stratum.get('connections', 0), - "last_share": format_time_abs(stratum.get('last_share_found_time', 0)), - "total_hashes": stratum.get('total_hashes', 0), + "conns": stratum.get("connections", 0), + "last_share": format_time_abs(stratum.get("last_share_found_time", 0)), + "total_hashes": stratum.get("total_hashes", 0), "wallet": s_addr, "wallet_short": _shorten(s_addr), }, "pool": { "type": metrics.pool_type, - "sidechain_height": local_pool.get('sidechain_height', 0), - "diff": f"{metrics.pool_difficulty/1e6:.2f} M", + "sidechain_height": local_pool.get("sidechain_height", 0), + "diff": f"{metrics.pool_difficulty / 1e6:.2f} M", "hr": format_hashrate(metrics.pool_hashrate), - "total_hashes": local_pool.get('total_hashes', 0), - "miners": local_pool.get('miners', 0), + "total_hashes": local_pool.get("total_hashes", 0), + "miners": local_pool.get("miners", 0), "pplns_win": f"{metrics.pplns_window} ({format_duration(metrics.pplns_window * metrics.block_time)})", - "pplns_wgt": local_pool.get('pplns_weight', 0), - "blocks": local_pool.get('blocks_found', 0), - "last_blk": format_time_abs(local_pool.get('last_block_ts', 0)), - "peers": f"{p2p.get('out_peers',0)} / {p2p.get('in_peers',0)}", - "uptime": format_duration(p2p.get('uptime', 0)), + "pplns_wgt": local_pool.get("pplns_weight", 0), + "blocks": local_pool.get("blocks_found", 0), + "last_blk": format_time_abs(local_pool.get("last_block_ts", 0)), + "peers": f"{p2p.get('out_peers', 0)} / {p2p.get('in_peers', 0)}", + "uptime": format_duration(p2p.get("uptime", 0)), }, "network": { "height": metrics.network_height, - "reward": f"{network.get('reward', 0)/1e12:.4f} XMR", - "diff": f"{metrics.network_difficulty/1e9:.2f} G", - "hash": _shorten(str(network.get('hash', 'N/A')), threshold=20), - "ts": format_time_abs(network.get('timestamp', 0)), + "reward": f"{network.get('reward', 0) / 1e12:.4f} XMR", + "diff": f"{metrics.network_difficulty / 1e9:.2f} G", + "hash": _shorten(str(network.get("hash", "N/A")), threshold=20), + "ts": format_time_abs(network.get("timestamp", 0)), + }, + "monero": { + "mode": metrics.monero_mode, + "db_size": _monero_db_size(data.get("monero_sync", {})), }, - "monero": {"mode": metrics.monero_mode, "db_size": _monero_db_size(data.get('monero_sync', {}))}, "shares_window": {"count": metrics.shares_in_window, "ok": metrics.shares_in_window > 0}, } def _monero_db_size(monero_sync): """Human-readable on-disk Monero DB size (Issue #32); em-dash when unknown.""" - db_bytes = monero_sync.get('db_size', 0) or 0 + db_bytes = monero_sync.get("db_size", 0) or 0 return f"{db_bytes / 1e9:.1f} GB" if db_bytes > 0 else "—" @@ -403,8 +437,8 @@ def _usage_level(percent, threshold=80): # rate crosses _REJECT_FLAG_RATE *and* it has enough rejects to not just be early-run noise, so an # operator can spot a misbehaving rig. A worker submitting all-rejects (rate 100%) still trips the # noise floor, so it flags as soon as the floor is reached. -_REJECT_FLAG_RATE = 0.05 # >= 5% of submitted shares rejected -_REJECT_FLAG_MIN = 3 # and at least this many rejects +_REJECT_FLAG_RATE = 0.05 # >= 5% of submitted shares rejected +_REJECT_FLAG_MIN = 3 # and at least this many rejects def _reject_flag(accepted, rejected): @@ -423,38 +457,41 @@ def build_system(data): These thresholds are purely presentational (how to colour a gauge), so they live here rather than in the domain metrics layer.""" - system = data.get('system', {}) + system = data.get("system", {}) - disk_usage = system.get('disk', {}) - disk_percent = disk_usage.get('percent', 0) + disk_usage = system.get("disk", {}) + disk_percent = disk_usage.get("percent", 0) disk_fill = "critical" if disk_percent > 90 else "warning" if disk_percent > 70 else "" - mem_usage = system.get('memory', {}) - cpu_str = system.get('cpu_percent', "0.0%") + mem_usage = system.get("memory", {}) + cpu_str = system.get("cpu_percent", "0.0%") try: - cpu_val = float(cpu_str.strip('%')) + cpu_val = float(cpu_str.strip("%")) except ValueError: cpu_val = 0.0 - load_raw = system.get('load', "0.00 0.00 0.00") + load_raw = system.get("load", "0.00 0.00 0.00") load_parts = load_raw.split() - load_avg = (f"1m: {load_parts[0]} 5m: {load_parts[1]} 15m: {load_parts[2]}" - if len(load_parts) == 3 else load_raw) + load_avg = ( + f"1m: {load_parts[0]} 5m: {load_parts[1]} 15m: {load_parts[2]}" + if len(load_parts) == 3 + else load_raw + ) - hp_status, hp_class, hp_val = system.get('hugepages', ["Disabled", "status-bad", "0/0"]) + hp_status, hp_class, hp_val = system.get("hugepages", ["Disabled", "status-bad", "0/0"]) return { "cpu": {"percent": cpu_str, "load": load_avg, "level": _usage_level(cpu_val)}, "mem": { "used": f"{mem_usage.get('used_gb', 0):.1f}", "total": f"{mem_usage.get('total_gb', 0):.1f}", - "percent": mem_usage.get('percent_str', '0%'), - "level": _usage_level(mem_usage.get('percent', 0)), + "percent": mem_usage.get("percent_str", "0%"), + "level": _usage_level(mem_usage.get("percent", 0)), }, "disk": { "used": f"{disk_usage.get('used_gb', 0):.1f}", "total": f"{disk_usage.get('total_gb', 0):.1f}", - "percent": disk_usage.get('percent_str', '0%'), + "percent": disk_usage.get("percent_str", "0%"), "width": f"{disk_percent}%", "fill": disk_fill, "level": _usage_level(disk_percent), @@ -471,44 +508,52 @@ def build_workers(workers): """Worker rows as data: raw numeric fields (for client-side sorting) alongside their formatted display strings, plus a pool token for the badge. Online first, then by name.""" rows = [] - sorted_workers = sorted(workers, key=lambda x: (x['status'] != 'online', x['name'])) + sorted_workers = sorted(workers, key=lambda x: (x["status"] != "online", x["name"])) for worker in sorted_workers: try: - active_pool = worker.get('active_pool', '') - if any(p in active_pool for p in ['3333', '37889', '37888', '37890']): + active_pool = worker.get("active_pool", "") + if any(p in active_pool for p in ["3333", "37889", "37888", "37890"]): pool = "p2pool" - elif any(p in active_pool for p in ['3344', '4247']): + elif any(p in active_pool for p in ["3344", "4247"]): pool = "xvb" else: pool = "unknown" - uptime = worker.get('uptime', 0) - h10 = worker.get('h10', 0) - h60 = worker.get('h60', 0) - h15 = worker.get('h15', 0) + uptime = worker.get("uptime", 0) + h10 = worker.get("h10", 0) + h60 = worker.get("h60", 0) + h15 = worker.get("h15", 0) # Per-worker share health (Issue #82). Raw counts for client-side sorting; a display # string that appends invalid only when it's non-zero (keeps the common case clean); # and an optional warning flag the client renders when the reject rate is high. - accepted = worker.get('accepted', 0) - rejected = worker.get('rejected', 0) - invalid = worker.get('invalid', 0) + accepted = worker.get("accepted", 0) + rejected = worker.get("rejected", 0) + invalid = worker.get("invalid", 0) rejected_str = f"{rejected:,} (+{invalid:,} inv)" if invalid else f"{rejected:,}" - rows.append({ - "name": worker['name'], - "ip": worker['ip'], - "ip_sort": _ip_to_sort_int(worker.get('ip', '0.0.0.0')), - "pool": pool, - "status": "online" if worker['status'] == 'online' else "offline", - "uptime": uptime, "uptime_str": format_duration(uptime), - "h10": h10, "h10_str": format_hashrate(h10), - "h60": h60, "h60_str": format_hashrate(h60), - "h15": h15, "h15_str": format_hashrate(h15), - "accepted": accepted, "accepted_str": f"{accepted:,}", - "rejected": rejected, "rejected_str": rejected_str, - "invalid": invalid, - "reject_flag": _reject_flag(accepted, rejected), - }) + rows.append( + { + "name": worker["name"], + "ip": worker["ip"], + "ip_sort": _ip_to_sort_int(worker.get("ip", "0.0.0.0")), + "pool": pool, + "status": "online" if worker["status"] == "online" else "offline", + "uptime": uptime, + "uptime_str": format_duration(uptime), + "h10": h10, + "h10_str": format_hashrate(h10), + "h60": h60, + "h60_str": format_hashrate(h60), + "h15": h15, + "h15_str": format_hashrate(h15), + "accepted": accepted, + "accepted_str": f"{accepted:,}", + "rejected": rejected, + "rejected_str": rejected_str, + "invalid": invalid, + "reject_flag": _reject_flag(accepted, rejected), + } + ) except Exception as e: logger.error(f"Error processing worker {worker.get('name', 'unknown')}: {e}") continue @@ -520,12 +565,12 @@ def build_proxy_summary(data): accepted/rejected/invalid/expired shares submitted to the upstream pool, the aggregate reject rate, and the best difficulty found. ``has_data`` is False until the proxy has been polled (no shares yet) so the client can hide an all-zero footer.""" - summary = data.get('proxy_summary', {}) or {} - accepted = summary.get('accepted', 0) or 0 - rejected = summary.get('rejected', 0) or 0 - invalid = summary.get('invalid', 0) or 0 - expired = summary.get('expired', 0) or 0 - best = summary.get('best', 0) or 0 + summary = data.get("proxy_summary", {}) or {} + accepted = summary.get("accepted", 0) or 0 + rejected = summary.get("rejected", 0) or 0 + invalid = summary.get("invalid", 0) or 0 + expired = summary.get("expired", 0) or 0 + best = summary.get("best", 0) or 0 total = accepted + rejected reject_pct = (rejected / total * 100) if total > 0 else 0.0 @@ -544,7 +589,7 @@ def build_proxy_summary(data): def _ip_to_sort_int(ip): """Pack a dotted-quad IP into an int for client-side numeric sorting; 0 on malformed input.""" try: - a, b, c, d = (int(part) for part in ip.split('.')) + a, b, c, d = (int(part) for part in ip.split(".")) return (a << 24) + (b << 16) + (c << 8) + d except (ValueError, IndexError, AttributeError): return 0 @@ -552,15 +597,15 @@ def _ip_to_sort_int(ip): def build_tari(data): """Tari merge-mining display values. ``status`` is plain text; the client adds the ✔.""" - tari_stats = data.get('tari', {}) - tari_active = tari_stats.get('active', False) - t_addr = tari_stats.get('address', 'Unknown') + tari_stats = data.get("tari", {}) + tari_active = tari_stats.get("active", False) + t_addr = tari_stats.get("address", "Unknown") return { "active": tari_active, - "status": tari_stats.get('status', 'Waiting...') if tari_active else 'Waiting...', + "status": tari_stats.get("status", "Waiting...") if tari_active else "Waiting...", "reward": f"{tari_stats.get('reward', 0):.2f} TARI", - "height": str(tari_stats.get('height', 0)), + "height": str(tari_stats.get("height", 0)), "diff": f"{int(tari_stats.get('difficulty', 0)):,}", "wallet": t_addr, "wallet_short": _shorten(t_addr), @@ -571,10 +616,16 @@ def build_sync(metrics, monero_db_size): """Sync-screen state for both chains, mapping each SyncMetric to the client's 3-state gauge: 'done' (caught up — checked first, since a synced node may have no target height), 'loading' (no target/data yet), else 'syncing'.""" + def section(sm, extra=None): state = "done" if sm.done else ("loading" if not sm.has_target else "syncing") - out = {"state": state, "percent": sm.percent, "current": sm.current, - "target": sm.target, "remaining": sm.remaining} + out = { + "state": state, + "percent": sm.percent, + "current": sm.current, + "target": sm.target, + "remaining": sm.remaining, + } if extra: out.update(extra) return out @@ -592,56 +643,102 @@ def build_badges(data, metrics, mode_variant, db_healthy=True): # Persistence health (#131): the dashboard keeps serving live data even if its SQLite DB can't # be written, but history/shares/stats would silently vanish on restart — surface it loudly. if not db_healthy: - badges.append({"text": "⚠ DB write failing", "variant": "bad", - "title": "The dashboard can't persist to its database — hashrate history, shares, and stats will be lost on restart. Check disk space and permissions on the dashboard data directory."}) + badges.append( + { + "text": "⚠ DB write failing", + "variant": "bad", + "title": "The dashboard can't persist to its database — hashrate history, shares, and stats will be lost on restart. Check disk space and permissions on the dashboard data directory.", + } + ) if metrics.global_syncing: badges.append({"text": "Syncing...", "variant": "warn"}) else: badges.append({"text": metrics.mode, "variant": mode_variant}) badges.append({"text": f"P2Pool {metrics.pool_type}", "variant": "outline"}) if metrics.low_hr_warning: - badges.append({"text": "⚠ Hashrate low for tier", "variant": "warn", "title": _LOW_HR_TITLE}) + badges.append( + {"text": "⚠ Hashrate low for tier", "variant": "warn", "title": _LOW_HR_TITLE} + ) # No PPLNS share while donating (#158): XvB wins are skipped + accrue a fail, regardless of # tier. A make-or-break gate worth surfacing loudly so donations aren't wasted. (This is the # share half of Raffle Eligible; the tier half is shown by the XvB Tier field.) if metrics.xvb_enabled and metrics.shares_in_window == 0: - badges.append({"text": "⚠ No PPLNS share — XvB wins skipped", "variant": "warn", "title": _NOT_ELIGIBLE_TITLE}) + badges.append( + { + "text": "⚠ No PPLNS share — XvB wins skipped", + "variant": "warn", + "title": _NOT_ELIGIBLE_TITLE, + } + ) # Node-down badges (Issue #31) — shown whenever a node is unreachable, regardless of sync. if metrics.monero.down: badges.append({"text": "monerod DOWN", "variant": "bad"}) if metrics.tari.down: badges.append({"text": "Tari DOWN", "variant": "bad"}) - if data.get('workers_rejected'): - badges.append({"text": "Workers rejected", "variant": "bad", - "title": "Workers rejected so they fail over to their backup pools"}) + if data.get("workers_rejected"): + badges.append( + { + "text": "Workers rejected", + "variant": "bad", + "title": "Workers rejected so they fail over to their backup pools", + } + ) # Miner held until required chain(s) finish their initial sync (Issue #35). - if data.get('miner_held'): - badges.append({"text": "Miner held (sync)", "variant": "warn", - "title": "p2pool and xmrig-proxy are held until the required chains finish syncing"}) + if data.get("miner_held"): + badges.append( + { + "text": "Miner held (sync)", + "variant": "warn", + "title": "p2pool and xmrig-proxy are held until the required chains finish syncing", + } + ) # Non-blocking Tari (Issue #51): stay operational, surface a top-bar badge with the live # percentage once known (omitted early so it isn't a stale "0%"). - if data.get('tari_syncing_passive'): + if data.get("tari_syncing_passive"): t_pct = metrics.tari.percent - label = f'Tari syncing {t_pct}%' if t_pct > 0 else 'Tari syncing' - badges.append({"text": label, "variant": "warn", - "title": "Tari is still syncing — merge mining resumes when it catches up; Monero mining continues"}) + label = f"Tari syncing {t_pct}%" if t_pct > 0 else "Tari syncing" + badges.append( + { + "text": label, + "variant": "warn", + "title": "Tari is still syncing — merge mining resumes when it catches up; Monero mining continues", + } + ) # Monero pruned/full badge (Issue #32) — only when known (local node). if metrics.monero_mode == "Pruned": - badges.append({"text": "XMR Pruned", "variant": "outline", "title": "Monero blockchain is pruned"}) + badges.append( + {"text": "XMR Pruned", "variant": "outline", "title": "Monero blockchain is pruned"} + ) elif metrics.monero_mode == "Full": - badges.append({"text": "XMR Full", "variant": "outline", "title": "Monero blockchain is full (not pruned)"}) + badges.append( + { + "text": "XMR Full", + "variant": "outline", + "title": "Monero blockchain is full (not pruned)", + } + ) # Low-disk badge (Issue #138). The data filesystem fills as the chains grow and logs accumulate; # a full disk corrupts monerod's DB mid-write. The disk *bar* shows the percentage, but it's easy # to miss — surface a prominent top-bar badge near full, on both the sync and main screens. - disk_percent = (data.get('system', {}).get('disk', {}) or {}).get('percent', 0) or 0 + disk_percent = (data.get("system", {}).get("disk", {}) or {}).get("percent", 0) or 0 if disk_percent >= DISK_CRITICAL_PERCENT: - badges.append({"text": f"⚠ Disk {disk_percent:.0f}% full", "variant": "bad", - "title": "The data disk is almost full — free space now; a full disk can corrupt the Monero database."}) + badges.append( + { + "text": f"⚠ Disk {disk_percent:.0f}% full", + "variant": "bad", + "title": "The data disk is almost full — free space now; a full disk can corrupt the Monero database.", + } + ) elif disk_percent >= DISK_WARN_PERCENT: - badges.append({"text": f"Disk {disk_percent:.0f}% full", "variant": "warn", - "title": "The data disk is filling up — free space or move a data_dir before it runs out."}) + badges.append( + { + "text": f"Disk {disk_percent:.0f}% full", + "variant": "warn", + "title": "The data disk is filling up — free space or move a data_dir before it runs out.", + } + ) return badges @@ -676,7 +773,7 @@ def build_earnings(data, metrics): ``available`` is False when the network figures needed for the rate are missing; the client then shows ``—`` instead of a bogus estimate (graceful degradation).""" - reward_atomic = (data.get('network', {}) or {}).get('reward', 0) or 0 + reward_atomic = (data.get("network", {}) or {}).get("reward", 0) or 0 coeff_day = xmr_per_hs_day(reward_atomic, metrics.network_difficulty) # Reuse the displayed P2Pool 1h average (header / Overview / My Node) so the calculator's # hashrate is consistent with the rest of the dashboard — and because that recorded average @@ -684,10 +781,10 @@ def build_earnings(data, metrics): p2pool_hr = metrics.p2pool_1h return { "available": coeff_day > 0, - "p2pool_hr": p2pool_hr, # raw H/s — the what-if default + "p2pool_hr": p2pool_hr, # raw H/s — the what-if default "p2pool_hr_str": format_hashrate(p2pool_hr), - "coeff_day": coeff_day, # XMR per H/s per day - "pool_difficulty": metrics.pool_difficulty, # for expected time-to-share (diff/hr) + "coeff_day": coeff_day, # XMR per H/s per day + "pool_difficulty": metrics.pool_difficulty, # for expected time-to-share (diff/hr) "block_reward": f"{reward_atomic / 1e12:.4f} XMR", # context, server-formatted like NetworkCard "disclaimer": _EARNINGS_DISCLAIMER, } @@ -697,6 +794,7 @@ def build_earnings(data, metrics): # Assembly. # -------------------------------------------------------------------------------------- + def host_display_addr(host): """The numeric IP to show *beside* the configured host in the header, or ``None`` (Issue #119). @@ -732,11 +830,13 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH return { "syncing": metrics.global_syncing, - "page_title": "Mining Dashboard - Syncing" if metrics.global_syncing else "Mining Dashboard", + "page_title": "Mining Dashboard - Syncing" + if metrics.global_syncing + else "Mining Dashboard", "host_ip": HOST_IP, "host_addr": host_display_addr(HOST_IP), "version": resolve_version(), - "update": data.get("update"), # {available, latest, url} | None — new-release badge (#224) + "update": data.get("update"), # {available, latest, url} | None — new-release badge (#224) "last_update": format_time_abs(time.time()), "range": range_arg, "window": {"from": window[0], "to": window[1]} if window else None, @@ -756,9 +856,9 @@ def build_state(data, state_mgr, range_arg, window=None, avg_window=DEFAULT_HASH "proxy_workers": metrics.workers_online, "earnings": build_earnings(data, metrics), "tari": build_tari(data), - "workers": build_workers(data.get('workers', [])), + "workers": build_workers(data.get("workers", [])), "proxy_summary": build_proxy_summary(data), - "chart": build_chart(history, data.get('shares', []), range_arg, window, avg_window), + "chart": build_chart(history, data.get("shares", []), range_arg, window, avg_window), } @@ -778,7 +878,7 @@ def get_shell_html(): try: mtime = os.path.getmtime(SHELL_PATH) if _SHELL_CACHE is None or mtime > _SHELL_MTIME: - with open(SHELL_PATH, 'r') as f: + with open(SHELL_PATH) as f: _SHELL_CACHE = f.read() _SHELL_MTIME = mtime except Exception as e: diff --git a/build/dashboard/pyproject.toml b/build/dashboard/pyproject.toml index 6c51c55..6b4c13c 100644 --- a/build/dashboard/pyproject.toml +++ b/build/dashboard/pyproject.toml @@ -32,6 +32,12 @@ test = [ "pytest-cov>=5", "pytest-aiohttp>=1.0", ] +# Developer tooling (Wave 7, #280). Pinned so local, pre-commit, and CI all run the SAME ruff +# — lint output is version-sensitive, so a floor would let CI and a contributor disagree. +dev = [ + "ruff==0.15.17", + "pre-commit>=4", +] [tool.setuptools.packages.find] include = ["mining_dashboard*"] @@ -59,3 +65,33 @@ exclude_also = [ "if __name__ == .__main__.:", "raise NotImplementedError", ] + +[tool.ruff] +# Lint + format for the dashboard (Wave 7 tooling, #280). Run via `make lint-py`, pre-commit, +# and CI. Keep target-version in lockstep with requires-python above. +target-version = "py311" +line-length = 100 +# Generated Tari gRPC stubs aren't ours to style — already omitted from coverage too. +extend-exclude = ["mining_dashboard/client/tari/generated"] + +[tool.ruff.lint] +# E/F/W pycodestyle+pyflakes · I import-sort · B bugbear · UP pyupgrade · +# ASYNC flake8-async (the aiohttp/asyncio event loop + Tor SOCKS paths) · +# S flake8-bandit (security). Annotation rules (ANN) are deferred to #284. +select = ["E", "F", "W", "I", "B", "UP", "ASYNC", "S"] +ignore = [ + "E501", # line length is owned by `ruff format` + "S104", # "bind all interfaces": every hit is the sentinel string "0.0.0.0" used as a + # default/comparison value — the dashboard's listen host is config-driven (aiohttp), + # it never binds a raw socket to 0.0.0.0 from Python. +] + +[tool.ruff.lint.per-file-ignores] +# Tests: asserts are the idiom (S101); fixtures use dummy creds (S106); broad +# pytest.raises(Exception) is fine (B017); os.path in async test bodies is harmless (ASYNC240). +"tests/**" = ["S101", "S106", "B017", "ASYNC240"] +# Generated-stub imports are intentionally grouped below the logger + the generation guidance. +"mining_dashboard/client/tari/tari_client.py" = ["E402"] + +[tool.ruff.lint.isort] +known-first-party = ["mining_dashboard"] diff --git a/build/dashboard/tests/client/test_docker_control.py b/build/dashboard/tests/client/test_docker_control.py index 0e9168e..8a0e70c 100644 --- a/build/dashboard/tests/client/test_docker_control.py +++ b/build/dashboard/tests/client/test_docker_control.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import mining_dashboard.client.docker.docker_control as dc_mod from mining_dashboard.client.docker.docker_control import DockerControl diff --git a/build/dashboard/tests/client/test_monero_client.py b/build/dashboard/tests/client/test_monero_client.py index d5c00a7..8c25e1a 100644 --- a/build/dashboard/tests/client/test_monero_client.py +++ b/build/dashboard/tests/client/test_monero_client.py @@ -1,4 +1,4 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import requests @@ -27,7 +27,9 @@ def test_no_username_means_no_auth(self): def test_success_returns_payload(self): client = MoneroClient(username="u", password="p") data = {"status": "OK", "height": 5, "target_height": 10} - with patch.object(monero_mod.requests, "get", return_value=_resp(json_data=data)) as mock_get: + with patch.object( + monero_mod.requests, "get", return_value=_resp(json_data=data) + ) as mock_get: assert client.get_info() == data # Digest auth and a bounded timeout are passed through on every call. assert mock_get.call_args.kwargs["timeout"] == client.timeout @@ -35,8 +37,9 @@ def test_success_returns_payload(self): def test_network_error_returns_none(self): client = MoneroClient(username="u", password="p") - with patch.object(monero_mod.requests, "get", - side_effect=requests.RequestException("refused")): + with patch.object( + monero_mod.requests, "get", side_effect=requests.RequestException("refused") + ): assert client.get_info() is None def test_non_200_returns_none(self): @@ -51,8 +54,9 @@ def test_non_json_returns_none(self): def test_busy_status_returns_none(self): client = MoneroClient(username="u", password="p") - with patch.object(monero_mod.requests, "get", - return_value=_resp(json_data={"status": "BUSY"})): + with patch.object( + monero_mod.requests, "get", return_value=_resp(json_data={"status": "BUSY"}) + ): assert client.get_info() is None @@ -64,17 +68,27 @@ def _client_with_info(self, info): def test_syncing(self): client = self._client_with_info( - {"status": "OK", "height": 50, "target_height": 100, "database_size": 85_000_000_000}) + {"status": "OK", "height": 50, "target_height": 100, "database_size": 85_000_000_000} + ) assert client.get_sync_status() == { - "is_syncing": True, "current": 50, "target": 100, "percent": 50, + "is_syncing": True, + "current": 50, + "target": 100, + "percent": 50, "db_size": 85_000_000_000, } def test_synced_via_flag(self): # `synchronized` is authoritative even if heights look mid-sync. client = self._client_with_info( - {"status": "OK", "synchronized": True, "height": 90, "target_height": 100, - "database_size": 200_000_000_000}) + { + "status": "OK", + "synchronized": True, + "height": 90, + "target_height": 100, + "database_size": 200_000_000_000, + } + ) assert client.get_sync_status() == {"is_syncing": False, "db_size": 200_000_000_000} def test_synced_via_zero_target(self): diff --git a/build/dashboard/tests/client/test_tari_client.py b/build/dashboard/tests/client/test_tari_client.py index c3d66e9..289e83b 100644 --- a/build/dashboard/tests/client/test_tari_client.py +++ b/build/dashboard/tests/client/test_tari_client.py @@ -1,6 +1,4 @@ -from unittest.mock import MagicMock, AsyncMock - -import pytest +from unittest.mock import AsyncMock, MagicMock from mining_dashboard.client.tari.tari_client import TariClient @@ -33,24 +31,39 @@ async def test_fully_synced(self): client, stub = _client_with_stub() stub.GetTipInfo = AsyncMock(return_value=_tip(500, synced=True)) status = await client.get_sync_status() - assert status == {"is_syncing": False, "current": 500, "target": 500, - "percent": 100, "reachable": True} + assert status == { + "is_syncing": False, + "current": 500, + "target": 500, + "percent": 100, + "reachable": True, + } async def test_syncing_with_target(self): client, stub = _client_with_stub() stub.GetTipInfo = AsyncMock(return_value=_tip(100, synced=False)) stub.GetSyncProgress = AsyncMock(return_value=_progress(100, 200)) status = await client.get_sync_status() - assert status == {"is_syncing": True, "current": 100, "target": 200, - "percent": 50, "reachable": True} + assert status == { + "is_syncing": True, + "current": 100, + "target": 200, + "percent": 50, + "reachable": True, + } async def test_syncing_without_reliable_target(self): client, stub = _client_with_stub() stub.GetTipInfo = AsyncMock(return_value=_tip(100, synced=False)) stub.GetSyncProgress = AsyncMock(return_value=_progress(100, 100)) # target <= local status = await client.get_sync_status() - assert status == {"is_syncing": True, "current": 100, "target": 0, - "percent": 0, "reachable": True} + assert status == { + "is_syncing": True, + "current": 100, + "target": 0, + "percent": 0, + "reachable": True, + } async def test_grpc_error_returns_default_when_no_cache(self): client, stub = _client_with_stub() @@ -77,7 +90,7 @@ async def test_stale_cache_expires(self, monkeypatch): stub.GetTipInfo = AsyncMock(return_value=_tip(300, synced=True)) await client.get_sync_status() # Push the cache timestamp beyond the stale window. - client._last_sync_ts -= (client._MAX_STALE_SECONDS + 1) + client._last_sync_ts -= client._MAX_STALE_SECONDS + 1 stub.GetTipInfo = AsyncMock(side_effect=RuntimeError("down")) assert await client.get_sync_status() == {"is_syncing": False, "reachable": False} diff --git a/build/dashboard/tests/client/test_xmrig_client.py b/build/dashboard/tests/client/test_xmrig_client.py index f63a22e..c655088 100644 --- a/build/dashboard/tests/client/test_xmrig_client.py +++ b/build/dashboard/tests/client/test_xmrig_client.py @@ -14,6 +14,7 @@ async def json(self): class FakeGet: """Mimics the async context manager returned by aiohttp ClientSession.get().""" + def __init__(self, response=None, exc=None): self._response = response self._exc = exc @@ -39,7 +40,9 @@ def get(self, url, headers=None, timeout=None): async def test_first_success_returns_payload_and_short_circuits(): - session = FakeSession(response=FakeResponse(200, {"kind": "proxy", "hashrate": {"total": [10]}})) + session = FakeSession( + response=FakeResponse(200, {"kind": "proxy", "hashrate": {"total": [10]}}) + ) client = XMRigWorkerClient(session) result = await client.get_stats("10.0.0.1", "rig1") assert result == {"kind": "proxy", "hashrate": {"total": [10]}} @@ -66,19 +69,23 @@ async def test_exceptions_are_swallowed(): # stratum. A worker name must NEVER become an outbound request host, and worker IPs pointing at our # own infrastructure / host-local services must never be probed. -@pytest.mark.parametrize("ip,name,why", [ - ("127.0.0.1", "rig", "loopback / host-local services"), - ("::1", "rig", "IPv6 loopback"), - ("172.28.0.30", "rig", "docker bridge — the read-only socket proxy"), - ("172.28.0.29", "rig", "docker bridge — xmrig-proxy itself"), - ("169.254.169.254", "rig", "link-local cloud-metadata endpoint"), - ("0.0.0.0", "rig", "unspecified"), - ("255.255.255.255", "rig", "reserved broadcast"), - ("not-an-ip", "rig", "garbage, not an address"), - ("", "127.0.0.1", "name is an internal IP string — must never become a host"), - ("", "172.28.0.30", "name is the socket proxy — must never become a host"), - ("", "evil.example.com", "name is a hostname — must never become a host"), -]) + +@pytest.mark.parametrize( + "ip,name,why", + [ + ("127.0.0.1", "rig", "loopback / host-local services"), + ("::1", "rig", "IPv6 loopback"), + ("172.28.0.30", "rig", "docker bridge — the read-only socket proxy"), + ("172.28.0.29", "rig", "docker bridge — xmrig-proxy itself"), + ("169.254.169.254", "rig", "link-local cloud-metadata endpoint"), + ("0.0.0.0", "rig", "unspecified"), + ("255.255.255.255", "rig", "reserved broadcast"), + ("not-an-ip", "rig", "garbage, not an address"), + ("", "127.0.0.1", "name is an internal IP string — must never become a host"), + ("", "172.28.0.30", "name is the socket proxy — must never become a host"), + ("", "evil.example.com", "name is a hostname — must never become a host"), + ], +) async def test_ssrf_targets_are_never_probed(ip, name, why): session = FakeSession(response=FakeResponse(200, {"ok": True})) client = XMRigWorkerClient(session) @@ -86,13 +93,16 @@ async def test_ssrf_targets_are_never_probed(ip, name, why): assert session.calls == [], f"issued a request despite {why}" -@pytest.mark.parametrize("ip", [ - "192.168.1.50", # LAN miner - "10.0.0.1", # LAN miner - "172.16.5.5", # LAN miner outside the docker bridge - "8.8.8.8", # public miner - "10.0.0.1:54321", # "ip:port" form is tolerated -]) +@pytest.mark.parametrize( + "ip", + [ + "192.168.1.50", # LAN miner + "10.0.0.1", # LAN miner + "172.16.5.5", # LAN miner outside the docker bridge + "8.8.8.8", # public miner + "10.0.0.1:54321", # "ip:port" form is tolerated + ], +) async def test_real_miner_ip_is_probed(ip): session = FakeSession(response=FakeResponse(200, {"ok": True})) client = XMRigWorkerClient(session) @@ -109,9 +119,9 @@ async def test_name_is_used_as_bearer_never_as_host(): client = XMRigWorkerClient(session) await client.get_stats("10.0.0.1", "rig1+worker") assert all("10.0.0.1" in url for url, _ in session.calls) - assert not any("rig1" in url for url, _ in session.calls) # name never becomes a host + assert not any("rig1" in url for url, _ in session.calls) # name never becomes a host bearers = [h.get("Authorization") for _, h in session.calls if h] - assert "Bearer rig1" in bearers # '+'-suffix stripped, used as token + assert "Bearer rig1" in bearers # '+'-suffix stripped, used as token async def test_long_name_token_is_capped(): diff --git a/build/dashboard/tests/client/test_xvb_client.py b/build/dashboard/tests/client/test_xvb_client.py index b3b8b1f..122ba2f 100644 --- a/build/dashboard/tests/client/test_xvb_client.py +++ b/build/dashboard/tests/client/test_xvb_client.py @@ -1,11 +1,10 @@ -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch import requests import mining_dashboard.client.xvb_client as xvb_mod from mining_dashboard.client.xvb_client import XvbClient - SAMPLE_HTML = "Fail Count: 2\n1hr avg: 1.5 kH/s\n24hr avg: 3.0 kH/s\n" @@ -31,7 +30,7 @@ def test_get_stats_routes_through_tor_proxy(): with patch.object(xvb_mod.requests, "get", return_value=resp) as mock_get: client.get_stats() proxies = mock_get.call_args.kwargs["proxies"] - assert proxies["https"].startswith("socks5h://") # socks5h resolves the host via Tor too + assert proxies["https"].startswith("socks5h://") # socks5h resolves the host via Tor too assert proxies["http"] == proxies["https"] diff --git a/build/dashboard/tests/collector/test_logs.py b/build/dashboard/tests/collector/test_logs.py index 73bce1e..b4ae059 100644 --- a/build/dashboard/tests/collector/test_logs.py +++ b/build/dashboard/tests/collector/test_logs.py @@ -1,7 +1,5 @@ import struct -from unittest.mock import patch, AsyncMock, MagicMock - -import pytest +from unittest.mock import AsyncMock, MagicMock, patch import mining_dashboard.collector.logs as logs @@ -71,14 +69,20 @@ async def _patch_file(self, content): return patch.object(logs.aiofiles, "open", return_value=_AsyncCM(_FakeFile(content))) async def test_syncing(self): - with patch.object(logs.aiofiles, "open", - return_value=_AsyncCM(_FakeFile('{"height": 50, "target_height": 100}'))): + with patch.object( + logs.aiofiles, + "open", + return_value=_AsyncCM(_FakeFile('{"height": 50, "target_height": 100}')), + ): status = await logs._get_remote_monero_sync_status() assert status == {"is_syncing": True, "current": 50, "target": 100, "percent": 50} async def test_synced(self): - with patch.object(logs.aiofiles, "open", - return_value=_AsyncCM(_FakeFile('{"height": 100, "target_height": 100}'))): + with patch.object( + logs.aiofiles, + "open", + return_value=_AsyncCM(_FakeFile('{"height": 100, "target_height": 100}')), + ): assert await logs._get_remote_monero_sync_status() == {"is_syncing": False} async def test_file_not_found(self): @@ -94,21 +98,28 @@ class TestLogSyncStatus: """Log-scraping fallback path (`_get_monero_sync_status_from_logs`).""" async def test_new_format_top_block_candidate(self): - with patch.object(logs, "get_monero_logs", - AsyncMock(return_value=["top block candidate: 100 -> 200 [node]"])): + with patch.object( + logs, + "get_monero_logs", + AsyncMock(return_value=["top block candidate: 100 -> 200 [node]"]), + ): status = await logs._get_monero_sync_status_from_logs() assert status["is_syncing"] is True assert status["current"] == 100 and status["target"] == 200 assert status["percent"] == 50 async def test_old_synced_format(self): - with patch.object(logs, "get_monero_logs", - AsyncMock(return_value=["Synced 50/100 (50%, ...)"])): + with patch.object( + logs, "get_monero_logs", AsyncMock(return_value=["Synced 50/100 (50%, ...)"]) + ): assert (await logs._get_monero_sync_status_from_logs())["percent"] == 50 async def test_already_synchronized(self): - with patch.object(logs, "get_monero_logs", - AsyncMock(return_value=["You are now synchronized with the network"])): + with patch.object( + logs, + "get_monero_logs", + AsyncMock(return_value=["You are now synchronized with the network"]), + ): assert await logs._get_monero_sync_status_from_logs() == {"is_syncing": False} async def test_error_logs(self): @@ -122,8 +133,10 @@ class TestLocalSyncStatus: async def test_rpc_result_used_when_available(self): # RPC returns a status → use it directly (flagged reachable), never touch the logs. rpc_status = {"is_syncing": True, "current": 10, "target": 20, "percent": 50} - with patch.object(logs._monero_client, "get_sync_status", return_value=rpc_status), \ - patch.object(logs, "get_monero_logs", AsyncMock()) as mock_logs: + with ( + patch.object(logs._monero_client, "get_sync_status", return_value=rpc_status), + patch.object(logs, "get_monero_logs", AsyncMock()) as mock_logs, + ): status = await logs._get_local_monero_sync_status() assert status["is_syncing"] is True and status["percent"] == 50 assert status["reachable"] is True @@ -132,9 +145,12 @@ async def test_rpc_result_used_when_available(self): async def test_falls_back_to_logs_when_rpc_unreachable(self): # RPC returns None (node unreachable / creds absent) → scrape logs, flagged not # reachable so the down-detector can act (Issue #31). - with patch.object(logs._monero_client, "get_sync_status", return_value=None), \ - patch.object(logs, "get_monero_logs", - AsyncMock(return_value=["Synced 50/100 (50%, ...)"])): + with ( + patch.object(logs._monero_client, "get_sync_status", return_value=None), + patch.object( + logs, "get_monero_logs", AsyncMock(return_value=["Synced 50/100 (50%, ...)"]) + ), + ): status = await logs._get_local_monero_sync_status() assert status["is_syncing"] is True and status["percent"] == 50 assert status["reachable"] is False @@ -142,13 +158,23 @@ async def test_falls_back_to_logs_when_rpc_unreachable(self): class TestDispatch: async def test_local_when_default_host(self): - with patch.object(logs, "MONERO_NODE_HOST", "172.28.0.26"), \ - patch.object(logs, "_get_local_monero_sync_status", AsyncMock(return_value={"is_syncing": True})): + with ( + patch.object(logs, "MONERO_NODE_HOST", "172.28.0.26"), + patch.object( + logs, "_get_local_monero_sync_status", AsyncMock(return_value={"is_syncing": True}) + ), + ): assert (await logs.get_monero_sync_status())["is_syncing"] is True async def test_remote_when_other_host(self): - with patch.object(logs, "MONERO_NODE_HOST", "10.0.0.9"), \ - patch.object(logs, "_get_remote_monero_sync_status", AsyncMock(return_value={"is_syncing": False})): + with ( + patch.object(logs, "MONERO_NODE_HOST", "10.0.0.9"), + patch.object( + logs, + "_get_remote_monero_sync_status", + AsyncMock(return_value={"is_syncing": False}), + ), + ): # Remote node is reported reachable so reject-workers no-ops for it (Issue #31). assert await logs.get_monero_sync_status() == {"is_syncing": False, "reachable": True} diff --git a/build/dashboard/tests/collector/test_pools.py b/build/dashboard/tests/collector/test_pools.py index 99ba66e..1fe7e21 100644 --- a/build/dashboard/tests/collector/test_pools.py +++ b/build/dashboard/tests/collector/test_pools.py @@ -2,12 +2,17 @@ import mining_dashboard.collector.pools as pools from mining_dashboard.collector.pools import ( - detect_pool_type, get_p2pool_stats, get_network_stats, - get_stratum_stats, get_tari_stats, + detect_pool_type, + get_network_stats, + get_p2pool_stats, + get_stratum_stats, + get_tari_stats, ) from mining_dashboard.config.config import ( - P2P_STATS_PATH, POOL_STATS_PATH, STRATUM_STATS_PATH, - NETWORK_STATS_PATH, TARI_STATS_PATH, SECOND_PER_BLOCK_MAIN, + P2P_STATS_PATH, + POOL_STATS_PATH, + SECOND_PER_BLOCK_MAIN, + STRATUM_STATS_PATH, ) @@ -29,8 +34,10 @@ def test_port_matched_exactly_not_as_substring(self): # Each of these returned a WRONG pool under the old `"37889" in p` substring check (#142). assert detect_pool_type(["1.1.1.1:137889"]) == "Unknown" # old: contained "37889" -> Main assert detect_pool_type(["1.1.1.1:378880"]) == "Unknown" # old: contained "37888" -> Mini - assert detect_pool_type(["1.1.1.1:37889x"]) == "Unknown" # trailing junk -> not the Main port - assert detect_pool_type(["1.1.1.1:37888"]) == "Mini" # exact port still detected + assert ( + detect_pool_type(["1.1.1.1:37889x"]) == "Unknown" + ) # trailing junk -> not the Main port + assert detect_pool_type(["1.1.1.1:37888"]) == "Mini" # exact port still detected def _read_json_map(mapping): @@ -41,8 +48,14 @@ def _read_json_map(mapping): class TestP2poolStats: def test_aggregates_sources(self): mapping = { - P2P_STATS_PATH: {"peers": ["1.1.1.1:37889"], "connections": 8, "incoming_connections": 2}, - POOL_STATS_PATH: {"pool_statistics": {"hashRate": 1234, "miners": 5, "pplnsWindowSize": 2160}}, + P2P_STATS_PATH: { + "peers": ["1.1.1.1:37889"], + "connections": 8, + "incoming_connections": 2, + }, + POOL_STATS_PATH: { + "pool_statistics": {"hashRate": 1234, "miners": 5, "pplnsWindowSize": 2160} + }, STRATUM_STATS_PATH: {"last_share_found_time": 99, "shares_found": 7}, } with patch.object(pools, "_read_json", side_effect=_read_json_map(mapping)): @@ -78,8 +91,13 @@ def test_worker_parsing(self): raw = {"workers": ["10.0.0.1:3333,x,y,z,rig-01,extra"]} with patch.object(pools, "_read_json", return_value=raw): _, workers = get_stratum_stats() - assert workers == [{"ip": "10.0.0.1", "name": "rig-01", - "parts": ["10.0.0.1:3333", "x", "y", "z", "rig-01", "extra"]}] + assert workers == [ + { + "ip": "10.0.0.1", + "name": "rig-01", + "parts": ["10.0.0.1:3333", "x", "y", "z", "rig-01", "extra"], + } + ] def test_worker_without_name_defaults_to_miner(self): with patch.object(pools, "_read_json", return_value={"workers": ["10.0.0.2:3333"]}): @@ -89,8 +107,17 @@ def test_worker_without_name_defaults_to_miner(self): class TestTariStats: def test_active_chain_converts_utari(self): - raw = {"chains": [{"channel_state": "Active", "wallet": "T123", "height": 5, - "reward": 2_000_000, "difficulty": 42}]} + raw = { + "chains": [ + { + "channel_state": "Active", + "wallet": "T123", + "height": 5, + "reward": 2_000_000, + "difficulty": 42, + } + ] + } with patch.object(pools, "_read_json", return_value=raw): s = get_tari_stats() assert s["active"] is True diff --git a/build/dashboard/tests/collector/test_system.py b/build/dashboard/tests/collector/test_system.py index 7c01bde..9e54b43 100644 --- a/build/dashboard/tests/collector/test_system.py +++ b/build/dashboard/tests/collector/test_system.py @@ -12,7 +12,9 @@ def _fake_open(content): class TestDiskUsage: def test_normal(self): - usage = SimpleNamespace(total=100 * system.BYTES_IN_GB, used=25 * system.BYTES_IN_GB, free=0) + usage = SimpleNamespace( + total=100 * system.BYTES_IN_GB, used=25 * system.BYTES_IN_GB, free=0 + ) with patch.object(system.shutil, "disk_usage", return_value=usage): d = system.get_disk_usage() assert d["total_gb"] == 100 diff --git a/build/dashboard/tests/config/test_config.py b/build/dashboard/tests/config/test_config.py index eef8715..e3ef510 100644 --- a/build/dashboard/tests/config/test_config.py +++ b/build/dashboard/tests/config/test_config.py @@ -1,11 +1,12 @@ -import os -import json import importlib +import json +import os from unittest.mock import patch def _reload_config(): import mining_dashboard.config.config as cfg + return importlib.reload(cfg) @@ -16,6 +17,7 @@ def teardown_method(self): def test_defaults_load(self): import mining_dashboard.config.config as cfg + assert cfg.XMRIG_API_PORT == 8080 assert cfg.XVB_DONATION_LEVEL == "auto" assert cfg.XVB_MAX_DONATION_FRACTION == 0.85 diff --git a/build/dashboard/tests/conftest.py b/build/dashboard/tests/conftest.py index f3925f6..b5917e5 100644 --- a/build/dashboard/tests/conftest.py +++ b/build/dashboard/tests/conftest.py @@ -2,6 +2,7 @@ Everything here keeps tests hermetic: no real database on disk, no network, no containers. """ + import pytest from mining_dashboard.service.storage_service import StateManager diff --git a/build/dashboard/tests/helper/test_utils.py b/build/dashboard/tests/helper/test_utils.py index 663be28..45c675f 100644 --- a/build/dashboard/tests/helper/test_utils.py +++ b/build/dashboard/tests/helper/test_utils.py @@ -1,9 +1,14 @@ -import pytest from unittest.mock import patch + from mining_dashboard.helper.utils import ( - parse_hashrate, format_hashrate, format_duration, - format_time_abs, get_tier_info, resolve_target_threshold, - is_ip_address, detect_host_ipv4, + detect_host_ipv4, + format_duration, + format_hashrate, + format_time_abs, + get_tier_info, + is_ip_address, + parse_hashrate, + resolve_target_threshold, ) @@ -86,8 +91,7 @@ def test_zero_threshold_ignored(self): class TestResolveTargetThreshold: - TIERS = {"donor_mega": 1_000_000, "donor_whale": 100_000, - "donor_vip": 10_000, "donor": 1_000} + TIERS = {"donor_mega": 1_000_000, "donor_whale": 100_000, "donor_vip": 10_000, "donor": 1_000} def test_auto_picks_highest_sustainable(self): # 15_000 * 0.85 = 12_750 -> VIP (10_000) is the highest we can hold. diff --git a/build/dashboard/tests/service/test_algo_service.py b/build/dashboard/tests/service/test_algo_service.py index eada5f4..72ed74c 100644 --- a/build/dashboard/tests/service/test_algo_service.py +++ b/build/dashboard/tests/service/test_algo_service.py @@ -1,17 +1,17 @@ import asyncio -from unittest.mock import MagicMock, AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest +from mining_dashboard.config.config import TIER_DEFAULTS, XVB_SWITCH_OVERHEAD_MS, XVB_TIME_ALGO_MS from mining_dashboard.service.algo_service import AlgoService -from mining_dashboard.config.config import TIER_DEFAULTS, XVB_TIME_ALGO_MS, XVB_SWITCH_OVERHEAD_MS @pytest.fixture def algo(): state_manager = MagicMock() state_manager.get_tiers.return_value = dict(TIER_DEFAULTS) - proxy_client = MagicMock() # called via asyncio.to_thread -> sync methods + proxy_client = MagicMock() # called via asyncio.to_thread -> sync methods data_service = MagicMock() data_service.workers_rejected = False # not rejecting workers (Issue #31 guard off) return AlgoService(state_manager, proxy_client, data_service) @@ -20,7 +20,7 @@ def algo(): # A share inside the PPLNS window (recent) so the "zero shares" guard doesn't trip. RECENT_SHARES = [{"ts": 10**12}] # far-future ts -> always within window P2P_MAIN = {"type": "Main"} -POOL_STATS = {"pplns_window": 2160} # no difficulty -> flat reserve +POOL_STATS = {"pplns_window": 2160} # no difficulty -> flat reserve POOL_STATS_DIFF = {"pplns_window": 2160, "difficulty": 120_000_000} @@ -36,22 +36,39 @@ def test_xvb_disabled_forces_p2pool(self, algo): def test_zero_shares_forces_p2pool(self, algo): with patch("mining_dashboard.service.algo_service.ENABLE_XVB", True): - mode, dur = algo.get_decision(10_000, 10_000, POOL_STATS, P2P_MAIN, - {"avg_24h": 0, "avg_1h": 0, "fail_count": 0}, []) + mode, dur = algo.get_decision( + 10_000, + 10_000, + POOL_STATS, + P2P_MAIN, + {"avg_24h": 0, "avg_1h": 0, "fail_count": 0}, + [], + ) assert mode == "P2POOL" def test_excessive_failures_forces_p2pool(self, algo): with patch("mining_dashboard.service.algo_service.ENABLE_XVB", True): - mode, _ = algo.get_decision(10_000, 10_000, POOL_STATS, P2P_MAIN, - {"avg_24h": 9_999_999, "avg_1h": 9_999_999, "fail_count": 3}, - RECENT_SHARES) + mode, _ = algo.get_decision( + 10_000, + 10_000, + POOL_STATS, + P2P_MAIN, + {"avg_24h": 9_999_999, "avg_1h": 9_999_999, "fail_count": 3}, + RECENT_SHARES, + ) assert mode == "P2POOL" def test_low_hashrate_no_tier_is_p2pool(self, algo): with patch("mining_dashboard.service.algo_service.ENABLE_XVB", True): # 100 H/s * 0.85 < lowest tier (1000) -> no tier -> P2POOL - mode, _ = algo.get_decision(100, 100, POOL_STATS, P2P_MAIN, - {"avg_24h": 0, "avg_1h": 0, "fail_count": 0}, RECENT_SHARES) + mode, _ = algo.get_decision( + 100, + 100, + POOL_STATS, + P2P_MAIN, + {"avg_24h": 0, "avg_1h": 0, "fail_count": 0}, + RECENT_SHARES, + ) assert mode == "P2POOL" def test_cold_start_seeds_feedforward(self, algo): @@ -59,8 +76,14 @@ def test_cold_start_seeds_feedforward(self, algo): estimate (reference / current_hr), so it starts donating a sane amount.""" algo.donation_level = "vip" # target 10_000 with patch("mining_dashboard.service.algo_service.ENABLE_XVB", True): - mode, dur = algo.get_decision(46_300, 46_300, POOL_STATS, P2P_MAIN, - {"avg_24h": 0, "avg_1h": 0, "fail_count": 0}, RECENT_SHARES) + mode, dur = algo.get_decision( + 46_300, + 46_300, + POOL_STATS, + P2P_MAIN, + {"avg_24h": 0, "avg_1h": 0, "fail_count": 0}, + RECENT_SHARES, + ) assert mode in ("SPLIT", "XVB") # reference 10_300 / 46_300 ~ 0.222 of the cycle. assert algo.donation_fraction == pytest.approx(10_300 / 46_300, rel=0.05) @@ -70,12 +93,24 @@ def test_loop_ramps_up_when_below_reference(self, algo): donated fraction upward (closed-loop catch-up, not a one-shot spike).""" algo.donation_level = "vip" with patch("mining_dashboard.service.algo_service.ENABLE_XVB", True): - d1 = algo.get_decision(46_300, 46_300, POOL_STATS, P2P_MAIN, - {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, RECENT_SHARES) + d1 = algo.get_decision( + 46_300, + 46_300, + POOL_STATS, + P2P_MAIN, + {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, + RECENT_SHARES, + ) f_after_seed = algo.donation_fraction - d2 = algo.get_decision(46_300, 46_300, POOL_STATS, P2P_MAIN, - {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, RECENT_SHARES) - assert algo.donation_fraction > f_after_seed # ramped up + d2 = algo.get_decision( + 46_300, + 46_300, + POOL_STATS, + P2P_MAIN, + {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, + RECENT_SHARES, + ) + assert algo.donation_fraction > f_after_seed # ramped up assert _split_ms(d2) > _split_ms(d1) def test_loop_backs_off_when_above_reference(self, algo): @@ -83,29 +118,59 @@ def test_loop_backs_off_when_above_reference(self, algo): donated fraction — the property the old unbounded catch-up lacked.""" algo.donation_level = "vip" with patch("mining_dashboard.service.algo_service.ENABLE_XVB", True): - algo.get_decision(46_300, 46_300, POOL_STATS, P2P_MAIN, - {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, RECENT_SHARES) + algo.get_decision( + 46_300, + 46_300, + POOL_STATS, + P2P_MAIN, + {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, + RECENT_SHARES, + ) seeded = algo.donation_fraction - algo.get_decision(46_300, 46_300, POOL_STATS, P2P_MAIN, - {"avg_1h": 30_000, "avg_24h": 30_000, "fail_count": 0}, RECENT_SHARES) + algo.get_decision( + 46_300, + 46_300, + POOL_STATS, + P2P_MAIN, + {"avg_1h": 30_000, "avg_24h": 30_000, "fail_count": 0}, + RECENT_SHARES, + ) assert algo.donation_fraction < seeded # backed off def test_advance_false_does_not_move_the_loop(self, algo): """_smart_sleep re-reads with advance=False; that must not step the loop.""" algo.donation_level = "vip" with patch("mining_dashboard.service.algo_service.ENABLE_XVB", True): - algo.get_decision(46_300, 46_300, POOL_STATS, P2P_MAIN, - {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, RECENT_SHARES) + algo.get_decision( + 46_300, + 46_300, + POOL_STATS, + P2P_MAIN, + {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, + RECENT_SHARES, + ) held = algo.donation_fraction - algo.get_decision(46_300, 46_300, POOL_STATS, P2P_MAIN, - {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, RECENT_SHARES, - advance=False) + algo.get_decision( + 46_300, + 46_300, + POOL_STATS, + P2P_MAIN, + {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, + RECENT_SHARES, + advance=False, + ) assert algo.donation_fraction == held def test_nano_pool_uses_longer_window(self, algo): with patch("mining_dashboard.service.algo_service.ENABLE_XVB", True): - mode, _ = algo.get_decision(10_000, 10_000, POOL_STATS, {"type": "Nano"}, - {"avg_24h": 0, "avg_1h": 0, "fail_count": 0}, RECENT_SHARES) + mode, _ = algo.get_decision( + 10_000, + 10_000, + POOL_STATS, + {"type": "Nano"}, + {"avg_24h": 0, "avg_1h": 0, "fail_count": 0}, + RECENT_SHARES, + ) assert mode in ("P2POOL", "XVB", "SPLIT") @@ -129,8 +194,14 @@ def test_loop_clamped_to_reserve(self, algo): algo.donation_level = "mega" # unsustainable target -> loop pushes up hard with patch("mining_dashboard.service.algo_service.ENABLE_XVB", True): for _ in range(50): - algo.get_decision(46_300, 46_300, POOL_STATS_DIFF, P2P_MAIN, - {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, RECENT_SHARES) + algo.get_decision( + 46_300, + 46_300, + POOL_STATS_DIFF, + P2P_MAIN, + {"avg_1h": 0, "avg_24h": 0, "fail_count": 0}, + RECENT_SHARES, + ) cap = algo._max_donation_fraction(46_300, 21600, POOL_STATS_DIFF) assert algo.donation_fraction <= cap + 1e-9 @@ -146,7 +217,9 @@ def test_reference_cushion_is_absolute_capped(self, algo): def test_fraction_to_ms_zero_and_positive(self, algo): assert algo._fraction_to_ms(0) == 0 assert algo._fraction_to_ms(-1) == 0 - assert algo._fraction_to_ms(0.2) == pytest.approx(0.2 * XVB_TIME_ALGO_MS + XVB_SWITCH_OVERHEAD_MS, abs=1) + assert algo._fraction_to_ms(0.2) == pytest.approx( + 0.2 * XVB_TIME_ALGO_MS + XVB_SWITCH_OVERHEAD_MS, abs=1 + ) def test_advance_noop_when_no_hashrate(self, algo): algo.donation_fraction = 0.3 @@ -185,7 +258,7 @@ async def test_switch_updates_proxy_and_state(self, algo): await algo.switch_miners("XVB") algo.proxy_client.update_config.assert_called_once() sent = algo.proxy_client.update_config.call_args[0][0] - assert sent["other"] == "keep" # preserves existing config + assert sent["other"] == "keep" # preserves existing config assert sent["pools"][0]["enabled"] is True algo.state_manager.update_xvb_stats.assert_called_once() @@ -197,22 +270,26 @@ async def test_switch_aborts_on_bad_config(self, algo): async def test_xvb_pool_routed_over_tor_by_default(self, algo): # #166: the XvB pool carries a per-pool socks5 (Tor); the local p2pool pool never does. algo.proxy_client.get_config.return_value = {"pools": []} - with patch("mining_dashboard.service.algo_service.XVB_TOR_ENABLED", True), \ - patch("mining_dashboard.service.algo_service.XVB_TOR_SOCKS5", "172.28.0.25:9050"): + with ( + patch("mining_dashboard.service.algo_service.XVB_TOR_ENABLED", True), + patch("mining_dashboard.service.algo_service.XVB_TOR_SOCKS5", "172.28.0.25:9050"), + ): await algo.switch_miners("XVB") pools = algo.proxy_client.update_config.call_args[0][0]["pools"] - xvb, local = pools[0], pools[1] # enabled XvB pool first in XVB mode + xvb, local = pools[0], pools[1] # enabled XvB pool first in XVB mode assert xvb["enabled"] is True and xvb["socks5"] == "172.28.0.25:9050" - assert "socks5" not in local # local p2pool dials direct, never via Tor + assert "socks5" not in local # local p2pool dials direct, never via Tor async def test_local_pool_never_routed_over_tor(self, algo): # Even in P2POOL mode (XvB pool present but disabled), only the XvB pool carries socks5. algo.proxy_client.get_config.return_value = {"pools": []} - with patch("mining_dashboard.service.algo_service.XVB_TOR_ENABLED", True), \ - patch("mining_dashboard.service.algo_service.XVB_TOR_SOCKS5", "172.28.0.25:9050"): + with ( + patch("mining_dashboard.service.algo_service.XVB_TOR_ENABLED", True), + patch("mining_dashboard.service.algo_service.XVB_TOR_SOCKS5", "172.28.0.25:9050"), + ): await algo.switch_miners("P2POOL") pools = algo.proxy_client.update_config.call_args[0][0]["pools"] - local, xvb = pools[0], pools[1] # enabled local pool first in P2POOL mode + local, xvb = pools[0], pools[1] # enabled local pool first in P2POOL mode assert "socks5" not in local assert xvb["socks5"] == "172.28.0.25:9050" @@ -240,7 +317,11 @@ async def test_aborts_early_when_below_tier(self, algo): """Even if the cached decision is P2POOL, a 1h average below the tier means we must catch up — bail to re-decide rather than wait out the dwell.""" algo.data_service.latest_data = dict(self.LATEST) - algo.state_manager.get_xvb_stats.return_value = {"avg_24h": 0, "avg_1h": 500, "fail_count": 0} + algo.state_manager.get_xvb_stats.return_value = { + "avg_24h": 0, + "avg_1h": 500, + "fail_count": 0, + } algo.get_decision = MagicMock(return_value=("P2POOL", 0)) with patch("asyncio.sleep", new_callable=AsyncMock) as slept: await algo._smart_sleep(600, check_interval_sec=30) @@ -249,7 +330,11 @@ async def test_aborts_early_when_below_tier(self, algo): async def test_sleeps_full_duration_when_in_tier_on_p2pool(self, algo): algo.data_service.latest_data = dict(self.LATEST) # In tier (1h above the VIP threshold) and decision P2POOL -> rest, no bail. - algo.state_manager.get_xvb_stats.return_value = {"avg_24h": 12_000, "avg_1h": 12_000, "fail_count": 0} + algo.state_manager.get_xvb_stats.return_value = { + "avg_24h": 12_000, + "avg_1h": 12_000, + "fail_count": 0, + } algo.get_decision = MagicMock(return_value=("P2POOL", 0)) with patch("asyncio.sleep", new_callable=AsyncMock) as slept: await algo._smart_sleep(90, check_interval_sec=30) @@ -258,8 +343,12 @@ async def test_sleeps_full_duration_when_in_tier_on_p2pool(self, algo): class TestRunLoop: async def test_run_invokes_switch_then_stops(self, algo): - algo.data_service.latest_data = {"total_live_h10": 10_000, "total_live_h15": 10_000, - "pool": {}, "shares": []} + algo.data_service.latest_data = { + "total_live_h10": 10_000, + "total_live_h15": 10_000, + "pool": {}, + "shares": [], + } algo.state_manager.get_xvb_stats.return_value = {"avg_24h": 0, "avg_1h": 0, "fail_count": 0} algo.get_decision = MagicMock(return_value=("P2POOL", 0)) algo.switch_miners = MagicMock(side_effect=lambda *a, **k: asyncio.sleep(0)) diff --git a/build/dashboard/tests/service/test_clearnet_sync.py b/build/dashboard/tests/service/test_clearnet_sync.py index d6285d9..94fe79f 100644 --- a/build/dashboard/tests/service/test_clearnet_sync.py +++ b/build/dashboard/tests/service/test_clearnet_sync.py @@ -1,6 +1,7 @@ """Tests for the clearnet→Tor auto-transition supervisor (#183/#234).""" + import os -from unittest.mock import MagicMock, AsyncMock +from unittest.mock import AsyncMock, MagicMock from mining_dashboard.service.clearnet_sync import ClearnetSyncSupervisor @@ -38,8 +39,10 @@ async def test_synced_writes_marker_and_restarts_onto_tor(self, tmp_path): exposed = await sup.maybe_transition("monero", "monerod", flag_on=True, synced=True) assert exposed is False # transitioned → no longer exposed assert os.path.exists(sup.marker_path("monero")) # persistent marker written - dc.stop.assert_awaited_once(); assert dc.stop.await_args.args[0] == "monerod" - dc.start.assert_awaited_once(); assert dc.start.await_args.args[0] == "monerod" + dc.stop.assert_awaited_once() + assert dc.stop.await_args.args[0] == "monerod" + dc.start.assert_awaited_once() + assert dc.start.await_args.args[0] == "monerod" assert events == [("monero", True)] async def test_marker_written_before_restart(self, tmp_path): @@ -47,9 +50,11 @@ async def test_marker_written_before_restart(self, tmp_path): guaranteed to render Tor even if the dashboard dies mid-flip.""" sup, dc = make_supervisor(tmp_path) seen = {} + async def record_stop(container, *a, **k): seen["marker_at_stop"] = os.path.exists(sup.marker_path("monero")) return True + dc.stop = AsyncMock(side_effect=record_stop) await sup.maybe_transition("monero", "monerod", flag_on=True, synced=True) assert seen["marker_at_stop"] is True @@ -57,7 +62,8 @@ async def record_stop(container, *a, **k): async def test_already_transitioned_is_idempotent(self, tmp_path): sup, dc = make_supervisor(tmp_path) await sup.maybe_transition("monero", "monerod", flag_on=True, synced=True) - dc.stop.reset_mock(); dc.start.reset_mock() + dc.stop.reset_mock() + dc.start.reset_mock() # Second call: already flipped this run → no further restarts. exposed = await sup.maybe_transition("monero", "monerod", flag_on=True, synced=True) assert exposed is False @@ -93,11 +99,14 @@ async def test_slow_stop_does_not_skip_start(self, tmp_path): # and report False; the old `stop and start` short-circuited and NEVER started it, leaving # the daemon down. start must ALWAYS be attempted — a slow/failed stop can't strand it. sup, dc = make_supervisor(tmp_path) - dc.stop = AsyncMock(return_value=False) # stop "fails" (HTTP timed out before the slow stop) - dc.start = AsyncMock(return_value=True) # the container does come back up + dc.stop = AsyncMock( + return_value=False + ) # stop "fails" (HTTP timed out before the slow stop) + dc.start = AsyncMock(return_value=True) # the container does come back up exposed = await sup.maybe_transition("tari", "tari", flag_on=True, synced=True) - assert exposed is False # transitioned despite the stop hiccup - dc.start.assert_awaited_once(); assert dc.start.await_args.args[0] == "tari" + assert exposed is False # transitioned despite the stop hiccup + dc.start.assert_awaited_once() + assert dc.start.await_args.args[0] == "tari" async def test_restart_stop_timeout_outlasts_kill_deadline(self, tmp_path): # The stop's HTTP timeout must exceed its SIGTERM→SIGKILL deadline, or a slow daemon's stop @@ -116,7 +125,8 @@ async def test_per_chain_independent(self, tmp_path): assert m is False and t is True assert os.path.exists(sup.marker_path("monero")) assert not os.path.exists(sup.marker_path("tari")) - dc.stop.assert_awaited_once(); assert dc.stop.await_args.args[0] == "monerod" + dc.stop.assert_awaited_once() + assert dc.stop.await_args.args[0] == "monerod" async def test_marker_write_failure_does_not_restart(self, tmp_path): # If the marker can't be persisted (e.g. read-only dir), DON'T restart — a restart without diff --git a/build/dashboard/tests/service/test_data_service.py b/build/dashboard/tests/service/test_data_service.py index 9e3f250..57db9a3 100644 --- a/build/dashboard/tests/service/test_data_service.py +++ b/build/dashboard/tests/service/test_data_service.py @@ -1,17 +1,26 @@ -from unittest.mock import MagicMock, AsyncMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest import mining_dashboard.service.data_service as ds_mod from mining_dashboard.service.data_service import ( - DataService, _normalize_proxy_workers, _merge_direct_stats, _aggregate_hashrate, - _aggregate_window_hashrates, _parse_proxy_list_worker, _parse_legacy_dict_worker, - _parse_proxy_summary, _merge_proxy_summary, _shares_to_record, WorkerLifecycle, + DataService, + WorkerLifecycle, + _aggregate_hashrate, + _aggregate_window_hashrates, + _merge_direct_stats, + _merge_proxy_summary, + _normalize_proxy_workers, + _parse_legacy_dict_worker, + _parse_proxy_list_worker, + _parse_proxy_summary, + _shares_to_record, ) class _FakeClientSession: """Stand-in for aiohttp.ClientSession used as an async context manager.""" + async def __aenter__(self): return MagicMock() @@ -61,10 +70,21 @@ def test_parse_list_row_named_fields(self): # native hashrate windows at idx8..12 = 1m/10m/1h/12h/24h kH/s (the 1h/12h/24h ones are #168). row = ["rig", "10.0.0.1", 1, 0, 0, 0, 0, 0, 1.0, 2.0, 3.0, 4.0, 5.0] w = _parse_proxy_list_worker(row) - assert w == {"name": "rig", "ip": "10.0.0.1", "status": "online", - "h10": 1000, "h60": 1000, "h15": 2000, - "h1h": 3000, "h12h": 4000, "h24h": 5000, "uptime": 0, - "accepted": 0, "rejected": 0, "invalid": 0} + assert w == { + "name": "rig", + "ip": "10.0.0.1", + "status": "online", + "h10": 1000, + "h60": 1000, + "h15": 2000, + "h1h": 3000, + "h12h": 4000, + "h24h": 5000, + "uptime": 0, + "accepted": 0, + "rejected": 0, + "invalid": 0, + } def test_parse_list_row_share_counts(self): # idx3=accepted, idx4=rejected, idx5=invalid are carried through (Issue #82). @@ -81,14 +101,26 @@ def test_parse_list_row_offline_and_uptime(self): assert w["uptime"] == 0 def test_parse_legacy_dict_row(self): - w = _parse_legacy_dict_worker({"id": "old", "ip": "1.2.3.4", - "hashrate": [10, 20, 30], "uptime": 5}) + w = _parse_legacy_dict_worker( + {"id": "old", "ip": "1.2.3.4", "hashrate": [10, 20, 30], "uptime": 5} + ) # The legacy shape has only 10s/60s/15m, so the #168 long windows fall back to its longest # available average (hr[2]=30) rather than reading zero. - assert w == {"name": "old", "ip": "1.2.3.4", "status": "online", - "h10": 10, "h60": 20, "h15": 30, - "h1h": 30, "h12h": 30, "h24h": 30, "uptime": 5, - "accepted": 0, "rejected": 0, "invalid": 0} + assert w == { + "name": "old", + "ip": "1.2.3.4", + "status": "online", + "h10": 10, + "h60": 20, + "h15": 30, + "h1h": 30, + "h12h": 30, + "h24h": 30, + "uptime": 5, + "accepted": 0, + "rejected": 0, + "invalid": 0, + } def test_parse_legacy_dict_share_counts(self): # When a legacy payload happens to carry share counts, they pass through. @@ -107,8 +139,8 @@ def test_list_format_online(self): assert w["ip"] == "10.0.0.1" assert w["status"] == "online" assert w["h10"] == 1000 and w["h60"] == 1000 # idx8 kH/s -> H/s - assert w["h15"] == 2000 # idx9 kH/s -> H/s - assert w["uptime"] == 0 # idx7 (last share ms) == 0 + assert w["h15"] == 2000 # idx9 kH/s -> H/s + assert w["uptime"] == 0 # idx7 (last share ms) == 0 def test_list_format_offline_when_no_connections(self): # A worker still listed by the proxy but with 0 connections is a stopped miner. @@ -151,10 +183,21 @@ class TestParseProxySummary: """Pool-wide share totals from the proxy /summary `results` block (Issue #82).""" def test_extracts_results_and_best(self): - summary = {"results": {"accepted": 1000, "rejected": 12, "invalid": 3, "expired": 1, - "best": [987654, 5000, 100]}} + summary = { + "results": { + "accepted": 1000, + "rejected": 12, + "invalid": 3, + "expired": 1, + "best": [987654, 5000, 100], + } + } assert _parse_proxy_summary(summary) == { - "accepted": 1000, "rejected": 12, "invalid": 3, "expired": 1, "best": 987654, + "accepted": 1000, + "rejected": 12, + "invalid": 3, + "expired": 1, + "best": 987654, } def test_best_defaults_to_zero_when_empty(self): @@ -178,8 +221,15 @@ class TestMergeProxySummary: _LAST = {"accepted": 999, "rejected": 9, "invalid": 1, "expired": 0, "best": 42} def test_valid_summary_is_adopted(self): - summary = {"results": {"accepted": 1000, "rejected": 12, "invalid": 3, "expired": 1, - "best": [987654]}} + summary = { + "results": { + "accepted": 1000, + "rejected": 12, + "invalid": 3, + "expired": 1, + "best": [987654], + } + } assert _merge_proxy_summary(self._LAST, summary) == self._GOOD def test_malformed_payload_keeps_last_good(self): @@ -204,9 +254,9 @@ def _w(name, status, uptime=0): def test_online_uptime_counts_from_first_seen(self): lc = WorkerLifecycle(falloff_sec=3600) [w] = lc.update([self._w("rig", "online")], now=1000.0) - assert w["uptime"] == 0 # just connected + assert w["uptime"] == 0 # just connected [w] = lc.update([self._w("rig", "online")], now=1075.0) - assert w["uptime"] == 75 # now - connected_since, monotonic + assert w["uptime"] == 75 # now - connected_since, monotonic def test_real_api_uptime_is_not_overwritten(self): # A worker whose direct API is reachable already carries a real (>0) uptime — keep it. @@ -216,11 +266,11 @@ def test_real_api_uptime_is_not_overwritten(self): def test_offline_worker_shown_until_falloff_then_dropped(self): lc = WorkerLifecycle(falloff_sec=3600) - lc.update([self._w("rig", "online")], now=1000.0) # active at t=1000 - kept = lc.update([self._w("rig", "offline")], now=4000.0) # 3000s later: within 1h window - assert [w["name"] for w in kept] == ["rig"] # still shown (as DOWN) - gone = lc.update([self._w("rig", "offline")], now=5000.0) # 4000s since active: > falloff - assert gone == [] # fell off the table + lc.update([self._w("rig", "online")], now=1000.0) # active at t=1000 + kept = lc.update([self._w("rig", "offline")], now=4000.0) # 3000s later: within 1h window + assert [w["name"] for w in kept] == ["rig"] # still shown (as DOWN) + gone = lc.update([self._w("rig", "offline")], now=5000.0) # 4000s since active: > falloff + assert gone == [] # fell off the table def test_fallen_off_worker_stays_gone_while_proxy_keeps_reporting_it(self): # Regression (#182): xmrig-proxy keeps a disconnected worker in /workers for HOURS, so the @@ -228,7 +278,7 @@ def test_fallen_off_worker_stays_gone_while_proxy_keeps_reporting_it(self): # internal state at falloff meant the next poll re-created it with last_active=now, resetting # the 1h clock — the row flickered off for one cycle then came back as DOWN forever. lc = WorkerLifecycle(falloff_sec=3600) - lc.update([self._w("rig", "online")], now=1000.0) # active at t=1000 + lc.update([self._w("rig", "online")], now=1000.0) # active at t=1000 assert lc.update([self._w("rig", "offline")], now=4700.0) == [] # 3700s > falloff → dropped # The proxy STILL reports it offline on every subsequent poll — it must stay gone, not flicker. for t in (4730.0, 8400.0, 8430.0, 30000.0): @@ -240,17 +290,17 @@ def test_fallen_off_worker_stays_gone_while_proxy_keeps_reporting_it(self): def test_reconnect_restarts_uptime_and_readds(self): lc = WorkerLifecycle(falloff_sec=10) lc.update([self._w("rig", "online")], now=1000.0) - lc.update([], now=2000.0) # proxy drops it entirely (fell off) - [w] = lc.update([self._w("rig", "online")], now=3000.0) # reconnects fresh - assert w["uptime"] == 0 # uptime restarts, not inherited + lc.update([], now=2000.0) # proxy drops it entirely (fell off) + [w] = lc.update([self._w("rig", "online")], now=3000.0) # reconnects fresh + assert w["uptime"] == 0 # uptime restarts, not inherited [w] = lc.update([self._w("rig", "online")], now=3050.0) assert w["uptime"] == 50 def test_offline_then_online_resets_connected_since(self): lc = WorkerLifecycle(falloff_sec=3600) lc.update([self._w("rig", "online")], now=1000.0) - lc.update([self._w("rig", "offline")], now=1100.0) # disconnect resets connected_since - [w] = lc.update([self._w("rig", "online")], now=1200.0) # back online — counts from here + lc.update([self._w("rig", "offline")], now=1100.0) # disconnect resets connected_since + [w] = lc.update([self._w("rig", "online")], now=1200.0) # back online — counts from here assert w["uptime"] == 0 @@ -258,8 +308,15 @@ class TestMergeDirectStats: """Augment proxy workers with direct-API stats; kind-based scaling + keep-online (#39/#28).""" def _worker(self): - return {"name": "rig", "ip": "10.0.0.1", "status": "online", - "h10": 1, "h60": 2, "h15": 3, "uptime": 0} + return { + "name": "rig", + "ip": "10.0.0.1", + "status": "online", + "h10": 1, + "h60": 2, + "h15": 3, + "uptime": 0, + } def test_proxy_kind_scales_khs_to_hs(self): extra = {"kind": "proxy", "uptime": 120, "hashrate": {"total": [1, 2, 3]}} @@ -300,7 +357,7 @@ def test_prefers_h15(self): def test_falls_back_to_h60_then_h10(self): workers = [ {"status": "online", "h15": 0, "h60": 1500, "h10": 500}, # uses h60 - {"status": "online", "h15": 0, "h60": 0, "h10": 700}, # uses h10 + {"status": "online", "h15": 0, "h60": 0, "h10": 700}, # uses h10 ] total_h15, total_h10 = _aggregate_hashrate(workers) assert total_h15 == 1500 + 700 @@ -324,10 +381,13 @@ class TestAggregateWindowHashrates: def test_sums_each_window_independently(self): workers = [ {"status": "online", "h10": 100, "h1h": 300, "h12h": 1200, "h24h": 2400}, - {"status": "online", "h10": 50, "h1h": 150, "h12h": 600, "h24h": 1200}, + {"status": "online", "h10": 50, "h1h": 150, "h12h": 600, "h24h": 1200}, ] assert _aggregate_window_hashrates(workers) == { - "1m": 150, "1h": 450, "12h": 1800, "24h": 3600, + "1m": 150, + "1h": 450, + "12h": 1800, + "24h": 3600, } def test_no_fallback_between_windows(self): @@ -339,8 +399,8 @@ def test_no_fallback_between_windows(self): def test_offline_excluded_and_missing_keys_zero(self): workers = [ - {"status": "online", "h10": 100}, # missing long windows -> 0 - {"status": "offline", "h10": 9999, "h1h": 9999, "h24h": 9999}, # excluded entirely + {"status": "online", "h10": 100}, # missing long windows -> 0 + {"status": "offline", "h10": 9999, "h1h": 9999, "h24h": 9999}, # excluded entirely ] assert _aggregate_window_hashrates(workers) == {"1m": 100, "1h": 0, "12h": 0, "24h": 0} @@ -402,7 +462,10 @@ def _tari(self, required=True): async def test_stop_when_monero_down(self): # monerod is required, so its outage always rejects — even with Tari non-blocking. svc = self._svc() - with self._tari(required=False), patch.object(ds_mod, "REJECT_WORKERS_CONTAINER", "xmrig-proxy"): + with ( + self._tari(required=False), + patch.object(ds_mod, "REJECT_WORKERS_CONTAINER", "xmrig-proxy"), + ): await svc._apply_worker_rejection(monero_down=True, tari_down=False) svc.docker_control.stop.assert_awaited_once_with("xmrig-proxy") assert svc.workers_rejected is True @@ -552,8 +615,15 @@ async def test_single_iteration_aggregates(self): # idx8=1.0 kH/s, idx9=2.0 kH/s -> h15 = 2000 H/s. worker_row = ["rig1", "10.0.0.1", 1, 0, 0, 0, 0, 0, 1.0, 2.0, 0, 0, 0] proxy.get_workers.return_value = {"workers": [worker_row]} - proxy.get_summary.return_value = {"results": {"accepted": 100, "rejected": 5, "invalid": 1, - "expired": 2, "best": [123456]}} + proxy.get_summary.return_value = { + "results": { + "accepted": 100, + "rejected": 5, + "invalid": 1, + "expired": 2, + "best": [123456], + } + } worker_client = MagicMock() worker_client.get_stats = AsyncMock(return_value={}) # direct API unreachable @@ -561,20 +631,39 @@ async def test_single_iteration_aggregates(self): tari_client.get_sync_status = AsyncMock(return_value={"is_syncing": False}) tari_client.close = AsyncMock() - with patch.object(ds_mod, "ClientSession", _FakeClientSession), \ - patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), \ - patch.object(ds_mod, "TariClient", return_value=tari_client), \ - patch.object(ds_mod, "get_stratum_stats", return_value=({}, [])), \ - patch.object(ds_mod, "get_network_stats", return_value={"height": 100}), \ - patch.object(ds_mod, "get_tari_stats", return_value={"active": True, "status": "OK", "height": 3}), \ - patch.object(ds_mod, "get_p2pool_stats", return_value={"pool": {"last_share_time": 0, "difficulty": 0}}), \ - patch.object(ds_mod, "get_monero_sync_status", AsyncMock(return_value={"is_syncing": False, "percent": 100, "target": 100, "current": 100})), \ - patch.object(ds_mod, "get_disk_usage", return_value={}), \ - patch.object(ds_mod, "get_hugepages_status", return_value=("Enabled", "ok", "1/2")), \ - patch.object(ds_mod, "get_memory_usage", return_value={}), \ - patch.object(ds_mod, "get_load_average", return_value="0"), \ - patch.object(ds_mod, "get_cpu_usage", return_value="0%"), \ - patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)): + with ( + patch.object(ds_mod, "ClientSession", _FakeClientSession), + patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), + patch.object(ds_mod, "TariClient", return_value=tari_client), + patch.object(ds_mod, "get_stratum_stats", return_value=({}, [])), + patch.object(ds_mod, "get_network_stats", return_value={"height": 100}), + patch.object( + ds_mod, "get_tari_stats", return_value={"active": True, "status": "OK", "height": 3} + ), + patch.object( + ds_mod, + "get_p2pool_stats", + return_value={"pool": {"last_share_time": 0, "difficulty": 0}}, + ), + patch.object( + ds_mod, + "get_monero_sync_status", + AsyncMock( + return_value={ + "is_syncing": False, + "percent": 100, + "target": 100, + "current": 100, + } + ), + ), + patch.object(ds_mod, "get_disk_usage", return_value={}), + patch.object(ds_mod, "get_hugepages_status", return_value=("Enabled", "ok", "1/2")), + patch.object(ds_mod, "get_memory_usage", return_value={}), + patch.object(ds_mod, "get_load_average", return_value="0"), + patch.object(ds_mod, "get_cpu_usage", return_value="0%"), + patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)), + ): with pytest.raises(StopAsyncIteration): await svc.run() @@ -584,7 +673,11 @@ async def test_single_iteration_aggregates(self): assert svc.latest_data["total_live_h15"] == 2000.0 # The proxy /summary totals were collected and surfaced (Issue #82). assert svc.latest_data["proxy_summary"] == { - "accepted": 100, "rejected": 5, "invalid": 1, "expired": 2, "best": 123456, + "accepted": 100, + "rejected": 5, + "invalid": 1, + "expired": 2, + "best": 123456, } sm.update_history.assert_called() sm.save_snapshot.assert_called() @@ -599,24 +692,46 @@ async def test_run_holds_miner_while_syncing(self): worker_client = MagicMock() worker_client.get_stats = AsyncMock(return_value={}) tari_client = MagicMock() - tari_client.get_sync_status = AsyncMock(return_value={"is_syncing": False, "reachable": True}) + tari_client.get_sync_status = AsyncMock( + return_value={"is_syncing": False, "reachable": True} + ) tari_client.close = AsyncMock() - with patch.object(ds_mod, "ClientSession", _FakeClientSession), \ - patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), \ - patch.object(ds_mod, "TariClient", return_value=tari_client), \ - patch.object(ds_mod, "SYNC_GATE_CONTAINERS", ["p2pool", "xmrig-proxy"]), \ - patch.object(ds_mod, "get_stratum_stats", return_value=({}, [])), \ - patch.object(ds_mod, "get_network_stats", return_value={"height": 100}), \ - patch.object(ds_mod, "get_tari_stats", return_value={"active": True, "status": "OK", "height": 3}), \ - patch.object(ds_mod, "get_p2pool_stats", return_value={"pool": {"last_share_time": 0, "difficulty": 0}}), \ - patch.object(ds_mod, "get_monero_sync_status", AsyncMock(return_value={"is_syncing": True, "reachable": True, "percent": 50, "current": 50, "target": 100})), \ - patch.object(ds_mod, "get_disk_usage", return_value={}), \ - patch.object(ds_mod, "get_hugepages_status", return_value=("Enabled", "ok", "1/2")), \ - patch.object(ds_mod, "get_memory_usage", return_value={}), \ - patch.object(ds_mod, "get_load_average", return_value="0"), \ - patch.object(ds_mod, "get_cpu_usage", return_value="0%"), \ - patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)): + with ( + patch.object(ds_mod, "ClientSession", _FakeClientSession), + patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), + patch.object(ds_mod, "TariClient", return_value=tari_client), + patch.object(ds_mod, "SYNC_GATE_CONTAINERS", ["p2pool", "xmrig-proxy"]), + patch.object(ds_mod, "get_stratum_stats", return_value=({}, [])), + patch.object(ds_mod, "get_network_stats", return_value={"height": 100}), + patch.object( + ds_mod, "get_tari_stats", return_value={"active": True, "status": "OK", "height": 3} + ), + patch.object( + ds_mod, + "get_p2pool_stats", + return_value={"pool": {"last_share_time": 0, "difficulty": 0}}, + ), + patch.object( + ds_mod, + "get_monero_sync_status", + AsyncMock( + return_value={ + "is_syncing": True, + "reachable": True, + "percent": 50, + "current": 50, + "target": 100, + } + ), + ), + patch.object(ds_mod, "get_disk_usage", return_value={}), + patch.object(ds_mod, "get_hugepages_status", return_value=("Enabled", "ok", "1/2")), + patch.object(ds_mod, "get_memory_usage", return_value={}), + patch.object(ds_mod, "get_load_average", return_value="0"), + patch.object(ds_mod, "get_cpu_usage", return_value="0%"), + patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)), + ): with pytest.raises(StopAsyncIteration): await svc.run() @@ -638,24 +753,38 @@ async def test_run_releases_despite_height_override(self): worker_client = MagicMock() worker_client.get_stats = AsyncMock(return_value={}) tari_client = MagicMock() - tari_client.get_sync_status = AsyncMock(return_value={"is_syncing": False, "reachable": True}) + tari_client.get_sync_status = AsyncMock( + return_value={"is_syncing": False, "reachable": True} + ) tari_client.close = AsyncMock() - with patch.object(ds_mod, "ClientSession", _FakeClientSession), \ - patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), \ - patch.object(ds_mod, "TariClient", return_value=tari_client), \ - patch.object(ds_mod, "SYNC_GATE_CONTAINERS", ["p2pool", "xmrig-proxy"]), \ - patch.object(ds_mod, "get_stratum_stats", return_value=({}, [])), \ - patch.object(ds_mod, "get_network_stats", return_value={"height": 0}), \ - patch.object(ds_mod, "get_tari_stats", return_value={"active": True, "status": "OK", "height": 3}), \ - patch.object(ds_mod, "get_p2pool_stats", return_value={"pool": {"last_share_time": 0, "difficulty": 0}}), \ - patch.object(ds_mod, "get_monero_sync_status", AsyncMock(return_value={"is_syncing": False, "reachable": True})), \ - patch.object(ds_mod, "get_disk_usage", return_value={}), \ - patch.object(ds_mod, "get_hugepages_status", return_value=("Enabled", "ok", "1/2")), \ - patch.object(ds_mod, "get_memory_usage", return_value={}), \ - patch.object(ds_mod, "get_load_average", return_value="0"), \ - patch.object(ds_mod, "get_cpu_usage", return_value="0%"), \ - patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)): + with ( + patch.object(ds_mod, "ClientSession", _FakeClientSession), + patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), + patch.object(ds_mod, "TariClient", return_value=tari_client), + patch.object(ds_mod, "SYNC_GATE_CONTAINERS", ["p2pool", "xmrig-proxy"]), + patch.object(ds_mod, "get_stratum_stats", return_value=({}, [])), + patch.object(ds_mod, "get_network_stats", return_value={"height": 0}), + patch.object( + ds_mod, "get_tari_stats", return_value={"active": True, "status": "OK", "height": 3} + ), + patch.object( + ds_mod, + "get_p2pool_stats", + return_value={"pool": {"last_share_time": 0, "difficulty": 0}}, + ), + patch.object( + ds_mod, + "get_monero_sync_status", + AsyncMock(return_value={"is_syncing": False, "reachable": True}), + ), + patch.object(ds_mod, "get_disk_usage", return_value={}), + patch.object(ds_mod, "get_hugepages_status", return_value=("Enabled", "ok", "1/2")), + patch.object(ds_mod, "get_memory_usage", return_value={}), + patch.object(ds_mod, "get_load_average", return_value="0"), + patch.object(ds_mod, "get_cpu_usage", return_value="0%"), + patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)), + ): with pytest.raises(StopAsyncIteration): await svc.run() @@ -677,25 +806,44 @@ async def test_run_nonblocking_tari_releases_and_stays_operational(self): worker_client.get_stats = AsyncMock(return_value={}) tari_client = MagicMock() tari_client.get_sync_status = AsyncMock( - return_value={"is_syncing": True, "reachable": True, "percent": 42, "current": 42, "target": 100}) + return_value={ + "is_syncing": True, + "reachable": True, + "percent": 42, + "current": 42, + "target": 100, + } + ) tari_client.close = AsyncMock() - with patch.object(ds_mod, "ClientSession", _FakeClientSession), \ - patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), \ - patch.object(ds_mod, "TariClient", return_value=tari_client), \ - patch.object(ds_mod, "SYNC_GATE_CONTAINERS", ["p2pool", "xmrig-proxy"]), \ - patch.object(ds_mod, "TARI_REQUIRED", False), \ - patch.object(ds_mod, "get_stratum_stats", return_value=({}, [])), \ - patch.object(ds_mod, "get_network_stats", return_value={"height": 100}), \ - patch.object(ds_mod, "get_tari_stats", return_value={"active": True, "status": "OK", "height": 3}), \ - patch.object(ds_mod, "get_p2pool_stats", return_value={"pool": {"last_share_time": 0, "difficulty": 0}}), \ - patch.object(ds_mod, "get_monero_sync_status", AsyncMock(return_value={"is_syncing": False, "reachable": True})), \ - patch.object(ds_mod, "get_disk_usage", return_value={}), \ - patch.object(ds_mod, "get_hugepages_status", return_value=("Enabled", "ok", "1/2")), \ - patch.object(ds_mod, "get_memory_usage", return_value={}), \ - patch.object(ds_mod, "get_load_average", return_value="0"), \ - patch.object(ds_mod, "get_cpu_usage", return_value="0%"), \ - patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)): + with ( + patch.object(ds_mod, "ClientSession", _FakeClientSession), + patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), + patch.object(ds_mod, "TariClient", return_value=tari_client), + patch.object(ds_mod, "SYNC_GATE_CONTAINERS", ["p2pool", "xmrig-proxy"]), + patch.object(ds_mod, "TARI_REQUIRED", False), + patch.object(ds_mod, "get_stratum_stats", return_value=({}, [])), + patch.object(ds_mod, "get_network_stats", return_value={"height": 100}), + patch.object( + ds_mod, "get_tari_stats", return_value={"active": True, "status": "OK", "height": 3} + ), + patch.object( + ds_mod, + "get_p2pool_stats", + return_value={"pool": {"last_share_time": 0, "difficulty": 0}}, + ), + patch.object( + ds_mod, + "get_monero_sync_status", + AsyncMock(return_value={"is_syncing": False, "reachable": True}), + ), + patch.object(ds_mod, "get_disk_usage", return_value={}), + patch.object(ds_mod, "get_hugepages_status", return_value=("Enabled", "ok", "1/2")), + patch.object(ds_mod, "get_memory_usage", return_value={}), + patch.object(ds_mod, "get_load_average", return_value="0"), + patch.object(ds_mod, "get_cpu_usage", return_value="0%"), + patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)), + ): with pytest.raises(StopAsyncIteration): await svc.run() @@ -712,11 +860,13 @@ async def test_iteration_survives_collector_error(self): tari_client = MagicMock() tari_client.get_sync_status = AsyncMock(return_value={}) - with patch.object(ds_mod, "ClientSession", _FakeClientSession), \ - patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), \ - patch.object(ds_mod, "TariClient", return_value=tari_client), \ - patch.object(ds_mod, "get_stratum_stats", side_effect=RuntimeError("boom")), \ - patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)): + with ( + patch.object(ds_mod, "ClientSession", _FakeClientSession), + patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), + patch.object(ds_mod, "TariClient", return_value=tari_client), + patch.object(ds_mod, "get_stratum_stats", side_effect=RuntimeError("boom")), + patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)), + ): # The error is caught inside the loop; the sleep after it raises to stop us. with pytest.raises(StopAsyncIteration): await svc.run() @@ -738,25 +888,44 @@ async def test_run_holds_when_tari_required_and_only_monero_synced(self): worker_client.get_stats = AsyncMock(return_value={}) tari_client = MagicMock() tari_client.get_sync_status = AsyncMock( - return_value={"is_syncing": True, "reachable": True, "percent": 80, "current": 80, "target": 100}) + return_value={ + "is_syncing": True, + "reachable": True, + "percent": 80, + "current": 80, + "target": 100, + } + ) tari_client.close = AsyncMock() - with patch.object(ds_mod, "ClientSession", _FakeClientSession), \ - patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), \ - patch.object(ds_mod, "TariClient", return_value=tari_client), \ - patch.object(ds_mod, "SYNC_GATE_CONTAINERS", ["p2pool", "xmrig-proxy"]), \ - patch.object(ds_mod, "TARI_REQUIRED", True), \ - patch.object(ds_mod, "get_stratum_stats", return_value=({}, [])), \ - patch.object(ds_mod, "get_network_stats", return_value={"height": 100}), \ - patch.object(ds_mod, "get_tari_stats", return_value={"active": True, "status": "OK", "height": 3}), \ - patch.object(ds_mod, "get_p2pool_stats", return_value={"pool": {"last_share_time": 0, "difficulty": 0}}), \ - patch.object(ds_mod, "get_monero_sync_status", AsyncMock(return_value={"is_syncing": False, "reachable": True})), \ - patch.object(ds_mod, "get_disk_usage", return_value={}), \ - patch.object(ds_mod, "get_hugepages_status", return_value=("Enabled", "ok", "1/2")), \ - patch.object(ds_mod, "get_memory_usage", return_value={}), \ - patch.object(ds_mod, "get_load_average", return_value="0"), \ - patch.object(ds_mod, "get_cpu_usage", return_value="0%"), \ - patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)): + with ( + patch.object(ds_mod, "ClientSession", _FakeClientSession), + patch.object(ds_mod, "XMRigWorkerClient", return_value=worker_client), + patch.object(ds_mod, "TariClient", return_value=tari_client), + patch.object(ds_mod, "SYNC_GATE_CONTAINERS", ["p2pool", "xmrig-proxy"]), + patch.object(ds_mod, "TARI_REQUIRED", True), + patch.object(ds_mod, "get_stratum_stats", return_value=({}, [])), + patch.object(ds_mod, "get_network_stats", return_value={"height": 100}), + patch.object( + ds_mod, "get_tari_stats", return_value={"active": True, "status": "OK", "height": 3} + ), + patch.object( + ds_mod, + "get_p2pool_stats", + return_value={"pool": {"last_share_time": 0, "difficulty": 0}}, + ), + patch.object( + ds_mod, + "get_monero_sync_status", + AsyncMock(return_value={"is_syncing": False, "reachable": True}), + ), + patch.object(ds_mod, "get_disk_usage", return_value={}), + patch.object(ds_mod, "get_hugepages_status", return_value=("Enabled", "ok", "1/2")), + patch.object(ds_mod, "get_memory_usage", return_value={}), + patch.object(ds_mod, "get_load_average", return_value="0"), + patch.object(ds_mod, "get_cpu_usage", return_value="0%"), + patch("asyncio.sleep", AsyncMock(side_effect=StopAsyncIteration)), + ): with pytest.raises(StopAsyncIteration): await svc.run() @@ -772,21 +941,25 @@ async def test_post_release_blip_lets_failover_act_without_rehold(self): # The two coexist: gate no-ops, rejection acts on the proxy only. svc, _sm, _proxy = _make_service() svc.miner_released = True - with patch.object(ds_mod, "SYNC_GATE_CONTAINERS", ["p2pool", "xmrig-proxy"]), \ - patch.object(ds_mod, "REJECT_WORKERS_CONTAINER", "xmrig-proxy"), \ - patch.object(ds_mod, "TARI_REQUIRED", True): - await svc._apply_sync_gate(gate_satisfied=False) # latch → no-op + with ( + patch.object(ds_mod, "SYNC_GATE_CONTAINERS", ["p2pool", "xmrig-proxy"]), + patch.object(ds_mod, "REJECT_WORKERS_CONTAINER", "xmrig-proxy"), + patch.object(ds_mod, "TARI_REQUIRED", True), + ): + await svc._apply_sync_gate(gate_satisfied=False) # latch → no-op await svc._apply_worker_rejection(monero_down=True, tari_down=False) stopped = [c.args[0] for c in svc.docker_control.stop.await_args_list] - assert stopped == ["xmrig-proxy"] # p2pool was NOT re-held + assert stopped == ["xmrig-proxy"] # p2pool was NOT re-held svc.docker_control.start.assert_not_called() assert svc.workers_rejected is True async def test_both_nodes_down_rejects_once(self): # A simultaneous Monero+Tari outage (both required) is a single rejection, not two. svc, _sm, _proxy = _make_service() - with patch.object(ds_mod, "REJECT_WORKERS_CONTAINER", "xmrig-proxy"), \ - patch.object(ds_mod, "TARI_REQUIRED", True): + with ( + patch.object(ds_mod, "REJECT_WORKERS_CONTAINER", "xmrig-proxy"), + patch.object(ds_mod, "TARI_REQUIRED", True), + ): await svc._apply_worker_rejection(monero_down=True, tari_down=True) svc.docker_control.stop.assert_awaited_once_with("xmrig-proxy") assert svc.workers_rejected is True diff --git a/build/dashboard/tests/service/test_earnings.py b/build/dashboard/tests/service/test_earnings.py index 5a567e2..0d1beb4 100644 --- a/build/dashboard/tests/service/test_earnings.py +++ b/build/dashboard/tests/service/test_earnings.py @@ -5,18 +5,21 @@ the graceful-degradation behaviour when inputs are missing. The client scales this rate to the what-if hashrate; that scaling/formatting is tested in tests/frontend/logic.test.mjs. """ + import pytest from mining_dashboard.service.earnings import ( - xmr_per_hs_day, ATOMIC_PER_XMR, SECONDS_PER_DAY, + ATOMIC_PER_XMR, + SECONDS_PER_DAY, + xmr_per_hs_day, ) class TestXmrPerHsDay: def test_matches_closed_form(self): # reward_xmr / difficulty * seconds_per_day, with reward given in atomic units. - reward_atomic = 0.6 * ATOMIC_PER_XMR # 0.6 XMR block reward - difficulty = 400_000_000_000 # 400 G + reward_atomic = 0.6 * ATOMIC_PER_XMR # 0.6 XMR block reward + difficulty = 400_000_000_000 # 400 G expected = 0.6 / difficulty * SECONDS_PER_DAY assert xmr_per_hs_day(reward_atomic, difficulty) == pytest.approx(expected) @@ -33,13 +36,16 @@ def test_linear_in_inputs(self): assert xmr_per_hs_day(2 * ATOMIC_PER_XMR, 1_000_000) == pytest.approx(2 * base) assert xmr_per_hs_day(ATOMIC_PER_XMR, 2_000_000) == pytest.approx(base / 2) - @pytest.mark.parametrize("reward,diff", [ - (0, 400_000_000_000), # no reward collected yet - (0.6 * ATOMIC_PER_XMR, 0), # no difficulty yet - (0, 0), # nothing collected - (-1, 400_000_000_000), # defensive: negative reward - (0.6 * ATOMIC_PER_XMR, -5), # defensive: negative difficulty - ]) + @pytest.mark.parametrize( + "reward,diff", + [ + (0, 400_000_000_000), # no reward collected yet + (0.6 * ATOMIC_PER_XMR, 0), # no difficulty yet + (0, 0), # nothing collected + (-1, 400_000_000_000), # defensive: negative reward + (0.6 * ATOMIC_PER_XMR, -5), # defensive: negative difficulty + ], + ) def test_missing_or_bad_inputs_are_zero(self, reward, diff): # A zero rate is the dashboard's "unavailable" signal (shows "—"); never raise or divide # by zero on incomplete live data. diff --git a/build/dashboard/tests/service/test_metrics.py b/build/dashboard/tests/service/test_metrics.py index 9a2fb3b..bab11e3 100644 --- a/build/dashboard/tests/service/test_metrics.py +++ b/build/dashboard/tests/service/test_metrics.py @@ -5,13 +5,17 @@ sync/down state. The web view layer and future consumers (#45 Telegram, #12 calculator) all read these, so the logic is covered thoroughly here rather than through rendered output. """ -import time +import time from unittest.mock import MagicMock import mining_dashboard.service.metrics as metrics -from mining_dashboard.service.metrics import build_metrics, _avg_p2pool_over_window, _avg_xvb_over_window from mining_dashboard.config.config import TIER_DEFAULTS +from mining_dashboard.service.metrics import ( + _avg_p2pool_over_window, + _avg_xvb_over_window, + build_metrics, +) def _mgr(history=None, mode="P2POOL", xvb=None, tiers=None): @@ -28,7 +32,10 @@ def _mgr(history=None, mode="P2POOL", xvb=None, tiers=None): def _data(**over): d = { - "shares": [], "workers": [], "global_sync": False, "total_live_h15": 0, + "shares": [], + "workers": [], + "global_sync": False, + "total_live_h15": 0, "monero_sync": {"percent": 100, "current": 10, "target": 10}, "tari_sync": {"percent": 100, "current": 5, "target": 5}, } @@ -106,13 +113,15 @@ def test_complements_p2pool_to_total(self): {"timestamp": now - 60, "v": 1000, "v_p2pool": 0, "v_xvb": 1000}, ] assert _avg_xvb_over_window(history, 3600) == 500.0 - assert _avg_p2pool_over_window(history, 3600) == 500.0 # 500 + 500 == 1000 total + assert _avg_p2pool_over_window(history, 3600) == 500.0 # 500 + 500 == 1000 total class TestHashrate: def test_total_and_stratum_passthrough(self): - data = _data(total_live_h15=12345, - stratum={"hashrate_15m": 100, "hashrate_1h": 200, "hashrate_24h": 300}) + data = _data( + total_live_h15=12345, + stratum={"hashrate_15m": 100, "hashrate_1h": 200, "hashrate_24h": 300}, + ) m = build_metrics(data, _mgr()) assert m.total_h15 == 12345 assert (m.stratum_h15, m.stratum_h1h, m.stratum_h24h) == (100, 200, 300) @@ -138,7 +147,7 @@ def test_xvb_routed_averages_from_history(self): m = build_metrics(_data(), _mgr(history=history, xvb={"avg_1h": 30_000, "avg_24h": 30_000})) assert m.xvb_routed_1h == 400.0 assert m.xvb_routed_24h == 400.0 - assert m.xvb_1h == 30_000 # credited, independent of routed + assert m.xvb_1h == 30_000 # credited, independent of routed def test_xvb_routed_zero_without_history(self): m = build_metrics(_data(total_live_h15=40_000), _mgr()) @@ -162,7 +171,7 @@ def test_xvb_disabled_overrides_mode_and_tiers(self, monkeypatch): def test_current_tier_from_min_1h_24h(self): # Current tier qualifies on the lower of the 1h/24h credited averages (#157) — set both. m = build_metrics(_data(), _mgr(xvb={"avg_1h": 50_000_000, "avg_24h": 50_000_000})) - assert m.current_tier != "None" # some tier qualifies at 50 MH/s on both windows + assert m.current_tier != "None" # some tier qualifies at 50 MH/s on both windows assert isinstance(m.target_threshold, float) def test_current_tier_uses_lower_of_1h_24h_on_drop(self): @@ -171,8 +180,8 @@ def test_current_tier_uses_lower_of_1h_24h_on_drop(self): high = build_metrics(_data(), _mgr(xvb={"avg_1h": 50_000_000, "avg_24h": 50_000_000})) dropped = build_metrics(_data(), _mgr(xvb={"avg_1h": 50_000, "avg_24h": 50_000_000})) only_1h = build_metrics(_data(), _mgr(xvb={"avg_1h": 50_000, "avg_24h": 50_000})) - assert dropped.current_tier != high.current_tier # the 1h drop lowered the tier - assert dropped.current_tier == only_1h.current_tier # tier follows the LOWER (1h) average + assert dropped.current_tier != high.current_tier # the 1h drop lowered the tier + assert dropped.current_tier == only_1h.current_tier # tier follows the LOWER (1h) average def test_low_hr_warning_for_unsustainable_explicit_tier(self, monkeypatch): monkeypatch.setattr(metrics, "ENABLE_XVB", True) @@ -199,11 +208,13 @@ def test_fail_count_and_last_update(self): class TestWorkers: def test_counts_online_and_total(self): - data = _data(workers=[ - {"name": "a", "status": "online"}, - {"name": "b", "status": "offline"}, - {"name": "c", "status": "online"}, - ]) + data = _data( + workers=[ + {"name": "a", "status": "online"}, + {"name": "b", "status": "offline"}, + {"name": "c", "status": "online"}, + ] + ) m = build_metrics(data, _mgr()) assert m.workers_online == 2 assert m.workers_total == 3 @@ -217,7 +228,7 @@ class TestSharesWindow: def test_counts_recent_within_pplns_window(self): now = time.time() data = _data( - pool={"pool": {"pplns_window": 10}}, # 10 blocks * 10s (Main) = 100s + pool={"pool": {"pplns_window": 10}}, # 10 blocks * 10s (Main) = 100s shares=[{"ts": now - 5}, {"ts": now - 50}, {"ts": now - 10_000}], ) m = build_metrics(data, _mgr()) @@ -274,8 +285,10 @@ def test_remote_unknown(self, monkeypatch): class TestCalculatorInputs: def test_pool_and_network_figures(self): data = _data( - pool={"p2p": {"type": "Mini"}, - "pool": {"hashrate": 120_000_000, "difficulty": 250_000_000}}, + pool={ + "p2p": {"type": "Mini"}, + "pool": {"hashrate": 120_000_000, "difficulty": 250_000_000}, + }, network={"difficulty": 380_000_000_000, "height": 3210001}, ) m = build_metrics(data, _mgr()) @@ -302,11 +315,11 @@ def test_empty_snapshot_does_not_crash(self): def test_history_fetched_when_not_passed(self): now = time.time() sm = _mgr(history=[{"timestamp": now - 10, "v": 500, "v_p2pool": 500, "v_xvb": 0}]) - m = build_metrics(_data(), sm) # no history arg -> pulled from state_mgr + m = build_metrics(_data(), sm) # no history arg -> pulled from state_mgr sm.get_history.assert_called_once() assert m.p2pool_1h == 500.0 def test_passed_history_avoids_refetch(self): sm = _mgr() - build_metrics(_data(), sm, history=[]) # explicit history -> no get_history call + build_metrics(_data(), sm, history=[]) # explicit history -> no get_history call sm.get_history.assert_not_called() diff --git a/build/dashboard/tests/service/test_node_health.py b/build/dashboard/tests/service/test_node_health.py index 83600eb..27eee3b 100644 --- a/build/dashboard/tests/service/test_node_health.py +++ b/build/dashboard/tests/service/test_node_health.py @@ -3,6 +3,7 @@ class _Clock: """Manually advanced monotonic clock for deterministic debounce tests.""" + def __init__(self): self.t = 1000.0 @@ -15,16 +16,18 @@ def advance(self, secs): def _monitor(down_after=90, recovery_after=60): clock = _Clock() - return NodeHealthMonitor(down_after=down_after, recovery_after=recovery_after, clock=clock), clock + return NodeHealthMonitor( + down_after=down_after, recovery_after=recovery_after, clock=clock + ), clock class TestDebounceToDown: def test_not_down_before_threshold(self): m, clock = _monitor() - m.update(True) # establish ever_up - m.update(False) # outage begins + m.update(True) # establish ever_up + m.update(False) # outage begins clock.advance(89) - assert m.update(False) is False # still within debounce window + assert m.update(False) is False # still within debounce window def test_down_after_threshold(self): m, clock = _monitor() @@ -36,8 +39,10 @@ def test_down_after_threshold(self): def test_single_blip_does_not_trip(self): m, clock = _monitor() m.update(True) - clock.advance(5); m.update(False) # brief blip - clock.advance(5); assert m.update(True) is False # recovered well before 90s + clock.advance(5) + m.update(False) # brief blip + clock.advance(5) + assert m.update(True) is False # recovered well before 90s assert m.down is False @@ -56,14 +61,18 @@ class TestRecoveryHysteresis: def test_down_clears_only_after_recovery_window(self): m, clock = _monitor() m.update(True) - m.update(False); clock.advance(90); m.update(False) # now DOWN + m.update(False) + clock.advance(90) + m.update(False) # now DOWN assert m.down is True # Node returns, but DOWN holds until reachable for recovery_after. m.update(True) assert m.down is True and m.healthy is False - clock.advance(59); m.update(True) + clock.advance(59) + m.update(True) assert m.down is True - clock.advance(1); m.update(True) + clock.advance(1) + m.update(True) assert m.down is False and m.healthy is True def test_healthy_requires_stable_window_from_unknown(self): @@ -71,7 +80,7 @@ def test_healthy_requires_stable_window_from_unknown(self): # observed the node reachable for the recovery window. m, clock = _monitor() assert m.update(True) is False - assert m.healthy is False # just came up — not yet confirmed + assert m.healthy is False # just came up — not yet confirmed clock.advance(60) m.update(True) assert m.healthy is True diff --git a/build/dashboard/tests/service/test_storage_service.py b/build/dashboard/tests/service/test_storage_service.py index c57fd55..e59ca13 100644 --- a/build/dashboard/tests/service/test_storage_service.py +++ b/build/dashboard/tests/service/test_storage_service.py @@ -3,8 +3,8 @@ import pytest +from mining_dashboard.config.config import HISTORY_RETENTION_SEC, TIER_DEFAULTS from mining_dashboard.service.storage_service import StateManager -from mining_dashboard.config.config import TIER_DEFAULTS, HISTORY_RETENTION_SEC class TestDefaults: @@ -77,7 +77,7 @@ def test_add_shares_records_count_distinct(self, state_manager): state_manager.add_shares(3, ts, 500) shares = state_manager.get_shares() assert len(shares) == 3 - assert len({s["ts"] for s in shares}) == 3 # all distinct timestamps + assert len({s["ts"] for s in shares}) == 3 # all distinct timestamps assert max(s["ts"] for s in shares) == pytest.approx(ts) # most recent stamped at latest_ts def test_add_shares_count_zero_or_one(self, state_manager): @@ -106,14 +106,16 @@ def test_per_window_splits_persisted(self, state_manager): # The chart's window toggle (#168): each window's (p2pool, xvb) split is stored in its own # column and read back; an omitted window defaults to 0. state_manager.update_history( - 1000, p2pool_hr=1000, xvb_hr=0, + 1000, + p2pool_hr=1000, + xvb_hr=0, windows={"1m": (900, 0), "1h": (1100, 0), "12h": (50, 0)}, # 24h intentionally omitted ) row = state_manager.get_history()[-1] assert (row["v_p2pool_1m"], row["v_xvb_1m"]) == (900, 0) assert (row["v_p2pool_1h"], row["v_xvb_1h"]) == (1100, 0) assert (row["v_p2pool_12h"], row["v_xvb_12h"]) == (50, 0) - assert (row["v_p2pool_24h"], row["v_xvb_24h"]) == (0, 0) # omitted -> default 0 + assert (row["v_p2pool_24h"], row["v_xvb_24h"]) == (0, 0) # omitted -> default 0 def test_per_window_splits_survive_reload(self, tmp_path): # Persisted to disk and re-read on a fresh StateManager (load() path), not just in-memory. @@ -140,7 +142,7 @@ def test_unhealthy_after_write_error(self): sm = StateManager(":memory:") assert sm.is_db_healthy() is True sm._conn.execute("DROP TABLE shares") - sm.add_share(time.time(), 500) # INSERT fails -> caught -> flag flips + sm.add_share(time.time(), 500) # INSERT fails -> caught -> flag flips assert sm.is_db_healthy() is False sm.close() @@ -163,19 +165,39 @@ def test_share_stats_persist_across_instances(self, tmp_path): # DataService restores on init). Save with one instance, read back with a fresh one. db = str(tmp_path / "state.db") sm1 = StateManager(db_path=db) - sm1.save_snapshot({ - "workers": [{"name": "rig1", "ip": "10.0.0.1", "status": "online", - "accepted": 1234, "rejected": 5, "invalid": 0}], - "proxy_summary": {"accepted": 12345, "rejected": 67, "invalid": 2, - "expired": 1, "best": 9876543}, - }) + sm1.save_snapshot( + { + "workers": [ + { + "name": "rig1", + "ip": "10.0.0.1", + "status": "online", + "accepted": 1234, + "rejected": 5, + "invalid": 0, + } + ], + "proxy_summary": { + "accepted": 12345, + "rejected": 67, + "invalid": 2, + "expired": 1, + "best": 9876543, + }, + } + ) sm1.close() snap = StateManager(db_path=db).load_snapshot() # fresh instance -> reads from disk assert snap["workers"][0]["accepted"] == 1234 assert snap["workers"][0]["rejected"] == 5 - assert snap["proxy_summary"] == {"accepted": 12345, "rejected": 67, "invalid": 2, - "expired": 1, "best": 9876543} + assert snap["proxy_summary"] == { + "accepted": 12345, + "rejected": 67, + "invalid": 2, + "expired": 1, + "best": 9876543, + } class TestPersistenceAndMigration: @@ -262,20 +284,32 @@ def test_per_window_columns_added_on_upgrade(self, tmp_path): now = time.time() t1 = time.strftime("%Y-%m-%d %H:%M:%S", time.gmtime(now - 3600)) conn = sqlite3.connect(db) - conn.execute("CREATE TABLE history (t TEXT, v REAL, v_p2pool REAL, v_xvb REAL, timestamp REAL)") - conn.execute("INSERT INTO history VALUES (?, ?, ?, ?, ?)", (t1, 800.0, 800.0, 0.0, now - 3600)) + conn.execute( + "CREATE TABLE history (t TEXT, v REAL, v_p2pool REAL, v_xvb REAL, timestamp REAL)" + ) + conn.execute( + "INSERT INTO history VALUES (?, ?, ?, ?, ?)", (t1, 800.0, 800.0, 0.0, now - 3600) + ) conn.commit() conn.close() sm = StateManager(db_path=db) try: cols = {info[1] for info in sm._conn.execute("PRAGMA table_info(history)").fetchall()} - for c in ("v_p2pool_1m", "v_xvb_1m", "v_p2pool_1h", "v_xvb_1h", - "v_p2pool_12h", "v_xvb_12h", "v_p2pool_24h", "v_xvb_24h"): + for c in ( + "v_p2pool_1m", + "v_xvb_1m", + "v_p2pool_1h", + "v_xvb_1h", + "v_p2pool_12h", + "v_xvb_12h", + "v_p2pool_24h", + "v_xvb_24h", + ): assert c in cols, f"migration missing {c}" old = sm.get_history()[-1] - assert old["v_p2pool"] == 800.0 # original 10m split preserved - assert old["v_p2pool_1h"] == 0 # forward-only: no per-window data pre-#168 + assert old["v_p2pool"] == 800.0 # original 10m split preserved + assert old["v_p2pool_1h"] == 0 # forward-only: no per-window data pre-#168 # a new write after the upgrade fills the per-window columns sm.update_history(900, p2pool_hr=900, xvb_hr=0, windows={"1h": (950, 0)}) assert sm.get_history()[-1]["v_p2pool_1h"] == 950 @@ -289,7 +323,9 @@ def test_orphaned_workers_table_dropped_on_upgrade(self, tmp_path): # xmrig-proxy, never from the DB. Also asserts the in-memory state key is gone. db = str(tmp_path / "with_workers.db") conn = sqlite3.connect(db) - conn.execute("CREATE TABLE history (t TEXT, v REAL, v_p2pool REAL, v_xvb REAL, timestamp REAL)") + conn.execute( + "CREATE TABLE history (t TEXT, v REAL, v_p2pool REAL, v_xvb REAL, timestamp REAL)" + ) conn.execute("CREATE TABLE workers (name TEXT PRIMARY KEY, ip TEXT, last_seen REAL)") conn.execute("INSERT INTO workers VALUES (?, ?, ?)", ("rig1", "10.0.0.1", 123.0)) conn.commit() @@ -297,8 +333,12 @@ def test_orphaned_workers_table_dropped_on_upgrade(self, tmp_path): sm = StateManager(db_path=db) # __init__ runs _migrate_db -> DROP TABLE IF EXISTS workers try: - tables = {r[0] for r in sm._conn.execute( - "SELECT name FROM sqlite_master WHERE type='table'").fetchall()} + tables = { + r[0] + for r in sm._conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + } assert "workers" not in tables, "orphaned workers table dropped (#144)" assert "known_workers" not in sm.state, "dead known_workers state key removed (#144)" assert {"history", "kv_store", "shares"} <= tables, "core tables left intact" @@ -313,10 +353,15 @@ class TestRetention: def test_history_older_than_retention_pruned_from_memory(self, state_manager): # Intent: appending a fresh sample drops in-memory points older than the 30-day window # (the popleft loop), so the deque can't grow without bound on a long-running dashboard. - state_manager.state["hashrate_history"].append({ - "t": "old", "v": 1.0, "v_p2pool": 0, "v_xvb": 0, - "timestamp": time.time() - HISTORY_RETENTION_SEC - 3600, # 30d + 1h ago - }) + state_manager.state["hashrate_history"].append( + { + "t": "old", + "v": 1.0, + "v_p2pool": 0, + "v_xvb": 0, + "timestamp": time.time() - HISTORY_RETENTION_SEC - 3600, # 30d + 1h ago + } + ) assert len(state_manager.get_history()) == 1 state_manager.update_history(2000.0) # a fresh sample at "now" hist = state_manager.get_history() @@ -329,12 +374,14 @@ def test_old_history_pruned_from_db_when_cleanup_fires(self, state_manager, monk with state_manager._db_lock: state_manager._conn.execute( "INSERT INTO history (t, v, v_p2pool, v_xvb, timestamp) VALUES (?,?,?,?,?)", - ("old", 1.0, 0, 0, old_ts)) + ("old", 1.0, 0, 0, old_ts), + ) state_manager._conn.commit() monkeypatch.setattr("mining_dashboard.service.storage_service.random.random", lambda: 0.0) state_manager.update_history(2000.0) with state_manager._db_lock: remaining = state_manager._conn.execute( "SELECT COUNT(*) FROM history WHERE timestamp < ?", - (time.time() - HISTORY_RETENTION_SEC,)).fetchone()[0] + (time.time() - HISTORY_RETENTION_SEC,), + ).fetchone()[0] assert remaining == 0, "expired DB rows are pruned" diff --git a/build/dashboard/tests/service/test_update_checker.py b/build/dashboard/tests/service/test_update_checker.py index dd6c843..fb7c716 100644 --- a/build/dashboard/tests/service/test_update_checker.py +++ b/build/dashboard/tests/service/test_update_checker.py @@ -1,10 +1,14 @@ """Tests for the notify-only new-release check (#224).""" + from unittest.mock import MagicMock, patch import requests from mining_dashboard.service.update_checker import ( - parse_semver, compute_update, GitHubReleaseClient, UpdateChecker, + GitHubReleaseClient, + UpdateChecker, + compute_update, + parse_semver, ) @@ -26,11 +30,15 @@ def test_rejects_garbage_partial_and_empty(self): class TestComputeUpdate: def test_newer_returns_payload(self): out = compute_update("0.1.0", "v0.2.0", "https://x/releases/tag/v0.2.0") - assert out == {"available": True, "latest": "v0.2.0", "url": "https://x/releases/tag/v0.2.0"} + assert out == { + "available": True, + "latest": "v0.2.0", + "url": "https://x/releases/tag/v0.2.0", + } def test_equal_or_older_returns_none(self): - assert compute_update("0.2.0", "v0.2.0", "u") is None # equal - assert compute_update("0.2.0", "v0.1.0", "u") is None # older + assert compute_update("0.2.0", "v0.2.0", "u") is None # equal + assert compute_update("0.2.0", "v0.1.0", "u") is None # older def test_unparseable_either_side_returns_none(self): assert compute_update("0.1.0", "nightly", "u") is None @@ -38,7 +46,7 @@ def test_unparseable_either_side_returns_none(self): def test_major_and_minor_ordering(self): assert compute_update("1.9.9", "v2.0.0", "u")["latest"] == "v2.0.0" - assert compute_update("1.2.0", "v1.10.0", "u")["latest"] == "v1.10.0" # 10 > 2, not lexical + assert compute_update("1.2.0", "v1.10.0", "u")["latest"] == "v1.10.0" # 10 > 2, not lexical class TestGitHubReleaseClient: @@ -50,27 +58,38 @@ def _resp(self, status=200, payload=None): def test_parses_tag_and_url(self): c = GitHubReleaseClient("https://api/releases/latest", tor_proxy="socks5h://t:9050") - with patch("mining_dashboard.service.update_checker.requests.get", - return_value=self._resp(200, {"tag_name": "v1.4.0", "html_url": "https://h/v1.4.0"})) as g: + with patch( + "mining_dashboard.service.update_checker.requests.get", + return_value=self._resp(200, {"tag_name": "v1.4.0", "html_url": "https://h/v1.4.0"}), + ) as g: assert c.latest_release() == {"tag": "v1.4.0", "url": "https://h/v1.4.0"} # routed through the Tor proxy - assert g.call_args.kwargs["proxies"] == {"http": "socks5h://t:9050", "https": "socks5h://t:9050"} + assert g.call_args.kwargs["proxies"] == { + "http": "socks5h://t:9050", + "https": "socks5h://t:9050", + } def test_non_200_is_silent_none(self): c = GitHubReleaseClient("u") - with patch("mining_dashboard.service.update_checker.requests.get", return_value=self._resp(404)): + with patch( + "mining_dashboard.service.update_checker.requests.get", return_value=self._resp(404) + ): assert c.latest_release() is None def test_network_error_is_silent_none(self): c = GitHubReleaseClient("u") - with patch("mining_dashboard.service.update_checker.requests.get", - side_effect=requests.RequestException("offline")): + with patch( + "mining_dashboard.service.update_checker.requests.get", + side_effect=requests.RequestException("offline"), + ): assert c.latest_release() is None def test_missing_fields_is_none(self): c = GitHubReleaseClient("u") - with patch("mining_dashboard.service.update_checker.requests.get", - return_value=self._resp(200, {"tag_name": "v1.0.0"})): # no html_url + with patch( + "mining_dashboard.service.update_checker.requests.get", + return_value=self._resp(200, {"tag_name": "v1.0.0"}), + ): # no html_url assert c.latest_release() is None @@ -101,15 +120,15 @@ def test_throttles_to_interval(self): c = _FakeClient({"tag": "v0.2.0", "url": "u"}) uc = UpdateChecker(c, "0.1.0", enabled=True, interval=3600) uc.maybe_check(1000) - uc.maybe_check(1000 + 1800) # within window -> cached, no network + uc.maybe_check(1000 + 1800) # within window -> cached, no network assert c.calls == 1 - uc.maybe_check(1000 + 3601) # past window -> network again + uc.maybe_check(1000 + 3601) # past window -> network again assert c.calls == 2 def test_failed_fetch_keeps_previous_result(self): uc = UpdateChecker(_FakeClient(None), "0.1.0", enabled=True, interval=0) uc.result = {"available": True, "latest": "v0.2.0", "url": "u"} - assert uc.maybe_check(2000)["latest"] == "v0.2.0" # a blip must not drop the badge + assert uc.maybe_check(2000)["latest"] == "v0.2.0" # a blip must not drop the badge def test_up_to_date_yields_none(self): uc = UpdateChecker(_FakeClient({"tag": "v0.1.0", "url": "u"}), "0.1.0", enabled=True) diff --git a/build/dashboard/tests/sim/test_donation_model.py b/build/dashboard/tests/sim/test_donation_model.py index f6136d6..9840450 100644 --- a/build/dashboard/tests/sim/test_donation_model.py +++ b/build/dashboard/tests/sim/test_donation_model.py @@ -9,21 +9,27 @@ tier at minimum waste regardless of how XvB scales the donation, never winds up, keeps p2pool "VIP", and recovers from disturbances. """ + import pytest from mining_dashboard.sim.donation_model import ( - Scenario, CYCLES_PER_DAY, CYCLES_PER_HOUR, - run_algo, + Scenario, build_controller, make_algo_controller, + run_algo, ) # A real-ish p2pool-main sidechain difficulty so the VIP reserve is exercised. DIFFICULTY = 120_000_000 -FIELD = dict(target_hr=10_000, current_hr=46_300, measurement="fixed", - p2pool_difficulty=DIFFICULTY, cycles=3 * CYCLES_PER_DAY) +FIELD = dict( + target_hr=10_000, + current_hr=46_300, + measurement="fixed", + p2pool_difficulty=DIFFICULTY, + cycles=3 * CYCLES_PER_DAY, +) class TestHoldsTierMinimumWaste: @@ -42,9 +48,14 @@ def test_no_windup_from_cold_start(self): @pytest.mark.parametrize("current_hr,expected_p2pool", [(46_300, 0.70), (200_000, 0.90)]) def test_more_headroom_means_more_p2pool(self, current_hr, expected_p2pool): - sc = Scenario(name="hr", target_hr=10_000, current_hr=current_hr, - measurement="fixed", p2pool_difficulty=DIFFICULTY, - cycles=3 * CYCLES_PER_DAY) + sc = Scenario( + name="hr", + target_hr=10_000, + current_hr=current_hr, + measurement="fixed", + p2pool_difficulty=DIFFICULTY, + cycles=3 * CYCLES_PER_DAY, + ) r = run_algo(sc) assert r.tier_held() assert r.p2pool_efficiency >= expected_p2pool @@ -55,9 +66,15 @@ class TestSelfCalibration: @pytest.mark.parametrize("k", [0.8, 1.0, 2.0, 3.0]) def test_holds_tier_across_credit_factor(self, k): - sc = Scenario(name=f"k{k}", target_hr=10_000, current_hr=46_300, - measurement="fixed", credit_factor=k, p2pool_difficulty=DIFFICULTY, - cycles=4 * CYCLES_PER_DAY) + sc = Scenario( + name=f"k{k}", + target_hr=10_000, + current_hr=46_300, + measurement="fixed", + credit_factor=k, + p2pool_difficulty=DIFFICULTY, + cycles=4 * CYCLES_PER_DAY, + ) r = run_algo(sc) assert r.tier_held() assert r.steady_overshoot_24h <= 1.12 # no k-x waste — the loop backs off @@ -65,21 +82,44 @@ def test_holds_tier_across_credit_factor(self, k): def test_overcredit_frees_p2pool(self): """When XvB over-credits, holding the tier needs *less* donation, so more hashrate stays on p2pool — the open-loop designs can't do this.""" - lo = run_algo(Scenario(name="k1", target_hr=10_000, current_hr=46_300, - measurement="fixed", credit_factor=1.0, - p2pool_difficulty=DIFFICULTY, cycles=4 * CYCLES_PER_DAY)) - hi = run_algo(Scenario(name="k3", target_hr=10_000, current_hr=46_300, - measurement="fixed", credit_factor=3.0, - p2pool_difficulty=DIFFICULTY, cycles=4 * CYCLES_PER_DAY)) + lo = run_algo( + Scenario( + name="k1", + target_hr=10_000, + current_hr=46_300, + measurement="fixed", + credit_factor=1.0, + p2pool_difficulty=DIFFICULTY, + cycles=4 * CYCLES_PER_DAY, + ) + ) + hi = run_algo( + Scenario( + name="k3", + target_hr=10_000, + current_hr=46_300, + measurement="fixed", + credit_factor=3.0, + p2pool_difficulty=DIFFICULTY, + cycles=4 * CYCLES_PER_DAY, + ) + ) assert hi.p2pool_efficiency > lo.p2pool_efficiency + 0.05 @pytest.mark.parametrize("semantics", ["fixed", "connected"]) def test_stable_under_lag(self, semantics): """Low gain keeps the integrator stable even with API reporting lag (the harness found that higher gains hunt).""" - sc = Scenario(name="lag", target_hr=10_000, current_hr=46_300, - measurement=semantics, report_lag_cycles=6, credit_factor=2.0, - p2pool_difficulty=DIFFICULTY, cycles=5 * CYCLES_PER_DAY) + sc = Scenario( + name="lag", + target_hr=10_000, + current_hr=46_300, + measurement=semantics, + report_lag_cycles=6, + credit_factor=2.0, + p2pool_difficulty=DIFFICULTY, + cycles=5 * CYCLES_PER_DAY, + ) r = run_algo(sc) assert r.tier_held() last_day = r.credited_1h[-CYCLES_PER_DAY:] @@ -98,9 +138,14 @@ def test_low_tier_high_difficulty_caps_donation_for_vip(self): """With a high sidechain difficulty relative to hashrate, the reserve must bite — donation is capped so p2pool still lands shares.""" # Difficulty needing ~half the rig on p2pool to keep ~2 shares/window. - sc = Scenario(name="tightvip", target_hr=10_000, current_hr=46_300, - measurement="fixed", p2pool_difficulty=300_000_000, - cycles=3 * CYCLES_PER_DAY) + sc = Scenario( + name="tightvip", + target_hr=10_000, + current_hr=46_300, + measurement="fixed", + p2pool_difficulty=300_000_000, + cycles=3 * CYCLES_PER_DAY, + ) r = run_algo(sc) assert r.vip_held() assert max(r.fraction) <= build_controller().max_donation_fraction + 1e-9 @@ -126,10 +171,18 @@ class TestDisturbanceRecovery: def test_recovers_after_worker_drop(self): """A sustained drop below tier capacity knocks the averages down; once hashrate returns the loop ramps back and re-qualifies by the end.""" - sc = Scenario(name="drop", target_hr=10_000, current_hr=46_300, warm_avg=10_300, - measurement="fixed", p2pool_difficulty=DIFFICULTY, - cycles=4 * CYCLES_PER_DAY, - drop_at=CYCLES_PER_DAY, drop_until=CYCLES_PER_DAY + 18, drop_factor=0.2) + sc = Scenario( + name="drop", + target_hr=10_000, + current_hr=46_300, + warm_avg=10_300, + measurement="fixed", + p2pool_difficulty=DIFFICULTY, + cycles=4 * CYCLES_PER_DAY, + drop_at=CYCLES_PER_DAY, + drop_until=CYCLES_PER_DAY + 18, + drop_factor=0.2, + ) r = run_algo(sc) # Re-qualified on the 1h average by the final cycles (24h lags a full day). assert min(r.credited_1h[-CYCLES_PER_HOUR:]) >= sc.target_hr * 0.98 diff --git a/build/dashboard/tests/test_version.py b/build/dashboard/tests/test_version.py index 9113aee..b2108fa 100644 --- a/build/dashboard/tests/test_version.py +++ b/build/dashboard/tests/test_version.py @@ -4,6 +4,7 @@ badge renders. A clean release must show ``vX.Y.Z``; anything else must show an unmistakable dev indicator (branch + short hash) so a working-tree build is never read as a release. """ + from mining_dashboard.version import resolve_version @@ -18,25 +19,40 @@ def test_leading_v_in_version_is_not_doubled(self): def test_explicit_release_flag_wins_over_git_metadata(self): # The (future) release pipeline can force a release even with git info present. - info = resolve_version({ - "PITHEAD_VERSION": "1.3.0", "PITHEAD_RELEASE": "1", - "PITHEAD_GIT_BRANCH": "main", "PITHEAD_GIT_COMMIT": "a1b2c3d", - }) + info = resolve_version( + { + "PITHEAD_VERSION": "1.3.0", + "PITHEAD_RELEASE": "1", + "PITHEAD_GIT_BRANCH": "main", + "PITHEAD_GIT_COMMIT": "a1b2c3d", + } + ) assert info["text"] == "v1.3.0" assert info["dev"] is False def test_release_flag_accepts_common_truthy_spellings(self): for val in ("1", "true", "TRUE", "yes", "on"): - assert resolve_version({"PITHEAD_VERSION": "1.0.0", "PITHEAD_RELEASE": val, - "PITHEAD_GIT_COMMIT": "deadbee"})["dev"] is False + assert ( + resolve_version( + { + "PITHEAD_VERSION": "1.0.0", + "PITHEAD_RELEASE": val, + "PITHEAD_GIT_COMMIT": "deadbee", + } + )["dev"] + is False + ) class TestDev: def test_branch_and_commit(self): - info = resolve_version({ - "PITHEAD_VERSION": "1.3.0", - "PITHEAD_GIT_BRANCH": "feature/x", "PITHEAD_GIT_COMMIT": "a1b2c3d", - }) + info = resolve_version( + { + "PITHEAD_VERSION": "1.3.0", + "PITHEAD_GIT_BRANCH": "feature/x", + "PITHEAD_GIT_COMMIT": "a1b2c3d", + } + ) assert info["text"] == "dev · feature/x @ a1b2c3d" assert info["dev"] is True assert "feature/x" in info["title"] and "a1b2c3d" in info["title"] @@ -49,7 +65,9 @@ def test_branch_only(self): def test_dirty_marker_passes_through(self): # render_env appends -dirty for uncommitted changes; it must reach the badge verbatim. - info = resolve_version({"PITHEAD_GIT_BRANCH": "main", "PITHEAD_GIT_COMMIT": "a1b2c3d-dirty"}) + info = resolve_version( + {"PITHEAD_GIT_BRANCH": "main", "PITHEAD_GIT_COMMIT": "a1b2c3d-dirty"} + ) assert info["text"] == "dev · main @ a1b2c3d-dirty" assert info["dev"] is True @@ -65,6 +83,7 @@ def test_no_metadata_falls_back_to_generic_dev(self): assert info == {"text": "dev build", "title": "Development build", "dev": True} def test_blank_values_treated_as_absent(self): - info = resolve_version({"PITHEAD_VERSION": " ", "PITHEAD_GIT_BRANCH": "", - "PITHEAD_GIT_COMMIT": " "}) + info = resolve_version( + {"PITHEAD_VERSION": " ", "PITHEAD_GIT_BRANCH": "", "PITHEAD_GIT_COMMIT": " "} + ) assert info["text"] == "dev build" diff --git a/build/dashboard/tests/web/test_server.py b/build/dashboard/tests/web/test_server.py index 74dfbc7..078eac9 100644 --- a/build/dashboard/tests/web/test_server.py +++ b/build/dashboard/tests/web/test_server.py @@ -2,9 +2,8 @@ import pytest -from mining_dashboard.web.server import create_app, _apply_security_headers from mining_dashboard.service.storage_service import StateManager - +from mining_dashboard.web.server import _apply_security_headers, create_app SECURITY_HEADERS = [ "X-Content-Type-Options", @@ -43,7 +42,7 @@ async def test_index_serves_shell(self, client): body = await resp.text() # Static shell: no data, just the app mount point + the module entry. assert 'id="app"' in body - assert '/static/dashboard.js' in body + assert "/static/dashboard.js" in body class TestStateApi: @@ -79,12 +78,17 @@ async def test_window_filters_history_end_to_end(self, aiohttp_client): base = 1_000_000 for i in range(20): sm.state["hashrate_history"].append( - {"t": "x", "v": 100, "v_p2pool": 100, "v_xvb": 0, "timestamp": base + i * 60}) - data = {"shares": [], "workers": [], "global_sync": False, - "monero_sync": {"percent": 100, "current": 1, "target": 1}, - "tari_sync": {"percent": 100, "current": 1, "target": 1}} + {"t": "x", "v": 100, "v_p2pool": 100, "v_xvb": 0, "timestamp": base + i * 60} + ) + data = { + "shares": [], + "workers": [], + "global_sync": False, + "monero_sync": {"percent": 100, "current": 1, "target": 1}, + "tari_sync": {"percent": 100, "current": 1, "target": 1}, + } cli = await aiohttp_client(create_app(sm, data)) - resp = await cli.get(f"/api/state?from={base + 300}&to={base + 540}") # indices 5..9 + resp = await cli.get(f"/api/state?from={base + 300}&to={base + 540}") # indices 5..9 body = await resp.json() sm.close() pts = [p for p in body["chart"]["p2pool"] if p["y"] is not None] @@ -94,7 +98,9 @@ async def test_window_filters_history_end_to_end(self, aiohttp_client): async def test_node_down_badges_in_state(self, aiohttp_client): # When a node is down / workers rejected, the state surfaces it (Issue #31). data = { - "shares": [], "workers": [], "global_sync": False, + "shares": [], + "workers": [], + "global_sync": False, "monero_sync": {"percent": 100, "current": 10, "target": 10, "down": True}, "tari_sync": {"percent": 100, "current": 10, "target": 10, "down": False}, "workers_rejected": True, @@ -110,7 +116,9 @@ async def test_node_down_badges_in_state(self, aiohttp_client): async def test_passive_tari_badge_in_state(self, aiohttp_client): # Non-blocking Tari (Issue #51): operational, with a top-bar "Tari syncing" badge. data = { - "shares": [], "workers": [], "global_sync": False, + "shares": [], + "workers": [], + "global_sync": False, "monero_sync": {"percent": 100, "current": 10, "target": 10}, "tari_sync": {"percent": 42, "current": 42, "target": 100}, "tari_syncing_passive": True, @@ -130,8 +138,8 @@ async def test_state_error_is_sanitized_json(self, aiohttp_client, app_data): assert resp.status == 500 body = await resp.json() assert "error" in body - assert "SECRET internal detail" not in str(body) # no leak - assert "X-Frame-Options" in resp.headers # headers still applied + assert "SECRET internal detail" not in str(body) # no leak + assert "X-Frame-Options" in resp.headers # headers still applied class TestSecurityHeaders: @@ -169,6 +177,7 @@ def test_js_mimetypes_registered(self): # Importing server registers these so .mjs/.js always serve as JS, even on slim # images with no /etc/mime.types (browsers refuse non-JS MIME for modules). import mimetypes + assert "javascript" in (mimetypes.guess_type("app.mjs")[0] or "") assert "javascript" in (mimetypes.guess_type("app.js")[0] or "") diff --git a/build/dashboard/tests/web/test_views.py b/build/dashboard/tests/web/test_views.py index 33b80f0..7cc0667 100644 --- a/build/dashboard/tests/web/test_views.py +++ b/build/dashboard/tests/web/test_views.py @@ -5,6 +5,7 @@ tested in tests/service/test_metrics.py; here we test the *display* mapping (formatting + presentation tokens), the chart series (Issue #65), and the full ``build_state`` contract. """ + import json import time from dataclasses import replace @@ -13,32 +14,71 @@ import pytest import mining_dashboard.web.views as views -from mining_dashboard.web.views import ( - build_chart, build_hashrate, build_pool_network, build_workers, build_tari, - build_system, build_sync, build_badges, build_earnings, build_state, build_raffle_eligibility, get_shell_html, - _mode_palette, parse_window, _target_points, _chart_tension, - build_proxy_summary, _reject_flag, host_display_addr, canonical_window, -) from mining_dashboard.config.config import DEFAULT_HASHRATE_WINDOW, HASHRATE_WINDOWS from mining_dashboard.service.metrics import Metrics, SyncMetric, _sync_metric - +from mining_dashboard.web.views import ( + _chart_tension, + _mode_palette, + _reject_flag, + _target_points, + build_badges, + build_chart, + build_earnings, + build_hashrate, + build_pool_network, + build_proxy_summary, + build_raffle_eligibility, + build_state, + build_sync, + build_system, + build_tari, + build_workers, + canonical_window, + get_shell_html, + host_display_addr, + parse_window, +) # --- Metrics fixtures for the presentation builders ----------------------------------- -_SYNC_DONE = SyncMetric(percent=100, current=10, target=10, remaining=0, - has_target=True, done=True, down=False) +_SYNC_DONE = SyncMetric( + percent=100, current=10, target=10, remaining=0, has_target=True, done=True, down=False +) _BASE = Metrics( - total_h15=10500.0, p2pool_1h=8000.0, p2pool_24h=8100.0, xvb_1h=2100.0, xvb_24h=2300.0, - xvb_routed_1h=2000.0, xvb_routed_24h=2050.0, - stratum_h15=10300.0, stratum_h1h=10400.0, stratum_h24h=10200.0, - mode="P2POOL", xvb_enabled=True, current_tier="Donor (1.00 kH/s+)", - target_tier="Donor (1.00 kH/s+)", target_threshold=1000.0, target_sustainable=True, - low_hr_warning=False, xvb_fail_count=0, xvb_last_update=0, - workers_online=2, workers_total=3, shares_in_window=5, pplns_window=2160, block_time=10, - pool_type="Mini", pool_hashrate=120_000_000.0, pool_difficulty=250_000_000.0, - network_difficulty=380_000_000_000.0, network_height=3210001, - global_syncing=False, monero=_SYNC_DONE, tari=_SYNC_DONE, monero_mode="Unknown", + total_h15=10500.0, + p2pool_1h=8000.0, + p2pool_24h=8100.0, + xvb_1h=2100.0, + xvb_24h=2300.0, + xvb_routed_1h=2000.0, + xvb_routed_24h=2050.0, + stratum_h15=10300.0, + stratum_h1h=10400.0, + stratum_h24h=10200.0, + mode="P2POOL", + xvb_enabled=True, + current_tier="Donor (1.00 kH/s+)", + target_tier="Donor (1.00 kH/s+)", + target_threshold=1000.0, + target_sustainable=True, + low_hr_warning=False, + xvb_fail_count=0, + xvb_last_update=0, + workers_online=2, + workers_total=3, + shares_in_window=5, + pplns_window=2160, + block_time=10, + pool_type="Mini", + pool_hashrate=120_000_000.0, + pool_difficulty=250_000_000.0, + network_difficulty=380_000_000_000.0, + network_height=3210001, + global_syncing=False, + monero=_SYNC_DONE, + tari=_SYNC_DONE, + monero_mode="Unknown", tari_mining=True, ) @@ -58,28 +98,42 @@ def _hashrate(metrics): # --- Chart (Issue #65: real-time x-axis, outage gaps as breaks) ----------------------- + class TestChart: def _line(self, n, start_ts, step=30): - return [{"timestamp": start_ts + i * step, "v": 100 + i, "v_p2pool": 100 + i, - "v_xvb": 0, "t": "x"} for i in range(n)] + return [ + { + "timestamp": start_ts + i * step, + "v": 100 + i, + "v_p2pool": 100 + i, + "v_xvb": 0, + "t": "x", + } + for i in range(n) + ] def test_point_shape_is_xy_with_epoch_ms(self): - chart = build_chart([{"timestamp": 1000, "v": 800, "v_p2pool": 500, "v_xvb": 300, "t": "a"}], - [], "all") + chart = build_chart( + [{"timestamp": 1000, "v": 800, "v_p2pool": 500, "v_xvb": 300, "t": "a"}], [], "all" + ) assert chart["p2pool"] == [{"x": 1_000_000, "y": 500}] assert chart["xvb"] == [{"x": 1_000_000, "y": 300}] def test_legacy_rows_attributed_to_p2pool(self): - chart = build_chart([{"timestamp": 1, "v": 800, "v_p2pool": 0, "v_xvb": 0, "t": "a"}], [], "all") + chart = build_chart( + [{"timestamp": 1, "v": 800, "v_p2pool": 0, "v_xvb": 0, "t": "a"}], [], "all" + ) assert chart["p2pool"][0]["y"] == 800 assert chart["xvb"][0]["y"] == 0 def test_range_filtering(self): now = time.time() - history = [{"timestamp": now - 7200, "v": 1, "v_p2pool": 1, "v_xvb": 0, "t": "x"}, - {"timestamp": now - 60, "v": 2, "v_p2pool": 2, "v_xvb": 0, "t": "x"}] + history = [ + {"timestamp": now - 7200, "v": 1, "v_p2pool": 1, "v_xvb": 0, "t": "x"}, + {"timestamp": now - 60, "v": 2, "v_p2pool": 2, "v_xvb": 0, "t": "x"}, + ] chart = build_chart(history, [], "1h") - assert len(chart["p2pool"]) == 1 # the 2h-old point is dropped + assert len(chart["p2pool"]) == 1 # the 2h-old point is dropped def test_downsampling_caps_points(self): now = time.time() @@ -93,12 +147,15 @@ def test_outage_inserts_null_break(self): # 10 regular 30s samples, a 2-hour outage, then 5 more. hist = self._line(10, 1_000_000) t = hist[-1]["timestamp"] + 7200 - hist += [{"timestamp": t + i * 30, "v": 200, "v_p2pool": 200, "v_xvb": 0, "t": "x"} for i in range(5)] + hist += [ + {"timestamp": t + i * 30, "v": 200, "v_p2pool": 200, "v_xvb": 0, "t": "x"} + for i in range(5) + ] chart = build_chart(hist, [], "all") nulls = [p for p in chart["p2pool"] if p["y"] is None] - assert len(nulls) == 1 # exactly one break, in the gap + assert len(nulls) == 1 # exactly one break, in the gap xs = [p["x"] for p in chart["p2pool"]] - assert xs == sorted(xs) # still chronological + assert xs == sorted(xs) # still chronological # both series break at the same place assert sum(1 for p in chart["xvb"] if p["y"] is None) == 1 @@ -119,7 +176,10 @@ def test_break_sits_inside_the_gap(self): # the empty span, making the gap visible) — not at an endpoint. hist = self._line(5, 1_000_000) t = hist[-1]["timestamp"] + 7200 - hist += [{"timestamp": t + i * 30, "v": 200, "v_p2pool": 200, "v_xvb": 0, "t": "x"} for i in range(5)] + hist += [ + {"timestamp": t + i * 30, "v": 200, "v_p2pool": 200, "v_xvb": 0, "t": "x"} + for i in range(5) + ] pts = build_chart(hist, [], "all")["p2pool"] i = next(k for k, p in enumerate(pts) if p["y"] is None) assert pts[i - 1]["x"] < pts[i]["x"] < pts[i + 1]["x"] @@ -135,14 +195,16 @@ def test_downsampled_outage_still_breaks(self): # The real #65 scenario: a long range that gets downsampled, with an outage in it. The # break must survive downsampling (gap detected on the post-downsample timestamps). now = time.time() - hist = self._line(1000, now - 100000, step=30) # dense, will downsample - gap_start = hist[-1]["timestamp"] + 6 * 3600 # 6h outage + hist = self._line(1000, now - 100000, step=30) # dense, will downsample + gap_start = hist[-1]["timestamp"] + 6 * 3600 # 6h outage hist += self._line(1000, gap_start, step=30) chart = build_chart(hist, [], "all") assert any(p["y"] is None for p in chart["p2pool"]) def test_single_point_no_break(self): - chart = build_chart([{"timestamp": 1, "v": 5, "v_p2pool": 5, "v_xvb": 0, "t": "a"}], [], "all") + chart = build_chart( + [{"timestamp": 1, "v": 5, "v_p2pool": 5, "v_xvb": 0, "t": "a"}], [], "all" + ) assert len(chart["p2pool"]) == 1 def test_share_points_sparse_and_top_pinned(self): @@ -152,21 +214,29 @@ def test_share_points_sparse_and_top_pinned(self): {"timestamp": 1000, "v": 500, "v_p2pool": 500, "v_xvb": 0, "t": "a"}, {"timestamp": 1030, "v": 600, "v_p2pool": 600, "v_xvb": 0, "t": "b"}, ] - shares = [{"ts": 1001}, {"ts": 1029}] # one near each sample + shares = [{"ts": 1001}, {"ts": 1029}] # one near each sample pts = build_chart(history, shares, "all")["shares"] assert pts == [ - {"x": 1_000_000, "y": 0.93, "r": 9, "c": 1}, # fixed top position, radius 6+3 + {"x": 1_000_000, "y": 0.93, "r": 9, "c": 1}, # fixed top position, radius 6+3 {"x": 1_030_000, "y": 0.93, "r": 9, "c": 1}, ] def test_share_marker_top_pinned_when_value_zero(self): # Same fixed position even at zero hashrate — the marker stays visible without a floor hack. - pts = build_chart([{"timestamp": 1000, "v": 0, "v_p2pool": 0, "v_xvb": 0, "t": "a"}], - [{"ts": 1000}], "all")["shares"] + pts = build_chart( + [{"timestamp": 1000, "v": 0, "v_p2pool": 0, "v_xvb": 0, "t": "a"}], + [{"ts": 1000}], + "all", + )["shares"] assert pts == [{"x": 1_000_000, "y": 0.93, "r": 9, "c": 1}] def test_no_shares_no_points(self): - assert build_chart([{"timestamp": 1, "v": 5, "v_p2pool": 5, "v_xvb": 0, "t": "a"}], [], "all")["shares"] == [] + assert ( + build_chart([{"timestamp": 1, "v": 5, "v_p2pool": 5, "v_xvb": 0, "t": "a"}], [], "all")[ + "shares" + ] + == [] + ) def test_unknown_range_keeps_everything(self): # An unrecognized range value falls through to "no filtering" (same as 'all'). @@ -181,10 +251,10 @@ def test_empty_history(self): def test_custom_window_filters_both_bounds(self): # A preset bounds only the lower end; a custom window clips BOTH ends. - hist = self._line(10, 1000) # timestamps 1000..1270 (step 30) + hist = self._line(10, 1000) # timestamps 1000..1270 (step 30) chart = build_chart(hist, [], "all", window=(1060, 1150)) xs = [p["x"] for p in chart["p2pool"]] - assert xs == [1060_000, 1090_000, 1120_000, 1150_000] # only ts in [1060, 1150] + assert xs == [1060_000, 1090_000, 1120_000, 1150_000] # only ts in [1060, 1150] def test_window_overrides_range(self): # When both a window and a range are given, the window wins. @@ -195,7 +265,7 @@ def test_window_overrides_range(self): def test_short_window_kept_at_native_resolution(self): # A <=1h window is never downsampled — full 30s detail (the "more detail zoomed in" goal). now = time.time() - hist = self._line(120, now - 119 * 30, step=30) # ~1h of 30s samples, ending now + hist = self._line(120, now - 119 * 30, step=30) # ~1h of 30s samples, ending now chart = build_chart(hist, [], "1h") assert len([p for p in chart["p2pool"] if p["y"] is not None]) == 120 @@ -206,11 +276,11 @@ def test_long_window_downsamples_to_tier(self): assert len([p for p in chart["p2pool"] if p["y"] is not None]) <= 600 def test_target_points_tiers(self): - assert _target_points(3600) == 0 # <= 1h: native - assert _target_points(3601) == 360 # <= 6h - assert _target_points(86400) == 480 # <= 24h - assert _target_points(604800) == 600 # <= 1w - assert _target_points(604801) == 700 # > 1w + assert _target_points(3600) == 0 # <= 1h: native + assert _target_points(3601) == 360 # <= 6h + assert _target_points(86400) == 480 # <= 24h + assert _target_points(604800) == 600 # <= 1w + assert _target_points(604801) == 700 # > 1w assert _target_points(30 * 86400) == 700 # ceiling def test_chart_tension_tiers(self): @@ -227,26 +297,28 @@ def test_stacked_series_sum_to_the_total(self): # Guard that invariant on the emitted points so a future data change can't silently # break the stack (Issue #47). hist = [ - {"timestamp": 1000, "v": 500, "v_p2pool": 500, "v_xvb": 0, "t": "a"}, # P2Pool sample - {"timestamp": 1030, "v": 700, "v_p2pool": 0, "v_xvb": 700, "t": "b"}, # XvB sample + {"timestamp": 1000, "v": 500, "v_p2pool": 500, "v_xvb": 0, "t": "a"}, # P2Pool sample + {"timestamp": 1030, "v": 700, "v_p2pool": 0, "v_xvb": 700, "t": "b"}, # XvB sample {"timestamp": 1060, "v": 600, "v_p2pool": 600, "v_xvb": 0, "t": "c"}, ] chart = build_chart(hist, [], "all") - for p2p, xvb, row in zip(chart["p2pool"], chart["xvb"], hist): - assert p2p["y"] + xvb["y"] == row["v"] # stack top == total at every point + for p2p, xvb, row in zip(chart["p2pool"], chart["xvb"], hist, strict=False): + assert p2p["y"] + xvb["y"] == row["v"] # stack top == total at every point def test_zoom_reveals_more_detail(self): # Core intent (Issue #47): zooming into a sub-window shows finer data than the wide view # of the same history. 8h of dense 30s samples — a ~1h window stays native resolution # while the full 8h downsamples, so the narrow window has more points per hour. now = time.time() - dense = self._line(960, now - 8 * 3600, step=30) # 8h @ 30s + dense = self._line(960, now - 8 * 3600, step=30) # 8h @ 30s wide = build_chart(dense, [], "all", window=(dense[0]["timestamp"], dense[-1]["timestamp"])) - narrow = build_chart(dense, [], "all", window=(dense[-120]["timestamp"], dense[-1]["timestamp"])) + narrow = build_chart( + dense, [], "all", window=(dense[-120]["timestamp"], dense[-1]["timestamp"]) + ) wide_pts = len([p for p in wide["p2pool"] if p["y"] is not None]) narrow_pts = len([p for p in narrow["p2pool"] if p["y"] is not None]) - assert narrow_pts == 120 # 1h window: native, untouched - assert narrow_pts / 1 > wide_pts / 8 # more points per hour zoomed in + assert narrow_pts == 120 # 1h window: native, untouched + assert narrow_pts / 1 > wide_pts / 8 # more points per hour zoomed in def test_all_range_adapts_density_to_data_extent(self): # With "all" (no preset length, no window) the adaptive density keys off the actual data @@ -263,18 +335,37 @@ class TestChartWindow: def _row(self): # One row carrying both the original 10m split and the per-window columns. - return {"timestamp": 1000, "t": "a", "v": 1000, "v_p2pool": 1000, "v_xvb": 0, - "v_p2pool_1m": 900, "v_xvb_1m": 0, "v_p2pool_1h": 1100, "v_xvb_1h": 0, - "v_p2pool_12h": 50, "v_xvb_12h": 0, "v_p2pool_24h": 10, "v_xvb_24h": 0} + return { + "timestamp": 1000, + "t": "a", + "v": 1000, + "v_p2pool": 1000, + "v_xvb": 0, + "v_p2pool_1m": 900, + "v_xvb_1m": 0, + "v_p2pool_1h": 1100, + "v_xvb_1h": 0, + "v_p2pool_12h": 50, + "v_xvb_12h": 0, + "v_p2pool_24h": 10, + "v_xvb_24h": 0, + } def test_default_window_is_10m(self): # No avg_window arg -> the original v_p2pool/v_xvb pair (today's headline series). chart = build_chart([self._row()], [], "all") assert chart["p2pool"][0]["y"] == 1000 - @pytest.mark.parametrize("win,expected", [ - ("1m", 900), ("10m", 1000), ("1h", 1100), ("12h", 50), ("24h", 10), - ]) + @pytest.mark.parametrize( + "win,expected", + [ + ("1m", 900), + ("10m", 1000), + ("1h", 1100), + ("12h", 50), + ("24h", 10), + ], + ) def test_each_window_selects_its_columns(self, win, expected): chart = build_chart([self._row()], [], "all", None, win) assert chart["p2pool"][0]["y"] == expected @@ -298,9 +389,9 @@ def test_downsample_preserves_per_window_columns(self): # downsampler dropped all but v/v_p2pool/v_xvb, so non-default Avg windows read 0 on any # range wide enough to downsample (24h/1w/1mo). base = self._row() - rows = [{**base, "timestamp": i} for i in range(600)] # 600 > target(480) for a 24h span + rows = [{**base, "timestamp": i} for i in range(600)] # 600 > target(480) for a 24h span out = views._downsample_history(rows, 86400) - assert len(out) < len(rows) # actually downsampled + assert len(out) < len(rows) # actually downsampled assert out[0]["v_p2pool_1m"] == 900 and out[-1]["v_p2pool_1m"] == 900 assert out[0]["v_p2pool_1h"] == 1100 and out[0]["v_p2pool_24h"] == 10 @@ -310,23 +401,24 @@ def test_wide_range_keeps_nondefault_avg_nonzero(self): history = [{**base, "timestamp": i * 30} for i in range(600)] chart = build_chart(history, [], "24h", (0, 86400), "1m") ys = [p["y"] for p in chart["p2pool"] if p["y"] is not None] - assert len(chart["p2pool"]) < 600 # downsampled + assert len(chart["p2pool"]) < 600 # downsampled assert ys and all(y == 900 for y in ys) # 1m series preserved (was 0 before the fix) def test_build_state_echoes_selected_window(self): state = build_state(_data(), _state_mgr(history=[self._row()]), "all", None, "1h") assert state["avg_window"] == "1h" assert state["avg_windows"] == HASHRATE_WINDOWS - assert state["chart"]["p2pool"][0]["y"] == 1100 # the 1h column, end to end + assert state["chart"]["p2pool"][0]["y"] == 1100 # the 1h column, end to end def test_build_state_defaults_to_10m(self): state = build_state(_data(), _state_mgr(history=[self._row()]), "all") assert state["avg_window"] == DEFAULT_HASHRATE_WINDOW - assert state["chart"]["p2pool"][0]["y"] == 1000 # the original 10m series + assert state["chart"]["p2pool"][0]["y"] == 1000 # the original 10m series # --- Hashrate / mode / tier formatting ------------------------------------------------ + class TestHashrate: def test_formats_hashrates(self): hr = _hashrate(_metrics(total_h15=10500, p2pool_1h=8000, xvb_1h=2100)) @@ -373,10 +465,15 @@ def test_tiers_and_fail_count_passthrough(self): # --- Sync display state mapping ------------------------------------------------------- + class TestSync: def test_loading_done_syncing_states(self): - m = _metrics(monero=_sync(has_target=False, done=False), - tari=_sync(has_target=True, done=False, percent=40, current=40, target=100, remaining=60)) + m = _metrics( + monero=_sync(has_target=False, done=False), + tari=_sync( + has_target=True, done=False, percent=40, current=40, target=100, remaining=60 + ), + ) sync = build_sync(m, "85.0 GB") assert sync["monero"]["state"] == "loading" assert sync["tari"]["state"] == "syncing" @@ -398,8 +495,11 @@ def test_synced_node_with_no_target_shows_done(self): def test_no_target_but_not_caught_up_is_not_done(self): # The same no-target shape, but NOT caught up, must not read "done". m_loading = _metrics(monero=_sync_metric({})) # no status yet - m_syncing = _metrics(monero=_sync_metric({"is_syncing": True, "reachable": True, - "current": 5, "target": 10, "percent": 50})) + m_syncing = _metrics( + monero=_sync_metric( + {"is_syncing": True, "reachable": True, "current": 5, "target": 10, "percent": 50} + ) + ) assert build_sync(m_loading, "1.0 GB")["monero"]["state"] == "loading" assert build_sync(m_syncing, "1.0 GB")["monero"]["state"] == "syncing" @@ -411,6 +511,7 @@ def test_monero_mode_and_db_passthrough(self): # --- Badges --------------------------------------------------------------------------- + class TestBadges: def _texts(self, badges): return [b["text"] for b in badges] @@ -436,10 +537,18 @@ def test_no_share_badge_when_donating_without_a_share(self): def test_no_share_badge_absent_when_has_share_or_xvb_off(self): # Has a share => no badge; XvB off => raffle moot, no badge. - assert not any("No PPLNS share" in t for t in self._texts( - build_badges({}, _metrics(xvb_enabled=True, shares_in_window=3), "ok"))) - assert not any("No PPLNS share" in t for t in self._texts( - build_badges({}, _metrics(xvb_enabled=False, shares_in_window=0), "ok"))) + assert not any( + "No PPLNS share" in t + for t in self._texts( + build_badges({}, _metrics(xvb_enabled=True, shares_in_window=3), "ok") + ) + ) + assert not any( + "No PPLNS share" in t + for t in self._texts( + build_badges({}, _metrics(xvb_enabled=False, shares_in_window=0), "ok") + ) + ) def test_node_down_and_rejected(self): m = _metrics(monero=_sync(down=True), tari=_sync(down=True)) @@ -452,7 +561,9 @@ def test_miner_held(self): assert "Miner held (sync)" in self._texts(out) def test_passive_tari_with_and_without_percent(self): - with_pct = build_badges({"tari_syncing_passive": True}, _metrics(tari=_sync(percent=42)), "ok") + with_pct = build_badges( + {"tari_syncing_passive": True}, _metrics(tari=_sync(percent=42)), "ok" + ) assert "Tari syncing 42%" in self._texts(with_pct) no_pct = build_badges({"tari_syncing_passive": True}, _metrics(tari=_sync(percent=0)), "ok") assert "Tari syncing" in self._texts(no_pct) @@ -489,14 +600,20 @@ def test_no_disk_badge_when_missing(self): # --- System (presentation thresholds) ------------------------------------------------- + class TestSystem: def test_high_usage_levels_and_fill(self): - s = build_system({"system": { - "disk": {"percent": 95, "used_gb": 90, "total_gb": 100, "percent_str": "95%"}, - "memory": {"percent": 85, "used_gb": 13, "total_gb": 16, "percent_str": "85%"}, - "cpu_percent": "90.0%", "load": "0.5 0.4 0.3", - "hugepages": ["Enabled", "status-ok", "1555/3072"], - }}) + s = build_system( + { + "system": { + "disk": {"percent": 95, "used_gb": 90, "total_gb": 100, "percent_str": "95%"}, + "memory": {"percent": 85, "used_gb": 13, "total_gb": 16, "percent_str": "85%"}, + "cpu_percent": "90.0%", + "load": "0.5 0.4 0.3", + "hugepages": ["Enabled", "status-ok", "1555/3072"], + } + } + ) assert s["disk"]["fill"] == "critical" assert s["disk"]["level"] == "high" assert s["mem"]["level"] == "high" @@ -519,61 +636,137 @@ def test_empty_system_defaults(self): # --- Workers -------------------------------------------------------------------------- + class TestWorkers: def test_pool_tokens(self): - assert build_workers([{"name": "a", "ip": "1.1.1.1", "status": "online", "active_pool": "3333"}])[0]["pool"] == "p2pool" - assert build_workers([{"name": "a", "ip": "1.1.1.1", "status": "online", "active_pool": "3344"}])[0]["pool"] == "xvb" - assert build_workers([{"name": "a", "ip": "1.1.1.1", "status": "online", "active_pool": ""}])[0]["pool"] == "unknown" + assert ( + build_workers( + [{"name": "a", "ip": "1.1.1.1", "status": "online", "active_pool": "3333"}] + )[0]["pool"] + == "p2pool" + ) + assert ( + build_workers( + [{"name": "a", "ip": "1.1.1.1", "status": "online", "active_pool": "3344"}] + )[0]["pool"] + == "xvb" + ) + assert ( + build_workers([{"name": "a", "ip": "1.1.1.1", "status": "online", "active_pool": ""}])[ + 0 + ]["pool"] + == "unknown" + ) def test_formatted_and_raw_fields(self): - row = build_workers([{"name": "r", "ip": "10.0.0.1", "status": "online", - "active_pool": "3333", "uptime": 3600, "h10": 5000, "h60": 5100, "h15": 5200}])[0] + row = build_workers( + [ + { + "name": "r", + "ip": "10.0.0.1", + "status": "online", + "active_pool": "3333", + "uptime": 3600, + "h10": 5000, + "h60": 5100, + "h15": 5200, + } + ] + )[0] assert row["uptime"] == 3600 and row["uptime_str"] assert row["h10"] == 5000 and "kH/s" in row["h10_str"] def test_online_sorted_before_offline(self): - rows = build_workers([ - {"name": "zzz", "ip": "10.0.0.9", "status": "offline", "active_pool": "3333"}, - {"name": "aaa", "ip": "10.0.0.1", "status": "online", "active_pool": "3333"}, - ]) + rows = build_workers( + [ + {"name": "zzz", "ip": "10.0.0.9", "status": "offline", "active_pool": "3333"}, + {"name": "aaa", "ip": "10.0.0.1", "status": "online", "active_pool": "3333"}, + ] + ) assert [r["name"] for r in rows] == ["aaa", "zzz"] def test_malformed_worker_skipped(self): - rows = build_workers([ - {"name": "good", "ip": "10.0.0.1", "status": "online", "active_pool": "3333"}, - {"name": "skipme", "status": "online", "active_pool": "3333"}, # no 'ip' - ]) + rows = build_workers( + [ + {"name": "good", "ip": "10.0.0.1", "status": "online", "active_pool": "3333"}, + {"name": "skipme", "status": "online", "active_pool": "3333"}, # no 'ip' + ] + ) assert [r["name"] for r in rows] == ["good"] def test_bad_ip_sorts_to_zero(self): - assert build_workers([{"name": "r", "ip": "nope", "status": "online", "active_pool": "3333"}])[0]["ip_sort"] == 0 + assert ( + build_workers([{"name": "r", "ip": "nope", "status": "online", "active_pool": "3333"}])[ + 0 + ]["ip_sort"] + == 0 + ) def test_name_passthrough(self): # Raw name as data; the client text-escapes it on render. - assert build_workers([{"name": "", "ip": "1.1.1.1", "status": "online", "active_pool": "3333"}])[0]["name"] == "" + assert ( + build_workers( + [{"name": "", "ip": "1.1.1.1", "status": "online", "active_pool": "3333"}] + )[0]["name"] + == "" + ) def test_share_counts_raw_and_formatted(self): # Per-worker accepted/rejected/invalid: raw counts (sort keys) + display strings (#82). - row = build_workers([{"name": "r", "ip": "10.0.0.1", "status": "online", "active_pool": "3333", - "accepted": 1234, "rejected": 5, "invalid": 0}])[0] + row = build_workers( + [ + { + "name": "r", + "ip": "10.0.0.1", + "status": "online", + "active_pool": "3333", + "accepted": 1234, + "rejected": 5, + "invalid": 0, + } + ] + )[0] assert row["accepted"] == 1234 and row["accepted_str"] == "1,234" assert row["rejected"] == 5 and row["rejected_str"] == "5" assert row["invalid"] == 0 def test_invalid_appended_to_rejected_string_only_when_nonzero(self): - with_inv = build_workers([{"name": "r", "ip": "1.1.1.1", "status": "online", - "active_pool": "3333", "rejected": 3, "invalid": 2}])[0] + with_inv = build_workers( + [ + { + "name": "r", + "ip": "1.1.1.1", + "status": "online", + "active_pool": "3333", + "rejected": 3, + "invalid": 2, + } + ] + )[0] assert with_inv["rejected_str"] == "3 (+2 inv)" def test_missing_share_fields_default_to_zero(self): # Workers restored from an old snapshot (pre-#82) lack the share fields entirely. - row = build_workers([{"name": "r", "ip": "1.1.1.1", "status": "online", "active_pool": "3333"}])[0] + row = build_workers( + [{"name": "r", "ip": "1.1.1.1", "status": "online", "active_pool": "3333"}] + )[0] assert (row["accepted"], row["rejected"], row["invalid"]) == (0, 0, 0) assert row["reject_flag"] is None def test_reject_flag_set_on_high_reject_rate(self): - row = build_workers([{"name": "r", "ip": "1.1.1.1", "status": "online", "active_pool": "3333", - "accepted": 90, "rejected": 10, "invalid": 0}])[0] + row = build_workers( + [ + { + "name": "r", + "ip": "1.1.1.1", + "status": "online", + "active_pool": "3333", + "accepted": 90, + "rejected": 10, + "invalid": 0, + } + ] + )[0] assert row["reject_flag"] and row["reject_flag"]["text"] == "⚠" assert "10.0%" in row["reject_flag"]["title"] @@ -586,14 +779,14 @@ def test_none_without_rejects(self): def test_none_below_noise_floor(self): # A couple of rejects out of a few shares is noise, even at a high rate. - assert _reject_flag(2, 1) is None # 33% but only 1 reject - assert _reject_flag(0, 2) is None # 100% but below the 3-reject floor + assert _reject_flag(2, 1) is None # 33% but only 1 reject + assert _reject_flag(0, 2) is None # 100% but below the 3-reject floor def test_none_when_rate_low(self): assert _reject_flag(1000, 5) is None # 5 rejects but only 0.5% def test_flags_high_rate_above_floor(self): - flag = _reject_flag(90, 10) # 10% with 10 rejects + flag = _reject_flag(90, 10) # 10% with 10 rejects assert flag["text"] == "⚠" assert "10.0%" in flag["title"] and "10 rejected" in flag["title"] @@ -604,10 +797,21 @@ def test_flags_all_rejects_at_floor(self): # --- Tari ----------------------------------------------------------------------------- + class TestTari: def test_active(self): - t = build_tari({"tari": {"active": True, "status": "Mining", "reward": 12.5, "height": 42, - "difficulty": 1234567, "address": "addr"}}) + t = build_tari( + { + "tari": { + "active": True, + "status": "Mining", + "reward": 12.5, + "height": 42, + "difficulty": 1234567, + "address": "addr", + } + } + ) assert t["active"] is True assert t["status"] == "Mining" assert t["reward"] == "12.50 TARI" @@ -624,10 +828,20 @@ def test_long_wallet_shortened(self): # --- Proxy summary (Issue #82) -------------------------------------------------------- + class TestProxySummary: def test_formats_totals_and_best(self): - ps = build_proxy_summary({"proxy_summary": { - "accepted": 12345, "rejected": 67, "invalid": 2, "expired": 1, "best": 9876543}}) + ps = build_proxy_summary( + { + "proxy_summary": { + "accepted": 12345, + "rejected": 67, + "invalid": 2, + "expired": 1, + "best": 9876543, + } + } + ) assert ps["accepted"] == "12,345" assert ps["rejected"] == "67" assert ps["invalid"] == "2" @@ -655,17 +869,32 @@ def test_empty_summary_has_no_data(self): # --- pool/network passthrough --------------------------------------------------------- + class TestPoolNetwork: def test_formats_from_metrics_and_data(self): data = { - "stratum": {"hashrate_15m": 0, "shares_found": 5, "shares_failed": 1, "wallet": "W" * 40}, + "stratum": { + "hashrate_15m": 0, + "shares_found": 5, + "shares_failed": 1, + "wallet": "W" * 40, + }, "pool": {"pool": {"sidechain_height": 100}}, "network": {"reward": 600_000_000_000, "hash": "abc", "timestamp": 0}, "monero_sync": {"db_size": 85_000_000_000}, } - pn = build_pool_network(data, _metrics(pool_hashrate=120_000_000, pool_difficulty=250_000_000, - network_difficulty=380_000_000_000, network_height=42, - pplns_window=2160, block_time=10, monero_mode="Pruned")) + pn = build_pool_network( + data, + _metrics( + pool_hashrate=120_000_000, + pool_difficulty=250_000_000, + network_difficulty=380_000_000_000, + network_height=42, + pplns_window=2160, + block_time=10, + monero_mode="Pruned", + ), + ) assert pn["pool"]["hr"] == "120.00 MH/s" assert pn["pool"]["diff"] == "250.00 M" assert pn["network"]["diff"] == "380.00 G" @@ -673,7 +902,7 @@ def test_formats_from_metrics_and_data(self): assert pn["stratum"]["shares"] == "5 / 1" assert pn["monero"]["mode"] == "Pruned" assert pn["monero"]["db_size"] == "85.0 GB" - assert pn["shares_window"]["count"] == 5 # from _BASE metrics + assert pn["shares_window"]["count"] == 5 # from _BASE metrics assert pn["shares_window"]["ok"] is True def test_db_size_dash_when_unknown(self): @@ -683,6 +912,7 @@ def test_db_size_dash_when_unknown(self): # --- Host address beside the hostname (Issue #119) ------------------------------------ + class TestHostDisplayAddr: def test_resolves_ip_for_a_hostname(self): with patch.object(views, "detect_host_ipv4", return_value="192.168.1.42"): @@ -705,15 +935,19 @@ def test_none_when_detected_ip_equals_host(self): # --- Earnings calculator (Issue #12) -------------------------------------------------- + class TestEarnings: - _NET = {"network": {"reward": 600_000_000_000}} # 0.6 XMR block reward (atomic units) + _NET = {"network": {"reward": 600_000_000_000}} # 0.6 XMR block reward (atomic units) def test_publishes_rate_and_inputs(self): # The server sends the daily XMR-per-H/s *rate* + the raw inputs the client scales/inverts # (the P2Pool hashrate, P2Pool share difficulty) — not pre-formatted earnings. - e = build_earnings(self._NET, _metrics(p2pool_1h=10500, - network_difficulty=400_000_000_000, - pool_difficulty=250_000_000)) + e = build_earnings( + self._NET, + _metrics( + p2pool_1h=10500, network_difficulty=400_000_000_000, pool_difficulty=250_000_000 + ), + ) assert e["available"] is True assert e["p2pool_hr"] == 10500 assert e["p2pool_hr_str"] == "10.50 kH/s" @@ -731,8 +965,10 @@ def test_default_hashrate_is_the_displayed_p2pool_1h(self): # (and its display string) matches build_hashrate's "p2p_1h" exactly. m = _metrics(total_h15=46_300, xvb_routed_1h=10_000, p2pool_1h=35_000) e = build_earnings(self._NET, m) - assert e["p2pool_hr"] == 35_000 # p2pool_1h, independent of total/routed - assert e["p2pool_hr_str"] == _hashrate(m)["p2p_1h"] # identical display string to the header + assert e["p2pool_hr"] == 35_000 # p2pool_1h, independent of total/routed + assert ( + e["p2pool_hr_str"] == _hashrate(m)["p2p_1h"] + ) # identical display string to the header def test_no_p2pool_hashrate_when_average_is_zero(self): # E.g. fresh start (no history) or full-XvB: p2pool_1h is 0 -> client shows 0 / "—" (honest). @@ -760,6 +996,7 @@ def test_p2pool_hr_passthrough_is_raw(self): # --- build_state integration ---------------------------------------------------------- + def _state_mgr(history=None, mode="P2POOL"): sm = MagicMock() sm.get_history.return_value = history or [] @@ -771,7 +1008,10 @@ def _state_mgr(history=None, mode="P2POOL"): def _data(**over): data = { - "shares": [], "workers": [], "global_sync": False, "total_live_h15": 0, + "shares": [], + "workers": [], + "global_sync": False, + "total_live_h15": 0, "monero_sync": {"percent": 100, "current": 10, "target": 10}, "tari_sync": {"percent": 50, "current": 5, "target": 10}, } @@ -782,10 +1022,31 @@ def _data(**over): class TestBuildState: def test_has_all_sections(self): st = build_state(_data(), _state_mgr(), "all") - for key in ("syncing", "page_title", "host_ip", "host_addr", "version", "last_update", - "range", "window", "badges", "hashrate", "system", "sync", "stratum", "pool", - "network", "monero", "shares_window", "proxy_workers", "earnings", "tari", - "workers", "proxy_summary", "chart"): + for key in ( + "syncing", + "page_title", + "host_ip", + "host_addr", + "version", + "last_update", + "range", + "window", + "badges", + "hashrate", + "system", + "sync", + "stratum", + "pool", + "network", + "monero", + "shares_window", + "proxy_workers", + "earnings", + "tari", + "workers", + "proxy_summary", + "chart", + ): assert key in st, f"missing section: {key}" def test_version_section_shape(self): @@ -836,13 +1097,19 @@ def test_syncing_flag_and_title(self): assert st["page_title"] == "Mining Dashboard - Syncing" def test_proxy_workers_from_metrics(self): - data = _data(workers=[{"name": "a", "ip": "1.1.1.1", "status": "online", "active_pool": "3333"}, - {"name": "b", "ip": "1.1.1.2", "status": "offline", "active_pool": "3333"}]) + data = _data( + workers=[ + {"name": "a", "ip": "1.1.1.1", "status": "online", "active_pool": "3333"}, + {"name": "b", "ip": "1.1.1.2", "status": "offline", "active_pool": "3333"}, + ] + ) assert build_state(data, _state_mgr(), "all")["proxy_workers"] == 1 def test_chart_uses_timestamps(self): - history = [{"timestamp": 100, "v": 500, "v_p2pool": 500, "v_xvb": 0, "t": "a"}, - {"timestamp": 160, "v": 600, "v_p2pool": 300, "v_xvb": 300, "t": "b"}] + history = [ + {"timestamp": 100, "v": 500, "v_p2pool": 500, "v_xvb": 0, "t": "a"}, + {"timestamp": 160, "v": 600, "v_p2pool": 300, "v_xvb": 300, "t": "b"}, + ] chart = build_state(_data(), _state_mgr(history=history), "all")["chart"] assert chart["p2pool"] == [{"x": 100_000, "y": 500}, {"x": 160_000, "y": 300}] assert chart["xvb"][1] == {"x": 160_000, "y": 300} @@ -862,14 +1129,17 @@ def test_absent_is_none(self): assert parse_window(None, None) is None assert parse_window("1000", None) is None - @pytest.mark.parametrize("frm,to", [ - ("bad", "2000"), # non-numeric - ("2000", "1000"), # from >= to - ("1000", "1000"), # zero-width - ("-5", "2000"), # non-positive - ("nan", "2000"), # not finite - ("inf", "2000"), - ]) + @pytest.mark.parametrize( + "frm,to", + [ + ("bad", "2000"), # non-numeric + ("2000", "1000"), # from >= to + ("1000", "1000"), # zero-width + ("-5", "2000"), # non-positive + ("nan", "2000"), # not finite + ("inf", "2000"), + ], + ) def test_malformed_falls_back_to_none(self, frm, to): assert parse_window(frm, to) is None @@ -878,13 +1148,12 @@ class TestShell: def test_returns_html_referencing_module(self): shell = get_shell_html() assert "" in shell - assert '/static/dashboard.js' in shell + assert "/static/dashboard.js" in shell assert 'id="app"' in shell def test_error_fallback(self, monkeypatch): views._SHELL_CACHE = None - monkeypatch.setattr(views.os.path, "getmtime", - lambda p: (_ for _ in ()).throw(OSError())) + monkeypatch.setattr(views.os.path, "getmtime", lambda p: (_ for _ in ()).throw(OSError())) assert get_shell_html() == "

Dashboard shell error

" @@ -906,4 +1175,8 @@ def test_no_when_in_tier_but_no_share(self): def test_na_when_xvb_off(self): m = _metrics(xvb_enabled=False, current_tier="Donor (1.00 kH/s+)", shares_in_window=5) - assert build_raffle_eligibility(m) == {"applies": False, "eligible": False, "label": "N/A (XvB off)"} + assert build_raffle_eligibility(m) == { + "applies": False, + "eligible": False, + "label": "N/A (XvB off)", + } diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..a7f01b2 --- /dev/null +++ b/ruff.toml @@ -0,0 +1,14 @@ +# Repo-root ruff config (Wave 7 tooling, #280). +# +# The dashboard package keeps the canonical ruff rules in build/dashboard/pyproject.toml; this +# root config `extend`s them so that Python *outside* the package — the integration-test fakes +# under tests/integration/ — is linted with the SAME rules, and so `ruff`/pre-commit can discover +# a config from anywhere in the repo (per-file discovery: dashboard files resolve to the package +# config, repo-level files resolve here). +extend = "build/dashboard/pyproject.toml" +extend-exclude = [".venv"] + +[lint.per-file-ignores] +# The contract-test fakes are test infrastructure: bare asserts (S101) and dummy creds (S106) +# are expected, as is a broad pytest.raises (B017) and os.path use in async bodies (ASYNC240). +"tests/integration/fakes/**" = ["S101", "S106", "B017", "ASYNC240"] diff --git a/tests/integration/fakes/fake_monerod.py b/tests/integration/fakes/fake_monerod.py index 82d3db7..1682b84 100644 --- a/tests/integration/fakes/fake_monerod.py +++ b/tests/integration/fakes/fake_monerod.py @@ -20,6 +20,7 @@ m.set(mode="syncing", height=1500, target_height=3000) ...point a real MoneroClient at m.url... """ + import argparse import json import threading @@ -60,8 +61,10 @@ def do_GET(self): # "busy" → RPC answers HTTP 200 but reports a non-OK status (e.g. mid-reorg). The # client must distrust the heights and treat it as unreachable, not as synced. if st["mode"] == "busy": - self._send(200, {"status": "BUSY", "height": st["height"], - "target_height": st["target_height"]}) + self._send( + 200, + {"status": "BUSY", "height": st["height"], "target_height": st["target_height"]}, + ) return if st["mode"] == "syncing": payload = { @@ -132,8 +135,12 @@ def main(): ap = argparse.ArgumentParser(description="Controllable fake monerod") ap.add_argument("--port", type=int, default=18081) ap.add_argument("--host", default="0.0.0.0") # noqa: S104 — test-only container - ap.add_argument("--mode", default="synced", choices=["synced", "syncing", "down"], - help="initial state (the mini-stack boots 'syncing' to exercise the hold)") + ap.add_argument( + "--mode", + default="synced", + choices=["synced", "syncing", "down"], + help="initial state (the mini-stack boots 'syncing' to exercise the hold)", + ) args = ap.parse_args() state = dict(DEFAULT_STATE, mode=args.mode) # "syncing" needs height < target_height to read as syncing (else it looks caught up). diff --git a/tests/integration/fakes/fake_tari.py b/tests/integration/fakes/fake_tari.py index 3f913d5..40ccc28 100644 --- a/tests/integration/fakes/fake_tari.py +++ b/tests/integration/fakes/fake_tari.py @@ -16,6 +16,7 @@ Use in-process (the contract test) via start_server(). """ + import argparse import asyncio import json @@ -117,8 +118,12 @@ def main(): ap = argparse.ArgumentParser(description="Controllable fake Tari base node") ap.add_argument("--grpc-port", type=int, default=18142) ap.add_argument("--control-port", type=int, default=18152) - ap.add_argument("--mode", default="synced", choices=["synced", "syncing", "down"], - help="initial state (the mini-stack boots 'syncing' to exercise the hold)") + ap.add_argument( + "--mode", + default="synced", + choices=["synced", "syncing", "down"], + help="initial state (the mini-stack boots 'syncing' to exercise the hold)", + ) args = ap.parse_args() state = dict(DEFAULT_STATE, mode=args.mode) if args.mode == "syncing" and state["height"] >= state["target_height"]: diff --git a/tests/integration/fakes/test_contract.py b/tests/integration/fakes/test_contract.py index 019f829..42bd459 100644 --- a/tests/integration/fakes/test_contract.py +++ b/tests/integration/fakes/test_contract.py @@ -8,12 +8,13 @@ Run: PYTHONPATH=build/dashboard python3 -m pytest tests/integration/fakes -q """ + import asyncio import pathlib import sys +from unittest.mock import MagicMock import requests -from unittest.mock import MagicMock _HERE = pathlib.Path(__file__).resolve().parent _REPO = _HERE.parents[2] @@ -23,6 +24,7 @@ from fake_monerod import FakeMonerod # noqa: E402 from fake_tari import start_server # noqa: E402 + from mining_dashboard.client.monero.monero_client import MoneroClient # noqa: E402 from mining_dashboard.client.tari.tari_client import TariClient # noqa: E402 @@ -76,7 +78,11 @@ def test_monero_db_size_unknown_reads_zero(): def test_monero_http_control_mutates_state(): # Validates the /control path the docker mini-stack drives over the network. with FakeMonerod() as m: - requests.post(m.url + "/control", json={"mode": "syncing", "height": 10, "target_height": 100}, timeout=5) + requests.post( + m.url + "/control", + json={"mode": "syncing", "height": 10, "target_height": 100}, + timeout=5, + ) info = requests.get(m.url + "/get_info", timeout=5).json() assert info["synchronized"] is False and info["height"] == 10 and info["target_height"] == 100 @@ -127,9 +133,9 @@ async def _impl(): client = TariClient(MagicMock()) client.grpc_address = f"127.0.0.1:{bound}" try: - first = await client.get_sync_status() # live: synced + reachable + first = await client.get_sync_status() # live: synced + reachable state["mode"] = "down" - second = await client.get_sync_status() # cached: last reading, reachable False + second = await client.get_sync_status() # cached: last reading, reachable False return first, second finally: await client.close() From 96b6ad5c711bd8434ea82c76ea691c39d21813b4 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Thu, 18 Jun 2026 01:22:24 -0500 Subject: [PATCH 13/44] build(#283): reproducible Python builds with uv + uv.lock (#297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * build(#283): reproducible Python builds with uv + uv.lock Wave 7 tooling. Adopts uv and commits a hashed uv.lock so the dashboard's dependencies resolve identically across Docker, CI, release, and local — closing the last unpinned supply-chain surface (loose `>=` floors) after the digest-pinned bases (#135) and SHA256-verified binaries. - uv.lock: 42 packages, hash-pinned, generated from pyproject (still the dep source of truth). Dependabot will bump it (#282). - Dockerfile (both stages): uv binary COPYed from the digest-pinned ghcr.io/astral-sh/uv image; `uv sync --locked` (prod) / `--extra test` (test) into /app/.venv on PATH, so entrypoint.sh's `python3` is unchanged. Test/dev extras are excluded from the prod image. - CI, Makefile, and the release path install/run from the lock via `uv run --locked`; ruff lint runs via uv from the locked `dev` extra. - uv installed in CI from the version-pinned installer URL (same posture as the curl-pinned hadolint), not a mutable-tag action. - Last pip reference (Tari stub regen) moved to uvx. - CONTRIBUTING / README / pre-commit docs updated for the uv workflow. Verified locally: Docker test stage runs 532 tests + 94% coverage in-container; prod image imports the app + runtime deps from /app/.venv with no pytest/ruff present; `make test` green (dashboard 532, selftest 97, fakes 12, lint sh+py). Co-Authored-By: Claude Opus 4.8 (1M context) * build(#283): uv best-practice polish (cache mounts, .python-version, drop redundant PYTHONPATH) Follow-up tidy from a best-practices review of the uv adoption: - Dockerfile: swap UV_NO_CACHE for BuildKit cache mounts on the `uv sync` steps (the documented uv-in-Docker pattern) — faster rebuilds, and the cache stays out of the image layers. - Add build/dashboard/.python-version (3.11) so local `uv` defaults to the same interpreter as the digest-pinned Docker base and CI. - Drop the now-redundant PYTHONPATH=build/dashboard from the fakes test (Makefile + CI): uv editable-installs the project into the venv, so the source no longer needs to be on sys.path. Verified the fakes still pass (12). Re-verified: docker test stage (532 tests in-container) + prod stage build with cache mounts; make test-dashboard/test-fakes/lint-py green; uv lock --check clean. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 32 +- .pre-commit-config.yaml | 2 +- CONTRIBUTING.md | 16 +- Makefile | 11 +- build/dashboard/.python-version | 1 + build/dashboard/Dockerfile | 22 +- build/dashboard/README.md | 11 +- .../mining_dashboard/client/tari/Readme.md | 4 +- build/dashboard/uv.lock | 1305 +++++++++++++++++ 9 files changed, 1369 insertions(+), 35 deletions(-) create mode 100644 build/dashboard/.python-version create mode 100644 build/dashboard/uv.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8471d47..104eb9c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,20 +9,26 @@ jobs: dashboard: name: Dashboard tests (pytest + coverage) runs-on: ubuntu-latest + env: + UV_PYTHON_DOWNLOADS: never # use the setup-python 3.11; don't fetch another interpreter steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" - - name: Install dashboard with test extras - run: pip install -e "build/dashboard[test]" - - name: Run pytest with coverage gate + - name: Install uv (pinned — reproducible, hash-locked installs, #283) + # Curl the pinned installer (same posture as hadolint below); avoids a mutable-tag action. + run: | + export UV_INSTALL_DIR="$HOME/.local/bin" # deterministic install dir (runners may set CARGO_HOME) + curl -LsSf https://astral.sh/uv/0.10.10/install.sh | sh + echo "$UV_INSTALL_DIR" >> "$GITHUB_PATH" + - name: Run pytest with coverage gate (deps from uv.lock) working-directory: build/dashboard - run: python -m pytest --cov=mining_dashboard --cov-report=term-missing --cov-fail-under=80 + run: uv run --locked --extra test python -m pytest --cov=mining_dashboard --cov-report=term-missing --cov-fail-under=80 - name: Fake-daemon contract test (real clients vs controllable fakes) # Points the real Monero/Tari clients at the integration fakes and asserts they parse # every state (synced/syncing/down). Docker-free, so it runs on every PR (issue #54). - run: PYTHONPATH=build/dashboard python -m pytest tests/integration/fakes -q + run: uv run --locked --project build/dashboard --extra test python -m pytest tests/integration/fakes -q frontend: name: Frontend logic tests (node --test) @@ -79,17 +85,21 @@ jobs: python-lint: name: Python lint + format (ruff) runs-on: ubuntu-latest + env: + UV_PYTHON_DOWNLOADS: never steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: "3.11" - - name: Install ruff (pinned via the dashboard's dev extra) - # Single version source: the `dev` extra in build/dashboard/pyproject.toml, so CI, - # pre-commit, and local devs can't drift to different ruff versions (lint is version-sensitive). - run: pip install -e "build/dashboard[dev]" - - name: ruff check + format --check (config in build/dashboard/pyproject.toml) - # Single source of truth: the Makefile `lint-py` target. + - name: Install uv (pinned) + run: | + export UV_INSTALL_DIR="$HOME/.local/bin" # deterministic install dir (runners may set CARGO_HOME) + curl -LsSf https://astral.sh/uv/0.10.10/install.sh | sh + echo "$UV_INSTALL_DIR" >> "$GITHUB_PATH" + - name: ruff check + format --check (config in build/dashboard/pyproject.toml + root ruff.toml) + # Single source of truth: the Makefile `lint-py` target, which runs ruff via uv from the + # locked `dev` extra — one pinned ruff for CI, pre-commit, and local devs. run: make lint-py shell: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f443f36..79ef781 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ # Pre-commit hooks (Wave 7 tooling, #280). Local == CI: these mirror `make lint` + the CI lint # jobs, so issues are caught before they reach a PR. Set up once with: -# pip install -e "build/dashboard[dev]" && pre-commit install +# uv sync --project build/dashboard --extra dev && uv run --project build/dashboard pre-commit install # Later Wave 7 children add more hooks here (#281 shfmt/Biome/yamllint/markdownlint; # #282 gitleaks). Keep the ruff `rev` in lockstep with the `dev` extra's ruff pin in # build/dashboard/pyproject.toml. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9cca473..f90dfaa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,16 +13,20 @@ whole new feature, contributions are very welcome. This guide covers the workflo ## Dev environment -The dashboard's Python tooling — [`ruff`](https://docs.astral.sh/ruff/) (lint + format) and -[`pre-commit`](https://pre-commit.com/) — is pinned in the `dev` extra. Install it once and -enable the git hooks, so the same checks that run in CI run on every commit (local == CI): +The dashboard uses [uv](https://docs.astral.sh/uv/) for dependency management — a hashed +`uv.lock` pins every transitive dependency so installs are reproducible build-to-build. Its +Python tooling ([`ruff`](https://docs.astral.sh/ruff/) lint + format, +[`pre-commit`](https://pre-commit.com/)) lives in the `dev` extra. Install uv, then from the +repo root: ```bash -pip install -e "build/dashboard[dev]" -pre-commit install +uv sync --project build/dashboard --extra dev # deps + tooling into build/dashboard/.venv, from the lock +uv run --project build/dashboard pre-commit install ``` -`pre-commit` then runs `ruff` (plus a few hygiene hooks) on your changed files automatically. +`make test` and `make lint-py` run through uv automatically (no venv to activate); `pre-commit` +then runs `ruff` (plus a few hygiene hooks) on your changed files. If you change dependencies in +`build/dashboard/pyproject.toml`, run `uv lock` and commit the updated `uv.lock`. ## Development workflow diff --git a/Makefile b/Makefile index bf10af4..173b591 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,8 @@ test: lint test-dashboard test-stack test-compose test-integration-selftest test-fakes ## Run everything that doesn't need a server/docker -test-dashboard: ## Dashboard unit/component tests with coverage gate - cd build/dashboard && PYTHONPATH=. python3 -m pytest \ +test-dashboard: ## Dashboard unit/component tests with coverage gate (deps from uv.lock) + cd build/dashboard && uv run --locked --extra test python -m pytest \ --cov=mining_dashboard --cov-report=term-missing --cov-fail-under=80 test-stack: ## pithead shell test suite @@ -17,7 +17,7 @@ test-integration-selftest: ## Integration harness pure-logic self-test (no serve bash tests/integration/selftest.sh test-fakes: ## Fake-daemon contract test — real dashboard clients vs controllable fakes (no docker) - PYTHONPATH=build/dashboard python3 -m pytest tests/integration/fakes -q + uv run --locked --project build/dashboard --extra test python -m pytest tests/integration/fakes -q test-mini-stack: ## Fake-daemon docker mini-stack end-to-end (needs docker; CI) bash tests/integration/mini-stack/run-mini-stack.sh @@ -43,8 +43,9 @@ lint-sh: ## shellcheck the CLI, the build/* container scripts, the release scrip shellcheck --severity=warning pithead scripts/*.sh build/*/*.sh tests/stack/run.sh tests/stack/test_compose.sh \ tests/inventory.sh tests/integration/*.sh tests/integration/mini-stack/*.sh -lint-py: ## ruff lint + format check on all repo Python (install ruff: pip install -e "build/dashboard[dev]") - ruff check . && ruff format --check . +lint-py: ## ruff lint + format check on all repo Python (ruff runs via uv from the locked dev extra) + uv run --locked --project build/dashboard --extra dev ruff check . + uv run --locked --project build/dashboard --extra dev ruff format --check . # Cut a release from the private build/test server (gouda) — GHCR publish, gated on the test suite + # the #54 integration matrix (issue #44). Pass options through ARGS, e.g. a safe plan-only preview: diff --git a/build/dashboard/.python-version b/build/dashboard/.python-version new file mode 100644 index 0000000..2c07333 --- /dev/null +++ b/build/dashboard/.python-version @@ -0,0 +1 @@ +3.11 diff --git a/build/dashboard/Dockerfile b/build/dashboard/Dockerfile index b58a597..768662d 100644 --- a/build/dashboard/Dockerfile +++ b/build/dashboard/Dockerfile @@ -4,6 +4,19 @@ # Pinned by digest (#135) so the python:3.11-slim tag can't be silently re-pointed. FROM python:3.11-slim@sha256:a3ab0b966bc4e91546a033e22093cb840908979487a9fc0e6e38295747e49ac0 AS base +# uv for reproducible, hash-locked installs from uv.lock (#283). The uv binary is copied from the +# official image, pinned by digest (same posture as the base image, #135). +COPY --from=ghcr.io/astral-sh/uv:0.10.10@sha256:cbe0a44ba994e327b8fe7ed72beef1aaa7d2c4c795fd406d1dbf328bacb2f1c5 /uv /uvx /bin/ + +# Install into a project venv on PATH (so entrypoint.sh's `python3` resolves to it); use the +# digest-pinned base interpreter (never let uv download a different Python); compile bytecode and +# copy (not hardlink) from the BuildKit cache mount used on the sync steps below. +ENV UV_PYTHON_DOWNLOADS=never \ + UV_PROJECT_ENVIRONMENT=/app/.venv \ + UV_COMPILE_BYTECODE=1 \ + UV_LINK_MODE=copy \ + PATH="/app/.venv/bin:$PATH" + # System dependencies. RUN apt-get update && apt-get install -y --no-install-recommends \ bash \ @@ -12,8 +25,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -# Package metadata first (better layer caching), then the source. -COPY pyproject.toml ./ +# Lock + metadata first (better layer caching), then the source. +COPY pyproject.toml uv.lock ./ COPY mining_dashboard/ ./mining_dashboard/ # ========================================================================== @@ -22,7 +35,7 @@ COPY mining_dashboard/ ./mining_dashboard/ # a normal `docker compose build` targets the production stage and skips it. # ========================================================================== FROM base AS test -RUN pip install --no-cache-dir -e ".[test]" +RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --extra test COPY tests/ ./tests/ RUN python -m pytest --cov=mining_dashboard --cov-report=term-missing --cov-fail-under=80 @@ -30,7 +43,8 @@ RUN python -m pytest --cov=mining_dashboard --cov-report=term-missing --cov-fail # Production stage: install the package (runtime deps only) and run it. # ========================================================================== FROM base AS production -RUN pip install --no-cache-dir -e . +# Runtime deps + the package only (no test/dev extras). +RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked COPY entrypoint.sh . RUN chmod +x entrypoint.sh diff --git a/build/dashboard/README.md b/build/dashboard/README.md index f74d5d3..3983c2f 100644 --- a/build/dashboard/README.md +++ b/build/dashboard/README.md @@ -53,9 +53,8 @@ It is a proper installable package (`pyproject.toml`): all internal imports are ## Development ```bash -# from build/dashboard/ -python3 -m venv .venv && source .venv/bin/activate # Python 3.11+ -pip install -e ".[test]" +# from build/dashboard/ — uv creates .venv and installs from the hashed uv.lock (Python 3.11+) +uv sync --extra test ``` ## Tests @@ -81,6 +80,6 @@ via the `state_manager` fixture and the auto-applied DB-isolation fixture in `te The `Dockerfile` is multi-stage: -- `base` — system deps + package metadata + source. -- `test` — `pip install -e .[test]` then `pytest --cov-fail-under=80` (build with `--target test`). -- `production` — runtime install + entrypoint (the default `docker compose build` target). +- `base` — uv (digest-pinned) + system deps + lock/metadata + source. +- `test` — `uv sync --locked --extra test` then `pytest --cov-fail-under=80` (build with `--target test`). +- `production` — `uv sync --locked` (runtime deps only) + entrypoint (the default `docker compose build` target). diff --git a/build/dashboard/mining_dashboard/client/tari/Readme.md b/build/dashboard/mining_dashboard/client/tari/Readme.md index c50a06a..1cf6602 100644 --- a/build/dashboard/mining_dashboard/client/tari/Readme.md +++ b/build/dashboard/mining_dashboard/client/tari/Readme.md @@ -4,6 +4,6 @@ Ensure `base_node.proto` and `types.proto` are in the `proto/` subdirectory, then run: ```bash -docker run --rm -v "$PWD":/work -w /work python:3.11-slim \ - /bin/bash -c "pip install grpcio-tools && python -m grpc_tools.protoc -Iproto --python_out=generated --grpc_python_out=generated proto/*.proto && sed -i 's/^import.*_pb2/from . \0/' generated/*_pb2*.py" +docker run --rm -v "$PWD":/work -w /work ghcr.io/astral-sh/uv:0.10.10-python3.11-trixie-slim \ + /bin/bash -c "uvx --from grpcio-tools python -m grpc_tools.protoc -Iproto --python_out=generated --grpc_python_out=generated proto/*.proto && sed -i 's/^import.*_pb2/from . \0/' generated/*_pb2*.py" ``` \ No newline at end of file diff --git a/build/dashboard/uv.lock b/build/dashboard/uv.lock new file mode 100644 index 0000000..104c128 --- /dev/null +++ b/build/dashboard/uv.lock @@ -0,0 +1,1305 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, + { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, + { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, + { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, + { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, + { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, + { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, + { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, + { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, + { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, + { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, + { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, + { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, + { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, + { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, + { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "distlib" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/02/bd72be9134d25ed783ecbbc38a539ffaefbf90c78418c7fb7229600dbac7/distlib-0.4.3.tar.gz", hash = "sha256:f152097224a0ae24be5a0f6bae1b9359af82133bce63f98a95f86cae1aede9ed", size = 615141, upload-time = "2026-06-12T08:04:52.847Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl", hash = "sha256:4b0ce306c966eb73bc3a7b6abad017c556dadd92c44701562cd528ac7fde4d5b", size = 470628, upload-time = "2026-06-12T08:04:50.506Z" }, +] + +[[package]] +name = "filelock" +version = "3.29.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "grpcio" +version = "1.81.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b0/b5/1ff353970a87eda4c98251e34d2dfd214abd4982dc89119c9252a2a482d2/grpcio-1.81.1.tar.gz", hash = "sha256:6fa10a767143a5e82e8eaab53918af0cd8909a57a27f8cb2288b80a613ac671b", size = 13026582, upload-time = "2026-06-11T12:46:51.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/ea/1c2fa386b718ff493225e61cfc052ef400b4d6ffc54cbe261026432624b5/grpcio-1.81.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:d71d30f2d92f67d944631c523713934fee37292469e182ebcd2c1dd8a64ce53f", size = 6093112, upload-time = "2026-06-11T12:44:52.131Z" }, + { url = "https://files.pythonhosted.org/packages/2b/18/acf45fa8bd1bc5d7b0c2fd3dc4c209379fbd5bb396b440b68a83342226b7/grpcio-1.81.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b137f4bf3ada9dc44d411478decc6ff09a79ed30b306cd2abaa98408c3588137", size = 12074277, upload-time = "2026-06-11T12:44:55.354Z" }, + { url = "https://files.pythonhosted.org/packages/48/d7/ee86a60699b7db039f772a2c4a7e4facc7138984ff42c0130933a0063884/grpcio-1.81.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a3acb384427816dd5d470f47e62137b87f74da694faa8a50147012cf40df276a", size = 6640348, upload-time = "2026-06-11T12:44:59.223Z" }, + { url = "https://files.pythonhosted.org/packages/26/ee/d2de5e47378ffc207d476c230fea3be4d2601edbce9995f4fe45535d4896/grpcio-1.81.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f9a0ebbe45c29b5e5866593c12b78bd9035f0f0f0d4bc8361680cd580d99db49", size = 7331842, upload-time = "2026-06-11T12:45:02.001Z" }, + { url = "https://files.pythonhosted.org/packages/23/d6/abeda5c2b896a0b341584fe5ac411bbf72e197a9a374c355fb90965e08d2/grpcio-1.81.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0a37165cc80b1a368384b383e63a4c38116a10467ae44c904d2d7468c4470ec2", size = 6842229, upload-time = "2026-06-11T12:45:04.76Z" }, + { url = "https://files.pythonhosted.org/packages/10/1c/1f0da7d590b4aeee006826ba568d0e419ca14b23e18f901a3da3e9fba613/grpcio-1.81.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6282caffb41ec326d4cb67ca9cf53b739d1b2f975a2acb498c7418e9f7d9a416", size = 7446096, upload-time = "2026-06-11T12:45:07.499Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/5c505d508f7c887aa7982d21443a4126597c80d34b0bcf40f9cec576d7f3/grpcio-1.81.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a35009284d0d3d5c2c9601c164a911b8b4331608d98a9a66d47d97bb2f522b70", size = 8445238, upload-time = "2026-06-11T12:45:10.243Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b2/524847365122ee509ca17bcc4e092198b700e94af7bfd5bb5e6dd9f3ee66/grpcio-1.81.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1b22c80559854b789a01fd89e8929b3798a156c0829b5282a8939f33ad4115ad", size = 7873989, upload-time = "2026-06-11T12:45:13.102Z" }, + { url = "https://files.pythonhosted.org/packages/18/fa/07c037c50b006909d1d13a5848774f8aa7b242f70dc03a035c64eea0e6db/grpcio-1.81.1-cp311-cp311-win32.whl", hash = "sha256:428bec0161b48d8cf583c068591bc0016d0d9cfff52462b72b3884861ea768c5", size = 4202223, upload-time = "2026-06-11T12:45:16.166Z" }, + { url = "https://files.pythonhosted.org/packages/41/ed/6bff15376920942fac6b95b9802752b837437172c9e8fc2d3170546b89cc/grpcio-1.81.1-cp311-cp311-win_amd64.whl", hash = "sha256:30e825f6848d9f18bba350ed6c75c1b02a0b5184474a31db9a32b1fa66fd8c79", size = 4941303, upload-time = "2026-06-11T12:45:18.724Z" }, + { url = "https://files.pythonhosted.org/packages/85/07/9a979c81738863a738dc23d65177056e71fbb2db817740ed870b33434e7a/grpcio-1.81.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:8b39472beafc0bdcafc4c8c73ad082ebfdb449d566897a61e7acb4fa88089115", size = 6053264, upload-time = "2026-06-11T12:45:21.017Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/539706ca0d3bd40dbad583dc56fd883da941f37556b629132da5762781b9/grpcio-1.81.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:12b7524c88d4026d3dcb7b0ebe16b6714f3b4af402ddd0f0639ab064a00c87c3", size = 12052560, upload-time = "2026-06-11T12:45:23.652Z" }, + { url = "https://files.pythonhosted.org/packages/e0/44/f257b7e0bd69c93b06c6cb8ac8d1b901ccb42bedabd83c1a4c77a71f8810/grpcio-1.81.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1e123f9b37edb8375fd74130d1f69c944bbf0a7b06761ae7211154b8759e94d2", size = 6595983, upload-time = "2026-06-11T12:45:26.963Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f3/19782aa04c960968bef8c5539329d8e3bbc3364e2e46d19eb5e5cc5e43b7/grpcio-1.81.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2c2e2ae6867c2966b8daccc836d54a13218e0007e9a490aeb81dd05be64d22d7", size = 7303455, upload-time = "2026-06-11T12:45:29.707Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8c/dea020b6d91508cd84463917a63149ec196ee7db505d032ae43fcb3303b9/grpcio-1.81.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:766bc7c9a9c340342f4c864ccbda8e78111e4751f13b895812b9c148fb79e9d0", size = 6809167, upload-time = "2026-06-11T12:45:32.52Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/3030dd940408083bd32cd95d634777a71605ade4887154d93e8a89244946/grpcio-1.81.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b259a04a737cb3496be0901328eb8b7552ed8df4865d8c8f1cf1bffcfc0776a3", size = 7412536, upload-time = "2026-06-11T12:45:35.403Z" }, + { url = "https://files.pythonhosted.org/packages/e0/dd/1172a9e42b168edcafefad6115346ef619a3fc02158bb170e66ced24bcdd/grpcio-1.81.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:85b10a45b8993d195c4f3ff57025b8d1e11834909ee475c403bfa60cb4caefaf", size = 8408276, upload-time = "2026-06-11T12:45:37.78Z" }, + { url = "https://files.pythonhosted.org/packages/25/7a/71437c7f3596e5246155c515852795a85a1a8d228190212432b13b97a95d/grpcio-1.81.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8ea1936c26b99999b27479853039a7f34713f56c49375ad52b38535ec93a796c", size = 7849660, upload-time = "2026-06-11T12:45:40.627Z" }, + { url = "https://files.pythonhosted.org/packages/65/40/7debc0da45d2efebafb82da75644be347497fe4ee250514b8cd3b86ae8bf/grpcio-1.81.1-cp312-cp312-win32.whl", hash = "sha256:a185a04039df6cae8648bc8ab6d6fde7bf94f7188ecf7828e76ac52eef1e41d6", size = 4185819, upload-time = "2026-06-11T12:45:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b9/8fe3ba5ed462067774ebc1f9c7f26aa7ebcc280ddd476be107153de1339e/grpcio-1.81.1-cp312-cp312-win_amd64.whl", hash = "sha256:3ad74f8bb1a18963914c5452d289422830b39459e8776ebbcd207be1fbfb1d94", size = 4930461, upload-time = "2026-06-11T12:45:45.775Z" }, + { url = "https://files.pythonhosted.org/packages/7a/42/dcc2e4b600538ef18327c0839d56b7d3c3812337c5d710df5877dbb39b1e/grpcio-1.81.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b10e1ff4756ed27d5a29d7fc79cfce7ef1ff56ad20025b89bac7cf79e09abbbe", size = 6054466, upload-time = "2026-06-11T12:45:48.43Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4a/a36e03210183a8a7d4c80c3936acee679f4bd77d5861f369db47b2cc5f05/grpcio-1.81.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:819edbdcb42ab8598b494bcf0222684bbb7a3c772bd1b1f0be7e029a6063c28e", size = 12048795, upload-time = "2026-06-11T12:45:54.011Z" }, + { url = "https://files.pythonhosted.org/packages/b0/d5/d68e30b29098f63beab6fe501100fe82674ff142b32c672532da86a99b3a/grpcio-1.81.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5bf2dc311127d91230cc79b92188c082634a06cf66c5234db49a43b910183b0", size = 6599094, upload-time = "2026-06-11T12:45:57.799Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/e837954d279754f638a11cca5dcf6b24a005efb398984cefaf7735945a54/grpcio-1.81.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:e8ca6a1fcdb2943c9cbc1804a1baf3acb6071d72a471591678ded84218006e14", size = 7307182, upload-time = "2026-06-11T12:46:00.568Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/b47957057e729adc6cdf519a47f8be2562b7140e280f1418443eb4022192/grpcio-1.81.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e64dd101d380a115cc5a0c7856788adb535f1a4e21fc543775602f8be95180ae", size = 6810962, upload-time = "2026-06-11T12:46:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/40/26/569868e364e05b19ec8f969da53d230bcd89c962cd198f7c29943155c4d3/grpcio-1.81.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:98a07f9bf591e3a8919797bee1c53f026ba4acd587e5a4404c8e57c9ec36b2a5", size = 7415698, upload-time = "2026-06-11T12:46:06.005Z" }, + { url = "https://files.pythonhosted.org/packages/36/0c/5440a0582cb5653fc42a6e262eeb22700943313f8076f9dc927491b20a59/grpcio-1.81.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c261d74b1a945cf895a9d6eccd1685a8e837531beaab782da4d630a8d12deffb", size = 8407779, upload-time = "2026-06-11T12:46:08.84Z" }, + { url = "https://files.pythonhosted.org/packages/ff/aa/66fe9f39871d766987d869a03ee0842a026f499c7b1e62decb9e78a8088e/grpcio-1.81.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:58ad1131c300d3c9b933802b3cc4dc69d380822935ba50b28703156ea826fbf7", size = 7844521, upload-time = "2026-06-11T12:46:12.171Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9e/69bb7194861bcd28fb3193261d4f9c3831b4446993f002cf59068943e7ab/grpcio-1.81.1-cp313-cp313-win32.whl", hash = "sha256:78e29211f26da2fdd0e9c6d2b79f489476140cf7029b6a64808ade7ca4156a42", size = 4182786, upload-time = "2026-06-11T12:46:15.192Z" }, + { url = "https://files.pythonhosted.org/packages/0d/20/3da8bb0d637feccdc3e1e419bb511ce93651ce7d54164f95de22cc0b8b34/grpcio-1.81.1-cp313-cp313-win_amd64.whl", hash = "sha256:edb59506291b647a30884b1d51a599d605f40b20af4a7dc3d33786a47a31de60", size = 4928648, upload-time = "2026-06-11T12:46:17.823Z" }, + { url = "https://files.pythonhosted.org/packages/b6/58/19414622b1bf6981bc9c05a365bd548e71876c89000083b3af489251e9c0/grpcio-1.81.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:506f48f2f9c29b143fca3dad7b0d518c188b6c9648c75a2ae6e2d9f2c13a060b", size = 6055336, upload-time = "2026-06-11T12:46:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/32/f1/2ec88adb92b0eba970dd0e0e7dd086341daa3c75eba4f735f9e44bf684b0/grpcio-1.81.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d865db4a6318e1c1bea83292e0ed231090538fc4ca45425b0f0480eb338bbc6e", size = 12056279, upload-time = "2026-06-11T12:46:24.255Z" }, + { url = "https://files.pythonhosted.org/packages/41/36/e8c5f8c6ec71de73733695ebc809e98b178b534ec6d8eaa31a7ebab4ad4c/grpcio-1.81.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2aa72e3ce1770317ef534f63d397b55e130725f5149bd36077c3b539019db27", size = 6608225, upload-time = "2026-06-11T12:46:27.601Z" }, + { url = "https://files.pythonhosted.org/packages/30/22/96fc577a845ab093326d9ab1adb874bd4936c8cf98ac8ed2f3db13a0a2fb/grpcio-1.81.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0490c30c261eded63f3f354979f9dc4502a9fb944cccb60cd9dc85f5a7349854", size = 7306576, upload-time = "2026-06-11T12:46:30.514Z" }, + { url = "https://files.pythonhosted.org/packages/76/7b/61dab5d5969f28d97fb1009cead1df0a5cd987d3315e1b37f18a4449f8bc/grpcio-1.81.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:410482da976329fe5f4067270401b12cf2bd552ff8020f054ecfaddb5475f9d6", size = 6812165, upload-time = "2026-06-11T12:46:33.699Z" }, + { url = "https://files.pythonhosted.org/packages/82/78/6e501929d4f5f96462fd82fd9f0f06e5f9612207582b862868d68757b27d/grpcio-1.81.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3657301562ac3cb8018d30d0d3ebfa39932239f7b5703422057ef14b69949f5", size = 7422962, upload-time = "2026-06-11T12:46:36.511Z" }, + { url = "https://files.pythonhosted.org/packages/2a/7e/f2157589e66daa78ebb3165942d05a08bdea93b9d11c2bc1e172aef89685/grpcio-1.81.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24c8e57504c8f45b237e40b99262d181071e5099a07053695b75d97bb53053a0", size = 8408176, upload-time = "2026-06-11T12:46:39.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/df/c6717fef716e00d235ffb96123baf6dce76d6004f6233fa767c502861460/grpcio-1.81.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b427c19380991a4eaab2f6144b64b99b412043314c6bf4ab544f97bb31ee4190", size = 7846681, upload-time = "2026-06-11T12:46:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/36/84/3502e9f210a6a5c4438c8aca3f88edd2e04f6a27f3d41b26cf0a0024b096/grpcio-1.81.1-cp314-cp314-win32.whl", hash = "sha256:61233fe8951e5c85dff81c2458b6528624760166946b5b47ea150a589168411f", size = 4264615, upload-time = "2026-06-11T12:46:45.741Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b0/4af731ff7492c68a96e4c71bfd0f4590acde92b31c6fe4894e6465c10ff6/grpcio-1.81.1-cp314-cp314-win_amd64.whl", hash = "sha256:3768a5ff1b2125e6f552e561b6b2dca0e64982d8949689b4df145cf8b98d7821", size = 5070275, upload-time = "2026-06-11T12:46:48.486Z" }, +] + +[[package]] +name = "identify" +version = "2.6.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "mining-dashboard" +version = "1.0.3" +source = { editable = "." } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "grpcio" }, + { name = "protobuf" }, + { name = "requests", extra = ["socks"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "pre-commit" }, + { name = "ruff" }, +] +test = [ + { name = "pytest" }, + { name = "pytest-aiohttp" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "pytest-mock" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles", specifier = ">=24.1" }, + { name = "aiohttp", specifier = ">=3.10.11" }, + { name = "grpcio", specifier = ">=1.78.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4" }, + { name = "protobuf", specifier = ">=6.31.1,<7" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, + { name = "pytest-aiohttp", marker = "extra == 'test'", specifier = ">=1.0" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.24" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=5" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.14" }, + { name = "requests", extras = ["socks"], specifier = ">=2.32.4" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.17" }, +] +provides-extras = ["test", "dev"] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { 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 = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "protobuf" +version = "6.33.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/70/e908e9c5e52ef7c3a6c7902c9dfbb34c7e29c25d2f81ade3856445fd5c94/protobuf-6.33.6.tar.gz", hash = "sha256:a6768d25248312c297558af96a9f9c929e8c4cee0659cb07e780731095f38135", size = 444531, upload-time = "2026-03-18T19:05:00.988Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/9f/2f509339e89cfa6f6a4c4ff50438db9ca488dec341f7e454adad60150b00/protobuf-6.33.6-cp310-abi3-win32.whl", hash = "sha256:7d29d9b65f8afef196f8334e80d6bc1d5d4adedb449971fefd3723824e6e77d3", size = 425739, upload-time = "2026-03-18T19:04:48.373Z" }, + { url = "https://files.pythonhosted.org/packages/76/5d/683efcd4798e0030c1bab27374fd13a89f7c2515fb1f3123efdfaa5eab57/protobuf-6.33.6-cp310-abi3-win_amd64.whl", hash = "sha256:0cd27b587afca21b7cfa59a74dcbd48a50f0a6400cfb59391340ad729d91d326", size = 437089, upload-time = "2026-03-18T19:04:50.381Z" }, + { url = "https://files.pythonhosted.org/packages/5c/01/a3c3ed5cd186f39e7880f8303cc51385a198a81469d53d0fdecf1f64d929/protobuf-6.33.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:9720e6961b251bde64edfdab7d500725a2af5280f3f4c87e57c0208376aa8c3a", size = 427737, upload-time = "2026-03-18T19:04:51.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/b3c01fdec7d2f627b3a6884243ba328c1217ed2d978def5c12dc50d328a3/protobuf-6.33.6-cp39-abi3-manylinux2014_aarch64.whl", hash = "sha256:e2afbae9b8e1825e3529f88d514754e094278bb95eadc0e199751cdd9a2e82a2", size = 324610, upload-time = "2026-03-18T19:04:53.096Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ca/25afc144934014700c52e05103c2421997482d561f3101ff352e1292fb81/protobuf-6.33.6-cp39-abi3-manylinux2014_s390x.whl", hash = "sha256:c96c37eec15086b79762ed265d59ab204dabc53056e3443e702d2681f4b39ce3", size = 339381, upload-time = "2026-03-18T19:04:54.616Z" }, + { url = "https://files.pythonhosted.org/packages/16/92/d1e32e3e0d894fe00b15ce28ad4944ab692713f2e7f0a99787405e43533a/protobuf-6.33.6-cp39-abi3-manylinux2014_x86_64.whl", hash = "sha256:e9db7e292e0ab79dd108d7f1a94fe31601ce1ee3f7b79e0692043423020b0593", size = 323436, upload-time = "2026-03-18T19:04:55.768Z" }, + { url = "https://files.pythonhosted.org/packages/c4/72/02445137af02769918a93807b2b7890047c32bfb9f90371cbc12688819eb/protobuf-6.33.6-py3-none-any.whl", hash = "sha256:77179e006c476e69bf8e8ce866640091ec42e1beb80b213c3900006ecfba6901", size = 170656, upload-time = "2026-03-18T19:04:59.826Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "pytest-aiohttp" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohttp" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4d/c6621fc79022f6c84a806e23d9b7eca24fae4f3ee779219bbe524339d666/pytest_aiohttp-1.1.1.tar.gz", hash = "sha256:3aa9c9fe26e543eaccc7eb0add381c685ba3ed3e2fed0af74540f63bcd31458d", size = 13704, upload-time = "2026-06-07T23:56:34.173Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b0/5056ed4c3f68a4db2b4a39fb0ec61b1e4cf1d89ee14effe5261cc587264c/pytest_aiohttp-1.1.1-py3-none-any.whl", hash = "sha256:f293441ad4f8446a1e12257130c26c7de03a615c2a5572a8cb046e5b3b4e5211", size = 9007, upload-time = "2026-06-07T23:56:33.333Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "pytest-mock" +version = "3.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" }, +] + +[[package]] +name = "python-discovery" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { 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 = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[package.optional-dependencies] +socks = [ + { name = "pysocks" }, +] + +[[package]] +name = "ruff" +version = "0.15.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" }, + { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" }, + { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" }, + { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" }, + { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" }, + { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" }, + { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" }, + { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" }, + { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" }, + { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "virtualenv" +version = "21.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, + { name = "python-discovery" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +] From 631e6fa9cfade7b0319ba541a6f2ea317a2fc874 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Thu, 18 Jun 2026 02:25:01 -0500 Subject: [PATCH 14/44] build(#282): supply-chain & secrets hardening (gitleaks, Trivy, Dependabot, SHA-pinned actions, zizmor) (#298) Wave 7 supply-chain hardening: SHA-pinned actions, zizmor (+ least-privilege permissions & persist-credentials), gitleaks (CI + pre-commit), Trivy image scanning (--ignore-unfixed + .trivyignore), Dependabot (actions/uv/docker), and uv dropped from the production image. Closes #282. --- .github/dependabot.yml | 37 ++++++++ .github/workflows/ci.yml | 90 +++++++++++++++++--- .github/workflows/integration-mini-stack.yml | 7 +- .github/workflows/release-gate.yml | 10 ++- .gitleaks.toml | 13 +++ .pre-commit-config.yaml | 11 ++- .trivyignore | 14 +++ build/dashboard/Dockerfile | 27 +++--- 8 files changed, 181 insertions(+), 28 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .gitleaks.toml create mode 100644 .trivyignore diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..079e528 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,37 @@ +# Dependabot (Wave 7 tooling, #282): automated dependency + CVE update PRs across every +# supply-chain surface. Grouped + weekly to keep PR volume sane. +version: 2 +updates: + # GitHub Actions — keep the SHA-pinned actions (#282) fresh. Dependabot rewrites the SHA and the + # `# vX.Y.Z` comment together. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + actions: + patterns: ["*"] + + # Dashboard Python deps — bumps the hashed uv.lock + pyproject floors (#283). + - package-ecosystem: "uv" + directory: "/build/dashboard" + schedule: + interval: "weekly" + groups: + python: + patterns: ["*"] + + # Base-image digests across every build/* Dockerfile (incl. the uv build image). This is the + # mechanism that clears base-distro CVEs — e.g. the openssl point-release accepted in .trivyignore. + - package-ecosystem: "docker" + directories: + - "/build/dashboard" + - "/build/monero" + - "/build/p2pool" + - "/build/tor" + - "/build/xmrig-proxy" + schedule: + interval: "weekly" + groups: + docker: + patterns: ["*"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 104eb9c..9acb657 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: branches: [main, develop] pull_request: +# Least privilege (#282): every job here only reads the repo — none push commits, comment, or +# publish packages. Narrowing the default GITHUB_TOKEN limits the blast radius of a compromised step. +permissions: + contents: read + jobs: dashboard: name: Dashboard tests (pytest + coverage) @@ -12,8 +17,10 @@ jobs: env: UV_PYTHON_DOWNLOADS: never # use the setup-python 3.11; don't fetch another interpreter steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" - name: Install uv (pinned — reproducible, hash-locked installs, #283) @@ -34,8 +41,10 @@ jobs: name: Frontend logic tests (node --test) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: "20" # Pure client logic (worker sort, tooltip formatting) lives in static/logic.mjs and is @@ -48,7 +57,9 @@ jobs: name: Dashboard image (Docker test stage) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Build the dashboard test stage (installs package + runs the suite in-container) run: docker build --target test ./build/dashboard @@ -65,15 +76,30 @@ jobs: matrix: service: [monero, p2pool, tor, xmrig-proxy, dashboard] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: docker build ./build/${{ matrix.service }} run: docker build -t "pithead-${{ matrix.service }}:ci" "./build/${{ matrix.service }}" + - name: Scan image for CVEs (Trivy) + # Gate on actionable (fixable) HIGH/CRITICAL only; accepted findings live in .trivyignore. + # Must stay green before v1.1 images publish (#282). + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + image-ref: pithead-${{ matrix.service }}:ci + scanners: vuln + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: "1" + trivyignores: .trivyignore hadolint: name: Dockerfile lint (hadolint) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Install hadolint run: | sudo curl -fsSL -o /usr/local/bin/hadolint \ @@ -88,8 +114,10 @@ jobs: env: UV_PYTHON_DOWNLOADS: never steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" - name: Install uv (pinned) @@ -102,11 +130,49 @@ jobs: # locked `dev` extra — one pinned ruff for CI, pre-commit, and local devs. run: make lint-py + gitleaks: + name: Secret scan (gitleaks) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + fetch-depth: 0 # full history so the scan covers every commit, not just the tip + - name: Scan git history for secrets (gitleaks, pinned by digest) + # Run the binary via its pinned image (the gitleaks-action requires a license for org repos). + # Accepted false positives live in .gitleaks.toml. + run: | + docker run --rm -v "$PWD":/repo \ + ghcr.io/gitleaks/gitleaks:v8.30.1@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9ab65d25e6bc8abbb7f \ + git /repo --no-banner --redact --config /repo/.gitleaks.toml + + zizmor: + name: Workflow audit (zizmor) + runs-on: ubuntu-latest + env: + UV_PYTHON_DOWNLOADS: never + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + - name: Install uv (pinned) + run: | + export UV_INSTALL_DIR="$HOME/.local/bin" + curl -LsSf https://astral.sh/uv/0.10.10/install.sh | sh + echo "$UV_INSTALL_DIR" >> "$GITHUB_PATH" + - name: Audit workflows (injection, token scope, self-hosted-runner risks) + run: uvx zizmor@1.25.2 --offline .github/workflows/ + shell: name: Shell tests (shellcheck + pithead suite) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false # shellcheck is preinstalled on ubuntu-* runners, so we invoke it directly. # Avoid `apt-get update`, which refreshes every configured source (incl. # unrelated third-party mirrors like dl.google.com) and intermittently fails @@ -134,6 +200,8 @@ jobs: name: Compose config + security hardening runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Validate docker-compose.yml interpolation + hardening invariants (#90) run: bash tests/stack/test_compose.sh diff --git a/.github/workflows/integration-mini-stack.yml b/.github/workflows/integration-mini-stack.yml index 958a41c..056d353 100644 --- a/.github/workflows/integration-mini-stack.yml +++ b/.github/workflows/integration-mini-stack.yml @@ -13,12 +13,17 @@ on: - "build/dashboard/**" - ".github/workflows/integration-mini-stack.yml" +permissions: + contents: read + jobs: mini-stack: name: Fake-daemon mini-stack (docker) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false # ubuntu-latest ships Docker with the Compose v2 plugin — no setup needed. - name: Run the fake-daemon mini-stack run: bash tests/integration/mini-stack/run-mini-stack.sh diff --git a/.github/workflows/release-gate.yml b/.github/workflows/release-gate.yml index 874384f..a8f25d4 100644 --- a/.github/workflows/release-gate.yml +++ b/.github/workflows/release-gate.yml @@ -32,6 +32,10 @@ concurrency: group: release-gate cancel-in-progress: false +# Least privilege (#282): this runs on a self-hosted box holding real keys — keep the token read-only. +permissions: + contents: read + jobs: release-gate: name: Tier-4 live matrix (real nodes) @@ -45,7 +49,9 @@ jobs: # box; prefer an ephemeral / just-in-time runner in its own runner group. runs-on: [self-hosted, pithead-release] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - name: Validate against the real synced nodes # Inputs go through env (not interpolated into the script) to avoid shell injection. @@ -70,7 +76,7 @@ jobs: - name: Upload artifacts (redacted) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: release-gate-results path: tests/integration/results/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..f4bab3e --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,13 @@ +# gitleaks secret-scan config (Wave 7 tooling, #282). Extends the upstream default ruleset. +[extend] +useDefault = true + +[allowlist] +description = "Accepted false positives" +regexTarget = "line" +# curl auth assembled from shell ENV VARS (e.g. `-u "${USER:-}:${PASS:-}"`) is not a hardcoded +# secret — the upstream `curl-auth-user` rule can't distinguish `${VAR}` from a literal credential. +# This pattern only matches env-var expansions, so a real `-u "admin:hunter2"` is still caught. +regexes = [ + '''-u "\$\{[A-Z_]+:-\}:\$\{[A-Z_]+:-\}"''', +] diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 79ef781..1979354 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,9 +1,8 @@ # Pre-commit hooks (Wave 7 tooling, #280). Local == CI: these mirror `make lint` + the CI lint # jobs, so issues are caught before they reach a PR. Set up once with: # uv sync --project build/dashboard --extra dev && uv run --project build/dashboard pre-commit install -# Later Wave 7 children add more hooks here (#281 shfmt/Biome/yamllint/markdownlint; -# #282 gitleaks). Keep the ruff `rev` in lockstep with the `dev` extra's ruff pin in -# build/dashboard/pyproject.toml. +# Later Wave 7 children add more hooks here (#281 shfmt/Biome/yamllint/markdownlint). Keep the +# ruff `rev` in lockstep with the `dev` extra's ruff pin in build/dashboard/pyproject.toml. repos: - repo: https://github.com/astral-sh/ruff-pre-commit rev: v0.15.17 @@ -14,6 +13,12 @@ repos: args: [--fix] - id: ruff-format + # Secret scanning (#282) — stop a key reaching history in the first place. Uses .gitleaks.toml. + - repo: https://github.com/gitleaks/gitleaks + rev: v8.30.1 + hooks: + - id: gitleaks + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..cd3c7dc --- /dev/null +++ b/.trivyignore @@ -0,0 +1,14 @@ +# Trivy ignore file (Wave 7 tooling, #282). One finding ID per line. +# +# The image scan gates on ACTIONABLE (fixable) HIGH/CRITICAL only (`--ignore-unfixed`) — unfixed +# distro CVEs are reported but don't block. The entries below are the currently-accepted *fixable* +# findings, each with a rationale and how it clears. Revisit whenever Dependabot bumps a base image. + +# openssl: the fix is a base-distro point release (Debian deb13u2 / Ubuntu 3.0.13-0ubuntu3.11) not +# yet in our digest-pinned bases. Cleared when Dependabot (docker) bumps the base image digests. +CVE-2026-45447 + +# Base-image build tooling living in the python:3.11-slim *system* site-packages — NOT in the app's +# /app/.venv and never invoked at runtime (the entrypoint runs the venv's python). Cleared by a base bump. +CVE-2026-23949 +CVE-2026-24049 diff --git a/build/dashboard/Dockerfile b/build/dashboard/Dockerfile index 768662d..de7b824 100644 --- a/build/dashboard/Dockerfile +++ b/build/dashboard/Dockerfile @@ -1,14 +1,12 @@ # ========================================================================== -# Shared base: system deps + package metadata + source. +# Shared base: system deps + package metadata + source. Deliberately has NO uv — +# the production image must not ship the build tool (a runtime image with uv pulls +# uv's own CVEs into image scans, #282). uv lives only in the build/test stages. # ========================================================================== # Pinned by digest (#135) so the python:3.11-slim tag can't be silently re-pointed. FROM python:3.11-slim@sha256:a3ab0b966bc4e91546a033e22093cb840908979487a9fc0e6e38295747e49ac0 AS base -# uv for reproducible, hash-locked installs from uv.lock (#283). The uv binary is copied from the -# official image, pinned by digest (same posture as the base image, #135). -COPY --from=ghcr.io/astral-sh/uv:0.10.10@sha256:cbe0a44ba994e327b8fe7ed72beef1aaa7d2c4c795fd406d1dbf328bacb2f1c5 /uv /uvx /bin/ - -# Install into a project venv on PATH (so entrypoint.sh's `python3` resolves to it); use the +# Run from a project venv on PATH (so entrypoint.sh's `python3` resolves to it); use the # digest-pinned base interpreter (never let uv download a different Python); compile bytecode and # copy (not hardlink) from the BuildKit cache mount used on the sync steps below. ENV UV_PYTHON_DOWNLOADS=never \ @@ -30,21 +28,28 @@ COPY pyproject.toml uv.lock ./ COPY mining_dashboard/ ./mining_dashboard/ # ========================================================================== -# Test stage: install with test extras and run the suite with coverage. +# Build stage: uv resolves the locked runtime deps into /app/.venv. uv is COPYed in +# here (and in test) but never into production. +# ========================================================================== +FROM base AS build +COPY --from=ghcr.io/astral-sh/uv:0.10.10@sha256:cbe0a44ba994e327b8fe7ed72beef1aaa7d2c4c795fd406d1dbf328bacb2f1c5 /uv /bin/ +RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked + +# ========================================================================== +# Test stage: add the test extras to the venv and run the suite with coverage. # Build explicitly with `docker build --target test .` (CI does this); # a normal `docker compose build` targets the production stage and skips it. # ========================================================================== -FROM base AS test +FROM build AS test RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --extra test COPY tests/ ./tests/ RUN python -m pytest --cov=mining_dashboard --cov-report=term-missing --cov-fail-under=80 # ========================================================================== -# Production stage: install the package (runtime deps only) and run it. +# Production stage: copy ONLY the resolved venv from `build` (no uv binary) and run it. # ========================================================================== FROM base AS production -# Runtime deps + the package only (no test/dev extras). -RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked +COPY --from=build /app/.venv /app/.venv COPY entrypoint.sh . RUN chmod +x entrypoint.sh From fbd641df33dd656fc8aa55a3e215c14f48813334 Mon Sep 17 00:00:00 2001 From: Vijit Singh Date: Thu, 18 Jun 2026 03:46:29 -0500 Subject: [PATCH 15/44] build(#281): round out per-surface lint/format (shfmt, Biome, yamllint, markdownlint, buf, taplo) (#302) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 7: every file surface under a linter/formatter — shfmt, Biome, yamllint, markdownlint+lychee(scheduled), buf, taplo — glued by the Makefile, pre-commit, and a new lint-surfaces CI job. Includes the docker-compose dashboard re-indent (yamllint indentation rule re-enabled). Closes #281. --- .github/workflows/ci.yml | 33 +- .github/workflows/lychee.yml | 27 + .lycheeignore | 7 + .markdownlint-cli2.jsonc | 21 + .pre-commit-config.yaml | 42 + .taplo.toml | 2 + .yamllint | 11 + CONTRIBUTING.md | 9 +- Makefile | 24 +- README.md | 2 +- biome.json | 28 + build/dashboard/entrypoint.sh | 4 +- .../mining_dashboard/client/tari/Readme.md | 3 +- .../client/tari/proto/buf.yaml | 13 + .../mining_dashboard/web/static/chart.mjs | 466 ++++--- .../web/static/components.mjs | 290 +++-- .../mining_dashboard/web/static/dashboard.css | 810 +++++++++--- .../mining_dashboard/web/static/dashboard.js | 169 +-- .../mining_dashboard/web/static/logic.mjs | 195 +-- .../mining_dashboard/web/static/preact.mjs | 7 +- .../mining_dashboard/web/static/theme-init.js | 16 +- .../web/static/vendor/README.md | 10 +- build/dashboard/pyproject.toml | 52 +- build/monero/entrypoint.sh | 6 +- build/monero/healthcheck.sh | 6 +- build/p2pool/entrypoint.sh | 30 +- build/tari/entrypoint.sh | 2 +- build/tor/entrypoint.sh | 2 +- build/tor/healthcheck.sh | 6 +- docker-compose.yml | 78 +- docs/configuration.md | 6 +- docs/getting-started.md | 2 + docs/privacy.md | 2 +- docs/test-server-architecture.md | 7 + docs/testing-guide.md | 4 + docs/testing-strategy.md | 8 + pithead | 827 ++++++++----- scripts/release.sh | 163 ++- .../benchmarks/bench-verify-egress.sh | 95 +- tests/integration/build-pruned-chain.sh | 20 +- tests/integration/compact-chain.sh | 30 +- tests/integration/e2e.sh | 158 ++- tests/integration/gouda-testbench-README.md | 5 + tests/integration/lib.sh | 134 +- .../integration/mini-stack/run-mini-stack.sh | 37 +- tests/integration/run.sh | 328 +++-- tests/integration/scenarios.sh | 5 +- tests/integration/selftest.sh | 119 +- tests/integration/system-info.sh | 13 +- tests/inventory.sh | 18 +- tests/stack/run.sh | 1097 +++++++++++------ tests/stack/test_compose.sh | 65 +- 52 files changed, 3634 insertions(+), 1880 deletions(-) create mode 100644 .github/workflows/lychee.yml create mode 100644 .lycheeignore create mode 100644 .markdownlint-cli2.jsonc create mode 100644 .taplo.toml create mode 100644 .yamllint create mode 100644 biome.json create mode 100644 build/dashboard/mining_dashboard/client/tari/proto/buf.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9acb657..13b905e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.11" - - name: Install uv (pinned — reproducible, hash-locked installs, #283) + - name: "Install uv (pinned — reproducible, hash-locked installs, #283)" # Curl the pinned installer (same posture as hadolint below); avoids a mutable-tag action. run: | export UV_INSTALL_DIR="$HOME/.local/bin" # deterministic install dir (runners may set CARGO_HOME) @@ -173,11 +173,16 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: persist-credentials: false + - name: "Install shfmt (pinned — RigForge convention, #281)" + run: | + sudo curl -fsSL -o /usr/local/bin/shfmt \ + https://github.com/mvdan/sh/releases/download/v3.13.1/shfmt_v3.13.1_linux_amd64 + sudo chmod +x /usr/local/bin/shfmt # shellcheck is preinstalled on ubuntu-* runners, so we invoke it directly. # Avoid `apt-get update`, which refreshes every configured source (incl. # unrelated third-party mirrors like dl.google.com) and intermittently fails # the job when one is briefly out of sync — see issue #64. - - name: Lint pithead, build/* container scripts, and test scripts + - name: Lint pithead, build/* container scripts, and test scripts (shellcheck + shfmt) # Single source of truth: the Makefile `lint-sh` target (so the file list can't drift # between here and the Makefile). Covers build/*/*.sh — the entrypoints + healthchecks that # run in every container (#124). Gate on warnings+errors; info-level style nits vary by @@ -205,3 +210,27 @@ jobs: persist-credentials: false - name: Validate docker-compose.yml interpolation + hardening invariants (#90) run: bash tests/stack/test_compose.sh + + lint-surfaces: + name: Lint per-surface (biome, yaml, markdown, proto, toml) + runs-on: ubuntu-latest + env: + UV_PYTHON_DOWNLOADS: never + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "20" + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.11" + - name: Install uv (pinned) + run: | + export UV_INSTALL_DIR="$HOME/.local/bin" + curl -LsSf https://astral.sh/uv/0.10.10/install.sh | sh + echo "$UV_INSTALL_DIR" >> "$GITHUB_PATH" + - name: Lint JS/CSS (Biome), YAML, Markdown, proto (buf), TOML (taplo) + # Single source of truth: the Makefile targets. Tools run via npx/uvx/docker (preinstalled). + run: make lint-js lint-yaml lint-md lint-proto lint-toml diff --git a/.github/workflows/lychee.yml b/.github/workflows/lychee.yml new file mode 100644 index 0000000..9e79ee3 --- /dev/null +++ b/.github/workflows/lychee.yml @@ -0,0 +1,27 @@ +name: Link check (lychee) + +# Scheduled (not per-PR) so flaky external links never redden a PR (#281). The run goes red in the +# Actions tab if a link breaks; trigger on demand with workflow_dispatch. Ignored URLs: .lycheeignore. +on: + schedule: + - cron: "0 6 * * 1" # Mondays 06:00 UTC + workflow_dispatch: + +permissions: + contents: read + +jobs: + lychee: + name: Check Markdown links + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + - name: Check links in Markdown + uses: lycheeverse/lychee-action@8646ba30535128ac92d33dfc9133794bfdd9b411 # v2.8.0 + with: + args: "--no-progress './**/*.md'" + fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # higher github.com rate limit for repo links diff --git a/.lycheeignore b/.lycheeignore new file mode 100644 index 0000000..4a4910b --- /dev/null +++ b/.lycheeignore @@ -0,0 +1,7 @@ +# Links lychee should skip (Wave 7 tooling, #281). One URL regex per line. +# Local/example/non-resolvable addresses that appear in docs as illustrations. +https?://127\.0\.0\.1.* +https?://localhost.* +https?://0\.0\.0\.0.* +https?://example\.(com|org).* +.*\.onion.* diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..2f90b55 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,21 @@ +// markdownlint-cli2 config (Wave 7 tooling, #281). Lints the repo's Markdown; the remaining +// rules (blanks around headings/lists/fences/tables, emphasis style, bare URLs, etc.) are mostly +// auto-fixable with `--fix`. +{ + "config": { + "MD013": false, // line-length: prose/tables/URLs run long — not worth gating + "MD033": false, // inline HTML: intentional in docs (
, ,
, badges) + "MD041": false, // first-line h1: some docs open with badges/intro before the heading + "MD040": false, // fenced-code-language: some blocks are plain console output + "MD028": false, // we place consecutive `>` admonition callouts intentionally + "MD001": false, // the README opens `# title` then an `###` tagline by design + "MD024": { "siblings_only": true } // Keep-a-Changelog repeats "Fixed"/"Added" under each version + }, + "globs": ["**/*.md"], + "ignores": [ + "**/node_modules/**", + "**/.venv/**", + "build/dashboard/mining_dashboard/client/tari/generated/**", + "docs/test-inventory.md" // generated by `make test-inventory`; not hand-edited + ] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1979354..73c8b04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,3 +31,45 @@ repos: - id: trailing-whitespace # Also skip Markdown — two trailing spaces are a hard line break (see .editorconfig). exclude: '(^build/dashboard/mining_dashboard/(client/tari/generated/|web/static/vendor/)|\.md$)' + + # Per-surface lint/format (#281). These delegate to the Makefile targets so tool versions and file + # lists stay single-sourced (no drift between local, pre-commit, and CI). They need the relevant + # tools available (shellcheck, shfmt, node/npx, uv, docker) — see CONTRIBUTING.md. + - repo: local + hooks: + - id: lint-sh + name: shellcheck + shfmt + entry: make lint-sh + language: system + types_or: [shell] + pass_filenames: false + - id: lint-js + name: biome (static frontend) + entry: make lint-js + language: system + files: ^build/dashboard/mining_dashboard/web/static/.*\.(js|mjs|css)$ + pass_filenames: false + - id: lint-yaml + name: yamllint + entry: make lint-yaml + language: system + types: [yaml] + pass_filenames: false + - id: lint-md + name: markdownlint + entry: make lint-md + language: system + types: [markdown] + pass_filenames: false + - id: lint-proto + name: buf (proto) + entry: make lint-proto + language: system + types: [proto] + pass_filenames: false + - id: lint-toml + name: taplo (toml) + entry: make lint-toml + language: system + types: [toml] + pass_filenames: false diff --git a/.taplo.toml b/.taplo.toml new file mode 100644 index 0000000..3bb24e9 --- /dev/null +++ b/.taplo.toml @@ -0,0 +1,2 @@ +# taplo (TOML formatter) config (Wave 7 tooling, #281). +exclude = ["**/uv.lock", "**/.venv/**", "**/node_modules/**"] diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..bdcce78 --- /dev/null +++ b/.yamllint @@ -0,0 +1,11 @@ +# yamllint config (Wave 7 tooling, #281). Extends the default ruleset; relaxes the rules that +# fight our house style while keeping the hygiene ones (trailing space, tabs, final newline, etc.). +extends: default +rules: + line-length: disable # config/comments/URLs run long — not worth gating or wrapping + document-start: disable # we don't use a leading `---` + truthy: + check-keys: false # allow GitHub Actions' `on:` key (otherwise flagged truthy) + comments: + min-spaces-from-content: 1 # we use `value # comment` with a single space in places + comments-indentation: disable diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f90dfaa..9e9b74b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,9 +40,12 @@ then runs `ruff` (plus a few hygiene hooks) on your changed files. If you change This runs everything CI does without a server or Docker: - - **lint** — `shellcheck` over `pithead` and the test scripts (keep them - `--severity=warning` clean), plus `ruff` lint + format check over all the repo's Python. - Run one surface on its own with `make lint-sh` or `make lint-py`. + - **lint** — every file surface gets a linter/formatter check (`make lint` runs them all; run one + with `make lint-`): `lint-sh` (shellcheck + shfmt), `lint-py` (ruff), `lint-js` (Biome), + `lint-yaml` (yamllint), `lint-md` (markdownlint), `lint-proto` (buf), `lint-toml` (taplo). The + non-Python tools run via `npx`/`uvx`/`docker`, so a contributor needs **Node, uv, and Docker** + on PATH (plus `shfmt`); `pre-commit` runs the same checks on changed files. Link-checking + (`lychee`) runs on a weekly schedule, not per-PR. - **test-dashboard** — the dashboard `pytest` suite (must stay ≥ the **80% coverage gate**). - **test-stack** — the `pithead` shell test suite. - **test-compose** — `docker-compose.yml` interpolation validation. diff --git a/Makefile b/Makefile index 173b591..7bd62e3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Local test entry points (mirror the GitHub Actions CI jobs). -.PHONY: test test-dashboard test-stack test-compose test-integration test-integration-selftest test-fakes test-mini-stack lint lint-sh lint-py release +.PHONY: test test-dashboard test-stack test-compose test-integration test-integration-selftest test-fakes test-mini-stack lint lint-sh lint-py lint-js lint-yaml lint-md lint-proto lint-toml release test: lint test-dashboard test-stack test-compose test-integration-selftest test-fakes ## Run everything that doesn't need a server/docker @@ -37,16 +37,34 @@ test-inventory-check: ## Fail if docs/test-inventory.md is stale (CI drift guard test-integration: ## Run the live config-matrix integration suite (requires a test box; pass ARGS=...) bash tests/integration/run.sh $(ARGS) -lint: lint-sh lint-py ## Lint every surface (shell + Python) +lint: lint-sh lint-py lint-js lint-yaml lint-md lint-proto lint-toml ## Lint/format-check every surface -lint-sh: ## shellcheck the CLI, the build/* container scripts, the release script, and the test scripts +lint-sh: ## shellcheck + shfmt over the CLI, build/* container scripts, release + test scripts shellcheck --severity=warning pithead scripts/*.sh build/*/*.sh tests/stack/run.sh tests/stack/test_compose.sh \ tests/inventory.sh tests/integration/*.sh tests/integration/mini-stack/*.sh + shfmt -i 4 -d pithead $(shell git ls-files '*.sh') lint-py: ## ruff lint + format check on all repo Python (ruff runs via uv from the locked dev extra) uv run --locked --project build/dashboard --extra dev ruff check . uv run --locked --project build/dashboard --extra dev ruff format --check . +lint-js: ## Biome lint + format check on the static frontend (config: biome.json) + npx --yes @biomejs/biome@2.5.0 check . + +lint-yaml: ## yamllint over all tracked YAML (config: .yamllint) + uvx yamllint $(shell git ls-files '*.yml' '*.yaml') + +lint-md: ## markdownlint over all Markdown (config: .markdownlint-cli2.jsonc) + npx --yes markdownlint-cli2@0.18.1 + +lint-proto: ## buf lint + build on the vendored Tari protos (config: .../tari/proto/buf.yaml) + cd build/dashboard/mining_dashboard/client/tari/proto && \ + docker run --rm -v "$$PWD":/workspace --workdir /workspace bufbuild/buf:1.71.0 lint && \ + docker run --rm -v "$$PWD":/workspace --workdir /workspace bufbuild/buf:1.71.0 build + +lint-toml: ## taplo TOML format check (config: .taplo.toml) + npx --yes @taplo/cli@0.7.0 fmt --check + # Cut a release from the private build/test server (gouda) — GHCR publish, gated on the test suite + # the #54 integration matrix (issue #44). Pass options through ARGS, e.g. a safe plan-only preview: # make release ARGS="--dry-run" diff --git a/README.md b/README.md index 5177b27..65dcf46 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # Pithead -### Private Monero + Tari merge mining, the whole stack, in one command. +### Private Monero + Tari merge mining, the whole stack, in one command [![CI](https://github.com/p2pool-starter-stack/pithead/actions/workflows/ci.yml/badge.svg)](https://github.com/p2pool-starter-stack/pithead/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..352bee0 --- /dev/null +++ b/biome.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", + "vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true }, + "files": { + "includes": [ + "build/dashboard/mining_dashboard/web/static/**/*.{js,mjs,css}", + "!build/dashboard/mining_dashboard/web/static/vendor", + "!build/dashboard/mining_dashboard/web/static/chart.umd.min.js" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style": { "useTemplate": "off" }, + "complexity": { "useOptionalChain": "off", "noImportantStyles": "off" } + } + }, + "javascript": { + "formatter": { "quoteStyle": "double", "semicolons": "always" } + } +} diff --git a/build/dashboard/entrypoint.sh b/build/dashboard/entrypoint.sh index 9c37403..8fb1003 100644 --- a/build/dashboard/entrypoint.sh +++ b/build/dashboard/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/bash set -e -# We no longer dynamically fetch the LAN IP here because the dashboard +# We no longer dynamically fetch the LAN IP here because the dashboard # MUST be bound to localhost (127.0.0.1) to remain secure behind Caddy. # The HOST_IP variable is now injected directly via docker-compose.yml. @@ -12,4 +12,4 @@ export HOST_IP="${HOST_IP:-127.0.0.1}" # 'exec' replaces the shell process so SIGTERM is handled correctly; # '-u' forces unbuffered stdout/stderr for real-time Docker logging. cd /app -exec python3 -u -m mining_dashboard.main \ No newline at end of file +exec python3 -u -m mining_dashboard.main diff --git a/build/dashboard/mining_dashboard/client/tari/Readme.md b/build/dashboard/mining_dashboard/client/tari/Readme.md index 1cf6602..99b5e78 100644 --- a/build/dashboard/mining_dashboard/client/tari/Readme.md +++ b/build/dashboard/mining_dashboard/client/tari/Readme.md @@ -1,9 +1,10 @@ # Tari gRPC Collector ## Generate Protobuf Files + Ensure `base_node.proto` and `types.proto` are in the `proto/` subdirectory, then run: ```bash docker run --rm -v "$PWD":/work -w /work ghcr.io/astral-sh/uv:0.10.10-python3.11-trixie-slim \ /bin/bash -c "uvx --from grpcio-tools python -m grpc_tools.protoc -Iproto --python_out=generated --grpc_python_out=generated proto/*.proto && sed -i 's/^import.*_pb2/from . \0/' generated/*_pb2*.py" -``` \ No newline at end of file +``` diff --git a/build/dashboard/mining_dashboard/client/tari/proto/buf.yaml b/build/dashboard/mining_dashboard/client/tari/proto/buf.yaml new file mode 100644 index 0000000..71d2998 --- /dev/null +++ b/build/dashboard/mining_dashboard/client/tari/proto/buf.yaml @@ -0,0 +1,13 @@ +# buf config (Wave 7 tooling, #281). These are vendored upstream Tari protos, so we lint with the +# MINIMAL ruleset (structural only — not the opinionated style rules we don't own) and except +# PACKAGE_DIRECTORY_MATCH (the protos declare `package tari.rpc` but are vendored flat into proto/). +# `buf build` still verifies they compile; `breaking: FILE` enables `buf breaking --against `. +version: v2 +lint: + use: + - MINIMAL + except: + - PACKAGE_DIRECTORY_MATCH +breaking: + use: + - FILE diff --git a/build/dashboard/mining_dashboard/web/static/chart.mjs b/build/dashboard/mining_dashboard/web/static/chart.mjs index 820dee4..91de21a 100644 --- a/build/dashboard/mining_dashboard/web/static/chart.mjs +++ b/build/dashboard/mining_dashboard/web/static/chart.mjs @@ -15,27 +15,39 @@ // scatter is kept on its own stack group so its y stays absolute. Zoom/pan (chartjs-plugin-zoom) // gestures hand the visible window up via onZoom, which refetches that window from the server at // duration-adaptive resolution — so zooming in reveals finer data. -import { Component, createRef, html } from './preact.mjs'; -import { fmtTimestamp, clampZoomWindow, bandBorderWidth } from './logic.mjs'; -const RANGES = [['1h', '1 Hr'], ['24h', '24 Hr'], ['1w', '1 Wk'], ['1m', '1 Mo']]; +import { bandBorderWidth, clampZoomWindow, fmtTimestamp } from "./logic.mjs"; +import { Component, createRef, html } from "./preact.mjs"; + +const RANGES = [ + ["1h", "1 Hr"], + ["24h", "24 Hr"], + ["1w", "1 Wk"], + ["1m", "1 Mo"], +]; // Hashrate-averaging windows for the chart toggle (#168): [param key, button label]. The keys match // the server's `avg` param; labels are spelled out so the "1m" window (1 MINUTE) isn't mistaken for // the "1 Mo" RANGE above. Persisted in dashboard.js ui.avg (localStorage), default 10m. 12h/24h read // low until a rig has been online that long — flagged via the button title so it doesn't look broken. -const WINDOWS = [['1m', '1 Min'], ['10m', '10 Min'], ['1h', '1 Hr'], ['12h', '12 Hr'], ['24h', '24 Hr']]; +const WINDOWS = [ + ["1m", "1 Min"], + ["10m", "10 Min"], + ["1h", "1 Hr"], + ["12h", "12 Hr"], + ["24h", "24 Hr"], +]; const WINDOW_HINT = { - '12h': 'Average over the last 12 hours — needs ~12h of rig uptime to fully fill', - '24h': 'Average over the last 24 hours — needs ~24h of rig uptime to fully fill', + "12h": "Average over the last 12 hours — needs ~12h of rig uptime to fully fill", + "24h": "Average over the last 24 hours — needs ~24h of rig uptime to fully fill", }; // Series the user can show/hide (Issue #47): dataset index, label and swatch colour class. // Visibility lives in dashboard.js ui.series (persisted); applied to the chart in applyVisibility. const SERIES = [ - { key: 'p2pool', label: 'P2Pool (routed)', idx: 0, dot: 'dot-p2pool' }, - { key: 'xvb', label: 'XvB (routed)', idx: 1, dot: 'dot-xvb' }, - { key: 'shares', label: 'Shares', idx: 2, dot: 'dot-shares' }, + { key: "p2pool", label: "P2Pool (routed)", idx: 0, dot: "dot-p2pool" }, + { key: "xvb", label: "XvB (routed)", idx: 1, dot: "dot-xvb" }, + { key: "shares", label: "Shares", idx: 2, dot: "dot-shares" }, ]; // Smallest zoom window (ms) — guards against requesting a sub-sample slice (30s native cadence). @@ -45,8 +57,8 @@ const ZOOM_DEBOUNCE_MS = 300; // Register the zoom/pan plugin once (UMD global from chartjs-plugin-zoom.min.js; see index.html). // Guarded so the module is harmless if the global is absent (e.g. outside the browser). -if (typeof Chart !== 'undefined' && typeof window !== 'undefined' && window.ChartZoom) { - Chart.register(window.ChartZoom); +if (typeof Chart !== "undefined" && typeof window !== "undefined" && window.ChartZoom) { + Chart.register(window.ChartZoom); } // Append an 8-bit alpha to a #rrggbb hex (Chart.js accepts #rrggbbaa). Non-hex values pass @@ -56,228 +68,308 @@ const withAlpha = (hex, aa) => (/^#[0-9a-fA-F]{6}$/.test(hex) ? hex + aa : hex); // The chart's colours, read from the active theme's CSS variables (Issue #43) so the chart // matches light/dark/auto. Re-read on every sync() so a theme switch recolours it in place. function paletteColors() { - const cs = getComputedStyle(document.documentElement); - const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback; - const accent = v('--accent', '#58a6ff'); - const purple = v('--purple', '#a371f7'); - return { - accent, purple, - shares: v('--bad', '#da3633'), - grid: v('--border', '#30363d'), - ticks: v('--text-muted', '#8b949e'), - band: withAlpha(accent, '26'), // drag-to-zoom selection band (≈ 0.15 alpha) - }; + const cs = getComputedStyle(document.documentElement); + const v = (name, fallback) => cs.getPropertyValue(name).trim() || fallback; + const accent = v("--accent", "#58a6ff"); + const purple = v("--purple", "#a371f7"); + return { + accent, + purple, + shares: v("--bad", "#da3633"), + grid: v("--border", "#30363d"), + ticks: v("--text-muted", "#8b949e"), + band: withAlpha(accent, "26"), // drag-to-zoom selection band (≈ 0.15 alpha) + }; } // Area-fill gradient stops (Issue #145): strong near the line, fading toward the axis, so a flat // series reads as a solid mass instead of a thin strip. Line a touch thicker than the default so // the top edge pops against the fill. -const FILL_TOP = '59'; // ≈ 0.35 alpha at the line -const FILL_BOTTOM = '0d'; // ≈ 0.05 alpha at the axis +const FILL_TOP = "59"; // ≈ 0.35 alpha at the line +const FILL_BOTTOM = "0d"; // ≈ 0.05 alpha at the axis const AREA_BORDER_WIDTH = 3.5; // A vertical gradient fill for a stacked area, keyed to the live chart area (pixels, not data) so // it spans the visible card regardless of the y-range. Scriptable: re-evaluated on resize/zoom. // Before the first layout `chartArea` is undefined, so fall back to the flat top tint. function areaFill(baseHex) { - return (ctx) => { - const area = ctx.chart.chartArea; - if (!area) return withAlpha(baseHex, FILL_TOP); - const g = ctx.chart.ctx.createLinearGradient(0, area.top, 0, area.bottom); - g.addColorStop(0, withAlpha(baseHex, FILL_TOP)); - g.addColorStop(1, withAlpha(baseHex, FILL_BOTTOM)); - return g; - }; + return (ctx) => { + const area = ctx.chart.chartArea; + if (!area) return withAlpha(baseHex, FILL_TOP); + const g = ctx.chart.ctx.createLinearGradient(0, area.top, 0, area.bottom); + g.addColorStop(0, withAlpha(baseHex, FILL_TOP)); + g.addColorStop(1, withAlpha(baseHex, FILL_BOTTOM)); + return g; + }; } // Pad the auto-fitted y-range so a near-flat line fills the card instead of hugging the bottom // (Issue #145). Pads by a fraction of the visible span, with a floor tied to the magnitude so a // dead-flat series isn't magnified into pure noise; never drops below zero. function padYAxis(scale) { - const { min, max } = scale; - if (!isFinite(min) || !isFinite(max)) return; // all series hidden / no data - const pad = Math.max((max - min) * 0.2, max * 0.03); - scale.min = Math.max(0, min - pad); - scale.max = max + pad; + const { min, max } = scale; + if (!Number.isFinite(min) || !Number.isFinite(max)) return; // all series hidden / no data + const pad = Math.max((max - min) * 0.2, max * 0.03); + scale.min = Math.max(0, min - pad); + scale.max = max + pad; } export class ChartCard extends Component { - constructor(props) { - super(props); - this.canvasRef = createRef(); - this.shareCounts = []; - this.applyingServerData = false; // suppress the gesture handler during programmatic resetZoom - this._zoomDebounce = null; - this._prevWindow = props.window; // track window prop to detect the zoomed -> preset transition - } + constructor(props) { + super(props); + this.canvasRef = createRef(); + this.shareCounts = []; + this.applyingServerData = false; // suppress the gesture handler during programmatic resetZoom + this._zoomDebounce = null; + this._prevWindow = props.window; // track window prop to detect the zoomed -> preset transition + } - componentDidMount() { this.create(); } - componentDidUpdate() { this.sync(); } - componentWillUnmount() { - clearTimeout(this._zoomDebounce); - if (this.chart) { this.chart.destroy(); this.chart = null; } + componentDidMount() { + this.create(); + } + componentDidUpdate() { + this.sync(); + } + componentWillUnmount() { + clearTimeout(this._zoomDebounce); + if (this.chart) { + this.chart.destroy(); + this.chart = null; } + } - // Debounced: a gesture (wheel/drag-zoom/pan) settled — hand the visible window up to refetch - // it from the server at the right resolution. Ignored while we're programmatically resetting. - onGesture() { - if (this.applyingServerData) return; - clearTimeout(this._zoomDebounce); - this._zoomDebounce = setTimeout(() => { - if (!this.chart) return; - const x = this.chart.scales.x; - const w = clampZoomWindow(x.min, x.max, MIN_ZOOM_MS); - if (w) this.props.onZoom(w.from / 1000, w.to / 1000); // epoch ms -> seconds - }, ZOOM_DEBOUNCE_MS); - } + // Debounced: a gesture (wheel/drag-zoom/pan) settled — hand the visible window up to refetch + // it from the server at the right resolution. Ignored while we're programmatically resetting. + onGesture() { + if (this.applyingServerData) return; + clearTimeout(this._zoomDebounce); + this._zoomDebounce = setTimeout(() => { + if (!this.chart) return; + const x = this.chart.scales.x; + const w = clampZoomWindow(x.min, x.max, MIN_ZOOM_MS); + if (w) this.props.onZoom(w.from / 1000, w.to / 1000); // epoch ms -> seconds + }, ZOOM_DEBOUNCE_MS); + } - create() { - const canvas = this.canvasRef.current; - if (!canvas || typeof Chart === 'undefined') return; - const d = this.props.chart; - this.shareCounts = d.shares.map((s) => s.c); - const c = paletteColors(); - const tension = d.tension ?? 0.3; - const vis = this.props.series || {}; // persisted show/hide state (Issue #47) - const self = this; - this.chart = new Chart(canvas, { - type: 'line', - data: { - datasets: [ - // segment.borderWidth hides each band's top border-line where the band is flat-zero, - // so an all-to-one-pool window reads as a single solid color instead of the empty - // series painting its edge line over the other's (#184). The upper (XvB) band fills - // down to the series below it (fill: '-1'), NOT to origin — otherwise its - // semi-transparent purple is painted all the way to zero over the blue P2Pool fill, - // tinting an all-P2Pool window (XvB ≈ 0) lavender instead of leaving it blue. - { label: 'P2Pool (routed)', data: d.p2pool, borderColor: c.accent, borderWidth: AREA_BORDER_WIDTH, - segment: { borderWidth: (ctx) => bandBorderWidth(d.p2pool, ctx, AREA_BORDER_WIDTH) }, - tension, fill: true, hidden: vis.p2pool === false, - stack: 'hr', backgroundColor: areaFill(c.accent), pointRadius: 0, pointHitRadius: 20 }, - { label: 'XvB (routed)', data: d.xvb, borderColor: c.purple, borderWidth: AREA_BORDER_WIDTH, - segment: { borderWidth: (ctx) => bandBorderWidth(d.xvb, ctx, AREA_BORDER_WIDTH) }, - tension, fill: '-1', hidden: vis.xvb === false, - stack: 'hr', backgroundColor: areaFill(c.purple), pointRadius: 0, pointHitRadius: 20 }, - // On its own hidden 0–1 axis (yAxisID) so the markers ride near the top edge and - // never inflate the hashrate y-range (Issue #145). - { label: 'Shares', data: d.shares, borderColor: c.shares, backgroundColor: c.shares, - hidden: vis.shares === false, yAxisID: 'shares', - pointStyle: 'triangle', rotation: 180, pointRadius: d.shares.map((s) => s.r), - pointHoverRadius: 15, pointHitRadius: 100, showLine: false }, - ], + create() { + const canvas = this.canvasRef.current; + if (!canvas || typeof Chart === "undefined") return; + const d = this.props.chart; + this.shareCounts = d.shares.map((s) => s.c); + const c = paletteColors(); + const tension = d.tension ?? 0.3; + const vis = this.props.series || {}; // persisted show/hide state (Issue #47) + const self = this; + this.chart = new Chart(canvas, { + type: "line", + data: { + datasets: [ + // segment.borderWidth hides each band's top border-line where the band is flat-zero, + // so an all-to-one-pool window reads as a single solid color instead of the empty + // series painting its edge line over the other's (#184). The upper (XvB) band fills + // down to the series below it (fill: '-1'), NOT to origin — otherwise its + // semi-transparent purple is painted all the way to zero over the blue P2Pool fill, + // tinting an all-P2Pool window (XvB ≈ 0) lavender instead of leaving it blue. + { + label: "P2Pool (routed)", + data: d.p2pool, + borderColor: c.accent, + borderWidth: AREA_BORDER_WIDTH, + segment: { borderWidth: (ctx) => bandBorderWidth(d.p2pool, ctx, AREA_BORDER_WIDTH) }, + tension, + fill: true, + hidden: vis.p2pool === false, + stack: "hr", + backgroundColor: areaFill(c.accent), + pointRadius: 0, + pointHitRadius: 20, + }, + { + label: "XvB (routed)", + data: d.xvb, + borderColor: c.purple, + borderWidth: AREA_BORDER_WIDTH, + segment: { borderWidth: (ctx) => bandBorderWidth(d.xvb, ctx, AREA_BORDER_WIDTH) }, + tension, + fill: "-1", + hidden: vis.xvb === false, + stack: "hr", + backgroundColor: areaFill(c.purple), + pointRadius: 0, + pointHitRadius: 20, + }, + // On its own hidden 0–1 axis (yAxisID) so the markers ride near the top edge and + // never inflate the hashrate y-range (Issue #145). + { + label: "Shares", + data: d.shares, + borderColor: c.shares, + backgroundColor: c.shares, + hidden: vis.shares === false, + yAxisID: "shares", + pointStyle: "triangle", + rotation: 180, + pointRadius: d.shares.map((s) => s.r), + pointHoverRadius: 15, + pointHitRadius: 100, + showLine: false, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + animation: false, + spanGaps: false, // {x, y: null} break markers split the line across outages + interaction: { mode: "nearest", axis: "x", intersect: false }, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { + title(items) { + return items.length ? fmtTimestamp(items[0].parsed.x) : ""; + }, + label(context) { + if (context.dataset.label === "Shares") + return self.shareCounts[context.dataIndex] + " Shares"; + let label = context.dataset.label || ""; + if (label) label += ": "; + if (context.parsed.y !== null) label += context.parsed.y + " H/s"; + return label; + }, }, - options: { - responsive: true, maintainAspectRatio: false, animation: false, - spanGaps: false, // {x, y: null} break markers split the line across outages - interaction: { mode: 'nearest', axis: 'x', intersect: false }, - plugins: { - legend: { display: false }, - tooltip: { callbacks: { - title(items) { return items.length ? fmtTimestamp(items[0].parsed.x) : ''; }, - label(context) { - if (context.dataset.label === 'Shares') return self.shareCounts[context.dataIndex] + ' Shares'; - let label = context.dataset.label || ''; - if (label) label += ': '; - if (context.parsed.y !== null) label += context.parsed.y + ' H/s'; - return label; - }, - } }, - // Drag = box-zoom, wheel = zoom, Shift-drag = pan (Issue #47). Each settled - // gesture triggers a server refetch of the visible window (onGesture). - zoom: { - zoom: { - wheel: { enabled: true }, - drag: { enabled: true, backgroundColor: c.band, borderColor: c.accent, borderWidth: 1 }, - mode: 'x', - onZoomComplete: () => self.onGesture(), - }, - pan: { enabled: true, mode: 'x', modifierKey: 'shift', onPanComplete: () => self.onGesture() }, - limits: { x: { minRange: MIN_ZOOM_MS } }, - }, - }, - scales: { - // Linear x positions points by real elapsed time (gaps occupy proportional - // space); axis hidden as before. y is stacked (P2Pool+XvB = total) and - // grid/ticks follow the theme; padYAxis keeps a flat line off the floor. - x: { type: 'linear', display: false }, - y: { stacked: true, grid: { color: c.grid }, ticks: { color: c.ticks }, afterDataLimits: padYAxis }, - // Hidden 0–1 axis the Shares scatter rides on; markers pin near the top (0.93, - // set server-side) so they never affect the hashrate y-range (Issue #145). - shares: { type: 'linear', display: false, min: 0, max: 1 }, - }, + }, + // Drag = box-zoom, wheel = zoom, Shift-drag = pan (Issue #47). Each settled + // gesture triggers a server refetch of the visible window (onGesture). + zoom: { + zoom: { + wheel: { enabled: true }, + drag: { + enabled: true, + backgroundColor: c.band, + borderColor: c.accent, + borderWidth: 1, + }, + mode: "x", + onZoomComplete: () => self.onGesture(), }, - }); - } + pan: { + enabled: true, + mode: "x", + modifierKey: "shift", + onPanComplete: () => self.onGesture(), + }, + limits: { x: { minRange: MIN_ZOOM_MS } }, + }, + }, + scales: { + // Linear x positions points by real elapsed time (gaps occupy proportional + // space); axis hidden as before. y is stacked (P2Pool+XvB = total) and + // grid/ticks follow the theme; padYAxis keeps a flat line off the floor. + x: { type: "linear", display: false }, + y: { + stacked: true, + grid: { color: c.grid }, + ticks: { color: c.ticks }, + afterDataLimits: padYAxis, + }, + // Hidden 0–1 axis the Shares scatter rides on; markers pin near the top (0.93, + // set server-side) so they never affect the hashrate y-range (Issue #145). + shares: { type: "linear", display: false, min: 0, max: 1 }, + }, + }, + }); + } - // Apply the persisted show/hide state to the datasets (Issue #47); each defaults to visible. - // Hiding a stacked series re-stacks the rest (Chart.js excludes hidden datasets from the sum). - applyVisibility() { - const vis = this.props.series || {}; - for (const s of SERIES) this.chart.setDatasetVisibility(s.idx, vis[s.key] !== false); - } + // Apply the persisted show/hide state to the datasets (Issue #47); each defaults to visible. + // Hiding a stacked series re-stacks the rest (Chart.js excludes hidden datasets from the sum). + applyVisibility() { + const vis = this.props.series || {}; + for (const s of SERIES) this.chart.setDatasetVisibility(s.idx, vis[s.key] !== false); + } - sync() { - if (!this.chart) { this.create(); return; } - const d = this.props.chart; - const c = paletteColors(); // re-read so a theme switch recolours in place - const tension = d.tension ?? 0.3; - this.shareCounts = d.shares.map((s) => s.c); - const ds = this.chart.data.datasets; - ds[0].data = d.p2pool; ds[0].borderColor = c.accent; ds[0].backgroundColor = areaFill(c.accent); ds[0].tension = tension; - ds[1].data = d.xvb; ds[1].borderColor = c.purple; ds[1].backgroundColor = areaFill(c.purple); ds[1].tension = tension; - ds[2].data = d.shares; ds[2].borderColor = c.shares; ds[2].backgroundColor = c.shares; - ds[2].pointRadius = d.shares.map((s) => s.r); - this.chart.options.scales.y.grid.color = c.grid; - this.chart.options.scales.y.ticks.color = c.ticks; - this.applyVisibility(); + sync() { + if (!this.chart) { + this.create(); + return; + } + const d = this.props.chart; + const c = paletteColors(); // re-read so a theme switch recolours in place + const tension = d.tension ?? 0.3; + this.shareCounts = d.shares.map((s) => s.c); + const ds = this.chart.data.datasets; + ds[0].data = d.p2pool; + ds[0].borderColor = c.accent; + ds[0].backgroundColor = areaFill(c.accent); + ds[0].tension = tension; + ds[1].data = d.xvb; + ds[1].borderColor = c.purple; + ds[1].backgroundColor = areaFill(c.purple); + ds[1].tension = tension; + ds[2].data = d.shares; + ds[2].borderColor = c.shares; + ds[2].backgroundColor = c.shares; + ds[2].pointRadius = d.shares.map((s) => s.r); + this.chart.options.scales.y.grid.color = c.grid; + this.chart.options.scales.y.ticks.color = c.ticks; + this.applyVisibility(); - // On the zoomed -> preset transition (Reset or picking a preset clears the window), drop - // any stale plugin zoom transform so the axis re-fits the new preset data. Keyed to the - // transition (not merely "window is null") so a refresh mid-gesture can't clobber an - // in-progress zoom before its debounce fires. - const justCleared = this._prevWindow && !this.props.window; - this._prevWindow = this.props.window; - if (justCleared && this.chart.isZoomedOrPanned && this.chart.isZoomedOrPanned()) { - this.applyingServerData = true; - this.chart.resetZoom('none'); - this.applyingServerData = false; - } - this.chart.update(); - this.chart.resize(); + // On the zoomed -> preset transition (Reset or picking a preset clears the window), drop + // any stale plugin zoom transform so the axis re-fits the new preset data. Keyed to the + // transition (not merely "window is null") so a refresh mid-gesture can't clobber an + // in-progress zoom before its debounce fires. + const justCleared = this._prevWindow && !this.props.window; + this._prevWindow = this.props.window; + if (justCleared && this.chart.isZoomedOrPanned && this.chart.isZoomedOrPanned()) { + this.applyingServerData = true; + this.chart.resetZoom("none"); + this.applyingServerData = false; } + this.chart.update(); + this.chart.resize(); + } - render(props) { - const zoomed = !!props.window; - return html` + render(props) { + const zoomed = !!props.window; + return html`
Range: - ${RANGES.map(([r, label]) => html` { e.preventDefault(); props.onRange(r); }}>${label}`)} - ${zoomed + ${RANGES.map( + ([r, label]) => html` { + e.preventDefault(); + props.onRange(r); + }}>${label}`, + )} + ${ + zoomed ? html`` - : html`Drag to zoom · Shift-drag to pan · Scroll to zoom`} + : html`Drag to zoom · Shift-drag to pan · Scroll to zoom` + }
Avg: - ${WINDOWS.map(([w, label]) => html``)} + title=${WINDOW_HINT[w] || label + " average"} + onClick=${() => props.onAvgWindow && props.onAvgWindow(w)}>${label}`, + )}
${SERIES.map((s) => { - const on = (props.series || {})[s.key] !== false; - return html``; })}
`; - } + } } diff --git a/build/dashboard/mining_dashboard/web/static/components.mjs b/build/dashboard/mining_dashboard/web/static/components.mjs index 533f337..9e1a0a1 100644 --- a/build/dashboard/mining_dashboard/web/static/components.mjs +++ b/build/dashboard/mining_dashboard/web/static/components.mjs @@ -2,40 +2,52 @@ // on the server). The server sends formatted display strings and semantic tokens // (variant: "ok"/"purple"/"accent"/"muted", level: "high"/"ok"); the client maps those to // classes — it does no number formatting or business logic of its own. -import { Component, Fragment, html } from './preact.mjs'; -import { ChartCard } from './chart.mjs'; + +import { ChartCard } from "./chart.mjs"; import { - WORKER_COLUMNS, sortWorkers, THEME_ORDER, THEME_LABELS, heroKpis, raffleCls, - computeEarnings, formatXmr, formatTimeToShare, parseHashrate, uptimeCell, -} from './logic.mjs'; + computeEarnings, + formatTimeToShare, + formatXmr, + heroKpis, + parseHashrate, + raffleCls, + sortWorkers, + THEME_LABELS, + THEME_ORDER, + uptimeCell, + WORKER_COLUMNS, +} from "./logic.mjs"; +import { Component, Fragment, html } from "./preact.mjs"; // Palette token -> text-colour class (defined in dashboard.css). -const cVar = (v) => 'c-' + v; +const cVar = (v) => "c-" + v; // --- Small shared pieces ------------------------------------------------------------- const StatCard = ({ label, value, cls, span }) => html` -
+
${label}
-

${value}

+

${value}

`; -const SharesStat = ({ sw, label = 'Share In Window' }) => html` +const SharesStat = ({ sw, label = "Share In Window" }) => html`
${label}
-

${sw.count}

+

${sw.count}

`; // Tari status with the ✔ the server signals via `active`. const TariStatus = ({ tari }) => html` -

+

${tari.status}${tari.active ? html` ` : null}

`; const Badges = ({ badges }) => html`
- ${badges.map((b) => html` - ${b.text}`)} + ${badges.map( + (b) => html` + ${b.text}`, + )}
`; // Build-version badge (Issue #58). Muted badge-outline so it reads as informative, not loud; @@ -43,24 +55,24 @@ const Badges = ({ badges }) => html` // release to `vX.Y.Z` and any other build to `dev · branch @ hash`, so a dev build is // unmistakable. `dev` adds a marker class purely as a class hook (text already distinguishes it). const VersionBadge = ({ version }) => - version && version.text - ? html`${version.text}` - : null; + version && version.text + ? html`${version.text}` + : null; // New-release callout (#224). Shown only when the server reports a newer GitHub release is available // (opt-in `dashboard.check_for_updates`, off by default). Notify-only — it's a link to the release, // not an upgrade button (#59). Accent so it's noticeable; opens the release page in a new tab. const UpdateBadge = ({ update }) => - update && update.available && update.url - ? html`New release ${update.latest} available ↗` - : null; + : null; const HighUsage = ({ level }) => - level === 'high' ? html`High Usage` : null; + level === "high" ? html`High Usage` : null; // Theme icons (Issue #43) — minimal Lucide-style line glyphs drawn with currentColor, so they // pick up the segment's text colour (muted → full on hover/active). Inline SVG keeps them crisp @@ -69,9 +81,13 @@ const svgIcon = (body) => html` `; const THEME_ICON = { - light: () => svgIcon(html``), - auto: () => svgIcon(html``), - dark: () => svgIcon(html``), + light: () => + svgIcon( + html``, + ), + auto: () => + svgIcon(html``), + dark: () => svgIcon(html``), }; // Fixed bottom-right segmented control to pick light / auto / dark (Issue #43). Icon-only and @@ -79,25 +95,28 @@ const THEME_ICON = { // in every app state (loading / sync / dashboard) so it's always reachable; the choice is // persisted by the onTheme handler in dashboard.js. const ThemeSwitcher = ({ theme, onTheme }) => { - const current = theme || 'auto'; - return html` + const current = theme || "auto"; + return html`
- ${THEME_ORDER.map((id) => html` - `)} + `, + )}
`; }; // --- Top bar ------------------------------------------------------------------------- function Header({ state }) { - const s = state.system, hr = state.hashrate; - const labelCls = (level) => (level === 'high' ? 'status-bad' : 'text-muted'); - const valCls = (level) => (level === 'high' ? 'status-bad' : ''); - return html` + const s = state.system, + hr = state.hashrate; + const labelCls = (level) => (level === "high" ? "status-bad" : "text-muted"); + const valCls = (level) => (level === "high" ? "status-bad" : ""); + return html`
@@ -121,13 +140,13 @@ function Header({ state }) {
RAM: ${s.mem.used} / ${s.mem.total} GB (${s.mem.percent}) <${HighUsage} level=${s.mem.level} /> - Huge Pages: ${s.hugepages.status} (${s.hugepages.value}) + Huge Pages: ${s.hugepages.status} (${s.hugepages.value})
- Disk: ${s.disk.used} / ${s.disk.total} GB (${s.disk.percent}) <${HighUsage} level=${s.disk.level} /> + Disk: ${s.disk.used} / ${s.disk.total} GB (${s.disk.percent}) <${HighUsage} level=${s.disk.level} />
-
+
@@ -135,8 +154,8 @@ function Header({ state }) {
Last Update: ${state.last_update}
-
P2Pool (routed): ${hr.p2p_1h} (1h) / ${hr.p2p_24h} (24h)
-
XvB (routed): ${hr.xvb_routed_1h} (1h) / ${hr.xvb_routed_24h} (24h)
+
P2Pool (routed): ${hr.p2p_1h} (1h) / ${hr.p2p_24h} (24h)
+
XvB (routed): ${hr.xvb_routed_1h} (1h) / ${hr.xvb_routed_24h} (24h)
`; } @@ -149,28 +168,33 @@ function Header({ state }) { // when operational — during sync the numbers aren't meaningful yet. const HeroBand = ({ state }) => html`
- ${heroKpis(state).map((k) => html` + ${heroKpis(state).map( + (k) => html`
-
${k.value}
+
${k.value}
${k.label}
-
`)} +
`, + )}
`; // --- Sync Mode ----------------------------------------------------------------------- function Gauge({ percent, state }) { - const inner = state === 'done' - ? html`` - : state === 'loading' ? '…' : percent + '%'; - return html` + const inner = + state === "done" + ? html`` + : state === "loading" + ? "…" + : percent + "%"; + return html`
-
+
${inner}
`; } function SyncView({ sync }) { - return html` + return html`

System is currently synchronizing with the network.

@@ -198,10 +222,12 @@ function SyncView({ sync }) { // --- Operational cards --------------------------------------------------------------- function Overview({ state }) { - const hr = state.hashrate, st = state.stratum, t = state.tari; - // Stat order (#159): fleet headline (total / mode / workers) → raffle status (tier / VIP / - // shares / target) → routed split → reference (last share / Tari / wallets). - return html` + const hr = state.hashrate, + st = state.stratum, + t = state.tari; + // Stat order (#159): fleet headline (total / mode / workers) → raffle status (tier / VIP / + // shares / target) → routed split → reference (last share / Tari / wallets). + return html`

Overview

@@ -225,8 +251,9 @@ function Overview({ state }) { } function NodeStats({ state }) { - const hr = state.hashrate, st = state.stratum; - return html` + const hr = state.hashrate, + st = state.stratum; + return html`

My P2Pool Node Stats

@@ -251,8 +278,8 @@ function NodeStats({ state }) { } function GlobalStats({ state }) { - const p = state.pool; - return html` + const p = state.pool; + return html`

Global P2Pool Stats

@@ -273,8 +300,8 @@ function GlobalStats({ state }) { } function XvBStats({ state }) { - const hr = state.hashrate; - return html` + const hr = state.hashrate; + return html`

XvB Donation Stats

@@ -291,8 +318,9 @@ function XvBStats({ state }) { } function NetworkCard({ state }) { - const n = state.network, m = state.monero; - return html` + const n = state.network, + m = state.monero; + return html`

XMR Network

@@ -314,28 +342,28 @@ function NetworkCard({ state }) { // live P2Pool 1h-average hashrate (the same `p2pool_hr` figure the header / Overview show, which // already excludes the XvB-donated slice) until they take control, then holds their raw text. class EarningsCard extends Component { - constructor(props) { - super(props); - this.state = { input: null }; - this.onInput = (e) => this.setState({ input: e.target.value }); - } - - render() { - const e = this.props.earnings; - if (!e || !e.available) { - return html` + constructor(props) { + super(props); + this.state = { input: null }; + this.onInput = (e) => this.setState({ input: e.target.value }); + } + + render() { + const e = this.props.earnings; + if (!e || !e.available) { + return html`

P2Pool Earnings (estimated)

Network stats unavailable — the estimate can't be computed right now.

`; - } - const { input } = this.state; - const useDefault = input === null; - // Default to your P2Pool 1h-average hashrate (the figure shown in the header / Overview, - // already excluding the XvB-donated slice); once edited, use the parsed what-if value. - const hr = useDefault ? e.p2pool_hr : parseHashrate(input); - const est = computeEarnings(hr, e); - return html` + } + const { input } = this.state; + const useDefault = input === null; + // Default to your P2Pool 1h-average hashrate (the figure shown in the header / Overview, + // already excluding the XvB-donated slice); once edited, use the parsed what-if value. + const hr = useDefault ? e.p2pool_hr : parseHashrate(input); + const est = computeEarnings(hr, e); + return html`

P2Pool Earnings (estimated)

Estimated XMR from P2Pool mining only — excludes XvB donations and Tari merge-mining.

@@ -354,11 +382,11 @@ class EarningsCard extends Component {

${e.disclaimer}

`; - } + } } function TariCard({ tari }) { - return html` + return html`

Tari Merge Mining

@@ -374,29 +402,29 @@ function TariCard({ tari }) { // --- Workers table (WORKER_COLUMNS + sortWorkers live in logic.mjs, unit-tested) ----- function PoolBadge({ pool }) { - if (pool === 'p2pool') return html`P2Pool`; - if (pool === 'xvb') return html`XvB`; - return html`Unknown`; + if (pool === "p2pool") return html`P2Pool`; + if (pool === "xvb") return html`XvB`; + return html`Unknown`; } // Pool-wide proxy share totals (Issue #82) — a footer under the table. Hidden until the proxy // has reported any shares so it isn't an all-zero line on a fresh start. const ProxyTotals = ({ summary }) => { - if (!summary || !summary.has_data) return null; - // htm trims whitespace that wraps across a newline at an element boundary, so the spaces - // around the rejected are added explicitly via ${' '} rather than left to indentation. - const rejCls = summary.reject_level === 'high' ? 'status-bad' : ''; - return html` + if (!summary || !summary.has_data) return null; + // htm trims whitespace that wraps across a newline at an element boundary, so the spaces + // around the rejected are added explicitly via ${' '} rather than left to indentation. + const rejCls = summary.reject_level === "high" ? "status-bad" : ""; + return html`
- Proxy totals: ${summary.accepted} accepted ·${' '} - ${summary.rejected} rejected (${summary.reject_pct}) ·${' '} + Proxy totals: ${summary.accepted} accepted ·${" "} + ${summary.rejected} rejected (${summary.reject_pct}) ·${" "} ${summary.invalid} invalid · Best diff ${summary.best}
`; }; function WorkersTable({ workers, summary, ui, onSort }) { - const rows = sortWorkers(workers, ui.sortIndex, ui.sortAsc); - return html` + const rows = sortWorkers(workers, ui.sortIndex, ui.sortAsc); + return html`

Workers Alive

@@ -405,8 +433,9 @@ function WorkersTable({ workers, summary, ui, onSort }) { ${WORKER_COLUMNS.map((c, i) => html` onSort(i)}>${c.label}`)} - ${rows.map((w) => html` - + ${rows.map( + (w) => html` + ${w.name} <${PoolBadge} pool=${w.pool} /> ${w.ip} ${uptimeCell(w)} @@ -414,10 +443,13 @@ function WorkersTable({ workers, summary, ui, onSort }) { ${w.h60_str} ${w.h15_str} ${w.accepted_str} - ${w.rejected_str}${w.reject_flag + ${w.rejected_str}${ + w.reject_flag ? html` ${w.reject_flag.text}` - : null} - `)} + : null + } + `, + )}
@@ -427,17 +459,27 @@ function WorkersTable({ workers, summary, ui, onSort }) { // --- Operational view ---------------------------------------------------------------- -function DashboardView({ state, ui, onRange, onSort, onView, onZoom, onResetZoom, onToggleSeries, onAvgWindow }) { - const advanced = ui.view === 'advanced'; - // Layout by operator relevance (#159): the at-a-glance chart and the rigs themselves lead (this - // stack may drive many machines), then this stack's own detail cards, then pool-wide and network - // context as reference at the bottom — "mine" first, "the world" last. - return html` -
+function DashboardView({ + state, + ui, + onRange, + onSort, + onView, + onZoom, + onResetZoom, + onToggleSeries, + onAvgWindow, +}) { + const advanced = ui.view === "advanced"; + // Layout by operator relevance (#159): the at-a-glance chart and the rigs themselves lead (this + // stack may drive many machines), then this stack's own detail cards, then pool-wide and network + // context as reference at the bottom — "mine" first, "the world" last. + return html` +
- - + +
@@ -463,26 +505,40 @@ function DashboardView({ state, ui, onRange, onSort, onView, onZoom, onResetZoom // --- Root ---------------------------------------------------------------------------- -export function App({ state, connected, ui, onRange, onSort, onView, onTheme, onZoom, onResetZoom, onToggleSeries, onAvgWindow }) { - // The theme toggle is fixed-position and always available, even before the first data load. - const switcher = html`<${ThemeSwitcher} theme=${ui.theme} onTheme=${onTheme} />`; - if (!state) { - return html`<${Fragment}> -
${connected ? 'Connecting to the dashboard…' : 'Cannot reach the dashboard.'}
+export function App({ + state, + connected, + ui, + onRange, + onSort, + onView, + onTheme, + onZoom, + onResetZoom, + onToggleSeries, + onAvgWindow, +}) { + // The theme toggle is fixed-position and always available, even before the first data load. + const switcher = html`<${ThemeSwitcher} theme=${ui.theme} onTheme=${onTheme} />`; + if (!state) { + return html`<${Fragment}> +
${connected ? "Connecting to the dashboard…" : "Cannot reach the dashboard."}
${switcher} `; - } - return html`<${Fragment}> + } + return html`<${Fragment}> <${Header} state=${state} /> ${!connected ? html`
Disconnected — showing last known data. Retrying…
` : null} - ${state.syncing + ${ + state.syncing ? html`<${SyncView} sync=${state.sync} />` : html`<${Fragment}> <${HeroBand} state=${state} /> <${DashboardView} state=${state} ui=${ui} onRange=${onRange} onSort=${onSort} onView=${onView} onZoom=${onZoom} onResetZoom=${onResetZoom} onToggleSeries=${onToggleSeries} onAvgWindow=${onAvgWindow} /> - `} + ` + } ${switcher} `; } diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css index a76bc85..ec4c46f 100644 --- a/build/dashboard/mining_dashboard/web/static/dashboard.css +++ b/build/dashboard/mining_dashboard/web/static/dashboard.css @@ -18,239 +18,723 @@ * Chart.js chart (which reads them via getComputedStyle) all follow the active theme. */ :root, :root[data-theme="dark"] { - --bg: #0d1117; --card: #161b22; --border: #30363d; - --text: #c9d1d9; --text-muted: #8b949e; - --accent: #58a6ff; --ok: #238636; --bad: #da3633; --warn: #d29922; --purple: #a371f7; - --elevated: #2d333b; /* raised surface (e.g. the active theme segment): lighter than --card */ + --bg: #0d1117; + --card: #161b22; + --border: #30363d; + --text: #c9d1d9; + --text-muted: #8b949e; + --accent: #58a6ff; + --ok: #238636; + --bad: #da3633; + --warn: #d29922; + --purple: #a371f7; + --elevated: #2d333b; /* raised surface (e.g. the active theme segment): lighter than --card */ } /* Light palette — GitHub Primer light, mirroring the dark set's GitHub-dark origin. Kept in * sync with the auto block below (plain CSS has no way to share one declaration list across a * selector and a media query). */ :root[data-theme="light"] { - --bg: #ffffff; --card: #f6f8fa; --border: #d0d7de; - --text: #1f2328; --text-muted: #656d76; - --accent: #0969da; --ok: #1a7f37; --bad: #cf222e; --warn: #9a6700; --purple: #8250df; - --elevated: #ffffff; /* white raised thumb on the off-white --card track */ + --bg: #ffffff; + --card: #f6f8fa; + --border: #d0d7de; + --text: #1f2328; + --text-muted: #656d76; + --accent: #0969da; + --ok: #1a7f37; + --bad: #cf222e; + --warn: #9a6700; + --purple: #8250df; + --elevated: #ffffff; /* white raised thumb on the off-white --card track */ } /* Auto: follow the system, but only when the user hasn't pinned dark or light. */ @media (prefers-color-scheme: light) { - :root:not([data-theme="dark"]):not([data-theme="light"]) { - --bg: #ffffff; --card: #f6f8fa; --border: #d0d7de; - --text: #1f2328; --text-muted: #656d76; - --accent: #0969da; --ok: #1a7f37; --bad: #cf222e; --warn: #9a6700; --purple: #8250df; - --elevated: #ffffff; - } + :root:not([data-theme="dark"]):not([data-theme="light"]) { + --bg: #ffffff; + --card: #f6f8fa; + --border: #d0d7de; + --text: #1f2328; + --text-muted: #656d76; + --accent: #0969da; + --ok: #1a7f37; + --bad: #cf222e; + --warn: #9a6700; + --purple: #8250df; + --elevated: #ffffff; + } } -body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); padding: 20px; margin: 0; transition: background-color 0.2s ease, color 0.2s ease; } +body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; + background: var(--bg); + color: var(--text); + padding: 20px; + margin: 0; + transition: + background-color 0.2s ease, + color 0.2s ease; +} -.container { max-width: 1400px; margin: 0 auto; } +.container { + max-width: 1400px; + margin: 0 auto; +} /* Layout & Grid */ -.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid var(--border); padding-bottom: 20px; margin-bottom: 20px; } -.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); gap: 20px; margin-bottom: 20px; } -.card { background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 20px; display: flex; flex-direction: column; min-width: 0; } +.header { + display: flex; + justify-content: space-between; + align-items: flex-start; + border-bottom: 1px solid var(--border); + padding-bottom: 20px; + margin-bottom: 20px; +} +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); + gap: 20px; + margin-bottom: 20px; +} +.card { + background: var(--card); + border: 1px solid var(--border); + border-radius: 6px; + padding: 20px; + display: flex; + flex-direction: column; + min-width: 0; +} /* Brand block (Issue #81): the Pithead mark + wordmark in the header, with the host IP demoted * to a subtitle beneath the name. The host IP (HOST_IP) is arbitrary user input; a long unbroken * value (no hyphens/dots to break at) would otherwise push the header — and the page — wider than * a phone (Issue #83), so overflow-wrap:anywhere lets it break mid-token and wrap instead. */ -.brand { display: flex; align-items: center; gap: 12px; min-width: 0; } -.brand-logo { width: 40px; height: 40px; flex: none; } -.brand-name { margin: 0; font-size: 1.5rem; font-weight: 700; letter-spacing: 0.5px; } -.brand-host { font-size: 0.8rem; margin-top: 3px; line-height: 1.35; overflow-wrap: anywhere; } +.brand { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} +.brand-logo { + width: 40px; + height: 40px; + flex: none; +} +.brand-name { + margin: 0; + font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.5px; +} +.brand-host { + font-size: 0.8rem; + margin-top: 3px; + line-height: 1.35; + overflow-wrap: anywhere; +} /* Host subtitle is "hostname @ ip" (Issue #119): the @ is a faint connector, not a value, so dim it and give it even breathing room. Hostname and ip stay at the one muted subtitle weight. */ -.brand-host-at { margin: 0 0.4em; opacity: 0.5; } +.brand-host-at { + margin: 0 0.4em; + opacity: 0.5; +} /* Hero KPI band (Issue #81): a prominent strip of the headline numbers under the header. A * responsive auto-fit grid so the cards sit in one row on a wide screen and reflow when narrow; * long tier/mode text wraps inside its card rather than overflowing. */ -.hero-band { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; margin-bottom: 20px; } -.hero-kpi { background: var(--card); border: 1px solid var(--border); border-radius: 8px; padding: 16px 18px; text-align: center; } -.hero-value { font-size: 1.6rem; font-weight: 700; line-height: 1.2; overflow-wrap: anywhere; } -.hero-label { margin-top: 6px; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); } +.hero-band { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 16px; + margin-bottom: 20px; +} +.hero-kpi { + background: var(--card); + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px 18px; + text-align: center; +} +.hero-value { + font-size: 1.6rem; + font-weight: 700; + line-height: 1.2; + overflow-wrap: anywhere; +} +.hero-label { + margin-top: 6px; + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} /* Typography */ -h2 { margin: 0; font-size: 1.5rem; font-weight: 600; } -h3 { margin: 0 0 15px 0; font-size: 0.85rem; text-transform: uppercase; color: var(--text-muted); letter-spacing: 0.5px; border-bottom: 1px solid var(--border); padding-bottom: 10px; } -.text-muted { color: var(--text-muted); } -.text-accent { color: var(--accent); } -.text-small { font-size: 0.85rem; } -.text-xs { font-size: 0.75rem; } -.font-mono { font-family: monospace; } +h2 { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} +h3 { + margin: 0 0 15px 0; + font-size: 0.85rem; + text-transform: uppercase; + color: var(--text-muted); + letter-spacing: 0.5px; + border-bottom: 1px solid var(--border); + padding-bottom: 10px; +} +.text-muted { + color: var(--text-muted); +} +.text-accent { + color: var(--accent); +} +.text-small { + font-size: 0.85rem; +} +.text-xs { + font-size: 0.75rem; +} +.font-mono { + font-family: monospace; +} /* Stats */ -.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.stat-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} /* min-width:0 lets the 1fr tracks shrink below their content's intrinsic width — without it a * long unbroken value (a shortened wallet like "48edf…aZ1", a hash, "Donor (1.00 kH/s+)") keeps * the grid wider than the card and overflows it on narrow screens (Issue #83). The values then * wrap via overflow-wrap on the

below rather than spilling out. */ -.stat-card { background: var(--bg); padding: 10px 12px; border-radius: 4px; border: 1px solid var(--border); min-width: 0; } -.stat-card h5 { margin: 0 0 4px 0; font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; } -.stat-card p { margin: 0; font-weight: 600; font-size: 0.95rem; overflow-wrap: anywhere; } -.col-span-2 { grid-column: span 2; } +.stat-card { + background: var(--bg); + padding: 10px 12px; + border-radius: 4px; + border: 1px solid var(--border); + min-width: 0; +} +.stat-card h5 { + margin: 0 0 4px 0; + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; +} +.stat-card p { + margin: 0; + font-weight: 600; + font-size: 0.95rem; + overflow-wrap: anywhere; +} +.col-span-2 { + grid-column: span 2; +} /* Earnings calculator what-if input (Issue #12) */ -.earnings-subtitle { margin: -4px 0 12px 0; line-height: 1.4; } -.earnings-input { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } -.earnings-input label { font-size: 0.7rem; color: var(--text-muted); text-transform: uppercase; } -.earnings-input input { flex: 1; min-width: 0; background: var(--bg); border: 1px solid var(--border); color: var(--text); border-radius: 4px; padding: 6px 10px; font-family: inherit; font-size: 0.9rem; } -.earnings-input input:focus { outline: none; border-color: var(--accent); } -.earnings-disclaimer { line-height: 1.4; } +.earnings-subtitle { + margin: -4px 0 12px 0; + line-height: 1.4; +} +.earnings-input { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} +.earnings-input label { + font-size: 0.7rem; + color: var(--text-muted); + text-transform: uppercase; +} +.earnings-input input { + flex: 1; + min-width: 0; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: 4px; + padding: 6px 10px; + font-family: inherit; + font-size: 0.9rem; +} +.earnings-input input:focus { + outline: none; + border-color: var(--accent); +} +.earnings-disclaimer { + line-height: 1.4; +} /* Table */ -table { width: 100%; border-collapse: collapse; font-size: 0.9rem; } -th { text-align: left; font-size: 0.75rem; color: var(--text-muted); padding: 10px; border-bottom: 1px solid var(--border); text-transform: uppercase; cursor: pointer; } -td { padding: 10px; border-bottom: 1px solid var(--border); } -tr:last-child td { border-bottom: none; } +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; +} +th { + text-align: left; + font-size: 0.75rem; + color: var(--text-muted); + padding: 10px; + border-bottom: 1px solid var(--border); + text-transform: uppercase; + cursor: pointer; +} +td { + padding: 10px; + border-bottom: 1px solid var(--border); +} +tr:last-child td { + border-bottom: none; +} /* Pool-wide proxy share totals under the Workers table (Issue #82). */ -.proxy-totals { margin-top: 12px; } +.proxy-totals { + margin-top: 12px; +} /* Horizontal-scroll wrapper for the workers table (Issue #83): the table can be wider than a * phone viewport (eight columns, incl. accepted/rejected per #82), so it lives in this wrapper * that scrolls sideways within its card instead of stretching the whole page. Cells stay on one * line so columns don't collapse; harmless on desktop, where the table fits and no scrollbar * appears. */ -.table-scroll { overflow-x: auto; } -.table-scroll th, .table-scroll td { white-space: nowrap; } +.table-scroll { + overflow-x: auto; +} +.table-scroll th, +.table-scroll td { + white-space: nowrap; +} /* Components */ -.status-ok { color: var(--ok); } -.status-bad { color: var(--bad); } -.status-warn { color: var(--warn); } - -.progress-bg { background: var(--border); border-radius: 4px; height: 6px; width: 100%; margin-top: 6px; overflow: hidden; } -.progress-fill { background: var(--accent); height: 100%; border-radius: 4px; } -.progress-fill.warning { background: var(--warn); } -.progress-fill.critical { background: var(--bad); } - -.badge { padding: 2px 6px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; vertical-align: middle; display: inline-block; } -.badge-outline { border: 1px solid var(--border); color: var(--text-muted); } -.badge-ok { background: var(--ok); color: white; } -.badge-purple { background: var(--purple); color: white; } -.badge-accent { background: var(--accent); color: white; } -.badge-bad { background: var(--bad); color: white; } -.badge-warn { background: var(--warn); color: white; } +.status-ok { + color: var(--ok); +} +.status-bad { + color: var(--bad); +} +.status-warn { + color: var(--warn); +} + +.progress-bg { + background: var(--border); + border-radius: 4px; + height: 6px; + width: 100%; + margin-top: 6px; + overflow: hidden; +} +.progress-fill { + background: var(--accent); + height: 100%; + border-radius: 4px; +} +.progress-fill.warning { + background: var(--warn); +} +.progress-fill.critical { + background: var(--bad); +} + +.badge { + padding: 2px 6px; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; + vertical-align: middle; + display: inline-block; +} +.badge-outline { + border: 1px solid var(--border); + color: var(--text-muted); +} +.badge-ok { + background: var(--ok); + color: white; +} +.badge-purple { + background: var(--purple); + color: white; +} +.badge-accent { + background: var(--accent); + color: white; +} +.badge-bad { + background: var(--bad); + color: white; +} +.badge-warn { + background: var(--warn); + color: white; +} /* Header status badges sit in a gapped row, so individual badges need no margins. */ -.badge-row { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-left: 10px; } +.badge-row { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-left: 10px; +} /* Build-version badge (Issue #58): monospace for the version/hash; a dev build keeps the muted palette but swaps to a dashed border so it's quietly, unmistakably not a release. */ -.version-badge { font-family: monospace; font-weight: 500; } -.version-badge.version-dev { border-style: dashed; } +.version-badge { + font-family: monospace; + font-weight: 500; +} +.version-badge.version-dev { + border-style: dashed; +} -.wallet-text { font-size: 0.7rem; color: var(--text-muted); margin-top: auto; padding-top: 15px; word-break: break-all; font-family: monospace; } +.wallet-text { + font-size: 0.7rem; + color: var(--text-muted); + margin-top: auto; + padding-top: 15px; + word-break: break-all; + font-family: monospace; +} /* Chart Controls */ -.chart-controls { display: flex; justify-content: center; align-items: center; flex-wrap: wrap; gap: 5px; margin-bottom: 15px; } -.btn-range { display: inline-block; padding: 4px 12px; border-radius: 4px; text-decoration: none; font-size: 0.8rem; border: 1px solid var(--border); background-color: var(--card); color: var(--text-muted); transition: all 0.2s; } -.btn-range:hover { border-color: var(--text-muted); color: var(--text); } -.btn-range.active { background-color: var(--ok); color: #fff; border-color: var(--ok); } +.chart-controls { + display: flex; + justify-content: center; + align-items: center; + flex-wrap: wrap; + gap: 5px; + margin-bottom: 15px; +} +.btn-range { + display: inline-block; + padding: 4px 12px; + border-radius: 4px; + text-decoration: none; + font-size: 0.8rem; + border: 1px solid var(--border); + background-color: var(--card); + color: var(--text-muted); + transition: all 0.2s; +} +.btn-range:hover { + border-color: var(--text-muted); + color: var(--text); +} +.btn-range.active { + background-color: var(--ok); + color: #fff; + border-color: var(--ok); +} /* Reset-zoom button reuses the range-button look but needs the