From 6642310290eb32f45cb974b6286054ec4fa606e2 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 27 Jul 2026 12:00:09 -0500 Subject: [PATCH 1/7] Replaced `debug` with expanded `system` API and added tests for new endpoints --- README.md | 24 ++++ pyproject.toml | 2 +- src/pqn_node/api/main.py | 21 +++- src/pqn_node/api/routes/debug.py | 18 --- src/pqn_node/api/routes/health.py | 76 +++++++------ src/pqn_node/api/routes/system.py | 112 +++++++++++++++++++ src/pqn_node/cli.py | 29 ++--- src/pqn_node/core/config.py | 88 +++++++++++++++ tests/pytest/test_config_updates.py | 141 ++++++++++++++++++++++++ tests/pytest/test_node_api_additions.py | 135 +++++++++++++++++++++++ uv.lock | 14 +-- 11 files changed, 583 insertions(+), 77 deletions(-) delete mode 100644 src/pqn_node/api/routes/debug.py create mode 100644 src/pqn_node/api/routes/system.py create mode 100644 tests/pytest/test_config_updates.py create mode 100644 tests/pytest/test_node_api_additions.py diff --git a/README.md b/README.md index 9ac2ca7..b5f1eef 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,30 @@ uv run fastapi run src/pqn_node/main.py Browse protocols at http://127.0.0.1:8000/docs. +### Node host provisioning + +Two routes under `/system` operate on the host itself and need one-time setup on each Node. Both are used by remote operations tooling; a Node without them still runs every protocol, it just answers those two routes with an error. + +**`GET /system/screenshot`** shells out to [`maim`](https://github.com/naelstrof/maim), which writes a PNG of the whole X root window to stdout (so a multi-monitor Node returns all its screens in one image): + +```bash +sudo apt install maim +``` + +The API process must be started from inside the desktop session — KDE autostart does this — so that it inherits `DISPLAY`, `XAUTHORITY` and `XDG_RUNTIME_DIR`. Started from a bare SSH shell, capture fails with a 503 rather than returning a black frame. + +**`POST /system/reboot`** runs `sudo systemctl reboot`, so the user running the API needs to do that without a password prompt: + +```bash +echo "$USER ALL=(root) NOPASSWD: /usr/bin/systemctl reboot" | sudo tee /etc/sudoers.d/pqn-reboot +sudo chmod 440 /etc/sudoers.d/pqn-reboot +``` + +The endpoint returns before the machine goes down, so the caller gets a response and can poll until the Node answers again. Recovery is unattended: on boot the machine autologs in and KDE autostart brings the API, GUI and kiosk back up. + +> [!WARNING] +> Neither route is authenticated, like every other Node API route — Nodes are expected to listen only on their VPN addresses, and membership of that network is the trust boundary. Any member of it can reboot any Node. + ### Daily report Run or schedule the Slack health-report digest: diff --git a/pyproject.toml b/pyproject.toml index 0b9b4b9..b2db309 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,7 @@ dependencies = [ "pydantic>=2.0", "pydantic-settings>=2.10.1", "pyserial>=3.5", - "tomli-w>=1.0.0", + "tomlkit>=0.13.0", "typer>=0.15.1", ] diff --git a/src/pqn_node/api/main.py b/src/pqn_node/api/main.py index 8dad6e4..7eda21e 100644 --- a/src/pqn_node/api/main.py +++ b/src/pqn_node/api/main.py @@ -3,18 +3,20 @@ from pqn_node.api.routes import chsh from pqn_node.api.routes import coordination -from pqn_node.api.routes import debug from pqn_node.api.routes import health from pqn_node.api.routes import qkd from pqn_node.api.routes import rng from pqn_node.api.routes import serial +from pqn_node.api.routes import system from pqn_node.api.routes import timetagger from pqn_node.api.routes.health import get_effective_availability from pqn_node.core.config import GamesAvailability from pqn_node.core.config import settings +from pqn_node.core.config import update_config class NodeConfig(BaseModel): + node_name: str follower_node_address: str | None @@ -25,8 +27,8 @@ class NodeConfig(BaseModel): api_router.include_router(rng.router) api_router.include_router(serial.router) api_router.include_router(coordination.router) -api_router.include_router(debug.router) api_router.include_router(health.router) +api_router.include_router(system.router) @api_router.get("/games/availability", tags=["games"]) @@ -34,6 +36,19 @@ def get_availability() -> GamesAvailability: return get_effective_availability() +@api_router.put("/games/availability", tags=["games"]) +def set_availability(availability: GamesAvailability) -> GamesAvailability: + """Set which games this Node offers, persistently and without a restart. + + Writes ``config.toml`` (comments preserved) *and* applies the change to the + live settings object, so the response already reflects the new configuration + gated by the most recent hardware probe. A game the hardware can't support + stays unavailable no matter what is set here. + """ + update_config({f"games_availability.{game}": value for game, value in availability.model_dump().items()}) + return get_effective_availability() + + @api_router.get("/node/config", tags=["node"]) def get_node_config() -> NodeConfig: - return NodeConfig(follower_node_address=settings.follower_node_address) + return NodeConfig(node_name=settings.node_name, follower_node_address=settings.follower_node_address) diff --git a/src/pqn_node/api/routes/debug.py b/src/pqn_node/api/routes/debug.py deleted file mode 100644 index d0dbfaa..0000000 --- a/src/pqn_node/api/routes/debug.py +++ /dev/null @@ -1,18 +0,0 @@ -from fastapi import APIRouter - -from pqn_node.api.deps import StateDep -from pqn_node.core.config import NodeState -from pqn_node.core.config import Settings -from pqn_node.core.config import settings - -router = APIRouter(prefix="/debug", tags=["debug"]) - - -@router.get("/state") -async def get_state(state: StateDep) -> NodeState: - return state - - -@router.get("/settings") -async def get_settings() -> Settings: - return settings diff --git a/src/pqn_node/api/routes/health.py b/src/pqn_node/api/routes/health.py index e9e711f..f4fcaff 100644 --- a/src/pqn_node/api/routes/health.py +++ b/src/pqn_node/api/routes/health.py @@ -2,6 +2,8 @@ import logging import time from collections.abc import Callable +from dataclasses import dataclass +from dataclasses import field import httpx import serial @@ -30,31 +32,7 @@ _probe_executor = concurrent.futures.ThreadPoolExecutor(max_workers=4, thread_name_prefix="health-probe") -class _AvailabilityCache: - """Holds what `/games/availability` reports: the last probe's gated result. - - The invariant, which the sticky-availability bug came from violating: - - value == effective_availability(config.toml, most recent probe) - - `value` is a pure function of those two inputs and carries no history. Every - probe *overwrites* it with a freshly computed result — the previous value is - never read back as an input, so a stale False cannot influence, and cannot - survive, the next probe. This is deliberately a cache beside the settings - singleton rather than a mutation of it: `settings.games_availability` stays - pristine as the configured baseline, because that baseline is what each - recomputation starts from. Mutating it in place (as this code once did) makes - the output its own next input, which latches the flags off permanently. - - `value` is None until the first probe runs; see `get_effective_availability`. - """ - - value: GamesAvailability | None = None - - -_availability_cache = _AvailabilityCache() - - +# FIXME: Why does this need to be its own function like this? def _run_with_timeout[T](fn: Callable[[], T], timeout_s: float) -> T: return _probe_executor.submit(fn).result(timeout=timeout_s) @@ -88,6 +66,33 @@ def all_ok(self) -> bool: return not (self.follower_node is not None and not self.follower_node.reachable) +@dataclass +class _LastProbe: + """The most recent probe's *inputs* to the availability gate — not its result. + + Holding the inputs is what lets `/games/availability` state, at any moment: + + availability == effective_availability(config.toml, most recent probe) + + Availability is recomputed from the pristine config on every read rather than + read back from a stored answer. Two things follow, and both are load-bearing: + + - a gated-off flag can never become an input to the next gating, so it cannot + latch games off; they come back on their own once hardware is reachable; + - a config change (`PUT /games/availability`) is visible immediately, with no + restart and no wait for the next probe. + + `ran` is False until the first probe completes; see `get_effective_availability`. + """ + + ran: bool = False + router: ComponentStatus = field(default_factory=lambda: ComponentStatus(reachable=False, error="no probe yet")) + follower_node: ComponentStatus | None = None + + +_last_probe = _LastProbe() + + def _elapsed_ms(start: float) -> float: return (time.perf_counter() - start) * 1000 @@ -238,9 +243,11 @@ def health() -> HealthStatus: else: follower_node = None - # Refresh what /games/availability reports. Recomputed from the pristine config - # (never from the cached value) so games recover once hardware comes back. - _availability_cache.value = effective_availability(settings.games_availability, router_status, follower_node) + # Record the gate's inputs, not its result: /games/availability recomputes from + # the pristine config on every read, so games recover once hardware comes back. + _last_probe.router = router_status + _last_probe.follower_node = follower_node + _last_probe.ran = True return HealthStatus( router=router_status, @@ -287,16 +294,17 @@ def effective_availability( def get_effective_availability() -> GamesAvailability: - """Return the availability computed by the most recent health probe. + """Return the current configuration, gated by the most recent health probe. Backs `GET /games/availability`. Read-only: this does not probe hardware, so - the answer is only as fresh as the last `health()` call. Today that means app - startup, the daily report, and any manual hit on `/health/` — so hitting - `/health/` is what re-enables games on a node whose hardware has recovered. + the *hardware* half of the answer is only as fresh as the last `health()` + call — app startup, the daily digest, or any manual hit on `/health/`, which + is what re-enables games on a Node whose hardware has recovered. The *config* + half is always current, so a `PUT /games/availability` shows up here at once. Falls back to the configured values when no probe has run yet, so the endpoint reports config rather than claiming everything is disabled. """ - if _availability_cache.value is None: + if not _last_probe.ran: return settings.games_availability.model_copy() - return _availability_cache.value + return effective_availability(settings.games_availability, _last_probe.router, _last_probe.follower_node) diff --git a/src/pqn_node/api/routes/system.py b/src/pqn_node/api/routes/system.py new file mode 100644 index 0000000..33ad835 --- /dev/null +++ b/src/pqn_node/api/routes/system.py @@ -0,0 +1,112 @@ +"""Operations on a Node's host, and read-only dumps of what the process believes. + +Both host operations need per-Node provisioning (see the README): screenshot needs +``maim`` installed, and reboot needs passwordless ``systemctl reboot``. Capture works +only because KDE autostart launches the API from inside the Plasma session, so it +inherits ``DISPLAY`` and ``XAUTHORITY`` — nothing here reconstructs that environment. +""" + +import asyncio +import logging + +from fastapi import APIRouter +from fastapi import BackgroundTasks +from fastapi import HTTPException +from fastapi import Response +from fastapi import status +from pydantic import BaseModel + +from pqn_node.api.deps import StateDep +from pqn_node.core.config import NodeState +from pqn_node.core.config import Settings +from pqn_node.core.config import settings + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/system") + + +class RebootAck(BaseModel): + scheduled: bool + detail: str + + +async def _run(command: tuple[str, ...], timeout_s: float) -> tuple[int, bytes, bytes]: + """Run ``command``, returning (returncode, stdout, stderr). Raises on timeout.""" + process = await asyncio.create_subprocess_exec( + *command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout_s) + except TimeoutError: + process.kill() + await process.wait() + raise + return process.returncode or 0, stdout, stderr + + +@router.get( + "/screenshot", + tags=["system"], + response_class=Response, + responses={200: {"content": {"image/png": {}}, "description": "PNG of the Node's display"}}, +) +async def screenshot(timeout_s: float = 20.0) -> Response: + """Capture the Node's display as a PNG.""" + command = ("maim", "--hidecursor") + try: + returncode, image, errors = await _run(command, timeout_s) + except FileNotFoundError: + detail = f"'{command[0]}' is not installed on this Node" + logger.exception(detail) + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail) from None + except TimeoutError: + detail = f"screenshot timed out after {timeout_s:.0f}s" + logger.error(detail) # noqa: TRY400 - the traceback adds nothing to a timeout + raise HTTPException(status.HTTP_504_GATEWAY_TIMEOUT, detail) from None + + if returncode != 0 or not image: + detail = f"screenshot failed: {errors.decode(errors='replace').strip() or f'exit code {returncode}'}" + logger.error(detail) + raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail) + + return Response(content=image, media_type="image/png") + + +async def _reboot_after_response(delay_s: float) -> None: + command = ("sudo", "systemctl", "reboot") + await asyncio.sleep(delay_s) + logger.warning("Rebooting: %s", " ".join(command)) + try: + returncode, _, errors = await _run(command, timeout_s=30.0) + except (OSError, TimeoutError): + logger.exception("Reboot command failed to run") + return + if returncode != 0: + logger.error("Reboot command exited %d: %s", returncode, errors.decode(errors="replace").strip()) + + +@router.post("/reboot", tags=["system"]) +async def reboot(background_tasks: BackgroundTasks, delay_s: float = 1.0) -> RebootAck: + """Reboot the Node's host. + + Scheduled as a background task so the caller gets a response instead of a dropped + connection, and can poll until the API answers again. ``delay_s`` is how long the + response has to leave the machine before systemd starts tearing it down. + """ + background_tasks.add_task(_reboot_after_response, delay_s) + return RebootAck(scheduled=True, detail=f"Rebooting in {delay_s:.0f}s") + + +@router.get("/state", tags=["debug"]) +async def get_node_state(state: StateDep) -> NodeState: + """Dump the Node's live coordination and protocol state, for eyeballing a running Node.""" + return state + + +@router.get("/settings", tags=["debug"]) +async def get_node_settings() -> Settings: + """Dump the settings the Node is actually running with, including any applied at runtime.""" + return settings diff --git a/src/pqn_node/cli.py b/src/pqn_node/cli.py index 7490193..61c2f3f 100644 --- a/src/pqn_node/cli.py +++ b/src/pqn_node/cli.py @@ -1,12 +1,12 @@ import logging -import tomllib from pathlib import Path from typing import Annotated -import tomli_w import typer +from pqn_node.core.config import config_path from pqn_node.core.config import get_settings +from pqn_node.core.config import write_config from pqn_node.cron_manager import describe_schedule from pqn_node.cron_manager import get_daily_report_job from pqn_node.cron_manager import remove_daily_report_job @@ -28,12 +28,21 @@ def toggle_game( games: Annotated[list[str], typer.Argument(help="Games to toggle: chsh, qf, ssm")], enable: Annotated[bool, typer.Option("--enable/--disable", help="Enable or disable the games")] = True, # noqa: FBT002 - config: Annotated[str, typer.Option(help="Path to config.toml")] = "./config.toml", + config: Annotated[ + Path | None, + typer.Option( + exists=True, + dir_okay=False, + writable=True, + help="Path to config.toml [default: the file the node loads]", + ), + ] = None, ) -> None: """ Enable or disable one or more games in config.toml. - Changes take effect on the next server restart. Games: chsh (Verify Quantum Link), qf (Quantum Fortune), ssm (Share a Secret Message). + Changes take effect on the next server restart (or immediately via `PUT /games/availability` + on a running Node). Games: chsh (Verify Quantum Link), qf (Quantum Fortune), ssm (Share a Secret Message). """ valid_games = {"chsh", "qf", "ssm"} invalid = [g for g in games if g not in valid_games] @@ -41,16 +50,8 @@ def toggle_game( msg = f"Game(s) must be one of: chsh, qf, ssm. Invalid: {invalid}" raise typer.BadParameter(msg) - path = Path(config) - with path.open("rb") as f: - cfg = tomllib.load(f) - - cfg.setdefault("games_availability", {}) - for game in games: - cfg["games_availability"][game] = enable - - with path.open("wb") as f: - tomli_w.dump(cfg, f) + path = config if config is not None else config_path() + write_config(path, {f"games_availability.{game}": enable for game in games}) status = "enabled" if enable else "disabled" logger.info("Games %s %s in %s. Restart the server for changes to take effect.", games, status, path) diff --git a/src/pqn_node/core/config.py b/src/pqn_node/core/config.py index 1def27c..202e25f 100644 --- a/src/pqn_node/core/config.py +++ b/src/pqn_node/core/config.py @@ -1,8 +1,14 @@ import asyncio import logging +import os +import tempfile +from collections.abc import Mapping from enum import Enum from functools import lru_cache +from pathlib import Path +from typing import Any +import tomlkit from pqn_hardware.measurement import MeasurementConfig from pydantic import BaseModel from pydantic import Field @@ -105,6 +111,88 @@ def get_settings() -> Settings: settings = get_settings() +def config_path() -> Path: + """Return the file the settings above are loaded from.""" + # pydantic-settings types this as "one path, or a list of them, or None"; ours is one path. + return Path(Settings.model_config["toml_file"]) # type: ignore[arg-type] + + +def write_config(path: Path, updates: Mapping[str, Any]) -> None: + """Set keys in a config file, applying them to nothing. + + For editing a Node that is not running: the CLI can be pointed at any config file, + and a Node need not be started from ``./config.toml``. A running Node calls + ``update_config`` instead, so that its live settings match what was written. + + Two guarantees: + + - **Comments survive.** The file keeps its comments, key order, and whitespace; + only the named keys change. Operators hand-write ``config.toml`` from a + commented example, so a write that reformatted it would destroy their notes. + - **The file is never left truncated.** Contents go to a temp file in the same + directory and are renamed over the target, which is atomic on POSIX. A crash + mid-write leaves the previous config intact. + + Parameters + ---------- + path + Config file to write. Created if missing, as are any missing tables in it. + updates + Dotted key path -> value, e.g. ``{"games_availability.qf": True}``. + """ + document = tomlkit.parse(path.read_text(encoding="utf-8")) if path.exists() else tomlkit.document() + + for dotted_key, value in updates.items(): + *tables, leaf = dotted_key.split(".") + node: Any = document + for table in tables: + if not isinstance(node.get(table), dict): + if len(node) > 0: + node.add(tomlkit.nl()) # keep a new table from being jammed against the previous line + node[table] = tomlkit.table() + node = node[table] + node[leaf] = value + + # Write beside the target and rename over it, which is atomic on POSIX. + fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + temp_path = Path(temp_name) + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(tomlkit.dumps(document)) + f.flush() + os.fsync(f.fileno()) + temp_path.replace(path) + except BaseException: + temp_path.unlink(missing_ok=True) + raise + + logger.info("Updated %s: %s", path, ", ".join(f"{k}={v!r}" for k, v in updates.items())) + + +def update_config(updates: Mapping[str, Any]) -> None: + """Set keys in this Node's own config file, and apply them to its live settings. + + The change takes effect immediately, so a running Node picks it up with no restart. + Modules import the settings object once and hold it, so it is patched in place + rather than rebuilt — replacing it would leave every module on a stale copy. + + Only leaf values are applied, by plain ``setattr``, so callers must pass values + that already type-check for the target field — in practice they come from a + validated model (see ``PUT /games/availability``). + + Persisting happens first: if the write fails, the in-memory state still matches + what is on disk, which is the recoverable direction to fail in. + """ + write_config(config_path(), updates) + + for dotted_key, value in updates.items(): + *attributes, leaf = dotted_key.split(".") + target: Any = settings + for attribute in attributes: + target = getattr(target, attribute) + setattr(target, leaf, value) + + class NodeRole(Enum): """Enum indicating the role of this Node. Enum values are strings to see the role explicitly in logging instead of seeing numeric values.""" diff --git a/tests/pytest/test_config_updates.py b/tests/pytest/test_config_updates.py new file mode 100644 index 0000000..ca5febc --- /dev/null +++ b/tests/pytest/test_config_updates.py @@ -0,0 +1,141 @@ +"""Tests for programmatic `config.toml` writes. + +The bug these cover: writes used to go through `tomli_w`, which rebuilds the file +from parsed data and so deleted every comment in it. The setup docs tell operators +to paste a commented block, so the first `toggle-game` silently destroyed it. +""" + +from pathlib import Path + +import pytest +import tomlkit + +from pqn_node.core import config +from pqn_node.core.config import GamesAvailability +from pqn_node.core.config import update_config +from pqn_node.core.config import write_config + +EXAMPLE_CONFIG = """\ +node_name = "example_node" # trailing comment + +# Router configuration +router_name = "router1" +timetagger = ["provider", "tagger"] # inline array must stay inline + +[games_availability] +chsh = true +qf = true # Quantum Fortune +ssm = true +""" + + +@pytest.fixture +def config_file(tmp_path: Path) -> Path: + path = tmp_path / "config.toml" + path.write_text(EXAMPLE_CONFIG, encoding="utf-8") + return path + + +class _FakeSettings: + """Stand-in for the real settings object, holding just what these tests write to.""" + + def __init__(self) -> None: + self.games_availability = GamesAvailability() + + +@pytest.fixture(autouse=True) +def fake_settings(monkeypatch: pytest.MonkeyPatch) -> _FakeSettings: + """Swap out the live settings singleton, so no test can apply changes to the real one.""" + fake = _FakeSettings() + monkeypatch.setattr(config, "settings", fake) + return fake + + +def test_write_preserves_comments_and_formatting(config_file: Path) -> None: + write_config(config_file, {"games_availability.qf": False}) + + written = config_file.read_text(encoding="utf-8") + assert "# Router configuration" in written + assert 'node_name = "example_node" # trailing comment' in written + assert "qf = false # Quantum Fortune" in written + # tomli_w exploded arrays one element per line; tomlkit leaves them as written. + assert 'timetagger = ["provider", "tagger"] # inline array must stay inline' in written + + +def test_only_the_named_key_changes(config_file: Path) -> None: + write_config(config_file, {"games_availability.qf": False}) + + document = tomlkit.parse(config_file.read_text(encoding="utf-8")) + assert document["games_availability"]["qf"] is False # type: ignore[index] + assert document["games_availability"]["chsh"] is True # type: ignore[index] + assert document["games_availability"]["ssm"] is True # type: ignore[index] + assert document["node_name"] == "example_node" + + +def test_availability_round_trips(config_file: Path) -> None: + """What is written parses back into the same model the endpoint was given.""" + requested = GamesAvailability(chsh=False, qf=True, ssm=False) + + write_config(config_file, {f"games_availability.{game}": value for game, value in requested.model_dump().items()}) + + written = tomlkit.parse(config_file.read_text(encoding="utf-8")) + assert GamesAvailability.model_validate(dict(written["games_availability"])) == requested # type: ignore[arg-type] + + +def test_update_config_persists_and_applies_in_place( + config_file: Path, fake_settings: _FakeSettings, monkeypatch: pytest.MonkeyPatch +) -> None: + """Modules import `settings` once and hold it, so the change must land on that object.""" + monkeypatch.setattr(config, "config_path", lambda: config_file) + held_reference = fake_settings.games_availability + + update_config({"games_availability.qf": False}) + + assert "qf = false # Quantum Fortune" in config_file.read_text(encoding="utf-8") + assert held_reference.qf is False + assert fake_settings.games_availability is held_reference + + +def test_write_config_does_not_touch_the_live_settings(config_file: Path, fake_settings: _FakeSettings) -> None: + """The CLI writes files, possibly for a Node other than this process, so there is nothing to apply.""" + write_config(config_file, {"games_availability.qf": False}) + + assert "qf = false" in config_file.read_text(encoding="utf-8") + assert fake_settings.games_availability.qf is True + + +def test_missing_table_is_created(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + path.write_text('# just a comment\nnode_name = "n"\n', encoding="utf-8") + + write_config(path, {"games_availability.ssm": False}) + + written = path.read_text(encoding="utf-8") + assert "# just a comment" in written + assert tomlkit.parse(written)["games_availability"]["ssm"] is False # type: ignore[index] + + +def test_missing_file_is_created(tmp_path: Path) -> None: + path = tmp_path / "config.toml" + + write_config(path, {"games_availability.ssm": False}) + + assert tomlkit.parse(path.read_text(encoding="utf-8"))["games_availability"]["ssm"] is False # type: ignore[index] + + +def test_failed_write_leaves_no_partial_file_and_no_temp_files( + config_file: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """A crash mid-write must leave the previous config intact, not a truncated one.""" + + def explode(*_args: object, **_kwargs: object) -> None: + msg = "disk gone" + raise OSError(msg) + + monkeypatch.setattr(Path, "replace", explode) + + with pytest.raises(OSError, match="disk gone"): + write_config(config_file, {"games_availability.qf": False}) + + assert config_file.read_text(encoding="utf-8") == EXAMPLE_CONFIG + assert list(config_file.parent.iterdir()) == [config_file] diff --git a/tests/pytest/test_node_api_additions.py b/tests/pytest/test_node_api_additions.py new file mode 100644 index 0000000..2fdebfc --- /dev/null +++ b/tests/pytest/test_node_api_additions.py @@ -0,0 +1,135 @@ +"""Tests for the Node API endpoints Whobot drives: node config, availability, screenshot, reboot. + +The reboot/screenshot tests only pin the *contract* — that reboot answers before +the machine dies, and that a missing `maim` is a clean error rather than a 500. +Whether the capture is a real desktop and whether the host comes back are +environmental and get verified on a live Node. +""" + +from collections.abc import Iterator +from http import HTTPStatus +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from pqn_node.api.routes import system +from pqn_node.api.routes.health import ComponentStatus +from pqn_node.api.routes.health import _last_probe +from pqn_node.core.config import GamesAvailability +from pqn_node.core.config import settings +from pqn_node.main import app + +UP = ComponentStatus(reachable=True) +DOWN = ComponentStatus(reachable=False, error="unreachable") + + +@pytest.fixture +def client() -> TestClient: + # No `with` block: the lifespan startup health check probes real hardware. + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _isolate_global_state(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Keep availability writes off the developer's real config.toml and settings.""" + monkeypatch.setattr("pqn_node.core.config.config_path", lambda: tmp_path / "config.toml") + original_availability = settings.games_availability + settings.games_availability = original_availability.model_copy() + original_probe = (_last_probe.ran, _last_probe.router, _last_probe.follower_node) + yield + settings.games_availability = original_availability + _last_probe.ran, _last_probe.router, _last_probe.follower_node = original_probe + + +def test_node_config_reports_the_node_name(client: TestClient) -> None: + """Whobot reads each Node's name from the Node itself, not from its own registry.""" + response = client.get("/node/config") + + assert response.status_code == HTTPStatus.OK + assert response.json()["node_name"] == settings.node_name + + +def test_put_availability_persists_and_applies(client: TestClient, tmp_path: Path) -> None: + _last_probe.ran, _last_probe.router, _last_probe.follower_node = True, UP, UP + + response = client.put("/games/availability", json={"chsh": True, "qf": False, "ssm": True}) + + assert response.status_code == HTTPStatus.OK + assert response.json() == {"chsh": True, "qf": False, "ssm": True} + assert settings.games_availability.qf is False + assert "qf = false" in (tmp_path / "config.toml").read_text(encoding="utf-8") + + +def test_put_availability_is_visible_to_get_without_a_restart(client: TestClient) -> None: + """The acceptance criterion for the Whobot 'change game availability' capability.""" + _last_probe.ran, _last_probe.router, _last_probe.follower_node = True, UP, UP + client.put("/games/availability", json={"chsh": True, "qf": True, "ssm": True}) + + client.put("/games/availability", json={"chsh": False, "qf": True, "ssm": True}) + + assert client.get("/games/availability").json() == {"chsh": False, "qf": True, "ssm": True} + + +def test_put_availability_cannot_enable_a_game_the_hardware_cannot_run(client: TestClient) -> None: + """config.toml is a veto, never an override: unreachable hardware still wins.""" + _last_probe.ran, _last_probe.router, _last_probe.follower_node = True, DOWN, None + + response = client.put("/games/availability", json={"chsh": True, "qf": True, "ssm": True}) + + assert response.json() == {"chsh": False, "qf": False, "ssm": False} + # ...but the configured baseline was still recorded, so games come back with the hardware. + assert settings.games_availability == GamesAvailability(chsh=True, qf=True, ssm=True) + + +def test_screenshot_returns_the_png_bytes(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + png = b"\x89PNG\r\n\x1a\ncaptured" + + async def fake_run(_command: tuple[str, ...], _timeout: float) -> tuple[int, bytes, bytes]: + return 0, png, b"" + + monkeypatch.setattr(system, "_run", fake_run) + + response = client.get("/system/screenshot") + + assert response.status_code == HTTPStatus.OK + assert response.headers["content-type"] == "image/png" + assert response.content == png + + +def test_screenshot_without_maim_installed_is_a_clean_503(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_run(_command: tuple[str, ...], _timeout: float) -> tuple[int, bytes, bytes]: + raise FileNotFoundError + + monkeypatch.setattr(system, "_run", fake_run) + + response = client.get("/system/screenshot") + + assert response.status_code == HTTPStatus.SERVICE_UNAVAILABLE + assert "maim" in response.json()["detail"] + + +def test_screenshot_timeout_is_a_504(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + async def fake_run(_command: tuple[str, ...], _timeout: float) -> tuple[int, bytes, bytes]: + raise TimeoutError + + monkeypatch.setattr(system, "_run", fake_run) + + assert client.get("/system/screenshot").status_code == HTTPStatus.GATEWAY_TIMEOUT + + +def test_reboot_answers_before_rebooting(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: + """The caller must get a response, not a dropped connection, so it can start polling.""" + rebooted = False + + async def fake_reboot(_delay_s: float) -> None: + nonlocal rebooted + rebooted = True + + monkeypatch.setattr(system, "_reboot_after_response", fake_reboot) + + response = client.post("/system/reboot") + + assert response.status_code == HTTPStatus.OK + assert response.json()["scheduled"] is True + assert rebooted, "the reboot must be scheduled as a background task, after the response" diff --git a/uv.lock b/uv.lock index 297f598..50aec2f 100644 --- a/uv.lock +++ b/uv.lock @@ -47,7 +47,7 @@ name = "cffi" version = "2.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pycparser", marker = "implementation_name != 'PyPy'" }, + { name = "pycparser" }, ] sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } wheels = [ @@ -748,7 +748,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyserial" }, - { name = "tomli-w" }, + { name = "tomlkit" }, { name = "typer" }, ] @@ -769,7 +769,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "pyserial", specifier = ">=3.5" }, - { name = "tomli-w", specifier = ">=1.0.0" }, + { name = "tomlkit", specifier = ">=0.13.0" }, { name = "typer", specifier = ">=0.15.1" }, ] @@ -1239,12 +1239,12 @@ wheels = [ ] [[package]] -name = "tomli-w" -version = "1.2.0" +name = "tomlkit" +version = "0.15.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +sdist = { url = "https://files.pythonhosted.org/packages/94/96/e07752635b98536177fa1f37671c8f3cdde2e724c6bcf6034b2cfb571565/tomlkit-0.15.1.tar.gz", hash = "sha256:e25bbf38843005246210a12982776f27f99cb9be67160e14434d0c0d21ee1e97", size = 180129, upload-time = "2026-07-17T01:48:04.562Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, + { url = "https://files.pythonhosted.org/packages/13/bc/8c13eb66537dce1d2bd3a57132902f38d0e7f5bb46fa9f4daed9fe9d76ee/tomlkit-0.15.1-py3-none-any.whl", hash = "sha256:177a05aece5a8ca5266fd3c448abb47b8d352f09d477d3ca8332db4d89b24304", size = 49449, upload-time = "2026-07-17T01:48:05.728Z" }, ] [[package]] From 989098038332a1f7dc5755bd93ca9b7994b5e668 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Mon, 27 Jul 2026 15:18:18 -0500 Subject: [PATCH 2/7] Added `pqn_whobot` package: node registry, CLI, config, and tests --- .gitignore | 5 +- pyproject.toml | 6 + src/pqn_whobot/__init__.py | 12 ++ src/pqn_whobot/cli.py | 92 ++++++++++++++ src/pqn_whobot/config.py | 110 ++++++++++++++++ src/pqn_whobot/node_client.py | 77 ++++++++++++ src/pqn_whobot/registry.py | 58 +++++++++ tests/pytest/test_import_boundary.py | 44 +++++++ tests/pytest/test_whobot_cli.py | 143 +++++++++++++++++++++ tests/pytest/test_whobot_config.py | 157 +++++++++++++++++++++++ tests/pytest/test_whobot_registry.py | 181 +++++++++++++++++++++++++++ 11 files changed, 884 insertions(+), 1 deletion(-) create mode 100644 src/pqn_whobot/__init__.py create mode 100644 src/pqn_whobot/cli.py create mode 100644 src/pqn_whobot/config.py create mode 100644 src/pqn_whobot/node_client.py create mode 100644 src/pqn_whobot/registry.py create mode 100644 tests/pytest/test_import_boundary.py create mode 100644 tests/pytest/test_whobot_cli.py create mode 100644 tests/pytest/test_whobot_config.py create mode 100644 tests/pytest/test_whobot_registry.py diff --git a/.gitignore b/.gitignore index b87b4b5..4a1f68a 100644 --- a/.gitignore +++ b/.gitignore @@ -167,4 +167,7 @@ cython_debug/ # Mac Os .DS_Store -config.toml \ No newline at end of file +config.toml + +# Whobot's config: holds the Slack tokens. +whobot.toml diff --git a/pyproject.toml b/pyproject.toml index b2db309..ae91353 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,11 @@ requires = ["uv_build"] build-backend = "uv_build" +[tool.uv.build-backend] +# Two deployables ship from this repo, so both modules must be named; the default is the +# one module matching the project name, which would silently omit pqn_whobot from a build. +module-name = ["pqn_node", "pqn_whobot"] + [project] name = "pqn-node" @@ -28,6 +33,7 @@ dependencies = [ [project.scripts] pqn-node = "pqn_node.cli:app" +whobot = "pqn_whobot.cli:app" [dependency-groups] diff --git a/src/pqn_whobot/__init__.py b/src/pqn_whobot/__init__.py new file mode 100644 index 0000000..7e5649f --- /dev/null +++ b/src/pqn_whobot/__init__.py @@ -0,0 +1,12 @@ +"""Whobot — the operations bot for a PQN Network. See ``WHOBOT.md``. + +One Whobot instance serves many Nodes: it posts the scheduled Daily Digest and lets an +operator probe and control any Node from a chat platform. + +Two rules this package is built to, both enforced by tests: + +- ``pqn_whobot`` may import from ``pqn_node``; nothing in ``pqn_node`` may import from + ``pqn_whobot``. +- Nothing here assumes which machine it runs on: no ``localhost`` defaults, every Node + addressed from the registry in ``whobot.toml``, and no reliance on the host's timezone. +""" diff --git a/src/pqn_whobot/cli.py b/src/pqn_whobot/cli.py new file mode 100644 index 0000000..da79e10 --- /dev/null +++ b/src/pqn_whobot/cli.py @@ -0,0 +1,92 @@ +"""Whobot's command line, for checking a host's config and its Nodes without Slack. + +Commands read ``./whobot.toml``, so run them from the directory holding it. +""" + +import asyncio +import logging +from pathlib import Path + +import typer + +from pqn_whobot.config import WhobotSettings +from pqn_whobot.config import config_path +from pqn_whobot.registry import Node +from pqn_whobot.registry import resolve_nodes + +logging.basicConfig(level=logging.INFO) +# httpx logs every request at INFO, which buries Whobot's own output in a listing. +logging.getLogger("httpx").setLevel(logging.WARNING) + +logger = logging.getLogger(__name__) + +app = typer.Typer(no_args_is_help=True, help="CLI for Whobot, the PQN Network operations bot.") + +_UNKNOWN_NAME = "(unknown)" + + +@app.callback() +def main() -> None: + """Keep subcommands addressable by name, which Typer collapses while there is only one.""" + + +def _load() -> WhobotSettings: + """Load ``./whobot.toml``, or exit naming the file and what is wrong with it. + + Checks the file exists first, since loading an absent one succeeds and yields defaults. + """ + path = config_path() + if not path.is_file(): + typer.echo(f"No {path} in {Path.cwd()}. Copy configs/whobot_example.toml there and fill it in.", err=True) + raise typer.Exit(code=1) + + try: + return WhobotSettings() + # Bad syntax (TOMLDecodeError) and bad values (ValidationError) are both ValueErrors. + except (OSError, ValueError) as e: + typer.echo(f"Could not load {path.resolve()}:\n{e}", err=True) + raise typer.Exit(code=1) from None + + +def _display_name(node: Node) -> str: + """Name this Node for the listing, or call it unknown if it never gave one.""" + return node.name or _UNKNOWN_NAME + + +def _node_line(node: Node, name_width: int) -> str: + columns = f" {_display_name(node):<{name_width}} {node.api_url}" + if not node.reachable: + return f"{columns} UNREACHABLE — {node.error}" + latency = f"{node.latency_ms:.0f}ms" if node.latency_ms is not None else "ok" + state = f"reachable ({latency})" + return f"{columns} {state} — {node.warning}" if node.warning else f"{columns} {state}" + + +@app.command() +def nodes() -> None: + """List every Node in the registry with its resolved name and reachability.""" + settings = _load() + if not settings.nodes: + typer.echo("No Nodes registered. Add a [[nodes]] entry with an api_url to whobot.toml.") + raise typer.Exit(code=1) + + resolved = asyncio.run(resolve_nodes(settings)) + name_width = max(len(_display_name(node)) for node in resolved) + unreachable = [node for node in resolved if not node.reachable] + warned = [node for node in resolved if node.reachable and node.warning] + + typer.echo(f"{len(resolved)} Node(s) in {config_path().resolve()}:") + for node in resolved: + typer.echo(_node_line(node, name_width)) + + if warned: + typer.echo(f"\n{len(warned)} of {len(resolved)} reachable with warnings.") + if unreachable: + typer.echo(f"\n{len(unreachable)} of {len(resolved)} unreachable.") + # Only an unreachable Node fails the command: a warning is for a human to read, and a + # partly-deployed fleet is a normal state that shouldn't look like an outage. + raise typer.Exit(code=1) + + +if __name__ == "__main__": + app() diff --git a/src/pqn_whobot/config.py b/src/pqn_whobot/config.py new file mode 100644 index 0000000..755c9b2 --- /dev/null +++ b/src/pqn_whobot/config.py @@ -0,0 +1,110 @@ +"""Whobot's configuration, read from ``whobot.toml`` in the working directory. + +``WhobotSettings()`` loads it. A missing file loads as all-defaults — no tokens, no Nodes — +so callers that need a real config check that the file exists first. +""" + +from datetime import datetime +from pathlib import Path +from zoneinfo import ZoneInfo +from zoneinfo import ZoneInfoNotFoundError + +from pydantic import BaseModel +from pydantic import ConfigDict +from pydantic import Field +from pydantic import field_validator +from pydantic_settings import BaseSettings +from pydantic_settings import PydanticBaseSettingsSource +from pydantic_settings import SettingsConfigDict +from pydantic_settings import TomlConfigSettingsSource + + +class NodeEntry(BaseModel): + """One entry of the Node Registry: a Node's address. + + Names are not configured here; Whobot reads each Node's name from the Node itself. + """ + + model_config = ConfigDict(extra="forbid") + + api_url: str + + @field_validator("api_url") + @classmethod + def _require_absolute_url(cls, value: str) -> str: + if not value.startswith(("http://", "https://")): + msg = f"api_url must start with http:// or https:// (got {value!r})" + raise ValueError(msg) + return value.rstrip("/") + + +class WhobotSettings(BaseSettings): + """Everything Whobot needs to run. Unknown keys in the file are rejected. + + The Slack tokens default to empty, so Node-facing commands run without credentials. + """ + + slack_bot_token: str = "" + slack_app_token: str = "" + digest_channel: str = "" + + # Daily Digest schedule, interpreted in schedule_timezone rather than the host's zone. + schedule_timezone: str = "America/Chicago" + schedule_hour: int = Field(default=7, ge=0, le=23) + schedule_minute: int = Field(default=0, ge=0, le=59) + + # Bounds for the serial digest, per Node rather than one bound for the whole run. + per_node_timeout_s: float = Field(default=900.0, gt=0) + per_game_timeout_s: float = Field(default=600.0, gt=0) + + # Bound for a single "are you there?" call, well under the digest's per-Node budget. + reachability_timeout_s: float = Field(default=5.0, gt=0) + + # What a digest run records about itself. + last_run_at: datetime | None = None + last_result: str | None = None + + # The Node Registry: the Nodes Whobot knows about. + nodes: list[NodeEntry] = Field(default_factory=list) + + model_config = SettingsConfigDict( + toml_file="./whobot.toml", + extra="forbid", + ) + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + # Unused, but pydantic-settings passes them by keyword, so the names must stay. + env_settings: PydanticBaseSettingsSource, # noqa: ARG003 + dotenv_settings: PydanticBaseSettingsSource, # noqa: ARG003 + file_secret_settings: PydanticBaseSettingsSource, # noqa: ARG003 + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Take values from ``whobot.toml``, then from keyword arguments.""" + return ( + TomlConfigSettingsSource(settings_cls), + init_settings, + ) + + @field_validator("schedule_timezone") + @classmethod + def _require_known_timezone(cls, value: str) -> str: + try: + ZoneInfo(value) + except (ZoneInfoNotFoundError, ValueError) as e: + msg = f"schedule_timezone {value!r} is not a known IANA timezone: {e}" + raise ValueError(msg) from e + return value + + @property + def timezone(self) -> ZoneInfo: + """The zone ``schedule_hour`` and ``schedule_minute`` are interpreted in.""" + return ZoneInfo(self.schedule_timezone) + + +def config_path() -> Path: + """Return the file settings are loaded from, relative to the working directory.""" + # pydantic-settings types this as "one path, or a list of them, or None"; ours is one path. + return Path(WhobotSettings.model_config["toml_file"]) # type: ignore[arg-type] diff --git a/src/pqn_whobot/node_client.py b/src/pqn_whobot/node_client.py new file mode 100644 index 0000000..1d874e3 --- /dev/null +++ b/src/pqn_whobot/node_client.py @@ -0,0 +1,77 @@ +"""Whobot's client for the Node API, the only way it talks to a Node. + +Every call carries a timeout, and every failure — refused connection, HTTP error, +unparseable body — arrives as a ``NodeApiError``. +""" + +import logging + +import httpx +from pydantic import BaseModel +from pydantic import ValidationError + +logger = logging.getLogger(__name__) + + +_NODE_CONFIG_KEYS = {"node_name", "follower_node_address"} + + +class NodeApiError(Exception): + """A call to a Node failed. The message is what an operator sees.""" + + +class NodeConfigResponse(BaseModel): + """The part of ``GET /node/config`` Whobot reads. + + ``node_name`` is optional because Nodes running code from before it was added to the + endpoint answer without it. Such a Node is out of date, not unreachable. + """ + + node_name: str | None = None + follower_node_address: str | None = None + + +class NodeClient: + """Talks to one Node, applying ``timeout_s`` to every call. + + Each call opens and closes its own connection; Nodes are probed minutes apart at most, + so there is nothing for a pooled connection to save. + """ + + def __init__(self, api_url: str, timeout_s: float, transport: httpx.AsyncBaseTransport | None = None) -> None: + self.api_url = api_url.rstrip("/") + self.timeout_s = timeout_s + self._transport = transport + + def __repr__(self) -> str: + return f"NodeClient({self.api_url!r}, timeout_s={self.timeout_s})" + + async def _get_json(self, path: str) -> object: + url = f"{self.api_url}{path}" + try: + async with httpx.AsyncClient(timeout=self.timeout_s, transport=self._transport) as client: + response = await client.get(url) + response.raise_for_status() + return response.json() + except httpx.HTTPError as e: + msg = f"{type(e).__name__}: {e}" + logger.warning("GET %s failed: %s", url, msg) + raise NodeApiError(msg) from e + except ValueError as e: # a 200 that isn't JSON: something other than a Node answered + msg = f"{url} did not return JSON: {e}" + logger.warning(msg) + raise NodeApiError(msg) from e + + async def get_config(self) -> NodeConfigResponse: + """Ask the Node for its name and follower address.""" + payload = await self._get_json("/node/config") + # Both fields are optional, so a bare `{}` would validate: check that the response + # carries at least one of them, or anything serving JSON on that port passes for a Node. + if not isinstance(payload, dict) or not _NODE_CONFIG_KEYS & payload.keys(): + msg = f"{self.api_url}/node/config is not a Node's config: {str(payload)[:100]}" + raise NodeApiError(msg) + try: + return NodeConfigResponse.model_validate(payload) + except ValidationError as e: + msg = f"unexpected /node/config response: {e}" + raise NodeApiError(msg) from e diff --git a/src/pqn_whobot/registry.py b/src/pqn_whobot/registry.py new file mode 100644 index 0000000..5391a20 --- /dev/null +++ b/src/pqn_whobot/registry.py @@ -0,0 +1,58 @@ +"""The Node Registry: the Nodes listed in ``whobot.toml``, with their names and reachability.""" + +import asyncio +import logging +import time +from dataclasses import dataclass + +from pqn_whobot.config import WhobotSettings +from pqn_whobot.node_client import NodeApiError +from pqn_whobot.node_client import NodeClient + +logger = logging.getLogger(__name__) + + +_NO_NAME_WARNING = "no node_name in /node/config; the Node is running older code — update it" + + +@dataclass(frozen=True) +class Node: + """One registered Node as Whobot currently sees it. + + ``name`` is None when the Node did not answer, or answered without one. ``warning`` + describes a Node that answered but not with what Whobot expects — reachable, but not + fully usable. + """ + + api_url: str + name: str | None + reachable: bool + error: str | None = None + warning: str | None = None + latency_ms: float | None = None + + +async def resolve_node(client: NodeClient) -> Node: + """Ask one Node for its name, timing the call. Unreachable is a result, not an exception.""" + started = time.perf_counter() + try: + config = await client.get_config() + except NodeApiError as e: + return Node(api_url=client.api_url, name=None, reachable=False, error=str(e)) + return Node( + api_url=client.api_url, + name=config.node_name, + reachable=True, + warning=None if config.node_name else _NO_NAME_WARNING, + latency_ms=(time.perf_counter() - started) * 1000, + ) + + +async def resolve_nodes(settings: WhobotSettings) -> list[Node]: + """Resolve every registered Node, concurrently, returning them in registry order. + + Concurrent because ``/node/config`` touches no hardware, so there is nothing for two + Nodes to contend for — unlike the digest, which runs Games and so must be serial. + """ + clients = [NodeClient(entry.api_url, settings.reachability_timeout_s) for entry in settings.nodes] + return list(await asyncio.gather(*(resolve_node(client) for client in clients))) diff --git a/tests/pytest/test_import_boundary.py b/tests/pytest/test_import_boundary.py new file mode 100644 index 0000000..2f9053f --- /dev/null +++ b/tests/pytest/test_import_boundary.py @@ -0,0 +1,44 @@ +"""``pqn_whobot`` may import from ``pqn_node``; nothing in ``pqn_node`` may import from ``pqn_whobot``. + +Checked by parsing imports rather than grepping, so a mention in a docstring or comment +can't fail the test and a deferred import can't sneak past it. +""" + +import ast +from pathlib import Path + +import pqn_node +import pqn_whobot + +NODE_ROOT = Path(pqn_node.__file__).parent +WHOBOT_PACKAGE = pqn_whobot.__name__ + + +def _imported_modules(source: Path) -> set[str]: + """Every module name imported by a file, including inside functions and `if TYPE_CHECKING`.""" + tree = ast.parse(source.read_text(encoding="utf-8"), filename=str(source)) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imported.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module is not None and node.level == 0: + imported.add(node.module) + return imported + + +def test_pqn_node_never_imports_pqn_whobot() -> None: + offenders = { + source.relative_to(NODE_ROOT).as_posix() + for source in NODE_ROOT.rglob("*.py") + if any(module.split(".")[0] == WHOBOT_PACKAGE for module in _imported_modules(source)) + } + + assert offenders == set(), f"pqn_node must not import pqn_whobot, but these files do: {sorted(offenders)}" + + +def test_the_boundary_check_can_actually_fail(tmp_path: Path) -> None: + """Guard the guard: an import scanner that finds nothing would pass vacuously.""" + offender = tmp_path / "offender.py" + offender.write_text("from pqn_whobot.registry import resolve_nodes\nimport pqn_whobot.config\n", encoding="utf-8") + + assert _imported_modules(offender) == {"pqn_whobot.registry", "pqn_whobot.config"} diff --git a/tests/pytest/test_whobot_cli.py b/tests/pytest/test_whobot_cli.py new file mode 100644 index 0000000..8b4bcec --- /dev/null +++ b/tests/pytest/test_whobot_cli.py @@ -0,0 +1,143 @@ +"""Tests for `whobot nodes`. + +Broken input must produce an explanation and a non-zero exit rather than a traceback, and an +unreachable Node must show in the output, not only in the exit code. + +The command reads `./whobot.toml`, so each test runs in a temp directory. +""" + +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from pqn_whobot import cli +from pqn_whobot.registry import Node + +runner = CliRunner() + +CONFIG = """\ +[[nodes]] +api_url = "http://node-a.invalid:9000" + +[[nodes]] +api_url = "http://offline.invalid:9000" +""" + +ALIVE = Node(api_url="http://node-a.invalid:9000", name="uiuc-public-left", reachable=True, latency_ms=12.3) +DEAD = Node(api_url="http://offline.invalid:9000", name=None, reachable=False, error="ConnectError: refused") +OUTDATED = Node( + api_url="http://node-b.invalid:9000", + name=None, + reachable=True, + warning="no node_name in /node/config; the Node is running older code — update it", + latency_ms=8.0, +) + + +@pytest.fixture(autouse=True) +def _in_a_clean_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + + +@pytest.fixture +def config_file(tmp_path: Path) -> Path: + path = tmp_path / "whobot.toml" + path.write_text(CONFIG, encoding="utf-8") + return path + + +def _resolve_to(monkeypatch: pytest.MonkeyPatch, *nodes: Node) -> None: + async def fake_resolve_nodes(_settings: object) -> list[Node]: + return list(nodes) + + monkeypatch.setattr(cli, "resolve_nodes", fake_resolve_nodes) + + +def test_lists_every_node_with_its_name_and_reachability(config_file: Path, monkeypatch: pytest.MonkeyPatch) -> None: + assert config_file.exists() # the command takes no path; this is the file it will find + _resolve_to(monkeypatch, ALIVE, DEAD) + + result = runner.invoke(cli.app, ["nodes"]) + + assert "uiuc-public-left" in result.output + assert "reachable (12ms)" in result.output + assert "UNREACHABLE — ConnectError: refused" in result.output + # The unreachable row names no Node, rather than printing its address in both columns. + assert "(unknown)" in result.output + assert result.output.count(DEAD.api_url) == 1 + # A Node that can't be reached makes the command fail, so a cron/monitor notices. + assert result.exit_code == 1 + + +@pytest.mark.usefixtures("config_file") +def test_a_reachable_node_with_a_warning_is_shown_but_does_not_fail(monkeypatch: pytest.MonkeyPatch) -> None: + """A partly-deployed fleet is a normal state, so it must not read as an outage.""" + _resolve_to(monkeypatch, ALIVE, OUTDATED) + + result = runner.invoke(cli.app, ["nodes"]) + + assert "reachable (8ms) — no node_name" in result.output + assert "UNREACHABLE" not in result.output + assert "1 of 2 reachable with warnings." in result.output + assert result.exit_code == 0 + + +@pytest.mark.usefixtures("config_file") +def test_all_reachable_exits_zero(monkeypatch: pytest.MonkeyPatch) -> None: + _resolve_to(monkeypatch, ALIVE) + + result = runner.invoke(cli.app, ["nodes"]) + + assert result.exit_code == 0 + assert "UNREACHABLE" not in result.output + + +def test_the_output_names_the_file_it_read(config_file: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The listing says which config file it read.""" + _resolve_to(monkeypatch, ALIVE) + + result = runner.invoke(cli.app, ["nodes"]) + + assert str(config_file.resolve()) in result.output + + +def test_a_missing_config_is_reported_not_treated_as_defaults(tmp_path: Path) -> None: + """Loading an absent file succeeds and yields defaults, so the CLI checks for it.""" + result = runner.invoke(cli.app, ["nodes"]) + + assert result.exit_code == 1 + assert "whobot_example.toml" in result.output + assert str(tmp_path) in result.output # says which directory it looked in + assert result.exception is None or isinstance(result.exception, SystemExit) + + +def test_malformed_toml_explains_itself_instead_of_raising(tmp_path: Path) -> None: + (tmp_path / "whobot.toml").write_text("schedule_hour = \n", encoding="utf-8") + + result = runner.invoke(cli.app, ["nodes"]) + + assert result.exit_code == 1 + assert str((tmp_path / "whobot.toml").resolve()) in result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + + +def test_an_invalid_value_explains_itself_instead_of_raising(tmp_path: Path) -> None: + """The message names both the file and the offending key.""" + (tmp_path / "whobot.toml").write_text("schedule_hour = 99\n", encoding="utf-8") + + result = runner.invoke(cli.app, ["nodes"]) + + assert result.exit_code == 1 + assert str((tmp_path / "whobot.toml").resolve()) in result.output + assert "schedule_hour" in result.output + assert result.exception is None or isinstance(result.exception, SystemExit) + + +def test_an_empty_registry_says_how_to_add_a_node(tmp_path: Path) -> None: + (tmp_path / "whobot.toml").write_text("schedule_hour = 7\n", encoding="utf-8") + + result = runner.invoke(cli.app, ["nodes"]) + + assert result.exit_code == 1 + assert "[[nodes]]" in result.output diff --git a/tests/pytest/test_whobot_config.py b/tests/pytest/test_whobot_config.py new file mode 100644 index 0000000..7916b59 --- /dev/null +++ b/tests/pytest/test_whobot_config.py @@ -0,0 +1,157 @@ +"""Tests for Whobot's config file: what it reads, and what it refuses. + +Whobot reads `./whobot.toml`, so each test runs in a temp directory holding one. +""" + +import tomllib +from datetime import timedelta +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from pqn_whobot.config import WhobotSettings +from pqn_whobot.config import config_path + +EXAMPLE_CONFIG = """\ +# Slack credentials. +slack_bot_token = "xoxb-secret" +slack_app_token = "xapp-secret" +digest_channel = "C0123456789" + +schedule_timezone = "America/Chicago" +schedule_hour = 7 # morning digest +schedule_minute = 0 + +per_node_timeout_s = 900 +per_game_timeout_s = 600 + +# The Node Registry. +[[nodes]] +api_url = "http://node-a.invalid:9000" + +[[nodes]] +api_url = "http://node-b.invalid:9000/" +""" + + +@pytest.fixture(autouse=True) +def _in_a_temp_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + + +@pytest.fixture +def config_file(tmp_path: Path) -> Path: + path = tmp_path / "whobot.toml" + path.write_text(EXAMPLE_CONFIG, encoding="utf-8") + return path + + +@pytest.mark.usefixtures("config_file") +def test_reads_tokens_schedule_and_registry() -> None: + settings = WhobotSettings() + + assert settings.slack_bot_token == "xoxb-secret" # noqa: S105 - a fake token, not a credential + assert settings.digest_channel == "C0123456789" + assert (settings.schedule_hour, settings.schedule_minute) == (7, 0) + assert [node.api_url for node in settings.nodes] == [ + "http://node-a.invalid:9000", + # Trailing slash normalised away, so it can be joined with a path unconditionally. + "http://node-b.invalid:9000", + ] + + +def test_the_config_is_found_relative_to_the_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The path mechanism: a fixed filename, resolved against the working directory.""" + elsewhere = tmp_path / "elsewhere" + elsewhere.mkdir() + (elsewhere / "whobot.toml").write_text("schedule_hour = 9\n", encoding="utf-8") + + assert config_path() == Path("whobot.toml") + assert WhobotSettings().schedule_hour != 9 # noqa: PLR2004 - cwd is tmp_path, which holds no config + + monkeypatch.chdir(elsewhere) + assert WhobotSettings().schedule_hour == 9 # noqa: PLR2004 - the value written above + + +def test_a_missing_file_reads_as_defaults() -> None: + """An absent file is "no values" to pydantic-settings, so loading one yields defaults. + + Startup has to check for the file itself; see `test_whobot_cli.py`. + """ + assert not config_path().exists() + + assert WhobotSettings().nodes == [] + + +def test_a_node_is_added_by_editing_the_file(config_file: Path) -> None: + """A new Node is config, not code.""" + before = len(WhobotSettings().nodes) + + with config_file.open("a", encoding="utf-8") as f: + f.write('\n[[nodes]]\napi_url = "http://node-c.invalid:9000"\n') + + after = WhobotSettings().nodes + assert len(after) == before + 1 + assert after[-1].api_url == "http://node-c.invalid:9000" + + +@pytest.mark.usefixtures("config_file") +def test_schedule_timezone_is_a_real_zone() -> None: + # America/Chicago is CST/CDT — a fixed -6 would drift for half the year. + assert WhobotSettings().timezone.key == "America/Chicago" + + +def test_defaults_are_host_agnostic() -> None: + """No default may point at the machine Whobot happens to run on.""" + settings = WhobotSettings() + + assert settings.nodes == [] + assert settings.schedule_timezone == "America/Chicago" + assert "localhost" not in settings.model_dump_json() + + +def test_unknown_timezone_is_rejected(tmp_path: Path) -> None: + (tmp_path / "whobot.toml").write_text('schedule_timezone = "Mars/Olympus_Mons"\n', encoding="utf-8") + + with pytest.raises(ValidationError, match="not a known IANA timezone"): + WhobotSettings() + + +@pytest.mark.parametrize( + ("body", "expected"), + [ + ("schedule_hour = 24\n", "schedule_hour"), + ("schedule_minute = -1\n", "schedule_minute"), + ("per_node_timeout_s = 0\n", "per_node_timeout_s"), + ('slack_bot_tokn = "typo"\n', "slack_bot_tokn"), + ('[[nodes]]\napi_url = "node-a.invalid:9000"\n', "api_url"), + ], +) +def test_invalid_values_name_the_offending_key(tmp_path: Path, body: str, expected: str) -> None: + """A typo'd or out-of-range key is an error, not a setting that silently never applies.""" + (tmp_path / "whobot.toml").write_text(body, encoding="utf-8") + + with pytest.raises(ValidationError, match=expected): + WhobotSettings() + + +def test_malformed_toml_is_a_parse_error(tmp_path: Path) -> None: + (tmp_path / "whobot.toml").write_text("schedule_hour = \n", encoding="utf-8") + + with pytest.raises(tomllib.TOMLDecodeError): + WhobotSettings() + + +def test_the_mutable_fields_round_trip_from_the_file(tmp_path: Path) -> None: + """A TOML timestamp reads back as an aware `datetime`, in its own offset.""" + (tmp_path / "whobot.toml").write_text( + 'last_run_at = 2026-01-02T07:00:00-06:00\nlast_result = "ok"\n', + encoding="utf-8", + ) + + settings = WhobotSettings() + + assert settings.last_result == "ok" + assert settings.last_run_at is not None + assert settings.last_run_at.utcoffset() == timedelta(hours=-6) diff --git a/tests/pytest/test_whobot_registry.py b/tests/pytest/test_whobot_registry.py new file mode 100644 index 0000000..d83359b --- /dev/null +++ b/tests/pytest/test_whobot_registry.py @@ -0,0 +1,181 @@ +"""Tests for the Node Registry and the Node API client, against a mocked Node API. + +What is pinned: names come from each Node, an unreachable Node is a result rather than an +exception, and no bad address can hang or crash a listing. All of it runs off +``httpx.MockTransport`` — no Node, no network. +""" + +import asyncio +from collections.abc import Callable +from pathlib import Path + +import httpx +import pytest + +from pqn_whobot.config import WhobotSettings +from pqn_whobot.node_client import NodeApiError +from pqn_whobot.node_client import NodeClient +from pqn_whobot.registry import Node +from pqn_whobot.registry import resolve_node +from pqn_whobot.registry import resolve_nodes + +ALICE = "http://node-a.invalid:9000" +BOB = "http://node-b.invalid:9000" +DEAD = "http://offline.invalid:9000" + +NAMES = {ALICE: "uiuc-public-left", BOB: "ufl-public-right"} + + +def node_api(handler: Callable[[httpx.Request], httpx.Response]) -> httpx.MockTransport: + return httpx.MockTransport(handler) + + +def _by_name(request: httpx.Request) -> httpx.Response: + """Answer /node/config for the Nodes this fake knows about, and refuse every other address.""" + origin = f"{request.url.scheme}://{request.url.netloc.decode()}" + if request.url.path != "/node/config": + return httpx.Response(404) + if origin not in NAMES: + msg = "Connection refused" + raise httpx.ConnectError(msg, request=request) + return httpx.Response(200, json={"node_name": NAMES[origin], "follower_node_address": None}) + + +@pytest.fixture(autouse=True) +def _in_a_temp_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Whobot reads `./whobot.toml`, so each test gets a directory of its own to hold one.""" + monkeypatch.chdir(tmp_path) + + +def settings_for(*api_urls: str, reachability_timeout_s: float = 5.0) -> WhobotSettings: + """Write a registry of these addresses to `whobot.toml` and load it the way Whobot does.""" + body = f"reachability_timeout_s = {reachability_timeout_s}\n" + body += "".join(f'\n[[nodes]]\napi_url = "{api_url}"\n' for api_url in api_urls) + Path("whobot.toml").write_text(body, encoding="utf-8") + return WhobotSettings() + + +def resolve_all(settings: WhobotSettings, transport: httpx.MockTransport) -> list[Node]: + """Resolve a registry with every client's transport swapped for the mock.""" + + async def run() -> list[Node]: + clients = [NodeClient(entry.api_url, settings.reachability_timeout_s, transport) for entry in settings.nodes] + return list(await asyncio.gather(*(resolve_node(client) for client in clients))) + + return asyncio.run(run()) + + +def test_names_come_from_each_node_not_from_the_registry() -> None: + """The registry holds addresses; the name comes from the Node.""" + resolved = resolve_all(settings_for(ALICE, BOB), node_api(_by_name)) + + assert [(node.name, node.api_url) for node in resolved] == [ + ("uiuc-public-left", ALICE), + ("ufl-public-right", BOB), + ] + assert all(node.reachable for node in resolved) + assert all(node.error is None for node in resolved) + assert all(node.latency_ms is not None for node in resolved) + + +def test_registry_order_is_preserved() -> None: + """Resolution is concurrent, so the answers must be re-ordered back to config order.""" + resolved = resolve_all(settings_for(BOB, ALICE), node_api(_by_name)) + + assert [node.api_url for node in resolved] == [BOB, ALICE] + + +def test_an_unreachable_node_has_no_name() -> None: + """A Node that won't answer never said what it is called.""" + resolved = resolve_all(settings_for(DEAD), node_api(_by_name)) + + assert resolved[0].reachable is False + assert resolved[0].name is None + assert resolved[0].error is not None + assert "ConnectError" in resolved[0].error + + +def test_one_dead_node_does_not_hide_the_healthy_ones() -> None: + resolved = resolve_all(settings_for(ALICE, DEAD, BOB), node_api(_by_name)) + + assert [node.reachable for node in resolved] == [True, False, True] + assert [node.name for node in resolved] == ["uiuc-public-left", None, "ufl-public-right"] + + +def test_an_empty_registry_resolves_to_nothing() -> None: + assert asyncio.run(resolve_nodes(settings_for())) == [] + + +def test_an_http_error_is_reported_not_raised() -> None: + """A Node that answers with a 500 is as unusable as one that doesn't answer at all.""" + resolved = resolve_all(settings_for(ALICE), node_api(lambda _request: httpx.Response(500))) + + assert resolved[0].reachable is False + assert "500" in str(resolved[0].error) + + +def test_a_non_node_answering_the_address_is_reported() -> None: + """Something else on that port must read as unreachable, not crash the listing.""" + resolved = resolve_all(settings_for(ALICE), node_api(lambda _request: httpx.Response(200, text="hi"))) + + assert resolved[0].reachable is False + assert "did not return JSON" in str(resolved[0].error) + + +def test_a_node_without_node_name_is_reachable_but_warned() -> None: + """A Node from before `node_name` was added to /node/config is out of date, not down. + + It answers, it is on the network, and it can still be reached — reporting it as + unreachable would send an operator to look for a network fault that isn't there. + """ + old_node = lambda _request: httpx.Response(200, json={"follower_node_address": None}) # noqa: E731 + + resolved = resolve_all(settings_for(ALICE), node_api(old_node)) + + assert resolved[0].reachable is True + assert resolved[0].error is None + assert resolved[0].name is None + assert resolved[0].warning is not None + assert "node_name" in resolved[0].warning + + +def test_json_without_any_expected_field_is_not_a_node() -> None: + """Both fields are optional, so this is what stops any JSON server passing for a Node.""" + resolved = resolve_all(settings_for(ALICE), node_api(lambda _request: httpx.Response(200, json={}))) + + assert resolved[0].reachable is False + assert "is not a Node's config" in str(resolved[0].error) + + +def test_a_hanging_node_times_out_rather_than_hanging() -> None: + """`reachability_timeout_s` bounds the wait on a Node that accepts but never answers.""" + + def hangs(request: httpx.Request) -> httpx.Response: + msg = "timed out" + raise httpx.ReadTimeout(msg, request=request) + + resolved = resolve_all(settings_for(ALICE, reachability_timeout_s=0.01), node_api(hangs)) + + assert resolved[0].reachable is False + assert "ReadTimeout" in str(resolved[0].error) + + +def test_the_client_reads_the_nodes_own_config() -> None: + client = NodeClient(ALICE, timeout_s=5.0, transport=node_api(_by_name)) + + config = asyncio.run(client.get_config()) + + assert config.node_name == "uiuc-public-left" + assert config.follower_node_address is None + + +def test_the_client_raises_one_error_type_for_every_failure() -> None: + client = NodeClient(DEAD, timeout_s=5.0, transport=node_api(_by_name)) + + with pytest.raises(NodeApiError): + asyncio.run(client.get_config()) + + +def test_the_client_normalises_a_trailing_slash() -> None: + """So paths can be appended without producing a double slash.""" + assert NodeClient(f"{ALICE}/", timeout_s=5.0).api_url == ALICE From 79612be9742775f9f3b3ae977ff4f1a241e57b3e Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Tue, 28 Jul 2026 23:56:37 -0500 Subject: [PATCH 3/7] Added `actions` module to `pqn_whobot` package, with the Action contract, payload codec, and scan function, along with tests --- .github/workflows/code-quality.yml | 1 + configs/whobot_example.toml | 66 +++ pyproject.toml | 19 +- src/pqn_whobot/__init__.py | 2 +- src/pqn_whobot/actions.py | 529 ++++++++++++++++++++++ src/pqn_whobot/cli.py | 78 +++- src/pqn_whobot/config.py | 4 + src/pqn_whobot/node_client.py | 52 ++- src/pqn_whobot/registry.py | 16 +- src/pqn_whobot/whobot.py | 435 ++++++++++++++++++ src/pqn_whobot/whobot_slack.py | 570 ++++++++++++++++++++++++ tests/pytest/test_whobot_actions.py | 445 +++++++++++++++++++ tests/pytest/test_whobot_cli.py | 3 +- tests/pytest/test_whobot_flow.py | 636 +++++++++++++++++++++++++++ tests/pytest/test_whobot_registry.py | 9 +- tests/pytest/test_whobot_slack.py | 320 ++++++++++++++ uv.lock | 527 ++++++++++++++++++++++ 17 files changed, 3677 insertions(+), 35 deletions(-) create mode 100644 configs/whobot_example.toml create mode 100644 src/pqn_whobot/actions.py create mode 100644 src/pqn_whobot/whobot.py create mode 100644 src/pqn_whobot/whobot_slack.py create mode 100644 tests/pytest/test_whobot_actions.py create mode 100644 tests/pytest/test_whobot_flow.py create mode 100644 tests/pytest/test_whobot_slack.py diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 2797786..873e65f 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -53,6 +53,7 @@ jobs: - uses: astral-sh/setup-uv@v7 with: enable-cache: true + - run: uv sync --locked --all-extras --dev - run: uv run scripts/test build: runs-on: ubuntu-latest diff --git a/configs/whobot_example.toml b/configs/whobot_example.toml new file mode 100644 index 0000000..53f38b5 --- /dev/null +++ b/configs/whobot_example.toml @@ -0,0 +1,66 @@ +# Whobot configuration — the reference copy. +# +# MAKE SURE TO RENAME THIS FILE TO whobot.toml AND PLACE IT WHERE WHOBOT IS STARTED FROM +# +# Copy this to whobot.toml (gitignored) and fill it in. Whobot reads ./whobot.toml — the +# filename is fixed and the directory is wherever Whobot is started from, exactly as a Node +# reads its own ./config.toml. There is no path flag and no environment variable. +# +# This file holds the Slack tokens, so it is never committed. Whobot only reads it today; +# once it starts recording the schedule and each digest run, it will write it atomically +# (temp file + rename) so a crash can never leave it truncated. +# +# Whobot must NOT be pointed at a Node's config.toml. + +# Slack credentials. Socket Mode needs both; Node-facing commands (`whobot nodes`) work +# without them. To create them, go to https://api.slack.com/apps, Create New App, From +# scratch, and name it Whobot. Then: +# +# Socket Mode toggle on, generate an app-level token with `connections:write`. +# That token is slack_app_token, and starts with "xapp-". +# OAuth & Permissions bot token scopes `chat:write`, `commands`, `files:write` (the +# last is for screenshot upload). Install to the workspace. That +# token is slack_bot_token, and starts with "xoxb-". +# Slash Commands create /whobot. No Request URL is needed under Socket Mode. +# Interactivity toggle on. Again no Request URL. +# +# Finally invite the bot to the channel the digest goes to, and put that channel's ID in +# digest_channel below. +slack_bot_token = "xoxb-..." +slack_app_token = "xapp-..." +digest_channel = "C0123456789" # channel ID the Daily Digest is posted to + +# Daily Digest schedule, interpreted in schedule_timezone — never the host's local time, +# so moving the Whobot host doesn't move the digest. DST is handled by the zone. +# Note UFL Nodes are Eastern, so 07:00 Central is 08:00 local for them. +schedule_timezone = "America/Chicago" +schedule_hour = 7 +schedule_minute = 0 + +# Per-Node bounds for the serial digest. Nodes are probed one at a time and each one runs +# its Games for real, so total runtime scales with Node count — hence per-Node timeouts +# rather than one global bound. +per_node_timeout_s = 900 +per_game_timeout_s = 600 + +# How long a mere "are you there?" call waits. Far shorter than the digest budget, so +# `whobot nodes` reports a dead address in seconds instead of appearing to hang. +reachability_timeout_s = 5 + +# Bound on one Node API call made by an Action from Slack. Neither key above fits: 5s is +# for "are you there?", and 900s is the digest's whole budget for a Node. +node_timeout_s = 30 + +# The Node Registry. Whobot knows about exactly these Nodes — adding one is an edit here, +# not a code change. Use each Node's address on the VPN; production Nodes listen on 9000. +# Node *names* are deliberately not listed: Whobot reads them from each Node's +# GET /node/config, so this file cannot drift out of date. +[[nodes]] +api_url = "http://xx.xx.xx.xx:9000" + +[[nodes]] +api_url = "http://xx.xx.xx.xx:9000" + +# Whobot writes these back itself after each digest run; leave them out of a fresh file. +# last_run_at = 2026-01-01T07:00:00-06:00 +# last_result = "ok" diff --git a/pyproject.toml b/pyproject.toml index ae91353..4de29d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,13 +31,27 @@ dependencies = [ "typer>=0.15.1", ] +[project.optional-dependencies] +# Whobot's Chat Platform transport. Every Node ships Whobot's code but never runs it, so +# these stay out of the base dependencies and a Node deployment does not install them. +# Whobot's host installs them with `uv sync --extra whobot`; `whobot nodes` needs neither. +# aiohttp is explicit because slack-bolt's `[async]` extra was removed in 1.30, and async +# Socket Mode still needs it. +whobot = [ + "slack-bolt>=1.18", + "aiohttp>=3.9", +] + [project.scripts] pqn-node = "pqn_node.cli:app" whobot = "pqn_whobot.cli:app" [dependency-groups] -dev = ["hypothesis", "mypy", "coverage", "pytest-randomly", "ruff"] +# The whobot extra is pulled in here so the Slack tests run with a plain `uv run pytest`. +# It stays an extra rather than a dev dependency because a Whobot host needs it in +# production, where dev groups are not installed. +dev = ["hypothesis", "mypy", "coverage", "pytest-randomly", "ruff", "pqn-node[whobot]"] [tool.mypy] @@ -83,7 +97,8 @@ extend-ignore = [ [tool.ruff.lint.extend-per-file-ignores] "tests/*" = [ - "S101", # Assert + "S101", # Assert + "ARG002", # A test double exists to have a signature, not to use every argument in it. ] # Run pytest lint rules only in test files. "!tests/*" = ["PT"] diff --git a/src/pqn_whobot/__init__.py b/src/pqn_whobot/__init__.py index 7e5649f..9c97136 100644 --- a/src/pqn_whobot/__init__.py +++ b/src/pqn_whobot/__init__.py @@ -1,4 +1,4 @@ -"""Whobot — the operations bot for a PQN Network. See ``WHOBOT.md``. +"""Whobot — the operations bot for a PQN Network. One Whobot instance serves many Nodes: it posts the scheduled Daily Digest and lets an operator probe and control any Node from a chat platform. diff --git a/src/pqn_whobot/actions.py b/src/pqn_whobot/actions.py new file mode 100644 index 0000000..faa045c --- /dev/null +++ b/src/pqn_whobot/actions.py @@ -0,0 +1,529 @@ +"""The Action contract: how an Action is declared, what it is passed, and what it returns. + +The types and machinery the Actions in ``whobot.py`` are built from. Nothing here is an +Action, and nothing here knows about a Chat Platform. + +Four things live here, in this order: + +* **The result vocabulary** — ``Status``, ``Field``, ``Section`` and ``Report``. An Action + returns one of these to describe *what happened*; a Chat Platform subclass decides what + that looks like, so platform markup never belongs in one. +* **Declaring an Action** — ``@action`` and ``@prefill``, which mark a method without + wrapping it; ``Scope``, ``ActionMeta`` and ``Parameter``, which record what a declaration + says; and ``Action``, one declared Action ready to run. ``Action.call`` invokes it on the + instance it is passed with the arguments its scope implies, and ``coerce_params`` turns a + payload's form values into those arguments. +* **The scan** — ``scan_actions`` finds the marked methods on a class, parses each signature + once into ``Parameter``s, and validates. It runs while the class is being created, so a + malformed Action fails at import rather than in front of an operator mid-click. +* **The payload** — ``PendingInvocation`` and its codec. A Chat Platform tells you nothing + between one click and the next except the string written into the widget rendered last, so + the payload holds the whole of the interaction state. ``ReplyHandle`` is here too: the + opaque marker saying where a reply goes, which each Chat Platform subclasses. +""" + +import inspect +import json +import logging +from collections.abc import Awaitable +from collections.abc import Callable +from collections.abc import Iterable +from dataclasses import dataclass +from dataclasses import field as dataclass_field +from dataclasses import replace +from enum import StrEnum +from typing import Any +from typing import TypeVar + +from pqn_whobot.registry import Node + +logger = logging.getLogger(__name__) + + +# -------------------------------------------------------------------------------------- +# The result vocabulary: what an Action returns. +# -------------------------------------------------------------------------------------- + + +class Status(StrEnum): + """What a ``Field`` or ``Section`` says about itself. + + ``OK``, ``WARN``, ``FAIL`` and ``SKIPPED`` are outcomes — how something turned out. + ``SKIPPED`` is not ``WARN``: one means it did not run, the other that it ran and looked + wrong. + + ``ON`` and ``OFF`` are states rather than outcomes, for a row answering "is this thing + on". Neither is bad news, so neither affects ``overall``, and a Chat Platform marks them + with something other than a tick or a cross. + """ + + OK = "ok" + WARN = "warn" + FAIL = "fail" + SKIPPED = "skipped" + ON = "on" + OFF = "off" + + @classmethod + def overall(cls, statuses: Iterable["Status | None"]) -> "Status": + """Reduce a group of statuses to the one a summary should show. + + ``FAIL`` wins, then ``WARN``, and anything else gives ``OK``. This is a bad-news + precedence and not an ordering: ``SKIPPED``, ``ON``, ``OFF`` and ``None`` are ranked + neither against each other nor against ``OK``. ``None`` is accepted so that callers + need not filter out their status-less Fields first. + """ + seen = set(statuses) + for candidate in (cls.FAIL, cls.WARN): + if candidate in seen: + return candidate + return cls.OK + + +@dataclass(frozen=True) +class Field: + """One named value in a result. + + ``status`` also selects the layout, so that no presentation flag is needed: a Field with + one is a checklist entry and renders as a line, and a Field without one is a measurement + and renders in a grid beside its neighbours. + """ + + name: str + value: str + status: Status | None = None + + +@dataclass(frozen=True) +class Section: + """A group of Fields under an optional heading. + + ``note`` is a short footer, such as an elapsed time. ``error`` is preformatted text, + such as a traceback, and is the one field exempt from the no-markup rule — a traceback's + punctuation is not Whobot's to sanitise. + """ + + label: str | None = None + status: Status | None = None + fields: list[Field] = dataclass_field(default_factory=list) + note: str | None = None + error: str | None = None + + +class ActionResult: + """Marker base class for anything an Action may return. + + A Chat Platform registers one renderer per concrete subclass and dispatches on the type. + A test asserts every subclass has a renderer, so an unrenderable result fails the suite + rather than an operator's click. + """ + + +@dataclass(frozen=True) +class Report(ActionResult): + """The general-purpose result: a status, a title, and a flat list of Sections. + + Return this unless a result needs a shape it cannot express. The Daily Digest is the one + Action that does, because Node x (hardware checklist + Games) nests one level deeper. + """ + + status: Status + title: str + summary: str | None = None + sections: list[Section] = dataclass_field(default_factory=list) + image: bytes | None = None + notes: list[str] = dataclass_field(default_factory=list) + + +# -------------------------------------------------------------------------------------- +# Declaring an Action. +# -------------------------------------------------------------------------------------- + + +class ActionDeclarationError(TypeError): + """An Action is declared wrongly. Raised while the class is being created, never later.""" + + +class Scope(StrEnum): + """What an Action acts on.""" + + NONE = "none" + """Whobot-level: no Node is chosen, and the method takes no ``node``.""" + + ONE = "one" + """One Node, chosen by the operator and passed as the method's first argument.""" + + +ActionMethod = Callable[..., Awaitable[ActionResult]] +PrefillMethod = Callable[..., Awaitable[dict[str, object]]] + +_ACTION_ATTR = "_whobot_action" +_PREFILL_ATTR = "_whobot_prefill" + +DEFAULT_TIMEOUT_S = 120.0 + +WIDGET_TYPES: tuple[type, ...] = (bool,) +"""Parameter types the form generator can render. ``bool`` becomes a checkbox. + +The scan rejects any other type by name, so an unsupported parameter fails at import with a +message rather than producing an empty form. Adding ``str``, ``int``/``float`` or +``Literal``/enum is one entry here and one branch in the Chat Platform's form renderer. +""" + + +@dataclass(frozen=True) +class Parameter: + """One question the form asks, parsed from an Action's signature. + + ``default`` is always set: a checkbox is ticked or it is not, so a ``bool`` with no + signature default starts unticked. A widget for which "no value" differs from a default + will need a ``required`` flag here. + """ + + name: str + annotation: type + default: object + + +@dataclass(frozen=True) +class ActionMeta: + """What ``@action`` records — only the things a signature cannot say itself.""" + + label: str + description: str | None = None + scope: Scope = Scope.NONE + destructive: bool = False + timeout_s: float = DEFAULT_TIMEOUT_S + + +@dataclass(frozen=True) +class Action: + """A declared Action: its metadata, its parsed parameters, and the name of its method. + + Pure metadata, holding no callable. ``name`` and ``prefill_name`` are attribute names on + the class that declared them, which ``call`` looks up on the owner it is passed — so the + owner binds the method, as ordinary attribute access always would. One of these is + therefore shared by every instance, with nothing to copy and nothing to rebind. + """ + + name: str + label: str + description: str | None + scope: Scope + destructive: bool + timeout_s: float + parameters: tuple[Parameter, ...] + prefill_name: str | None = None + + async def call(self, owner: object, node: Node | None, params: dict[str, object] | None) -> ActionResult: + """Run the Action against ``owner``, passing the Node only when its scope declares one. + + A ``Scope.ONE`` method takes the Node as its first argument and a ``Scope.NONE`` + method takes none. The scan enforces that, so the call shape follows from ``scope``. + """ + arguments = (node,) if self.scope is Scope.ONE else () + method: ActionMethod = getattr(owner, self.name) + return await method(*arguments, **self.coerce_params(params)) + + async def prefill_values(self, owner: object, node: Node | None) -> dict[str, object]: + """Ask the ``@prefill`` what the form should open on. Empty when there is none.""" + if self.prefill_name is None: + return {} + arguments = (node,) if self.scope is Scope.ONE else () + method: PrefillMethod = getattr(owner, self.prefill_name) + return await method(*arguments) + + def coerce_params(self, raw: dict[str, object] | None) -> dict[str, object]: + """Turn a payload's form values into this Action's keyword arguments. + + Unknown keys are dropped and missing ones take their default, because a payload can + outlive the code that wrote it: a modal may have been rendered before a deploy + renamed a parameter, and that must run the Action as declared today rather than + raise ``TypeError`` inside the call. + """ + supplied = raw or {} + unknown = supplied.keys() - {parameter.name for parameter in self.parameters} + if unknown: + logger.warning("%s: ignoring parameters that no longer exist: %s", self.name, sorted(unknown)) + + return { + parameter.name: ( + parameter.annotation(supplied[parameter.name]) if parameter.name in supplied else parameter.default + ) + for parameter in self.parameters + } + + +F = TypeVar("F", bound=ActionMethod) +P = TypeVar("P", bound=PrefillMethod) + + +def action( + *, + label: str, + description: str | None = None, + scope: Scope = Scope.NONE, + destructive: bool = False, + timeout_s: float = DEFAULT_TIMEOUT_S, +) -> Callable[[F], F]: + """Mark a method as an Action, recording what its signature cannot say. + + The method is returned unchanged: this staples metadata onto it rather than wrapping it, + so an Action stays an ordinary method and ``@prefill`` can link to it by identity. + """ + + def decorate(method: F) -> F: + setattr( + method, + _ACTION_ATTR, + ActionMeta( + label=label, + description=description, + scope=scope, + destructive=destructive, + timeout_s=timeout_s, + ), + ) + return method + + return decorate + + +def prefill(target: ActionMethod) -> Callable[[P], P]: + """Mark a method as the source of an Action's starting form values. + + ``target`` is the Action's function as written in the class body — the same object the + scan finds, since ``@action`` does not wrap it. The link is therefore by identity and + cannot be broken by a misspelt name. + """ + + def decorate(method: P) -> P: + setattr(method, _PREFILL_ATTR, target) + return method + + return decorate + + +# -------------------------------------------------------------------------------------- +# The scan: turning a class into validated Actions as the class is created. +# -------------------------------------------------------------------------------------- + + +def _fail(name: str, problem: str) -> ActionDeclarationError: + """Build a declaration error naming the Action, so the message stands on its own.""" + return ActionDeclarationError(f"Action {name!r}: {problem}") + + +def _is_node_parameter(parameter: inspect.Parameter) -> bool: + return parameter.name == "node" or parameter.annotation is Node + + +def _without_the_node(name: str, signature: inspect.Signature, scope: Scope) -> list[inspect.Parameter]: + """Check the signature agrees with the scope about a Node, and return what remains. + + What remains is what the operator is asked for. The Node comes from a dropdown, so it + must never also appear in the form. + """ + declared = [p for p in signature.parameters.values() if p.name != "self"] + + if scope is Scope.ONE: + if not declared or not _is_node_parameter(declared[0]): + msg = "scope=ONE requires a first parameter 'node: Node'" + raise _fail(name, msg) + if declared[0].annotation is not Node: + msg = f"parameter 'node' must be annotated Node, not {declared[0].annotation!r}" + raise _fail(name, msg) + return declared[1:] + + if any(_is_node_parameter(p) for p in declared): + msg = "scope=NONE must not take a 'node' parameter; declare scope=Scope.ONE to act on one Node" + raise _fail(name, msg) + return declared + + +def _parse_parameters(name: str, signature: inspect.Signature, scope: Scope) -> tuple[Parameter, ...]: + """Parse the parameters an operator supplies, rejecting any the form cannot ask for. + + They must be keyword-only. ``Action.call`` invokes every Action as + ``method(node, **params)``, so a positional form parameter describes a call that never + happens. + """ + parameters = [] + for parameter in _without_the_node(name, signature, scope): + if parameter.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD): + msg = f"parameter {parameter.name!r} is *args/**kwargs, which no form can ask for" + raise _fail(name, msg) + if parameter.kind is not inspect.Parameter.KEYWORD_ONLY: + msg = f"parameter {parameter.name!r} must be keyword-only; put a '*' before it" + raise _fail(name, msg) + if parameter.annotation is inspect.Parameter.empty: + msg = f"parameter {parameter.name!r} has no type annotation, so no widget can be chosen for it" + raise _fail(name, msg) + if parameter.annotation not in WIDGET_TYPES: + supported = ", ".join(widget.__name__ for widget in WIDGET_TYPES) + msg = ( + f"parameter {parameter.name!r} is {parameter.annotation!r}, which has no widget; supported: {supported}" + ) + raise _fail(name, msg) + default = parameter.default if parameter.default is not inspect.Parameter.empty else parameter.annotation() + parameters.append(Parameter(name=parameter.name, annotation=parameter.annotation, default=default)) + + return tuple(parameters) + + +def _parse_action(name: str, method: ActionMethod, meta: ActionMeta) -> Action: + """Validate one marked method against its metadata, and parse its form parameters.""" + if not inspect.iscoroutinefunction(method): + msg = "must be 'async def' — every Action is awaited" + raise _fail(name, msg) + + # eval_str resolves annotations that are strings, so a module using postponed + # evaluation is compared against real types rather than against their spelling. + signature = inspect.signature(method, eval_str=True) + + returns = signature.return_annotation + if returns is inspect.Signature.empty: + msg = "has no return annotation; an Action must declare it returns an ActionResult" + raise _fail(name, msg) + if not (isinstance(returns, type) and issubclass(returns, ActionResult)): + msg = f"returns {returns!r}; an Action must return an ActionResult" + raise _fail(name, msg) + + return Action( + name=name, + label=meta.label, + description=meta.description, + scope=meta.scope, + destructive=meta.destructive, + timeout_s=meta.timeout_s, + parameters=_parse_parameters(name, signature, meta.scope), + ) + + +def _attach_prefills( + cls: type, + actions: dict[str, Action], + members: list[tuple[str, Any]], + by_function: dict[ActionMethod, str], +) -> dict[str, Action]: + """Link each ``@prefill`` to the Action it points at, rejecting one that points nowhere. + + ``by_function`` is keyed by the Action's function because ``@prefill`` records its target + by identity; what is stored on the Action is only the prefill's name. + """ + attached = dict(actions) + + for name, method in members: + target = getattr(method, _PREFILL_ATTR, None) + if target is None: + continue + if not inspect.iscoroutinefunction(method): + msg = f"{cls.__name__}: prefill {name!r} must be 'async def'" + raise ActionDeclarationError(msg) + action_name = by_function.get(target) + if action_name is None: + named = getattr(target, "__name__", target) + msg = f"{cls.__name__}: prefill {name!r} points at {named!r}, which is not an Action" + raise ActionDeclarationError(msg) + if attached[action_name].prefill_name is not None: + msg = f"{cls.__name__}: Action {action_name!r} has more than one prefill" + raise ActionDeclarationError(msg) + attached[action_name] = replace(attached[action_name], prefill_name=name) + + return attached + + +def scan_actions(cls: type) -> dict[str, Action]: + """Find, validate and return every Action a class declares, inherited ones included. + + Called from ``__init_subclass__``, so it works on the class and produces metadata only. + Being an Action is opt-in, so adding a helper method cannot turn it into a menu entry. + """ + members = inspect.getmembers(cls, inspect.isfunction) + marked = [ + (name, method, meta) for name, method in members if (meta := getattr(method, _ACTION_ATTR, None)) is not None + ] + actions = {name: _parse_action(name, method, meta) for name, method, meta in marked} + return _attach_prefills(cls, actions, members, {method: name for name, method, _ in marked}) + + +# -------------------------------------------------------------------------------------- +# The payload: the only thing that survives between one click and the next. +# -------------------------------------------------------------------------------------- + + +class PayloadError(ValueError): + """A payload could not be decoded, which means something other than Whobot wrote it.""" + + +class ReplyHandle: + """Where a reply goes. An opaque marker, which ``Whobot`` passes around without opening. + + A Chat Platform subclasses it to carry whatever addressing it needs — ``WhobotSlack`` + carries a channel and a thread — so those reach the renderer without ``Whobot`` ever + learning what a ``thread_ts`` is. + """ + + +@dataclass(frozen=True) +class PendingInvocation: + """An interaction in progress: what has been chosen so far, and what has not. + + ``dispatch`` reads nothing else, so Whobot can restart between any two clicks and the + next one still works. ``action=None`` means nothing has been chosen yet, so opening the + menu needs no special case. + """ + + action: str | None = None + node_url: str | None = None + params: dict[str, object] | None = None + confirmed: bool = False + + +# Short keys, because Slack's tightest slot is a dropdown option's 75-character value. +_KEY_ACTION = "a" +_KEY_NODE = "n" +_KEY_PARAMS = "p" +_KEY_CONFIRMED = "c" + + +def encode(pending: PendingInvocation) -> str: + """Encode a pending invocation for a widget, omitting everything not yet chosen.""" + payload: dict[str, object] = {} + if pending.action is not None: + payload[_KEY_ACTION] = pending.action + if pending.node_url is not None: + payload[_KEY_NODE] = pending.node_url + if pending.params is not None: + payload[_KEY_PARAMS] = pending.params + if pending.confirmed: + payload[_KEY_CONFIRMED] = True + return json.dumps(payload, separators=(",", ":")) + + +def decode(raw: str) -> PendingInvocation: + """Decode what a widget sent back. + + This rejects only what is not a payload at all. Whether the Action still exists, or the + Node is still registered, is ``dispatch``'s question — it re-renders for those. + """ + try: + payload = json.loads(raw) + except ValueError as e: + msg = f"not a Whobot payload: {e}" + raise PayloadError(msg) from e + if not isinstance(payload, dict): + msg = f"not a Whobot payload: expected an object, got {type(payload).__name__}" + raise PayloadError(msg) + + params = payload.get(_KEY_PARAMS) + if params is not None and not isinstance(params, dict): + msg = f"payload parameters must be an object, got {type(params).__name__}" + raise PayloadError(msg) + + return PendingInvocation( + action=payload.get(_KEY_ACTION), + node_url=payload.get(_KEY_NODE), + params=params, + confirmed=bool(payload.get(_KEY_CONFIRMED, False)), + ) diff --git a/src/pqn_whobot/cli.py b/src/pqn_whobot/cli.py index da79e10..38da88a 100644 --- a/src/pqn_whobot/cli.py +++ b/src/pqn_whobot/cli.py @@ -22,8 +22,6 @@ app = typer.Typer(no_args_is_help=True, help="CLI for Whobot, the PQN Network operations bot.") -_UNKNOWN_NAME = "(unknown)" - @app.callback() def main() -> None: @@ -48,13 +46,8 @@ def _load() -> WhobotSettings: raise typer.Exit(code=1) from None -def _display_name(node: Node) -> str: - """Name this Node for the listing, or call it unknown if it never gave one.""" - return node.name or _UNKNOWN_NAME - - def _node_line(node: Node, name_width: int) -> str: - columns = f" {_display_name(node):<{name_width}} {node.api_url}" + columns = f" {node.name:<{name_width}} {node.api_url}" if not node.reachable: return f"{columns} UNREACHABLE — {node.error}" latency = f"{node.latency_ms:.0f}ms" if node.latency_ms is not None else "ok" @@ -71,7 +64,7 @@ def nodes() -> None: raise typer.Exit(code=1) resolved = asyncio.run(resolve_nodes(settings)) - name_width = max(len(_display_name(node)) for node in resolved) + name_width = max(len(node.name) for node in resolved) unreachable = [node for node in resolved if not node.reachable] warned = [node for node in resolved if node.reachable and node.warning] @@ -88,5 +81,70 @@ def nodes() -> None: raise typer.Exit(code=1) +@app.command() +def serve() -> None: + """Connect to Slack and stay up, answering /whobot until stopped.""" + settings = _load() + + missing = [ + name + for name, value in ( + ("slack_bot_token", settings.slack_bot_token), + ("slack_app_token", settings.slack_app_token), + ) + if not value + ] + if missing: + # Socket Mode needs both, and the failure without one is an opaque Slack error, so + # it is worth naming exactly which is absent. + typer.echo( + f"{config_path()} is missing {' and '.join(missing)}. " + "See configs/whobot_example.toml for where each token comes from.", + err=True, + ) + raise typer.Exit(code=1) + + if not settings.nodes: + typer.echo("Warning: no Nodes registered, so every Node Action will have nothing to offer.", err=True) + + # Imported here rather than at module scope because the Slack libraries are an optional + # extra: a Node installs neither, and `whobot nodes` must keep working without them. + try: + from slack_sdk.errors import SlackApiError # noqa: PLC0415 + + from pqn_whobot.whobot_slack import WhobotSlack # noqa: PLC0415 + except ImportError as e: + typer.echo(f"Whobot's Slack support is not installed ({e.name}).", err=True) + typer.echo(" Install it with: uv sync --extra whobot", err=True) + raise typer.Exit(code=1) from None + + bot = WhobotSlack(settings) + + async def run() -> None: + # Check the tokens first: start_async retries a rejected one forever rather than + # raising, so without this a bad token looks like a bot that started and then + # quietly never answered. + await bot.check_credentials() + typer.echo(f"Connected. {len(settings.nodes)} Node(s) registered. Ctrl-C to stop.") + await bot.serve() + + typer.echo("Whobot connecting to Slack…") + try: + asyncio.run(run()) + except KeyboardInterrupt: + # asyncio.run has already cancelled the loop; serve's finally block reported any + # Action that was interrupted, so there is nothing to add but a clean exit. + typer.echo("Stopped.") + except SlackApiError as e: + error = e.response.get("error", "unknown") + typer.echo(f"Slack rejected Whobot's credentials: {error}.", err=True) + typer.echo( + " slack_bot_token is the 'xoxb-' Bot User OAuth token (OAuth & Permissions).\n" + " slack_app_token is the 'xapp-' app-level token with connections:write (Socket Mode).", + err=True, + ) + raise typer.Exit(code=1) from None + + if __name__ == "__main__": - app() + app(["serve"]) diff --git a/src/pqn_whobot/config.py b/src/pqn_whobot/config.py index 755c9b2..4fc0c5e 100644 --- a/src/pqn_whobot/config.py +++ b/src/pqn_whobot/config.py @@ -60,6 +60,10 @@ class WhobotSettings(BaseSettings): # Bound for a single "are you there?" call, well under the digest's per-Node budget. reachability_timeout_s: float = Field(default=5.0, gt=0) + # Bound for one Node API call made by an Action. Neither existing key fits: 5s is for + # "are you there?", and 900s is the digest's whole budget for a Node. + node_timeout_s: float = Field(default=30.0, gt=0) + # What a digest run records about itself. last_run_at: datetime | None = None last_result: str | None = None diff --git a/src/pqn_whobot/node_client.py b/src/pqn_whobot/node_client.py index 1d874e3..a49285d 100644 --- a/src/pqn_whobot/node_client.py +++ b/src/pqn_whobot/node_client.py @@ -10,10 +10,9 @@ from pydantic import BaseModel from pydantic import ValidationError -logger = logging.getLogger(__name__) - +from pqn_node.core.config import GamesAvailability -_NODE_CONFIG_KEYS = {"node_name", "follower_node_address"} +logger = logging.getLogger(__name__) class NodeApiError(Exception): @@ -46,28 +45,61 @@ def __init__(self, api_url: str, timeout_s: float, transport: httpx.AsyncBaseTra def __repr__(self) -> str: return f"NodeClient({self.api_url!r}, timeout_s={self.timeout_s})" - async def _get_json(self, path: str) -> object: + async def _send_json(self, method: str, path: str, body: object | None = None) -> object: url = f"{self.api_url}{path}" try: async with httpx.AsyncClient(timeout=self.timeout_s, transport=self._transport) as client: - response = await client.get(url) + response = await client.request(method, url, json=body) response.raise_for_status() return response.json() except httpx.HTTPError as e: msg = f"{type(e).__name__}: {e}" - logger.warning("GET %s failed: %s", url, msg) + logger.warning("%s %s failed: %s", method, url, msg) raise NodeApiError(msg) from e except ValueError as e: # a 200 that isn't JSON: something other than a Node answered msg = f"{url} did not return JSON: {e}" logger.warning(msg) raise NodeApiError(msg) from e + def _parse_availability(self, payload: object) -> GamesAvailability: + # Every Game must be named: the model defaults each to True, so a partial answer + # would report a Game as available on a Node that never mentioned it. + if not isinstance(payload, dict) or not payload.keys() >= GamesAvailability.model_fields.keys(): + msg = f"{self.api_url}/games/availability is not a Games availability: {str(payload)[:100]}" + raise NodeApiError(msg) + try: + return GamesAvailability.model_validate(payload) + except ValidationError as e: + msg = f"{self.api_url} did not answer with a Games availability: {e}" + raise NodeApiError(msg) from e + + async def get_availability(self) -> GamesAvailability: + """Ask the Node which Games it currently offers. + + This is the Node's *effective* availability — its configuration gated by the last + hardware probe — because that is what the endpoint returns. A Game switched on in + config still reads as unavailable while the router it needs is unreachable. + """ + return self._parse_availability(await self._send_json("GET", "/games/availability")) + + async def set_availability(self, availability: GamesAvailability) -> GamesAvailability: + """Set which Games the Node offers, persistently and without a restart. + + Returns what the Node reports *afterwards*, which is not necessarily what was + asked for: the endpoint answers with effective availability, so a Game switched on + here still reads as unavailable if its hardware is unreachable. + """ + payload = await self._send_json("PUT", "/games/availability", availability.model_dump()) + return self._parse_availability(payload) + async def get_config(self) -> NodeConfigResponse: """Ask the Node for its name and follower address.""" - payload = await self._get_json("/node/config") - # Both fields are optional, so a bare `{}` would validate: check that the response - # carries at least one of them, or anything serving JSON on that port passes for a Node. - if not isinstance(payload, dict) or not _NODE_CONFIG_KEYS & payload.keys(): + payload = await self._send_json("GET", "/node/config") + # *Any* of the fields will do here, unlike availability: a Node running code from + # before node_name existed answers with only the follower address, and that is out + # of date rather than unreachable. Both fields are optional, so without this check + # a bare `{}` would validate and anything on that port would pass for a Node. + if not isinstance(payload, dict) or not NodeConfigResponse.model_fields.keys() & payload.keys(): msg = f"{self.api_url}/node/config is not a Node's config: {str(payload)[:100]}" raise NodeApiError(msg) try: diff --git a/src/pqn_whobot/registry.py b/src/pqn_whobot/registry.py index 5391a20..d0696e2 100644 --- a/src/pqn_whobot/registry.py +++ b/src/pqn_whobot/registry.py @@ -14,19 +14,23 @@ _NO_NAME_WARNING = "no node_name in /node/config; the Node is running older code — update it" +UNKNOWN_NAME = "(unknown)" +"""What a Node is called when it has not said. Substituted here, at the one place a Node is +built, so that nothing downstream carries a fallback of its own.""" + @dataclass(frozen=True) class Node: """One registered Node as Whobot currently sees it. - ``name`` is None when the Node did not answer, or answered without one. ``warning`` - describes a Node that answered but not with what Whobot expects — reachable, but not - fully usable. + ``name`` is always a name to show: a Node that did not answer, or answered without one, + is called ``UNKNOWN_NAME``. ``warning`` describes a Node that answered but not with what + Whobot expects — reachable, but not fully usable. """ api_url: str - name: str | None reachable: bool + name: str = UNKNOWN_NAME error: str | None = None warning: str | None = None latency_ms: float | None = None @@ -38,10 +42,10 @@ async def resolve_node(client: NodeClient) -> Node: try: config = await client.get_config() except NodeApiError as e: - return Node(api_url=client.api_url, name=None, reachable=False, error=str(e)) + return Node(api_url=client.api_url, reachable=False, error=str(e)) return Node( api_url=client.api_url, - name=config.node_name, + name=config.node_name or UNKNOWN_NAME, reachable=True, warning=None if config.node_name else _NO_NAME_WARNING, latency_ms=(time.perf_counter() - started) * 1000, diff --git a/src/pqn_whobot/whobot.py b/src/pqn_whobot/whobot.py new file mode 100644 index 0000000..b12898d --- /dev/null +++ b/src/pqn_whobot/whobot.py @@ -0,0 +1,435 @@ +"""The ``Whobot`` base class: its Actions, and the flow that runs them. + +Whobot is the operations bot for a PQN Network. One instance serves many Nodes, letting an +operator probe and control any of them from a Chat Platform. This module is the +platform-independent half: what Whobot can do, and the flow that decides what to ask for +next. Rendering lives in a subclass such as ``whobot_slack.py``; the machinery an Action is +declared with, and the result vocabulary it returns, live in ``actions.py``. + +Actions +------- + +An Action is one thing Whobot can do, and it is the unit of extension: adding one means +adding a single ``@action``-decorated method to this class. No menu code is edited and no +Chat Platform code is touched, because the menu entry, the parameter form and the help text +are all derived from the declaration and the signature:: + + @action(label="Node Info", scope=Scope.ONE) + async def node_info(self, node: Node) -> Report: ... + +``@action`` records only what a signature cannot say: the menu label and description, the +``scope``, whether the Action is ``destructive`` and so needs an explicit confirmation, and +the ``timeout_s`` bounding the whole invocation. The signature says the rest. +``scope=Scope.ONE`` means the Action acts on one Node, which the operator picks and which +arrives as the method's first argument; ``Scope.NONE`` means it acts on the Network and +takes no Node. Every remaining parameter is keyword-only and becomes a question in the +parameter form: one checkbox per ``bool``, which is the only widget mapping there is. + +An Action returns an ``ActionResult``, usually a ``Report``, describing *what happened*. It +must not emit platform markup: deciding what a result looks like belongs to the subclass, +and keeping that out of Actions is what allows the same Action to render anywhere. + +``@prefill(some_action)`` marks a method as the source of that Action's starting form +values. It runs before the form opens and may talk to a Node, which is how the availability +form opens on a Node's real flags rather than on the signature's defaults. A prefill that +fails is logged and the defaults stand, since a form opening on defaults beats no form. + +The flow +-------- + +A Chat Platform delivers each click as an independent event. Nothing links one click to the +previous one except the string written into the widget that was rendered last, so that +string carries the whole of the interaction state: a ``PendingInvocation`` recording which +Action, which Node, which parameters, and whether it has been confirmed. + +``dispatch`` is therefore a pure function of that payload. It asks for whatever is still +missing, and runs the Action once nothing is:: + + /whobot PendingInvocation() -> the menu + pick an Action action="set_availability" -> the Node dropdown + pick a Node + node_url="http://..." -> the parameter form + submit the form + params={"chsh": True, ...} -> runs + +Each widget carries a fuller payload than the one before, so the next click re-enters +``dispatch`` one step further along. Nothing about an interaction in progress is held in the +process, which means Whobot can restart between any two clicks and the next click still +works. It also means a payload can outlive the code that wrote it: an Action name that no +longer exists, or a Node that has left the registry, re-renders the step before it with a +note rather than raising or acting on the wrong Node. + +``execute`` runs one Action as a tracked task rather than inline, because an Action takes +seconds to minutes while a Chat Platform expects to be acknowledged in about three. It +announces the run, awaits the Action under its ``timeout_s``, and posts the result. The +invariant that makes the bot trustworthy is that **every announcement is eventually followed +by a result**: a timeout, an unhandled exception and a shutdown that cancels the run each +produce a ``FAIL`` result rather than silence, because an announcement with no reply leaves +an operator unable to tell whether the work happened. + +What a Chat Platform implements +------------------------------- + +Six abstract methods: the four steps that can be asked for — ``show_menu``, +``ask_for_target``, ``ask_for_params``, ``ask_to_confirm`` — and the two halves of a run, +``announce_start`` and ``post_result``. A subclass implements those and nothing else. It +declares no Actions today, though the scan runs on every subclass and would find any it did. + +This module must not reference a Chat Platform. +""" + +import asyncio +import logging +from abc import ABC +from abc import abstractmethod +from collections.abc import Coroutine +from typing import Any +from typing import ClassVar + +from pqn_node.core.config import GamesAvailability +from pqn_whobot.actions import Action +from pqn_whobot.actions import ActionResult +from pqn_whobot.actions import Field +from pqn_whobot.actions import PendingInvocation +from pqn_whobot.actions import ReplyHandle +from pqn_whobot.actions import Report +from pqn_whobot.actions import Scope +from pqn_whobot.actions import Section +from pqn_whobot.actions import Status +from pqn_whobot.actions import action +from pqn_whobot.actions import prefill +from pqn_whobot.actions import scan_actions +from pqn_whobot.config import WhobotSettings +from pqn_whobot.node_client import NodeApiError +from pqn_whobot.node_client import NodeClient +from pqn_whobot.registry import UNKNOWN_NAME +from pqn_whobot.registry import Node +from pqn_whobot.registry import resolve_nodes + +logger = logging.getLogger(__name__) + +SHUTDOWN_GRACE_S = 10.0 +"""How long a running Action gets to finish on shutdown before it is cancelled.""" + + +class Whobot(ABC): + """The platform-independent half of Whobot: its Actions and the flow that runs them. + + A Chat Platform subclasses this and implements the six abstract methods, which cover the + menu, the target list, the parameter form, the confirmation, the start announcement and + the result. Actions are found by scanning the class as it is created, so a subclass may + declare Actions of its own; every Action shipped today is declared here. + """ + + actions: ClassVar[dict[str, Action]] = {} + """Actions as the scan found them. Pure metadata, so one dict serves every instance.""" + + def __init_subclass__(cls, **kwargs: object) -> None: + """Re-scan on every subclass, so a Chat Platform may add Actions of its own.""" + super().__init_subclass__(**kwargs) + cls.actions = scan_actions(cls) + + def __init__(self, settings: WhobotSettings) -> None: + self.settings = settings + self._tasks: set[asyncio.Task[None]] = set() + # Interruption replies, which must survive the cancellation that caused them. + self._finalisers: set[asyncio.Task[None]] = set() + self._accepting = True + + # ---------------------------------------------------------------------------------- + # Actions: what Whobot can do. Each returns an ActionResult. + # ---------------------------------------------------------------------------------- + + @action(label="List Nodes", description="Every Node in the registry, with its reachability.") + async def list_nodes(self) -> Report: + """Report every registered Node, whether Whobot can reach it, and its name.""" + nodes = await resolve_nodes(self.settings) + if not nodes: + return Report( + status=Status.WARN, + title="Nodes", + summary="No Nodes are registered. Add a [[nodes]] entry to whobot.toml.", + ) + + fields = [] + for node in nodes: + # Unreachable, reachable-but-not-as-expected, and reachable are three states, and + # a row's glyph and its text have to agree about which one a Node is in. + latency = "" if node.latency_ms is None else f" ({node.latency_ms:.0f}ms)" + if not node.reachable: + value, status = f"{node.api_url} — {node.error}", Status.FAIL + elif node.warning: + value, status = f"{node.api_url}{latency} — {node.warning}", Status.WARN + else: + value, status = f"{node.api_url}{latency}", Status.OK + + fields.append(Field(name=node.name, value=value, status=status)) + + reachable = sum(1 for node in nodes if node.reachable) + + return Report( + status=Status.overall(field.status for field in fields), + title="Nodes", + summary=f"{reachable} of {len(nodes)} reachable", + sections=[Section(fields=fields)], + ) + + @action(label="Node Info", description="One Node's name and follower address.", scope=Scope.ONE) + async def node_info(self, node: Node) -> Report: + """Report what a Node says about itself, read fresh rather than from the registry.""" + try: + config = await self._client(node).get_config() + except NodeApiError as e: + return Report( + status=Status.FAIL, + title=f"{node.name} — {node.api_url}", + summary="The Node did not answer.", + sections=[Section(error=str(e))], + ) + + return Report( + status=Status.OK if config.node_name else Status.WARN, + title=f"{node.name} — {node.api_url}", + summary=None + if config.node_name + else "This Node reports no name; it is running code from before that was added.", + sections=[ + Section( + fields=[ + Field(name="Name", value=config.node_name or UNKNOWN_NAME), + Field(name="Follower", value=config.follower_node_address or "none configured"), + ] + ) + ], + ) + + @action(label="Change Game availability", description="Choose which Games a Node offers.", scope=Scope.ONE) + async def set_availability(self, node: Node, *, chsh: bool = True, qf: bool = True, ssm: bool = True) -> Report: + """Set which Games a Node offers, then report what the Node says afterwards. + + One parameter per Game, so the form asks one checkbox per Game. The report loop below + reads ``GamesAvailability.model_fields``, so a Game added to the Node's model shows up + there on its own — but it must be added to this signature too, or the form will never + ask about it. ``test_the_form_asks_about_every_game`` is what makes that a failing test + rather than a silent omission. + + The Node answers with *effective* availability, so a Game switched on here still + reads as unavailable while the hardware it needs is unreachable. Reporting the + request and the outcome side by side is the only way that difference is visible. + """ + games = GamesAvailability(chsh=chsh, qf=qf, ssm=ssm) + try: + applied = await self._client(node).set_availability(games) + except NodeApiError as e: + return Report( + status=Status.FAIL, + title=f"{node.name} — {node.api_url}", + summary="The Node did not answer.", + sections=[Section(error=str(e))], + ) + + fields = [] + for game in GamesAvailability.model_fields: + wanted, is_on = getattr(games, game), getattr(applied, game) + if wanted and not is_on: + # The write did land in the Node's config; something on another machine is + # holding the Game off, and it returns on its own once that is reachable. So + # this must not read as "off, as you asked". + value, status = "saved as on, but gated off by unreachable hardware", Status.WARN + elif is_on and not wanted: + # Not reachable today: gating only ever clears flags, and config is an + # absolute veto. Reported rather than ignored, because silence would be a lie. + value, status = "saved as off, but the Node still reports it on", Status.WARN + elif is_on: + value, status = "available", Status.ON + else: + value, status = "not available", Status.OFF + + fields.append(Field(name=game.upper(), value=value, status=status)) + + gated = [field.name for field in fields if field.status is Status.WARN] + return Report( + status=Status.overall(field.status for field in fields), + title=f"Game availability — {node.name}", + summary=None + if not gated + else f"{', '.join(gated)}: enabled in config, but gated off. Check the Router and the follower Node.", + sections=[Section(fields=fields)], + notes=["Availability is saved to the Node's config and applied without a restart."], + ) + + @prefill(set_availability) + async def _availability_prefill(self, node: Node) -> dict[str, object]: + """Open the availability form on what the Node currently reports. + + ``model_dump`` keys this by Game name, which is what the form asks for, and is what + keeps this method from naming the Games itself. + """ + availability = await self._client(node).get_availability() + return dict(availability.model_dump()) + + # ---------------------------------------------------------------------------------- + # The flow. The single entry point from any Chat Platform. + # ---------------------------------------------------------------------------------- + + async def dispatch(self, pending: PendingInvocation, handle: ReplyHandle) -> None: + """Ask for whatever is still missing, and run the Action once nothing is. + + A pure function of ``pending``: it never asks what happened before, so Whobot can + restart between any two clicks and the next click still works. + """ + if pending.action is None: + return await self.show_menu(self._menu(), handle) + + act = self.actions.get(pending.action) + if act is None: + # The payload outlived the Action. Never a crash, and never a wrong Action. + logger.info("payload names %r, which no longer exists", pending.action) + return await self.show_menu(self._menu(), handle, note=f"{pending.action!r} no longer exists.") + + node: Node | None = None + if act.scope is Scope.ONE: + nodes = await resolve_nodes(self.settings) + node = next((candidate for candidate in nodes if candidate.api_url == pending.node_url), None) + if node is None: + note = None if pending.node_url is None else f"{pending.node_url} is no longer registered." + return await self.ask_for_target(act, nodes, handle, note=note) + + if act.parameters and pending.params is None: + return await self.ask_for_params(act, pending, await self._initial_params(act, node), handle) + + if act.destructive and not pending.confirmed: + return await self.ask_to_confirm(act, pending, handle) + + self._spawn(self.execute(act, pending, node, handle)) + return None + + async def execute( + self, + act: Action, + pending: PendingInvocation, + node: Node | None, + handle: ReplyHandle, + ) -> None: + """Announce the run, run it, and post the outcome — whatever the outcome is. + + The invariant that makes the bot trustworthy is that **every announcement is + eventually followed by a result**. An orphaned "Running Reboot on ufl-public-right" + with no reply is worse than a clear failure: the operator cannot tell whether it + happened, and has to go and check by hand. So every way out of the call posts + something, including cancellation. + """ + reply = await self.announce_start(act, pending, handle) + + try: + result = await asyncio.wait_for(act.call(self, node, pending.params), act.timeout_s) + except TimeoutError: + logger.warning("%s timed out after %ss", act.name, act.timeout_s) + result = Report(status=Status.FAIL, title=act.label, summary=f"Timed out after {act.timeout_s:.0f}s.") + except asyncio.CancelledError: + # Posting from inside a cancelled coroutine cannot be awaited here — the await + # would be cancelled too. Hand it to a task nothing cancels, which shutdown + # waits for, then let the cancellation continue. + interrupted = Report(status=Status.FAIL, title=act.label, summary="Interrupted — Whobot shut down mid-run.") + self._finalise(self.post_result(act, interrupted, reply)) + raise + except Exception: + # An Action may not take the process down, ever. That is what makes "kill a + # Node mid-Action and the bot stays alive" true. + logger.exception("%s raised", act.name) + result = Report(status=Status.FAIL, title=act.label, summary="The Action raised an unhandled error.") + + await self.post_result(act, result, reply) + + # ---------------------------------------------------------------------------------- + # What a Chat Platform must provide. The whole abstract surface. + # ---------------------------------------------------------------------------------- + + @abstractmethod + async def show_menu(self, actions: list[Action], handle: ReplyHandle, note: str | None = None) -> None: ... + + @abstractmethod + async def ask_for_target( + self, act: Action, nodes: list[Node], handle: ReplyHandle, note: str | None = None + ) -> None: ... + + @abstractmethod + async def ask_for_params( + self, act: Action, pending: PendingInvocation, initial: dict[str, object], handle: ReplyHandle + ) -> None: ... + + @abstractmethod + async def ask_to_confirm(self, act: Action, pending: PendingInvocation, handle: ReplyHandle) -> None: ... + + @abstractmethod + async def announce_start(self, act: Action, pending: PendingInvocation, handle: ReplyHandle) -> ReplyHandle: ... + + @abstractmethod + async def post_result(self, act: Action, result: ActionResult, reply: ReplyHandle) -> None: ... + + # ---------------------------------------------------------------------------------- + # Running work, and stopping. + # ---------------------------------------------------------------------------------- + + def _spawn(self, coro: Coroutine[Any, Any, None]) -> None: + """Run an Action without waiting for it, keeping a reference so it survives. + + Python garbage-collects a task nobody holds a reference to, so the set is + load-bearing rather than bookkeeping. + """ + if not self._accepting: + coro.close() + logger.warning("refusing new work: Whobot is shutting down") + return + task = asyncio.create_task(coro) + self._tasks.add(task) + task.add_done_callback(self._tasks.discard) + + def _finalise(self, coro: Coroutine[Any, Any, None]) -> None: + """Run a reply that must outlive the cancellation that prompted it.""" + task = asyncio.create_task(coro) + self._finalisers.add(task) + task.add_done_callback(self._finalisers.discard) + + async def shutdown(self, grace_s: float = SHUTDOWN_GRACE_S) -> None: + """Stop accepting work, let what is running finish, then cancel the rest. + + Each cancelled Action reports itself as interrupted, so shutdown waits for those + replies too — otherwise the process would exit having orphaned exactly the + announcements the invariant above promises to answer. + """ + self._accepting = False + + if self._tasks: + _, running = await asyncio.wait(set(self._tasks), timeout=grace_s) + for task in running: + task.cancel() + if running: + await asyncio.wait(running, timeout=grace_s) + + if self._finalisers: + await asyncio.wait(set(self._finalisers), timeout=grace_s) + + # ---------------------------------------------------------------------------------- + # Helpers. None of these is an Action, so none can be invoked from a Chat Platform. + # ---------------------------------------------------------------------------------- + + def _client(self, node: Node) -> NodeClient: + """Open a client for one Node, bounded by the timeout an Action's calls get.""" + return NodeClient(node.api_url, self.settings.node_timeout_s) + + def _menu(self) -> list[Action]: + """Every Action, in the order they are declared. The menu is the class body.""" + return list(self.actions.values()) + + async def _initial_params(self, act: Action, node: Node | None) -> dict[str, object]: + """Work out what a parameter form should open on. + + Signature defaults, unless the Action has a ``@prefill`` that can say better. A + prefill talks to a Node and so can fail; a form opening on defaults is a great deal + better than no form, so a failure is logged and the defaults stand. + """ + defaults: dict[str, object] = {parameter.name: parameter.default for parameter in act.parameters} + try: + return defaults | await act.prefill_values(self, node) + except Exception: + logger.exception("%s: prefill failed, opening the form on its defaults", act.name) + return defaults diff --git a/src/pqn_whobot/whobot_slack.py b/src/pqn_whobot/whobot_slack.py new file mode 100644 index 0000000..ed6ba85 --- /dev/null +++ b/src/pqn_whobot/whobot_slack.py @@ -0,0 +1,570 @@ +"""Whobot on Slack: the only module that knows Block Kit or Bolt exists. + +Everything here is rendering and transport. No Action lives in this file, and no decision +about *what* to report — only about what it looks like once decided. + +Three Slack facts shape the code: + +* **Bolt injects handler arguments by parameter name** (``ack``, ``body``, ``client``, + ``logger``), so handlers are thin closures registered in ``_register_handlers`` rather + than bound methods, whose ``self`` Bolt would try and fail to inject. +* **An interaction must be acked within 3 seconds**, which is Slack asking "did you receive + this?" and not an answer. Every handler acks first and works afterwards. +* **The async flavour is a separate import path.** Mixing ``AsyncApp`` with the synchronous + socket-mode handler yields a bot that connects and then silently never responds. +""" + +import asyncio +import json +import logging +from collections.abc import Iterator +from collections.abc import Sequence +from dataclasses import dataclass +from dataclasses import replace +from functools import singledispatchmethod +from typing import Any + +from slack_bolt.async_app import AsyncApp +from slack_sdk.webhook.async_client import AsyncWebhookClient + +from pqn_whobot.actions import Action +from pqn_whobot.actions import ActionResult +from pqn_whobot.actions import PayloadError +from pqn_whobot.actions import PendingInvocation +from pqn_whobot.actions import ReplyHandle +from pqn_whobot.actions import Report +from pqn_whobot.actions import Section +from pqn_whobot.actions import Status +from pqn_whobot.actions import decode +from pqn_whobot.actions import encode +from pqn_whobot.config import WhobotSettings +from pqn_whobot.registry import Node +from pqn_whobot.whobot import Whobot + +logger = logging.getLogger(__name__) + +Block = dict[str, Any] + +OPTION_VALUE_LIMIT = 75 +"""Slack's cap on a dropdown option's ``value``, and the tightest slot a payload rides in.""" + +HEADER_LIMIT = 150 +"""Slack's cap on a header block's text.""" + +FIELDS_PER_SECTION = 10 +"""Slack's cap on a section's ``fields`` grid. Actions emit one Section; this splits it.""" + +STATUS_EMOJI = { + Status.OK: ":white_check_mark:", + Status.WARN: ":warning:", + Status.FAIL: ":x:", + Status.SKIPPED: ":grey_question:", + # The state pair. Dots rather than ticks and crosses, because these rows answer "is this + # on" and a tick beside "not available" reads as a contradiction. + Status.ON: ":large_green_circle:", + Status.OFF: ":red_circle:", +} +"""One glyph per ``Status``. A test asserts the mapping is total, because ``_emoji`` falls +back to no glyph at all, which would silently drop the marker from every affected row.""" + +# Block IDs and action IDs. Slack sends these straight back, so they are the only way a +# handler knows which widget it is hearing from. +MENU_ACTION = "whobot_menu" +TARGET_ACTION = "whobot_target" +CONFIRM_ACTION = "whobot_confirm" +CANCEL_ACTION = "whobot_cancel" +PARAMS_VIEW = "whobot_params" +PARAMS_BLOCK = "whobot_params_block" + + +@dataclass(frozen=True) +class SlackReply(ReplyHandle): + """Where a reply goes, and how. The base class never opens one of these. + + ``response_url`` addresses the ephemeral scaffolding — menu, target list, confirmation — + each step replacing the last. ``channel`` and ``thread_ts`` address the public record + that begins at ``announce_start``. ``trigger_id`` is Slack's permission to open a modal + and is valid for about three seconds, which is why the form is opened from the handler's + own turn rather than from a spawned task. + """ + + channel: str + thread_ts: str | None = None + response_url: str | None = None + trigger_id: str | None = None + replace: bool = False + + +def _escape(text: str) -> str: + """Escape the three characters Slack treats as markup control characters.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def _chunked(items: Sequence[Any], size: int) -> Iterator[Sequence[Any]]: + for start in range(0, len(items), size): + yield items[start : start + size] + + +def _section(text: str) -> Block: + return {"type": "section", "text": {"type": "mrkdwn", "text": text}} + + +def _context(text: str) -> Block: + return {"type": "context", "elements": [{"type": "mrkdwn", "text": text}]} + + +def _target_label(node: Node) -> str: + """Every rendered target shows name and address, resolved from the current registry.""" + return f"{node.name} — {node.api_url}" + + +def _option(text: str, value: str) -> Block: + return {"text": {"type": "plain_text", "text": text[:75], "emoji": True}, "value": value} + + +def _option_value(pending: PendingInvocation, what: str) -> str: + """Encode a payload for a dropdown option, refusing to build one Slack will reject. + + Slack answers an over-long option value with a bare ``invalid_blocks``, which names + neither the block nor the field. Failing here instead means the message says which + Action was being rendered. + """ + encoded = encode(pending) + if len(encoded) > OPTION_VALUE_LIMIT: + msg = ( + f"{what}: encoded payload is {len(encoded)} characters, over Slack's " + f"{OPTION_VALUE_LIMIT}-character option value limit: {encoded}" + ) + raise ValueError(msg) + return encoded + + +class WhobotSlack(Whobot): + """Whobot speaking Slack. Declares no Actions; it only draws what it is asked to draw.""" + + def __init__(self, settings: WhobotSettings) -> None: + super().__init__(settings) + self.app = AsyncApp(token=settings.slack_bot_token, raise_error_for_unhandled_request=False) + self._register_handlers() + + # ---------------------------------------------------------------------------------- + # Bolt handlers. Each acks first, then hands the decoded payload to dispatch. + # ---------------------------------------------------------------------------------- + + def _register_handlers(self) -> None: + """Register every handler as a closure, because Bolt injects arguments by name.""" + app = self.app + + @app.command("/whobot") + async def _command(ack: Any, body: dict[str, Any]) -> None: + await ack() + reply = SlackReply( + channel=body.get("channel_id", ""), + response_url=body.get("response_url"), + trigger_id=body.get("trigger_id"), + ) + await self._safely(PendingInvocation(), reply) + + @app.action(MENU_ACTION) + async def _menu_chosen(ack: Any, body: dict[str, Any]) -> None: + await ack() + await self._from_interaction(body) + + @app.action(TARGET_ACTION) + async def _target_chosen(ack: Any, body: dict[str, Any]) -> None: + await ack() + await self._from_interaction(body) + + @app.action(CONFIRM_ACTION) + async def _confirmed(ack: Any, body: dict[str, Any]) -> None: + await ack() + await self._from_interaction(body) + + @app.action(CANCEL_ACTION) + async def _cancelled(ack: Any, body: dict[str, Any]) -> None: + await ack() + await self._respond(self._reply_from(body, replace=True), [_section("Cancelled. Nothing was run.")]) + + @app.view(PARAMS_VIEW) + async def _form_submitted(ack: Any, body: dict[str, Any]) -> None: + # A view submission carries no channel and no response_url, so both had to be + # written into private_metadata when the modal was opened. + await ack() + view = body.get("view", {}) + try: + metadata = json.loads(view.get("private_metadata") or "{}") + pending = decode(metadata.get("pending", "{}")) + except (PayloadError, ValueError): + logger.exception("could not decode a modal's private_metadata") + return + reply = SlackReply(channel=metadata.get("channel", ""), response_url=metadata.get("response_url")) + act = self.actions.get(pending.action or "") + if act is None: + # The modal outlived the Action it was rendered for. There is nothing to read + # the form against, so let dispatch re-render with its note. + await self._safely(replace(pending, params=None), reply) + return + values = view.get("state", {}).get("values", {}).get(PARAMS_BLOCK, {}) + await self._safely(replace(pending, params=self._read_checkboxes(act, values)), reply) + + async def _from_interaction(self, body: dict[str, Any]) -> None: + """Decode the widget the operator just used and continue the flow.""" + actions = body.get("actions") or [{}] + chosen = actions[0] + raw = chosen.get("value") or (chosen.get("selected_option") or {}).get("value") + reply = self._reply_from(body, replace=True) + try: + pending = decode(raw or "{}") + except PayloadError: + logger.exception("undecodable payload from Slack; re-rendering the menu") + await self.show_menu(self._menu(), reply, note="That menu was not readable. Here it is again.") + return + await self._safely(pending, reply) + + @staticmethod + def _reply_from(body: dict[str, Any], *, replace: bool = False) -> SlackReply: + return SlackReply( + channel=(body.get("channel") or {}).get("id", ""), + response_url=body.get("response_url"), + trigger_id=body.get("trigger_id"), + replace=replace, + ) + + @staticmethod + def _read_checkboxes(act: Action, values: dict[str, Any]) -> dict[str, object]: + """Read a checkbox group back as one boolean per declared parameter. + + Slack reports only the *ticked* boxes, so an unticked one arrives as an absence + rather than a ``False``. A submitted view echoes nothing else either — in particular + **not** the ``initial_options`` it was rendered with — so the False floor has to come + from what the Action declares. Without it a cleared box is missing from the payload, + takes its default in ``coerce_params``, and switches the flag back *on*. + """ + params: dict[str, object] = { + parameter.name: False for parameter in act.parameters if parameter.annotation is bool + } + for element in values.values(): + for option in element.get("selected_options", []) or []: + params[option["value"]] = True + return params + + async def _safely(self, pending: PendingInvocation, reply: SlackReply) -> None: + """Run dispatch so that no handler can take the socket down. + + Bolt logs and swallows a handler exception, but a Whobot that stops answering is + worse than one that says it failed, so the operator is told either way. + """ + try: + await self.dispatch(pending, reply) + except Exception: + logger.exception("dispatch failed for %r", pending) + await self._respond(reply, [_section(":x: Whobot could not handle that. Check its logs.")]) + + # ---------------------------------------------------------------------------------- + # The ephemeral scaffolding: each step replaces the last. + # ---------------------------------------------------------------------------------- + + async def show_menu(self, actions: list[Action], handle: ReplyHandle, note: str | None = None) -> None: + reply = self._slack(handle) + options = [ + _option(act.label, _option_value(PendingInvocation(action=act.name), f"menu entry {act.name!r}")) + for act in actions + ] + blocks: list[Block] = [] + if note: + blocks.append(_context(f":warning: {_escape(note)}")) + blocks.append( + { + "type": "section", + "text": {"type": "mrkdwn", "text": "*What would you like Whobot to do?*"}, + "accessory": { + "type": "static_select", + "action_id": MENU_ACTION, + "placeholder": {"type": "plain_text", "text": "Choose an Action"}, + "options": options, + }, + } + ) + descriptions = [f"*{_escape(a.label)}* — {_escape(a.description)}" for a in actions if a.description] + if descriptions: + blocks.append(_context("\n".join(descriptions))) + await self._respond(reply, blocks) + + async def ask_for_target( + self, act: Action, nodes: list[Node], handle: ReplyHandle, note: str | None = None + ) -> None: + reply = self._slack(handle) + if not nodes: + await self._respond(reply, [_section("No Nodes are registered. Add one to `whobot.toml`.")]) + return + + options = [ + _option( + _target_label(node), + _option_value(PendingInvocation(action=act.name, node_url=node.api_url), f"target for {act.name!r}"), + ) + for node in nodes + ] + blocks: list[Block] = [] + if note: + blocks.append(_context(f":warning: {_escape(note)}")) + blocks.append( + { + "type": "section", + "text": {"type": "mrkdwn", "text": f"*{_escape(act.label)}* — which Node?"}, + "accessory": { + "type": "static_select", + "action_id": TARGET_ACTION, + "placeholder": {"type": "plain_text", "text": "Choose a Node"}, + "options": options, + }, + } + ) + await self._respond(reply, blocks) + + async def ask_for_params( + self, act: Action, pending: PendingInvocation, initial: dict[str, object], handle: ReplyHandle + ) -> None: + """Open a modal generated from the Action's signature, on the values given.""" + reply = self._slack(handle) + if reply.trigger_id is None: + logger.error("%s: no trigger_id, so no modal can be opened", act.name) + await self._respond(reply, [_section(":x: Slack did not allow a form to open. Try again.")]) + return + + metadata = json.dumps( + {"pending": encode(pending), "channel": reply.channel, "response_url": reply.response_url} + ) + await self.app.client.views_open( + trigger_id=reply.trigger_id, + view={ + "type": "modal", + "callback_id": PARAMS_VIEW, + "title": {"type": "plain_text", "text": act.label[:24]}, + "submit": {"type": "plain_text", "text": "Run"}, + "close": {"type": "plain_text", "text": "Cancel"}, + "private_metadata": metadata, + "blocks": [self._checkbox_block(act, initial)], + }, + ) + + @staticmethod + def _checkbox_block(act: Action, initial: dict[str, object]) -> Block: + """Render every parameter as a checkbox. The only widget mapping that exists. + + The scan has already refused any parameter type without a mapping, so reaching here + with something other than a ``bool`` is a bug in the scan rather than a bad Action. + """ + options = [_option(parameter.name.upper(), parameter.name) for parameter in act.parameters] + ticked = [ + _option(parameter.name.upper(), parameter.name) + for parameter in act.parameters + if initial.get(parameter.name, parameter.default) + ] + element: Block = {"type": "checkboxes", "action_id": PARAMS_BLOCK, "options": options} + if ticked: + # Slack rejects an empty initial_options outright, so it is omitted rather than sent. + element["initial_options"] = ticked + return { + "type": "input", + "block_id": PARAMS_BLOCK, + "optional": True, + "label": {"type": "plain_text", "text": "Enabled"}, + "element": element, + } + + async def ask_to_confirm(self, act: Action, pending: PendingInvocation, handle: ReplyHandle) -> None: + """Name the target before anything happens. A dropdown pick must never act.""" + reply = self._slack(handle) + target = f" on `{_escape(pending.node_url)}`" if pending.node_url else "" + await self._respond( + reply, + [ + _section(f":warning: *{_escape(act.label)}*{target}.\nThis cannot be undone. Run it?"), + { + "type": "actions", + "elements": [ + { + "type": "button", + "action_id": CONFIRM_ACTION, + "style": "danger", + "text": {"type": "plain_text", "text": f"Yes, {act.label}"}, + # A button's value allows 2000 characters, so params ride here. + "value": encode(replace(pending, confirmed=True)), + }, + { + "type": "button", + "action_id": CANCEL_ACTION, + "text": {"type": "plain_text", "text": "Cancel"}, + "value": "{}", + }, + ], + }, + ], + ) + + # ---------------------------------------------------------------------------------- + # The public record: an in-channel ack, then the result threaded under it. + # ---------------------------------------------------------------------------------- + + async def announce_start(self, act: Action, pending: PendingInvocation, handle: ReplyHandle) -> ReplyHandle: + """Say publicly that work has begun, and return where its result belongs. + + Everything before this is scaffolding nobody else needs to watch. The moment work + starts it becomes record — which matters, because with no Slack-side access control + the channel *is* the audit log of who changed what on which Node. + """ + reply = self._slack(handle) + channel = reply.channel or self.settings.digest_channel + target = f" on `{_escape(pending.node_url)}`" if pending.node_url else "" + posted = await self.app.client.chat_postMessage( + channel=channel, + text=f"Running {act.label}…", + blocks=[_section(f":hourglass_flowing_sand: Running *{_escape(act.label)}*{target}…")], + ) + return SlackReply(channel=channel, thread_ts=posted["ts"]) + + async def post_result(self, act: Action, result: ActionResult, reply: ReplyHandle) -> None: + """Post an Action's outcome as a threaded reply under its announcement.""" + slack = self._slack(reply) + blocks = self._render(result) + await self.app.client.chat_postMessage( + channel=slack.channel, + thread_ts=slack.thread_ts, + text=act.label, + blocks=blocks, + ) + image = getattr(result, "image", None) + if image: + await self.app.client.files_upload_v2( + channel=slack.channel, + thread_ts=slack.thread_ts, + file=image, + filename=f"{act.name}.png", + title=act.label, + ) + + # ---------------------------------------------------------------------------------- + # Renderers: pure ActionResult -> blocks, so they can be tested without posting. + # ---------------------------------------------------------------------------------- + + @singledispatchmethod + def _render(self, result: ActionResult) -> list[Block]: + """Refuse to guess. A result type with no renderer is caught by a test, not here.""" + msg = f"no renderer registered for {type(result).__name__}" + raise NotImplementedError(msg) + + @_render.register + def _render_report(self, result: Report) -> list[Block]: + blocks: list[Block] = [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": f"{self._emoji(result.status)} {result.title}"[:HEADER_LIMIT], + "emoji": True, + }, + } + ] + if result.summary: + blocks.append(_section(_escape(result.summary))) + for section in result.sections: + blocks += self._render_section(section) + if result.notes: + blocks.append(_context("\n".join(_escape(note) for note in result.notes))) + return blocks + + @classmethod + def _render_section(cls, section: Section) -> list[Block]: + """Render one Section, splitting its measurements across Slack's ten-field cap.""" + blocks: list[Block] = [] + if section.label: + heading = f"*{_escape(section.label)}*" + if section.status is not None: + heading = f"{cls._emoji(section.status)} {heading}" + blocks.append(_section(heading)) + + # A Field with a status is a checklist entry and renders as a line; one without is a + # measurement and renders in a grid. The rule comes from the data, not from a flag. + lines = [f"{cls._emoji(f.status)} {_escape(f.name)} — {_escape(f.value)}" for f in section.fields if f.status] + if lines: + blocks.append(_section("\n".join(lines))) + + grid = [f for f in section.fields if f.status is None] + blocks.extend( + { + "type": "section", + "fields": [{"type": "mrkdwn", "text": f"*{_escape(f.name)}*\n{_escape(f.value)}"} for f in chunk], + } + for chunk in _chunked(grid, FIELDS_PER_SECTION) + ) + + if section.note: + blocks.append(_context(_escape(section.note))) + if section.error: + blocks.append(_section(f"```{section.error[:2800]}```")) + return blocks + + @staticmethod + def _emoji(status: Status | None) -> str: + return STATUS_EMOJI.get(status, "") if status is not None else "" + + # ---------------------------------------------------------------------------------- + # Plumbing. + # ---------------------------------------------------------------------------------- + + @staticmethod + def _slack(handle: ReplyHandle) -> SlackReply: + """Narrow the opaque handle the base class passes around back to this platform's.""" + if not isinstance(handle, SlackReply): + msg = f"WhobotSlack was handed a {type(handle).__name__}, not a SlackReply" + raise TypeError(msg) + return handle + + @staticmethod + async def _respond(reply: SlackReply, blocks: list[Block]) -> None: + """Send an ephemeral step, replacing the previous one where there is one.""" + if reply.response_url is None: + logger.error("no response_url; dropping an ephemeral message") + return + await AsyncWebhookClient(reply.response_url).send( + text="Whobot", + blocks=blocks, + response_type="ephemeral", + replace_original=reply.replace, + ) + + async def check_credentials(self) -> None: + """Verify both tokens before connecting, raising ``SlackApiError`` if either is bad. + + This exists because ``start_async`` **retries forever** rather than raising: with a + rejected token an operator sees a bot that appears to start, logs a traceback every + few seconds, and never answers. Retrying is right for a network that dropped and + wrong for a credential that will never be accepted, and only a preflight can tell + those apart. Each token is checked by the cheapest call that exercises it. + """ + from slack_sdk.web.async_client import AsyncWebClient # noqa: PLC0415 + + await self.app.client.auth_test() + # The URL this returns is deliberately discarded; the handler opens its own. + await AsyncWebClient().apps_connections_open(app_token=self.settings.slack_app_token) + + async def serve(self) -> None: + """Hold the Socket Mode connection until the process is asked to stop. + + Bolt reconnects on its own. While disconnected an operator's click simply fails in + their client — Slack does not queue interactions, so there is nothing to replay. + """ + # Imported here so that importing this module needs no aiohttp, which keeps the + # renderer tests independent of the transport. + from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler # noqa: PLC0415 + + handler = AsyncSocketModeHandler(self.app, self.settings.slack_app_token) + try: + # slack_bolt ships no annotations for these two, and mypy is strict here. + await handler.start_async() # type: ignore[no-untyped-call] + except (KeyboardInterrupt, asyncio.CancelledError): + logger.info("stopping") + finally: + await handler.close_async() # type: ignore[no-untyped-call] + await self.shutdown() diff --git a/tests/pytest/test_whobot_actions.py b/tests/pytest/test_whobot_actions.py new file mode 100644 index 0000000..efaa93b --- /dev/null +++ b/tests/pytest/test_whobot_actions.py @@ -0,0 +1,445 @@ +"""Tests for the Action contract: the result vocabulary, the scan, and the payload codec. + +The scan's job is to reject a malformed Action at import, so most of what is pinned here is +a *failure*: the message an author gets, and that they get it at all. Every case runs +``scan_actions`` directly on a throwaway class, so none of it needs a Whobot, a Node, or +Slack. That the scan also runs on class creation is pinned in the ``whobot.py`` tests. +""" + +import asyncio +import json + +import pytest + +from pqn_whobot.actions import ActionDeclarationError +from pqn_whobot.actions import Field +from pqn_whobot.actions import PayloadError +from pqn_whobot.actions import PendingInvocation +from pqn_whobot.actions import Report +from pqn_whobot.actions import Scope +from pqn_whobot.actions import Section +from pqn_whobot.actions import Status +from pqn_whobot.actions import action +from pqn_whobot.actions import decode +from pqn_whobot.actions import encode +from pqn_whobot.actions import prefill +from pqn_whobot.actions import scan_actions +from pqn_whobot.registry import Node + +OK = Report(status=Status.OK, title="fine") + +A_NODE = Node(api_url="http://node-a.invalid:9000", name="uiuc-public-left", reachable=True) + +SLACK_OPTION_VALUE_LIMIT = 75 +"""Slack's cap on a dropdown option's ``value`` — the tightest slot a payload rides in.""" + + +# -------------------------------------------------------------------------------------- +# The result vocabulary. +# -------------------------------------------------------------------------------------- + + +def test_skipped_is_distinct_from_warn() -> None: + """Did-not-run and ran-and-looked-wrong are different answers to different questions.""" + assert Status.SKIPPED is not Status.WARN + assert {status.value for status in Status} == {"ok", "warn", "fail", "skipped", "on", "off"} + + +def test_the_state_pair_is_not_an_outcome() -> None: + """A row read as "is this on" must not be glyphed with an outcome, or it contradicts itself.""" + assert Status.ON is not Status.OK + assert Status.OFF is not Status.FAIL + + +def test_a_field_is_a_measurement_unless_it_carries_a_status() -> None: + """The rule the renderer branches on, stated once here so it cannot drift silently.""" + assert Field(name="S", value="2.4142").status is None + assert Field(name="Router", value="12ms", status=Status.OK).status is Status.OK + + +def test_a_section_needs_nothing_but_what_it_means() -> None: + empty = Section() + assert empty.label is None + assert empty.fields == [] + assert empty.note is None + assert empty.error is None + + +def test_reports_do_not_share_mutable_defaults() -> None: + """A default_factory slip here would have one Report's sections appear in the next.""" + first = Report(status=Status.OK, title="First") + second = Report(status=Status.OK, title="Second") + first.sections.append(Section(label="only mine")) + first.notes.append("only mine") + assert second.sections == [] + assert second.notes == [] + + +def test_a_report_states_only_status_and_title_at_minimum() -> None: + report = Report(status=Status.OK, title="List Nodes") + assert report.summary is None + assert report.image is None + assert report.sections == [] + + +# -------------------------------------------------------------------------------------- +# The scan: what it accepts. +# -------------------------------------------------------------------------------------- + + +class Declarer: + """A stand-in for a Whobot subclass, carrying one Action of each shape.""" + + @action(label="List Nodes", description="Every Node, with its reachability.") + async def list_nodes(self) -> Report: + return OK + + @action(label="Node Info", scope=Scope.ONE) + async def node_info(self, node: Node) -> Report: + return Report(status=Status.OK, title=node.api_url) + + @action(label="Change Game availability", scope=Scope.ONE, timeout_s=30.0) + async def set_availability(self, node: Node, *, chsh: bool = True, qf: bool = False, ssm: bool = True) -> Report: + return Report(status=Status.OK, title=f"{node.api_url} {chsh} {qf} {ssm}") + + @prefill(set_availability) + async def _availability_prefill(self, node: Node) -> dict[str, object]: + return {"chsh": False, "qf": True, "ssm": False} + + @action(label="Reboot", scope=Scope.ONE, destructive=True, timeout_s=360.0) + async def reboot(self, node: Node) -> Report: + return OK + + async def a_helper(self, node: Node) -> Report: + """Undecorated, so it must never appear in the menu.""" + return OK + + +def test_the_scan_finds_only_marked_methods() -> None: + """Opt-in is what stops adding a helper from accidentally adding a button in Slack.""" + found = scan_actions(Declarer) + assert set(found) == {"list_nodes", "node_info", "set_availability", "reboot"} + + +def test_an_action_takes_its_name_from_the_method_and_its_text_from_the_decorator() -> None: + act = scan_actions(Declarer)["list_nodes"] + assert act.name == "list_nodes" + assert act.label == "List Nodes" + assert act.description == "Every Node, with its reachability." + assert act.scope is Scope.NONE + assert act.destructive is False + + +def test_declaration_defaults_are_non_destructive_and_whobot_level() -> None: + """The safe values are the ones an author gets without asking for them.""" + act = scan_actions(Declarer)["list_nodes"] + assert act.scope is Scope.NONE + assert act.destructive is False + assert act.timeout_s == pytest.approx(120.0) + + +def test_the_node_argument_is_not_a_form_parameter() -> None: + """A Node is chosen from a dropdown, so it must not also be asked for in the form.""" + assert scan_actions(Declarer)["node_info"].parameters == () + + +def test_parameters_are_parsed_from_the_signature_with_its_defaults() -> None: + """There is no second parameter declaration, which is why none can fall out of step.""" + parameters = scan_actions(Declarer)["set_availability"].parameters + assert [p.name for p in parameters] == ["chsh", "qf", "ssm"] + assert [p.default for p in parameters] == [True, False, True] + assert {p.annotation for p in parameters} == {bool} + + +def test_a_bool_without_a_signature_default_starts_unticked() -> None: + class NoDefault: + @action(label="Toggle") + async def toggle(self, *, flag: bool) -> Report: + return OK + + assert scan_actions(NoDefault)["toggle"].parameters[0].default is False + + +def test_a_subclass_inherits_its_parents_actions() -> None: + """WhobotSlack declares no Actions; every one it serves is found on the base.""" + + class Subclass(Declarer): + @action(label="Extra") + async def extra(self) -> Report: + return OK + + found = scan_actions(Subclass) + assert "extra" in found + assert "list_nodes" in found + + +def test_a_prefill_is_attached_to_the_action_it_names() -> None: + found = scan_actions(Declarer) + assert found["set_availability"].prefill_name == "_availability_prefill" + assert found["node_info"].prefill_name is None + + +# -------------------------------------------------------------------------------------- +# The scan: what it rejects, and with what message. +# -------------------------------------------------------------------------------------- + + +def test_scope_one_without_a_node_parameter_is_rejected() -> None: + class Bad: + @action(label="Bad", scope=Scope.ONE) + async def bad(self) -> Report: + return OK + + with pytest.raises(ActionDeclarationError, match=r"'bad'.*scope=ONE requires"): + scan_actions(Bad) + + +def test_scope_one_with_a_node_that_is_not_a_node_is_rejected() -> None: + class Bad: + @action(label="Bad", scope=Scope.ONE) + async def bad(self, node: str) -> Report: + return OK + + with pytest.raises(ActionDeclarationError, match="must be annotated Node"): + scan_actions(Bad) + + +def test_scope_none_taking_a_node_is_rejected() -> None: + """The opposite mismatch, and the one that would silently never be passed a Node.""" + + class Bad: + @action(label="Bad") + async def bad(self, node: Node) -> Report: + return OK + + with pytest.raises(ActionDeclarationError, match="scope=NONE must not take a 'node'"): + scan_actions(Bad) + + +def test_a_parameter_type_with_no_widget_is_rejected_by_name() -> None: + """The message must name the Action and the parameter, or it is not actionable.""" + + class Bad: + @action(label="Bad") + async def bad(self, *, reason: str = "") -> Report: + return OK + + with pytest.raises(ActionDeclarationError, match=r"'bad'.*'reason'.*no widget"): + scan_actions(Bad) + + +def test_an_unannotated_parameter_is_rejected() -> None: + class Bad: + @action(label="Bad") + async def bad(self, *, flag=True) -> Report: + return OK + + with pytest.raises(ActionDeclarationError, match="no type annotation"): + scan_actions(Bad) + + +def test_a_positional_form_parameter_is_rejected() -> None: + """Every Action is called as method(node, **params), so a positional one is a fiction.""" + + class Bad: + @action(label="Bad") + # The positional bool ruff objects to is the whole point of this case: the scan + # must reject it too, so that the rule is enforced rather than merely linted. + async def bad(self, flag: bool = True) -> Report: # noqa: FBT001, FBT002 + return OK + + with pytest.raises(ActionDeclarationError, match="must be keyword-only"): + scan_actions(Bad) + + +def test_varargs_are_rejected() -> None: + class Bad: + @action(label="Bad") + async def bad(self, *flags: bool) -> Report: + return OK + + with pytest.raises(ActionDeclarationError, match="which no form can ask for"): + scan_actions(Bad) + + +def test_a_synchronous_action_is_rejected() -> None: + """Every Action is awaited, so a plain def would return a coroutine-shaped nothing.""" + + class Bad: + @action(label="Bad") + def bad(self) -> Report: + return OK + + with pytest.raises(ActionDeclarationError, match="must be 'async def'"): + scan_actions(Bad) + + +def test_an_action_with_no_return_annotation_is_rejected() -> None: + class Bad: + @action(label="Bad") + async def bad(self): + return OK + + with pytest.raises(ActionDeclarationError, match="no return annotation"): + scan_actions(Bad) + + +def test_an_action_returning_something_other_than_a_result_is_rejected() -> None: + """The one rule that keeps Block Kit out: an Action returns the neutral type or nothing.""" + + class Bad: + @action(label="Bad") + async def bad(self) -> dict[str, str]: + return {} + + with pytest.raises(ActionDeclarationError, match="must return an ActionResult"): + scan_actions(Bad) + + +def test_a_prefill_pointing_at_a_non_action_is_rejected() -> None: + class Bad: + async def not_an_action(self) -> Report: + return OK + + @prefill(not_an_action) + async def _fill(self) -> dict[str, object]: + return {} + + with pytest.raises(ActionDeclarationError, match=r"'not_an_action'.*not an Action"): + scan_actions(Bad) + + +def test_a_synchronous_prefill_is_rejected() -> None: + class Bad: + @action(label="Bad") + async def bad(self, *, flag: bool = True) -> Report: + return OK + + @prefill(bad) + def _fill(self) -> dict[str, object]: + return {} + + with pytest.raises(ActionDeclarationError, match=r"prefill.*must be 'async def'"): + scan_actions(Bad) + + +def test_two_prefills_for_one_action_are_rejected() -> None: + """Ambiguous, and the loser would be picked by member ordering — a silent coin toss.""" + + class Bad: + @action(label="Bad") + async def bad(self, *, flag: bool = True) -> Report: + return OK + + @prefill(bad) + async def _fill_a(self) -> dict[str, object]: + return {} + + @prefill(bad) + async def _fill_b(self) -> dict[str, object]: + return {} + + with pytest.raises(ActionDeclarationError, match="more than one prefill"): + scan_actions(Bad) + + +# -------------------------------------------------------------------------------------- +# Calling and parameter coercion. +# -------------------------------------------------------------------------------------- + + +def test_calling_looks_the_method_up_on_the_owner() -> None: + act = scan_actions(Declarer)["node_info"] + result = asyncio.run(act.call(Declarer(), A_NODE, None)) + assert isinstance(result, Report) + assert result.title == A_NODE.api_url + + +def test_a_prefill_is_resolved_on_the_owner() -> None: + act = scan_actions(Declarer)["set_availability"] + assert act.prefill_name is not None + assert asyncio.run(act.prefill_values(Declarer(), A_NODE)) == {"chsh": False, "qf": True, "ssm": False} + + +def test_coercing_fills_in_every_declared_parameter() -> None: + act = scan_actions(Declarer)["set_availability"] + assert act.coerce_params({"chsh": False}) == {"chsh": False, "qf": False, "ssm": True} + + +def test_coercing_drops_a_parameter_the_action_no_longer_declares() -> None: + """A modal can outlive the deploy that renamed a parameter; that must not raise.""" + act = scan_actions(Declarer)["set_availability"] + coerced = act.coerce_params({"chsh": True, "removed_last_week": True}) + assert "removed_last_week" not in coerced + assert coerced["chsh"] is True + + +def test_coercing_nothing_yields_the_declared_defaults() -> None: + act = scan_actions(Declarer)["set_availability"] + assert act.coerce_params(None) == {"chsh": True, "qf": False, "ssm": True} + + +def test_coercing_an_action_with_no_parameters_yields_nothing() -> None: + act = scan_actions(Declarer)["node_info"] + assert act.coerce_params({"stale": True}) == {} + + +# -------------------------------------------------------------------------------------- +# The payload codec. +# -------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "pending", + [ + PendingInvocation(), + PendingInvocation(action="list_nodes"), + PendingInvocation(action="node_info", node_url="http://node-b.invalid:9000"), + PendingInvocation(action="reboot", node_url="http://node-b.invalid:9000", confirmed=True), + PendingInvocation( + action="set_availability", + node_url="http://node-b.invalid:9000", + params={"chsh": True, "qf": False, "ssm": True}, + confirmed=False, + ), + ], +) +def test_a_payload_round_trips(pending: PendingInvocation) -> None: + assert decode(encode(pending)) == pending + + +def test_nothing_chosen_yet_encodes_as_an_empty_payload() -> None: + """Opening the menu is the same code path as every other click, carrying no state.""" + assert encode(PendingInvocation()) == "{}" + + +def test_the_encoding_omits_what_has_not_been_chosen() -> None: + """Every byte spent on a null is a byte the 75-character dropdown slot does not have.""" + encoded = encode(PendingInvocation(action="node_info")) + assert json.loads(encoded) == {"a": "node_info"} + + +def test_a_dropdown_payload_fits_slacks_seventy_five_character_option_value() -> None: + """The tightest slot carries the least: params never ride in a dropdown option.""" + longest = PendingInvocation(action="set_availability", node_url="http://node-b.invalid:9000") + assert len(encode(longest)) <= SLACK_OPTION_VALUE_LIMIT + + +def test_decoding_something_that_is_not_json_is_rejected() -> None: + with pytest.raises(PayloadError, match="not a Whobot payload"): + decode("this is not json") + + +def test_decoding_json_that_is_not_an_object_is_rejected() -> None: + with pytest.raises(PayloadError, match="expected an object"): + decode("[1, 2, 3]") + + +def test_decoding_a_payload_whose_parameters_are_not_an_object_is_rejected() -> None: + with pytest.raises(PayloadError, match="parameters must be an object"): + decode('{"a":"set_availability","p":"chsh"}') + + +def test_decoding_does_not_judge_whether_the_action_still_exists() -> None: + """Staleness is dispatch's question — it re-renders. The codec only reads.""" + assert decode('{"a":"deleted_last_week"}') == PendingInvocation(action="deleted_last_week") diff --git a/tests/pytest/test_whobot_cli.py b/tests/pytest/test_whobot_cli.py index 8b4bcec..743e584 100644 --- a/tests/pytest/test_whobot_cli.py +++ b/tests/pytest/test_whobot_cli.py @@ -25,10 +25,9 @@ """ ALIVE = Node(api_url="http://node-a.invalid:9000", name="uiuc-public-left", reachable=True, latency_ms=12.3) -DEAD = Node(api_url="http://offline.invalid:9000", name=None, reachable=False, error="ConnectError: refused") +DEAD = Node(api_url="http://offline.invalid:9000", reachable=False, error="ConnectError: refused") OUTDATED = Node( api_url="http://node-b.invalid:9000", - name=None, reachable=True, warning="no node_name in /node/config; the Node is running older code — update it", latency_ms=8.0, diff --git a/tests/pytest/test_whobot_flow.py b/tests/pytest/test_whobot_flow.py new file mode 100644 index 0000000..c40294b --- /dev/null +++ b/tests/pytest/test_whobot_flow.py @@ -0,0 +1,636 @@ +"""Tests for the flow: which branch ``dispatch`` takes, and what ``execute`` guarantees. + +Driven by ``WhobotSpy``, a Chat Platform that draws nothing and records what it was asked +to draw. That is the whole point of the abstract surface — the flow can be pinned without +Slack, without a socket, and without a Node. + +The invariant these tests exist to defend is that **every announcement is followed by a +result**, however the Action ends: normally, by raising, by timing out, or by cancellation. + +Two things every test here needs. It runs in a temp working directory, because +``WhobotSettings`` reads ``./whobot.toml`` *before* its keyword arguments, so a real config +in the repo root would otherwise decide what the fleet is. And ``resolve_nodes`` is faked, +because dispatch consults the registry over HTTP on every ``scope=ONE`` click. +""" + +import asyncio +import re +from collections.abc import Callable +from collections.abc import Iterator +from dataclasses import dataclass +from dataclasses import field +from pathlib import Path + +import httpx +import pytest + +from pqn_node.core.config import GamesAvailability +from pqn_whobot.actions import Action +from pqn_whobot.actions import ActionResult +from pqn_whobot.actions import PendingInvocation +from pqn_whobot.actions import ReplyHandle +from pqn_whobot.actions import Report +from pqn_whobot.actions import Scope +from pqn_whobot.actions import Status +from pqn_whobot.actions import action +from pqn_whobot.actions import prefill +from pqn_whobot.config import NodeEntry +from pqn_whobot.config import WhobotSettings +from pqn_whobot.node_client import NodeClient +from pqn_whobot.registry import Node +from pqn_whobot.whobot import Whobot + +ALICE = "http://node-a.invalid:9000" +BOB = "http://node-b.invalid:9000" + +REACHABLE = [ + Node(api_url=ALICE, name="uiuc-public-left", reachable=True, latency_ms=18.0), + Node(api_url=BOB, name="ufl-public-right", reachable=True, latency_ms=24.0), +] + +DISTINCT_FAILURE_MODES = 3 +"""Raising, timing out and being interrupted: three failures an operator must tell apart.""" + +FLEET: list[Node] = [] +"""What the faked registry currently reports. Set by ``spy``, reset between tests.""" + + +# -------------------------------------------------------------------------------------- +# The spy Chat Platform. +# -------------------------------------------------------------------------------------- + + +@dataclass +class Drawn: + """What a Chat Platform was asked to draw, in order.""" + + calls: list[str] = field(default_factory=list) + menu: list[Action] = field(default_factory=list) + targets: list[Node] = field(default_factory=list) + initial: dict[str, object] = field(default_factory=dict) + notes: list[str | None] = field(default_factory=list) + results: list[ActionResult] = field(default_factory=list) + + +class WhobotSpy(Whobot): + """A Chat Platform that renders nothing and remembers everything.""" + + def __init__(self, settings: WhobotSettings) -> None: + super().__init__(settings) + self.drawn = Drawn() + + async def show_menu(self, actions: list[Action], handle: ReplyHandle, note: str | None = None) -> None: + self.drawn.calls.append("show_menu") + self.drawn.menu = actions + self.drawn.notes.append(note) + + async def ask_for_target( + self, act: Action, nodes: list[Node], handle: ReplyHandle, note: str | None = None + ) -> None: + self.drawn.calls.append("ask_for_target") + self.drawn.targets = nodes + self.drawn.notes.append(note) + + async def ask_for_params( + self, act: Action, pending: PendingInvocation, initial: dict[str, object], handle: ReplyHandle + ) -> None: + self.drawn.calls.append("ask_for_params") + self.drawn.initial = initial + + async def ask_to_confirm(self, act: Action, pending: PendingInvocation, handle: ReplyHandle) -> None: + self.drawn.calls.append("ask_to_confirm") + + async def announce_start(self, act: Action, pending: PendingInvocation, handle: ReplyHandle) -> ReplyHandle: + self.drawn.calls.append("announce_start") + return handle + + async def post_result(self, act: Action, result: ActionResult, reply: ReplyHandle) -> None: + self.drawn.calls.append("post_result") + self.drawn.results.append(result) + + +class FlowSpy(WhobotSpy): + """Adds the Action shapes the three real ones do not cover. + + These are declared here rather than in ``whobot.py`` for the reason the plan gives for + not shipping a destructive Action early: no production Action should exist to serve a + test. A test-only subclass covers the branches the real Actions do not reach yet. + """ + + @action(label="Quiet", description="Does nothing, quickly.") + async def quiet(self) -> Report: + return Report(status=Status.OK, title="Quiet") + + @action(label="Needs A Form") + async def needs_form(self, *, loud: bool = False) -> Report: + return Report(status=Status.OK, title=f"loud={loud}") + + @action(label="Dangerous", destructive=True) + async def dangerous(self) -> Report: + return Report(status=Status.OK, title="Dangerous") + + @action(label="Raises") + async def raises(self) -> Report: + msg = "the Node caught fire" + raise RuntimeError(msg) + + @action(label="Slow", timeout_s=0.01) + async def slow(self) -> Report: + await asyncio.sleep(10) + return Report(status=Status.OK, title="Slow") + + @action(label="Blocks") + async def blocks(self) -> Report: + await asyncio.sleep(10) + return Report(status=Status.OK, title="Blocks") + + @action(label="Prefilled", scope=Scope.ONE) + async def prefilled(self, node: Node, *, loud: bool = False) -> Report: + return Report(status=Status.OK, title=f"{node.api_url} loud={loud}") + + @prefill(prefilled) + async def _prefilled_fill(self, node: Node) -> dict[str, object]: + return {"loud": True} + + @action(label="Prefill Explodes", scope=Scope.ONE) + async def prefill_explodes(self, node: Node, *, loud: bool = False) -> Report: + return Report(status=Status.OK, title="Prefill Explodes") + + @prefill(prefill_explodes) + async def _explode(self, node: Node) -> dict[str, object]: + msg = "the Node did not answer" + raise RuntimeError(msg) + + +class ClientSpy(FlowSpy): + """A Whobot whose Node API calls are answered by a handler rather than the network.""" + + handler: Callable[[httpx.Request], httpx.Response] + + def _client(self, node: Node) -> NodeClient: + return NodeClient(node.api_url, 5.0, transport=httpx.MockTransport(self.handler)) + + +# -------------------------------------------------------------------------------------- +# Harness. +# -------------------------------------------------------------------------------------- + + +@pytest.fixture(autouse=True) +def _in_a_temp_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep a real ./whobot.toml from deciding what these tests see. + + ``WhobotSettings`` reads the file *before* its keyword arguments, so a developer's own + config in the repo root would silently replace every fleet built here. + """ + monkeypatch.chdir(tmp_path) + + +@pytest.fixture(autouse=True) +def _registry_is_not_the_network(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: + """Answer the registry from ``FLEET``, filtered by what the settings actually register.""" + + async def fake(settings: WhobotSettings) -> list[Node]: + registered = {entry.api_url for entry in settings.nodes} + return [node for node in FLEET if node.api_url in registered] + + monkeypatch.setattr("pqn_whobot.whobot.resolve_nodes", fake) + FLEET[:] = REACHABLE + yield + FLEET[:] = [] + + +def settings_for(*urls: str) -> WhobotSettings: + return WhobotSettings(nodes=[NodeEntry(api_url=url) for url in urls]) + + +def spy(*urls: str, nodes: list[Node] | None = None) -> FlowSpy: + """Build a Whobot registered for ``urls``, with the fleet the registry will report.""" + if nodes is not None: + FLEET[:] = nodes + return FlowSpy(settings_for(*urls)) + + +async def dispatched(bot: WhobotSpy, pending: PendingInvocation) -> None: + """Run one click, then let anything it spawned finish.""" + await bot.dispatch(pending, ReplyHandle()) + await bot.shutdown(grace_s=1.0) + + +def run(bot: FlowSpy, pending: PendingInvocation) -> FlowSpy: + asyncio.run(dispatched(bot, pending)) + return bot + + +def only_report(bot: WhobotSpy) -> Report: + """Return the single Report the bot posted, asserting there is exactly one.""" + assert len(bot.drawn.results) == 1 + result = bot.drawn.results[0] + assert isinstance(result, Report) + return result + + +# -------------------------------------------------------------------------------------- +# dispatch: the five branches. +# -------------------------------------------------------------------------------------- + + +def test_nothing_chosen_shows_the_menu() -> None: + """``action=None`` is why opening the menu needs no special case in a handler.""" + bot = run(spy(ALICE), PendingInvocation()) + assert bot.drawn.calls == ["show_menu"] + + +def test_the_menu_comes_from_the_class_not_a_hardcoded_list() -> None: + """The extensibility claim: a method becomes a menu entry with no menu code edited.""" + bot = run(spy(ALICE), PendingInvocation()) + labels = [act.label for act in bot.drawn.menu] + assert "List Nodes" in labels + assert "Dangerous" in labels # declared only on FlowSpy, and it appears anyway + assert labels == [act.label for act in bot.actions.values()] + + +def test_a_scope_none_action_runs_straight_away() -> None: + bot = run(spy(ALICE), PendingInvocation(action="quiet")) + assert bot.drawn.calls == ["announce_start", "post_result"] + + +def test_a_scope_one_action_asks_for_a_target_first() -> None: + bot = run(spy(ALICE, BOB), PendingInvocation(action="node_info")) + assert bot.drawn.calls == ["ask_for_target"] + assert [node.api_url for node in bot.drawn.targets] == [ALICE, BOB] + assert bot.drawn.notes == [None] # nothing went wrong; the note is for staleness only + + +def test_an_action_with_parameters_asks_for_them() -> None: + bot = run(spy(ALICE), PendingInvocation(action="needs_form")) + assert bot.drawn.calls == ["ask_for_params"] + + +def test_a_destructive_action_asks_to_confirm_before_running() -> None: + """Selecting Reboot from a dropdown must never reboot anything.""" + bot = run(spy(ALICE), PendingInvocation(action="dangerous")) + assert bot.drawn.calls == ["ask_to_confirm"] + assert "announce_start" not in bot.drawn.calls + + +def test_a_confirmed_destructive_action_runs() -> None: + bot = run(spy(ALICE), PendingInvocation(action="dangerous", confirmed=True)) + assert bot.drawn.calls == ["announce_start", "post_result"] + + +def test_the_branches_are_taken_in_order_target_then_params() -> None: + """A form must not open before its Node is known, or the prefill has nothing to read.""" + bot = run(spy(ALICE, BOB), PendingInvocation(action="prefilled")) + assert bot.drawn.calls == ["ask_for_target"] + + +def test_supplied_parameters_reach_the_action() -> None: + bot = run(spy(ALICE), PendingInvocation(action="needs_form", params={"loud": True})) + assert bot.drawn.calls == ["announce_start", "post_result"] + assert only_report(bot).title == "loud=True" + + +# -------------------------------------------------------------------------------------- +# dispatch: a stale payload re-renders rather than raising. +# -------------------------------------------------------------------------------------- + + +def test_an_action_that_no_longer_exists_re_renders_the_menu_with_a_note() -> None: + bot = run(spy(ALICE), PendingInvocation(action="deleted_last_week")) + assert bot.drawn.calls == ["show_menu"] + assert bot.drawn.notes[0] is not None + assert "deleted_last_week" in bot.drawn.notes[0] + + +def test_a_node_no_longer_registered_re_renders_the_target_list_with_a_note() -> None: + """The payload names a Node the registry has since dropped.""" + bot = run(spy(ALICE), PendingInvocation(action="node_info", node_url=BOB)) + assert bot.drawn.calls == ["ask_for_target"] + assert bot.drawn.notes[0] is not None + assert BOB in bot.drawn.notes[0] + assert [node.api_url for node in bot.drawn.targets] == [ALICE] + + +def test_no_action_runs_against_a_node_the_payload_did_not_name() -> None: + """Why ``api_url`` is the identifier rather than a registry index.""" + bot = run(spy(ALICE), PendingInvocation(action="node_info", node_url=BOB)) + assert "announce_start" not in bot.drawn.calls + + +# -------------------------------------------------------------------------------------- +# execute: every announcement gets a result. +# -------------------------------------------------------------------------------------- + + +def test_a_raising_action_becomes_a_failed_result() -> None: + """An Action may not take the process down, ever.""" + bot = run(spy(ALICE), PendingInvocation(action="raises")) + assert bot.drawn.calls == ["announce_start", "post_result"] + assert only_report(bot).status is Status.FAIL + + +def test_a_timing_out_action_becomes_a_failed_result_naming_the_timeout() -> None: + bot = run(spy(ALICE), PendingInvocation(action="slow")) + result = only_report(bot) + assert result.status is Status.FAIL + assert result.summary is not None + assert "imed out" in result.summary + + +def test_a_cancelled_action_still_reports_itself() -> None: + """An orphaned announcement is worse than a clear failure, so shutdown must not orphan one.""" + + async def scenario() -> FlowSpy: + bot = spy(ALICE) + await bot.dispatch(PendingInvocation(action="blocks"), ReplyHandle()) + await asyncio.sleep(0) # let the task reach its first await + await bot.shutdown(grace_s=0.01) + return bot + + bot = asyncio.run(scenario()) + assert bot.drawn.calls == ["announce_start", "post_result"] + result = only_report(bot) + assert result.status is Status.FAIL + assert result.summary is not None + assert "nterrupted" in result.summary + + +def test_the_three_failure_modes_are_distinguishable() -> None: + """Raising, timing out and being interrupted must not read the same to an operator.""" + + async def scenario() -> list[str]: + summaries = [] + for name in ("raises", "slow"): + bot = spy(ALICE) + await dispatched(bot, PendingInvocation(action=name)) + summary = only_report(bot).summary + assert summary is not None + summaries.append(summary) + + interrupted = spy(ALICE) + await interrupted.dispatch(PendingInvocation(action="blocks"), ReplyHandle()) + await asyncio.sleep(0) + await interrupted.shutdown(grace_s=0.01) + summary = only_report(interrupted).summary + assert summary is not None + summaries.append(summary) + return summaries + + assert len(set(asyncio.run(scenario()))) == DISTINCT_FAILURE_MODES + + +# -------------------------------------------------------------------------------------- +# Task bookkeeping and shutdown. +# -------------------------------------------------------------------------------------- + + +def test_a_running_action_is_held_so_it_cannot_be_garbage_collected() -> None: + """Python collects a task nobody references, so the set is load-bearing.""" + + async def scenario() -> None: + bot = spy(ALICE) + await bot.dispatch(PendingInvocation(action="blocks"), ReplyHandle()) + assert len(bot._tasks) == 1 # noqa: SLF001 + await bot.shutdown(grace_s=0.01) + + asyncio.run(scenario()) + + +def test_a_finished_action_is_dropped_from_the_task_set() -> None: + async def scenario() -> None: + bot = spy(ALICE) + await dispatched(bot, PendingInvocation(action="quiet")) + assert bot._tasks == set() # noqa: SLF001 + + asyncio.run(scenario()) + + +def test_shutdown_refuses_new_work() -> None: + """A click arriving mid-shutdown must not start an Action at all.""" + + async def scenario() -> FlowSpy: + bot = spy(ALICE) + await bot.shutdown(grace_s=0.01) + await bot.dispatch(PendingInvocation(action="quiet"), ReplyHandle()) + return bot + + bot = asyncio.run(scenario()) + assert bot.drawn.calls == [] + + +# -------------------------------------------------------------------------------------- +# Prefill. +# -------------------------------------------------------------------------------------- + + +def test_a_form_without_a_prefill_opens_on_the_signature_defaults() -> None: + bot = run(spy(ALICE), PendingInvocation(action="needs_form")) + assert bot.drawn.initial == {"loud": False} + + +def test_a_prefill_overrides_the_signature_defaults() -> None: + bot = run(spy(ALICE), PendingInvocation(action="prefilled", node_url=ALICE)) + assert bot.drawn.calls == ["ask_for_params"] + assert bot.drawn.initial == {"loud": True} + + +def test_a_failing_prefill_still_opens_the_form_on_its_defaults() -> None: + """A form opening on defaults beats no form, so a dead Node must not block the modal.""" + bot = run(spy(ALICE), PendingInvocation(action="prefill_explodes", node_url=ALICE)) + assert bot.drawn.calls == ["ask_for_params"] + assert bot.drawn.initial == {"loud": False} + + +# -------------------------------------------------------------------------------------- +# list_nodes. +# -------------------------------------------------------------------------------------- + + +def test_list_nodes_reports_one_row_per_registered_node() -> None: + bot = run(spy(ALICE, BOB), PendingInvocation(action="list_nodes")) + result = only_report(bot) + assert result.summary == "2 of 2 reachable" + assert [f.name for f in result.sections[0].fields] == ["uiuc-public-left", "ufl-public-right"] + assert all(f.status is Status.OK for f in result.sections[0].fields) + + +def test_list_nodes_marks_an_unreachable_node_without_failing_the_other() -> None: + dead = [REACHABLE[0], Node(api_url=BOB, reachable=False, error="ConnectError: refused")] + bot = run(spy(ALICE, BOB, nodes=dead), PendingInvocation(action="list_nodes")) + result = only_report(bot) + assert result.status is Status.FAIL + assert result.summary == "1 of 2 reachable" + assert [f.status for f in result.sections[0].fields] == [Status.OK, Status.FAIL] + + +def test_list_nodes_says_so_when_the_registry_is_empty() -> None: + bot = run(spy(), PendingInvocation(action="list_nodes")) + result = only_report(bot) + assert result.status is Status.WARN + assert result.sections == [] + + +def test_a_node_that_answers_without_a_name_is_a_warning_not_a_failure() -> None: + """A partly-deployed fleet is normal; a status that cries wolf stops being read.""" + older = [Node(api_url=ALICE, reachable=True, warning="no node_name", latency_ms=12.0)] + bot = run(spy(ALICE, nodes=older), PendingInvocation(action="list_nodes")) + result = only_report(bot) + assert result.status is Status.WARN + assert result.sections[0].fields[0].status is Status.WARN + + +# -------------------------------------------------------------------------------------- +# node_info and set_availability, against a mocked Node API. +# -------------------------------------------------------------------------------------- + + +def with_node_api(handler: Callable[[httpx.Request], httpx.Response], *urls: str) -> ClientSpy: + bot = ClientSpy(settings_for(*urls)) + bot.handler = handler + return bot + + +def test_node_info_reports_what_the_node_says_about_itself() -> None: + def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001 + return httpx.Response(200, json={"node_name": "uiuc-public-left", "follower_node_address": "10.0.0.9"}) + + bot = with_node_api(handler, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="node_info", node_url=ALICE))) + result = only_report(bot) + assert result.status is Status.OK + assert [f.value for f in result.sections[0].fields] == ["uiuc-public-left", "10.0.0.9"] + + +def test_node_info_on_a_node_that_does_not_answer_is_one_failed_result() -> None: + """One dead Node is a FAIL result, not a dead Action and not a dead process.""" + + def handler(request: httpx.Request) -> httpx.Response: + msg = "Connection refused" + raise httpx.ConnectError(msg, request=request) + + bot = with_node_api(handler, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="node_info", node_url=ALICE))) + result = only_report(bot) + assert result.status is Status.FAIL + assert result.sections[0].error is not None + + +def test_set_availability_reports_each_game_as_on_or_off() -> None: + """The rows answer "is this Game on", so an off Game reads as off, not as a failure.""" + + def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001 + return httpx.Response(200, json={"chsh": True, "qf": True, "ssm": False}) + + bot = with_node_api(handler, ALICE) + pending = PendingInvocation( + action="set_availability", node_url=ALICE, params={"chsh": True, "qf": True, "ssm": False} + ) + asyncio.run(dispatched(bot, pending)) + result = only_report(bot) + assert result.status is Status.OK + assert [f.status for f in result.sections[0].fields] == [Status.ON, Status.ON, Status.OFF] + assert [f.value for f in result.sections[0].fields] == ["available", "available", "not available"] + + +def test_a_game_switched_off_on_purpose_is_not_bad_news() -> None: + """A report headlined WARN for an off Game trains an operator to ignore the headline.""" + + def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001 + return httpx.Response(200, json={"chsh": False, "qf": False, "ssm": False}) + + bot = with_node_api(handler, ALICE) + pending = PendingInvocation( + action="set_availability", node_url=ALICE, params={"chsh": False, "qf": False, "ssm": False} + ) + asyncio.run(dispatched(bot, pending)) + result = only_report(bot) + assert result.status is Status.OK + assert result.summary is None + + +def test_set_availability_warns_when_the_node_could_not_apply_it() -> None: + """The endpoint answers with *effective* availability, so asking is not getting. + + The Node answered, so this is not an unreachable Node — the Router or the follower lives + on another machine, and a Game gated off by one of those is a WARN rather than an OFF: + the write landed in config and comes back on its own, so the operator must be sent to + look at the hardware rather than told the Game is simply off. + """ + + def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001 + return httpx.Response(200, json={"chsh": False, "qf": True, "ssm": True}) + + bot = with_node_api(handler, ALICE) + pending = PendingInvocation( + action="set_availability", node_url=ALICE, params={"chsh": True, "qf": True, "ssm": True} + ) + asyncio.run(dispatched(bot, pending)) + result = only_report(bot) + assert result.status is Status.WARN + assert result.sections[0].fields[0].status is Status.WARN + assert "gated off" in result.sections[0].fields[0].value + assert result.summary is not None + assert "CHSH" in result.summary + # The Games that did apply still read as plain state, so the one problem stands out. + assert [f.status for f in result.sections[0].fields[1:]] == [Status.ON, Status.ON] + + +def test_the_availability_prefill_reads_the_nodes_current_flags() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + return httpx.Response(200, json={"chsh": False, "qf": True, "ssm": False}) + + bot = with_node_api(handler, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="set_availability", node_url=ALICE))) + assert bot.drawn.calls == ["ask_for_params"] + assert bot.drawn.initial == {"chsh": False, "qf": True, "ssm": False} + + +def test_the_form_asks_about_every_game() -> None: + """A Game added to the Node's model must also be added to ``set_availability``. + + The report loop reads ``model_fields`` and so picks a new Game up on its own; the form + reads the signature and cannot. Without this, adding one would silently produce a form + that never asks about it — the failure that a model-valued parameter used to prevent, at + the cost of an expansion mechanism nothing else in the system wanted. + """ + asked = {parameter.name for parameter in WhobotSpy.actions["set_availability"].parameters} + assert asked == set(GamesAvailability.model_fields) + + +# -------------------------------------------------------------------------------------- +# Neutrality: nothing an Action produces may carry platform markup. +# -------------------------------------------------------------------------------------- + + +SHORTCODE = re.compile(r":[a-z0-9_+-]+:") + + +def _human_strings(report: Report) -> list[str]: + """Every string an operator reads, except ``Section.error``, which may hold a traceback.""" + strings = [report.title, *([report.summary] if report.summary else []), *report.notes] + for section in report.sections: + strings += [text for text in (section.label, section.note) if text] + strings += [f.name for f in section.fields] + strings += [f.value for f in section.fields] + return strings + + +@pytest.mark.parametrize( + "pending", + [ + PendingInvocation(action="list_nodes"), + PendingInvocation(action="quiet"), + PendingInvocation(action="raises"), + PendingInvocation(action="slow"), + PendingInvocation(action="needs_form", params={"loud": True}), + ], +) +def test_no_action_output_contains_platform_markup(pending: PendingInvocation) -> None: + """An Action describes what happened; decoration is the Chat Platform's business.""" + bot = run(spy(ALICE, BOB), pending) + for text in _human_strings(only_report(bot)): + assert "*" not in text, text + assert "`" not in text, text + assert not SHORTCODE.search(text), text diff --git a/tests/pytest/test_whobot_registry.py b/tests/pytest/test_whobot_registry.py index d83359b..71f5d7e 100644 --- a/tests/pytest/test_whobot_registry.py +++ b/tests/pytest/test_whobot_registry.py @@ -15,6 +15,7 @@ from pqn_whobot.config import WhobotSettings from pqn_whobot.node_client import NodeApiError from pqn_whobot.node_client import NodeClient +from pqn_whobot.registry import UNKNOWN_NAME from pqn_whobot.registry import Node from pqn_whobot.registry import resolve_node from pqn_whobot.registry import resolve_nodes @@ -86,11 +87,11 @@ def test_registry_order_is_preserved() -> None: def test_an_unreachable_node_has_no_name() -> None: - """A Node that won't answer never said what it is called.""" + """A Node that won't answer never said what it is called, so it is called unknown.""" resolved = resolve_all(settings_for(DEAD), node_api(_by_name)) assert resolved[0].reachable is False - assert resolved[0].name is None + assert resolved[0].name == UNKNOWN_NAME assert resolved[0].error is not None assert "ConnectError" in resolved[0].error @@ -99,7 +100,7 @@ def test_one_dead_node_does_not_hide_the_healthy_ones() -> None: resolved = resolve_all(settings_for(ALICE, DEAD, BOB), node_api(_by_name)) assert [node.reachable for node in resolved] == [True, False, True] - assert [node.name for node in resolved] == ["uiuc-public-left", None, "ufl-public-right"] + assert [node.name for node in resolved] == ["uiuc-public-left", UNKNOWN_NAME, "ufl-public-right"] def test_an_empty_registry_resolves_to_nothing() -> None: @@ -134,7 +135,7 @@ def test_a_node_without_node_name_is_reachable_but_warned() -> None: assert resolved[0].reachable is True assert resolved[0].error is None - assert resolved[0].name is None + assert resolved[0].name == UNKNOWN_NAME assert resolved[0].warning is not None assert "node_name" in resolved[0].warning diff --git a/tests/pytest/test_whobot_slack.py b/tests/pytest/test_whobot_slack.py new file mode 100644 index 0000000..f5a99f5 --- /dev/null +++ b/tests/pytest/test_whobot_slack.py @@ -0,0 +1,320 @@ +"""Tests for the Slack layer: the renderers, the widgets, and the guards around them. + +Renderers are pure ``ActionResult -> blocks``, which is why they can be called directly +here with no socket, no token and no Node. What is worth pinning is the handful of Slack +limits that produce useless errors when breached — a 75-character option value, ten fields +to a section — and the two rendering rules that come from the data rather than from a flag. +""" + +import json +from pathlib import Path +from typing import Any + +import pytest + +from pqn_whobot.actions import ActionResult +from pqn_whobot.actions import Field +from pqn_whobot.actions import PendingInvocation +from pqn_whobot.actions import Report +from pqn_whobot.actions import Section +from pqn_whobot.actions import Status +from pqn_whobot.actions import scan_actions +from pqn_whobot.config import NodeEntry +from pqn_whobot.config import WhobotSettings +from pqn_whobot.registry import Node +from pqn_whobot.whobot_slack import FIELDS_PER_SECTION +from pqn_whobot.whobot_slack import HEADER_LIMIT +from pqn_whobot.whobot_slack import OPTION_VALUE_LIMIT +from pqn_whobot.whobot_slack import PARAMS_BLOCK +from pqn_whobot.whobot_slack import STATUS_EMOJI +from pqn_whobot.whobot_slack import SlackReply +from pqn_whobot.whobot_slack import WhobotSlack +from pqn_whobot.whobot_slack import _option_value + +ALICE = "http://node-a.invalid:9000" + +Block = dict[str, Any] + + +@pytest.fixture(autouse=True) +def _in_a_temp_working_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Keep a real ./whobot.toml out of these tests, as it outranks keyword arguments.""" + monkeypatch.chdir(tmp_path) + + +@pytest.fixture +def bot() -> WhobotSlack: + """Build a real WhobotSlack. It creates an AsyncApp but never connects, so the token is a placeholder.""" + return WhobotSlack(WhobotSettings(slack_bot_token="xoxb-not-a-real-token", nodes=[NodeEntry(api_url=ALICE)])) # noqa: S106 + + +def texts(blocks: list[Block]) -> str: + """Everything renderable in one string, for asserting that something appears at all.""" + return json.dumps(blocks) + + +# -------------------------------------------------------------------------------------- +# Report rendering. +# -------------------------------------------------------------------------------------- + + +def test_a_report_renders_a_header_and_a_summary(bot: WhobotSlack) -> None: + blocks = bot._render(Report(status=Status.OK, title="Nodes", summary="2 of 2 reachable")) # noqa: SLF001 + assert blocks[0]["type"] == "header" + assert "Nodes" in blocks[0]["text"]["text"] + assert blocks[1]["text"]["text"] == "2 of 2 reachable" + + +def test_a_field_with_a_status_renders_as_a_checklist_line(bot: WhobotSlack) -> None: + """The rule the whole two-mode layout rests on, derived from the data not a flag.""" + report = Report( + status=Status.OK, + title="Nodes", + sections=[Section(fields=[Field(name="Router", value="12ms", status=Status.OK)])], + ) + lines = [b for b in bot._render(report) if "fields" not in b and b["type"] == "section"] # noqa: SLF001 + assert lines[-1]["text"]["text"] == ":white_check_mark: Router — 12ms" + + +def test_a_field_without_a_status_renders_in_a_grid(bot: WhobotSlack) -> None: + report = Report( + status=Status.OK, + title="Info", + sections=[Section(fields=[Field(name="Name", value="uiuc-public-left")])], + ) + grids = [b for b in bot._render(report) if "fields" in b] # noqa: SLF001 + assert len(grids) == 1 + assert grids[0]["fields"][0]["text"] == "*Name*\nuiuc-public-left" + + +def test_fourteen_measurements_split_into_blocks_of_ten_and_four(bot: WhobotSlack) -> None: + """Slack caps a section at ten fields. An Action emits one Section; this splits it.""" + fields = [Field(name=f"m{i}", value=str(i)) for i in range(14)] + report = Report(status=Status.OK, title="Many", sections=[Section(fields=fields)]) + grids = [b for b in bot._render(report) if "fields" in b] # noqa: SLF001 + assert [len(b["fields"]) for b in grids] == [FIELDS_PER_SECTION, 4] + + +def test_the_chunking_limit_is_slacks_and_not_the_actions(bot: WhobotSlack) -> None: + """An Action emitting exactly ten stays in one block; the eleventh starts a second.""" + ten = [Field(name=f"m{i}", value=str(i)) for i in range(FIELDS_PER_SECTION)] + report = Report(status=Status.OK, title="Ten", sections=[Section(fields=ten)]) + assert len([b for b in bot._render(report) if "fields" in b]) == 1 # noqa: SLF001 + + +@pytest.mark.parametrize( + ("status", "emoji"), + [ + (Status.OK, ":white_check_mark:"), + (Status.WARN, ":warning:"), + (Status.FAIL, ":x:"), + (Status.SKIPPED, ":grey_question:"), + (Status.ON, ":large_green_circle:"), + (Status.OFF, ":red_circle:"), + ], +) +def test_each_status_renders_as_its_own_emoji(bot: WhobotSlack, status: Status, emoji: str) -> None: + """SKIPPED must not look like FAIL: did-not-run and ran-and-failed are different news.""" + report = Report(status=Status.OK, title="T", sections=[Section(fields=[Field(name="x", value="y", status=status)])]) + assert emoji in texts(bot._render(report)) # noqa: SLF001 + + +def test_every_status_has_a_glyph() -> None: + """``_emoji`` falls back to no glyph, so a missing one drops the marker silently.""" + assert set(STATUS_EMOJI) == set(Status) + assert len(set(STATUS_EMOJI.values())) == len(Status), "two statuses share a glyph" + + +def test_skipped_renders_differently_from_fail(bot: WhobotSlack) -> None: + def one(status: Status) -> str: + report = Report( + status=Status.OK, title="T", sections=[Section(fields=[Field(name="x", value="y", status=status)])] + ) + return texts(bot._render(report)) # noqa: SLF001 + + assert one(Status.SKIPPED) != one(Status.FAIL) + + +def test_a_section_note_and_error_render_as_different_blocks(bot: WhobotSlack) -> None: + """A footer and a traceback look nothing alike, which is why they are separate fields.""" + report = Report( + status=Status.FAIL, + title="CHSH", + sections=[Section(note="34.2s", error="Traceback (most recent call last): ...")], + ) + blocks = bot._render(report) # noqa: SLF001 + assert any(b["type"] == "context" and "34.2s" in texts([b]) for b in blocks) + assert any(b["type"] == "section" and "```" in texts([b]) for b in blocks) + + +def test_report_notes_render_as_a_footer(bot: WhobotSlack) -> None: + report = Report(status=Status.OK, title="T", notes=["Applied without a restart."]) + assert "Applied without a restart." in texts(bot._render(report)) # noqa: SLF001 + + +def test_a_long_title_is_truncated_rather_than_rejected(bot: WhobotSlack) -> None: + """Slack rejects an over-long header outright, and losing the whole result is worse.""" + blocks = bot._render(Report(status=Status.OK, title="T" * 400)) # noqa: SLF001 + assert len(blocks[0]["text"]["text"]) <= HEADER_LIMIT + + +def test_slack_control_characters_in_a_value_are_escaped(bot: WhobotSlack) -> None: + """A Node error containing < or & must not be read as markup.""" + report = Report( + status=Status.FAIL, + title="T", + sections=[Section(fields=[Field(name="Error", value="a < b & c")])], + ) + assert "a < b & c" in texts(bot._render(report)) # noqa: SLF001 + + +# -------------------------------------------------------------------------------------- +# Every result type must be renderable. +# -------------------------------------------------------------------------------------- + + +def concrete_result_types() -> list[type[ActionResult]]: + """Every shipped ActionResult subclass, found rather than listed. + + Restricted to ``pqn_whobot``: a throwaway subclass declared inside another test stays + in ``__subclasses__`` until it is collected, which would otherwise make this pass or + fail depending on the order pytest-randomly picked. + """ + + def walk(cls: type[ActionResult]) -> list[type[ActionResult]]: + found = [] + for sub in cls.__subclasses__(): + found += walk(sub) + if sub.__module__.startswith("pqn_whobot."): + found.append(sub) + return found + + return walk(ActionResult) + + +def test_every_action_result_subclass_has_a_renderer() -> None: + """Scanned, not maintained as a list, so a new result type fails pytest not an operator.""" + # Reached through __dict__ because attribute access on the class returns the bound + # descriptor's result rather than the singledispatchmethod holding the registry. + registry = WhobotSlack.__dict__["_render"].dispatcher.registry + unrenderable = [cls.__name__ for cls in concrete_result_types() if cls not in registry] + assert not unrenderable, f"no renderer registered for: {unrenderable}" + + +def test_a_result_type_with_no_renderer_fails_loudly(bot: WhobotSlack) -> None: + """The fallback must refuse to guess, so the test above is what catches a gap.""" + + class Unrenderable(ActionResult): + pass + + with pytest.raises(NotImplementedError, match="no renderer registered"): + bot._render(Unrenderable()) # noqa: SLF001 + + +# -------------------------------------------------------------------------------------- +# The payload guards. +# -------------------------------------------------------------------------------------- + + +def test_an_option_value_within_slacks_limit_is_returned_unchanged() -> None: + pending = PendingInvocation(action="set_availability", node_url=ALICE) + assert _option_value(pending, "menu") == '{"a":"set_availability","n":"http://node-a.invalid:9000"}' + + +def test_an_over_long_option_value_raises_naming_what_was_being_rendered() -> None: + """Slack answers an over-long value with a bare invalid_blocks, which names nothing.""" + pending = PendingInvocation(action="x" * 60, node_url="http://" + "y" * 60) + with pytest.raises(ValueError, match=r"menu entry 'x+'.*option value limit"): + _option_value(pending, "menu entry " + repr("x" * 60)) + + +def test_the_limit_guard_measures_the_encoding_and_not_the_action_name() -> None: + borderline = PendingInvocation(action="a" * OPTION_VALUE_LIMIT) + with pytest.raises(ValueError, match="over Slack's"): + _option_value(borderline, "menu") + + +# -------------------------------------------------------------------------------------- +# Widgets built from an Action's declaration. +# -------------------------------------------------------------------------------------- + + +def availability_action() -> Any: + return scan_actions(WhobotSlack)["set_availability"] + + +def test_a_bool_parameter_becomes_a_checkbox(bot: WhobotSlack) -> None: + block = bot._checkbox_block(availability_action(), {}) # noqa: SLF001 + assert block["element"]["type"] == "checkboxes" + assert [o["value"] for o in block["element"]["options"]] == ["chsh", "qf", "ssm"] + + +def test_the_form_opens_ticked_on_the_values_it_was_given(bot: WhobotSlack) -> None: + """The prefill's whole purpose: the form shows the Node's flags, not the defaults.""" + block = bot._checkbox_block(availability_action(), {"chsh": False, "qf": True, "ssm": False}) # noqa: SLF001 + assert [o["value"] for o in block["element"]["initial_options"]] == ["qf"] + + +def test_a_form_with_nothing_ticked_omits_initial_options(bot: WhobotSlack) -> None: + """Slack rejects an empty initial_options outright rather than treating it as none.""" + block = bot._checkbox_block(availability_action(), {"chsh": False, "qf": False, "ssm": False}) # noqa: SLF001 + assert "initial_options" not in block["element"] + + +def submitted(*ticked: str) -> dict[str, Any]: + """Build a view submission's state as Slack really sends it. + + Only ``selected_options`` comes back. ``initial_options`` is part of the block that was + *sent* and is not echoed, which an earlier version of these tests assumed it was — so the + False floor looked reconstructable from the payload when it is not. + """ + return {PARAMS_BLOCK: {PARAMS_BLOCK: {"type": "checkboxes", "selected_options": [{"value": t} for t in ticked]}}} + + +def test_an_unticked_checkbox_comes_back_as_false_rather_than_missing(bot: WhobotSlack) -> None: + """Slack reports only what is ticked, so absence has to be reconstructed as False.""" + read = bot._read_checkboxes(availability_action(), submitted("qf")[PARAMS_BLOCK]) # noqa: SLF001 + assert read == {"chsh": False, "qf": True, "ssm": False} + + +def test_a_form_submitted_with_everything_unticked_reads_as_all_false(bot: WhobotSlack) -> None: + """The regression that mattered: an unticked box must not fall through to its default. + + Every default on ``set_availability`` is True, so a payload of ``{}`` plus + ``coerce_params`` used to turn "switch everything off" into "switch everything on". + """ + read = bot._read_checkboxes(availability_action(), submitted()[PARAMS_BLOCK]) # noqa: SLF001 + assert read == {"chsh": False, "qf": False, "ssm": False} + + +def test_a_cleared_box_survives_coerce_params_as_false(bot: WhobotSlack) -> None: + """The floor is only worth anything if it reaches the Action's arguments.""" + act = availability_action() + arguments = act.coerce_params(bot._read_checkboxes(act, submitted("qf")[PARAMS_BLOCK])) # noqa: SLF001 + assert arguments == {"chsh": False, "qf": True, "ssm": False} + + +# -------------------------------------------------------------------------------------- +# The reply handle. +# -------------------------------------------------------------------------------------- + + +def test_the_base_class_handle_is_rejected_rather_than_silently_mishandled(bot: WhobotSlack) -> None: + from pqn_whobot.actions import ReplyHandle # noqa: PLC0415 + + with pytest.raises(TypeError, match="not a SlackReply"): + bot._slack(ReplyHandle()) # noqa: SLF001 + + +def test_a_slack_reply_carries_where_a_result_belongs() -> None: + reply = SlackReply(channel="C123", thread_ts="1700000000.000100") + assert reply.channel == "C123" + assert reply.thread_ts == "1700000000.000100" + + +def test_targets_are_labelled_with_both_name_and_address() -> None: + """A name can be absent or duplicated, so the address is always shown beside it.""" + from pqn_whobot.whobot_slack import _target_label # noqa: PLC0415 + + assert _target_label(Node(api_url=ALICE, name="uiuc-public-left", reachable=True)) == f"uiuc-public-left — {ALICE}" + assert _target_label(Node(api_url=ALICE, reachable=False)) == f"(unknown) — {ALICE}" diff --git a/uv.lock b/uv.lock index 50aec2f..4dd92ae 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,128 @@ version = 1 revision = 3 requires-python = ">=3.12" +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +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/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[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 = "annotated-doc" version = "0.0.4" @@ -33,6 +155,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, ] +[[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 = "2025.11.12" @@ -348,6 +479,95 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, ] +[[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/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 = "h11" version = "0.16.0" @@ -592,6 +812,105 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[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/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 = "mypy" version = "1.19.0" @@ -752,36 +1071,141 @@ dependencies = [ { name = "typer" }, ] +[package.optional-dependencies] +whobot = [ + { name = "aiohttp" }, + { name = "slack-bolt" }, +] + [package.dev-dependencies] dev = [ { name = "coverage" }, { name = "hypothesis" }, { name = "mypy" }, + { name = "pqn-node", extra = ["whobot"] }, { name = "pytest-randomly" }, { name = "ruff" }, ] [package.metadata] requires-dist = [ + { name = "aiohttp", marker = "extra == 'whobot'", specifier = ">=3.9" }, { name = "fastapi", extras = ["standard"], specifier = ">=0.115.14" }, { name = "httpx", specifier = ">=0.28.1" }, { name = "pqn-hardware", git = "https://github.com/PublicQuantumNetwork/pqn-hardware.git?rev=master" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.10.1" }, { name = "pyserial", specifier = ">=3.5" }, + { name = "slack-bolt", marker = "extra == 'whobot'", specifier = ">=1.18" }, { name = "tomlkit", specifier = ">=0.13.0" }, { name = "typer", specifier = ">=0.15.1" }, ] +provides-extras = ["whobot"] [package.metadata.requires-dev] dev = [ { name = "coverage" }, { name = "hypothesis" }, { name = "mypy" }, + { name = "pqn-node", extras = ["whobot"] }, { name = "pytest-randomly" }, { name = "ruff" }, ] +[[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/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 = "pycparser" version = "2.23" @@ -1204,6 +1628,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "slack-bolt" +version = "1.30.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "slack-sdk" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e3/4f/5ba15533d66da2e7174334cc0e2805142e5390c9f4c5f31633df78b17006/slack_bolt-1.30.0.tar.gz", hash = "sha256:af38258d41f801ad9c74503090e0f39accd66c49f667f7e55c97fcdb0e51b886", size = 131180, upload-time = "2026-07-15T20:47:33.679Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/ee/1a7a286cf98fa3f4eeffaabc090e82f58f058ab4812aa1d7421d92c2637a/slack_bolt-1.30.0-py2.py3-none-any.whl", hash = "sha256:81f5bc46e79516d23d5e2a31dded6304dd1b8b6b72c0083f2f31d5d801e262c4", size = 235341, upload-time = "2026-07-15T20:47:32.113Z" }, +] + +[[package]] +name = "slack-sdk" +version = "3.43.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/75/a4964eb771a0c74d79ee7a3bee6fb5d9718909dd1b675e80d62a6a0ad90a/slack_sdk-3.43.0.tar.gz", hash = "sha256:0553152e46c4259eb69f7464cdadc35ba4802ca10f9f5a849c92cf03d6c2ba07", size = 252769, upload-time = "2026-06-30T18:04:41.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/55/42141b8338d46323d5b3c6095201b044c670c20f898643b322ea9b1543a1/slack_sdk-3.43.0-py2.py3-none-any.whl", hash = "sha256:4b6557c65577fc172f685af218b811f9f3b4909e24cddd839ada09565f10c585", size = 315866, upload-time = "2026-06-30T18:04:39.636Z" }, +] + [[package]] name = "sortedcontainers" version = "2.4.0" @@ -1448,3 +1893,85 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, ] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] From c2956f6aba0f71907e102c25a7ebc6cedaf316fd Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 29 Jul 2026 00:43:30 -0500 Subject: [PATCH 4/7] Add screenshot and reboot actions with configurations and test coverage --- configs/whobot_example.toml | 13 ++ src/pqn_whobot/config.py | 45 ++++++ src/pqn_whobot/node_client.py | 76 +++++++++- src/pqn_whobot/whobot.py | 175 +++++++++++++++++++++- src/pqn_whobot/whobot_slack.py | 14 +- tests/pytest/test_whobot_config.py | 24 +++ tests/pytest/test_whobot_flow.py | 226 +++++++++++++++++++++++++++-- tests/pytest/test_whobot_slack.py | 24 +++ 8 files changed, 579 insertions(+), 18 deletions(-) diff --git a/configs/whobot_example.toml b/configs/whobot_example.toml index 53f38b5..fa44fca 100644 --- a/configs/whobot_example.toml +++ b/configs/whobot_example.toml @@ -51,6 +51,19 @@ reachability_timeout_s = 5 # for "are you there?", and 900s is the digest's whole budget for a Node. node_timeout_s = 30 +# How long a rebooted Node has to answer again before Whobot reports it as still down. Raise +# it for a machine with a slow POST or one that fscks on boot. It shares the Reboot Action's +# 360s budget with node_timeout_s and reachability_timeout_s, and Whobot refuses to start if +# the three together leave the Action no time to report what it found. +reboot_wait_s = 300 + +# Development only. Screenshot is the one Action that needs a Node in front of a real +# display — X11, KDE and `maim` — so on a laptop it can only ever fail. Point this at any +# image file and the Screenshot Action answers with it and never calls the Node. The report +# says as much, in a warning naming this setting, so nobody mistakes the picture for a Node. +# Leave it out in production. +# debug_screenshot_path = "/path/to/some-image.png" + # The Node Registry. Whobot knows about exactly these Nodes — adding one is an edit here, # not a code change. Use each Node's address on the VPN; production Nodes listen on 9000. # Node *names* are deliberately not listed: Whobot reads them from each Node's diff --git a/src/pqn_whobot/config.py b/src/pqn_whobot/config.py index 4fc0c5e..b26d244 100644 --- a/src/pqn_whobot/config.py +++ b/src/pqn_whobot/config.py @@ -13,11 +13,21 @@ from pydantic import ConfigDict from pydantic import Field from pydantic import field_validator +from pydantic import model_validator from pydantic_settings import BaseSettings from pydantic_settings import PydanticBaseSettingsSource from pydantic_settings import SettingsConfigDict from pydantic_settings import TomlConfigSettingsSource +REBOOT_TIMEOUT_S = 360.0 +"""Outer bound on a whole Reboot invocation, as ``@action`` declares it. + +It lives here rather than beside the Action because it is the ceiling ``reboot_wait_s`` is +checked against, and this module may not import ``whobot.py`` — the dependency runs the other +way. An Action's ``timeout_s`` is read by the scan while the class is being created, before +any settings exist, so it cannot itself come from configuration. +""" + class NodeEntry(BaseModel): """One entry of the Node Registry: a Node's address. @@ -64,6 +74,18 @@ class WhobotSettings(BaseSettings): # "are you there?", and 900s is the digest's whole budget for a Node. node_timeout_s: float = Field(default=30.0, gt=0) + # How long a rebooted Node has to answer again before it is reported as still down. + # Site-specific, which is why it is here: a machine with a slow POST, or one that fscks + # on boot, legitimately takes longer than one that does not. + reboot_wait_s: float = Field(default=300.0, gt=0) + + # Development only: answer the Screenshot Action with this file instead of calling a + # Node. Screenshot is the one Action that needs a Node in front of a real display — + # X11, KDE and `maim` — which a laptop has none of, so without this the Action cannot + # be exercised at all until Phase 8. Set it and Whobot never calls the Node; the report + # says so rather than passing the file off as the Node's display. + debug_screenshot_path: Path | None = None + # What a digest run records about itself. last_run_at: datetime | None = None last_result: str | None = None @@ -102,6 +124,29 @@ def _require_known_timezone(cls, value: str) -> str: raise ValueError(msg) from e return value + @model_validator(mode="after") + def _reboot_must_be_able_to_report_itself(self) -> "WhobotSettings": + """Keep the reboot wait inside what the Reboot Action is allowed to take. + + A wait that outlasts the Action's ``timeout_s`` is worse than a shorter one: + ``execute`` cuts the run off and posts "Timed out after 360s", losing the "still down + after 5 minutes — go and look at the machine" report the wait exists to produce. + + The three keys are added because they are what one invocation spends: the call that + asks for the reboot, the wait, and the last poll of that wait. + """ + budget = self.node_timeout_s + self.reboot_wait_s + self.reachability_timeout_s + if budget > REBOOT_TIMEOUT_S: + msg = ( + f"reboot_wait_s ({self.reboot_wait_s:.0f}s) leaves the Reboot Action no time to report: " + f"with node_timeout_s ({self.node_timeout_s:.0f}s) and reachability_timeout_s " + f"({self.reachability_timeout_s:.0f}s) it needs {budget:.0f}s of the {REBOOT_TIMEOUT_S:.0f}s " + f"the Action is allowed. Lower reboot_wait_s to at most " + f"{REBOOT_TIMEOUT_S - self.node_timeout_s - self.reachability_timeout_s:.0f}s." + ) + raise ValueError(msg) + return self + @property def timezone(self) -> ZoneInfo: """The zone ``schedule_hour`` and ``schedule_minute`` are interpreted in.""" diff --git a/src/pqn_whobot/node_client.py b/src/pqn_whobot/node_client.py index a49285d..a453169 100644 --- a/src/pqn_whobot/node_client.py +++ b/src/pqn_whobot/node_client.py @@ -30,6 +30,32 @@ class NodeConfigResponse(BaseModel): follower_node_address: str | None = None +class RebootAck(BaseModel): + """What ``POST /system/reboot`` answers with: the reboot was *scheduled*, not done. + + The Node replies before it starts dying, so an ack says nothing about whether the + machine comes back. Only polling it does. + """ + + scheduled: bool + detail: str | None = None + + +def _detail(response: httpx.Response) -> str: + """Pull FastAPI's ``detail`` out of an error body, falling back to the status line. + + Worth the few lines: the Node says *why* it refused — "'maim' is not installed on this + Node" — and without this an operator sees only "Server error '503'", which sends them + to the Node's logs to learn something the Node already told them. + """ + try: + body = response.json() + except ValueError: + return response.reason_phrase + detail = body.get("detail") if isinstance(body, dict) else None + return str(detail) if detail else response.reason_phrase + + class NodeClient: """Talks to one Node, applying ``timeout_s`` to every call. @@ -45,19 +71,30 @@ def __init__(self, api_url: str, timeout_s: float, transport: httpx.AsyncBaseTra def __repr__(self) -> str: return f"NodeClient({self.api_url!r}, timeout_s={self.timeout_s})" - async def _send_json(self, method: str, path: str, body: object | None = None) -> object: + async def _request(self, method: str, path: str, body: object | None = None) -> httpx.Response: + """Make one call, turning every way it can fail into a ``NodeApiError``.""" url = f"{self.api_url}{path}" try: async with httpx.AsyncClient(timeout=self.timeout_s, transport=self._transport) as client: response = await client.request(method, url, json=body) response.raise_for_status() - return response.json() + return response + except httpx.HTTPStatusError as e: + # The Node answered and refused, and it said why. That reason is the message. + msg = f"HTTP {e.response.status_code}: {_detail(e.response)}" + logger.warning("%s %s refused: %s", method, url, msg) + raise NodeApiError(msg) from e except httpx.HTTPError as e: msg = f"{type(e).__name__}: {e}" logger.warning("%s %s failed: %s", method, url, msg) raise NodeApiError(msg) from e + + async def _send_json(self, method: str, path: str, body: object | None = None) -> object: + response = await self._request(method, path, body) + try: + return response.json() except ValueError as e: # a 200 that isn't JSON: something other than a Node answered - msg = f"{url} did not return JSON: {e}" + msg = f"{self.api_url}{path} did not return JSON: {e}" logger.warning(msg) raise NodeApiError(msg) from e @@ -92,6 +129,39 @@ async def set_availability(self, availability: GamesAvailability) -> GamesAvaila payload = await self._send_json("PUT", "/games/availability", availability.model_dump()) return self._parse_availability(payload) + async def get_screenshot(self) -> bytes: + """Capture the Node's display, returning the image bytes as they arrived. + + The endpoint answers ``image/png``. The content type is checked because a proxy or + a captive portal on the way in would otherwise be uploaded to Slack as a screenshot. + """ + response = await self._request("GET", "/system/screenshot") + content_type = response.headers.get("content-type", "") + if not content_type.startswith("image/") or not response.content: + msg = ( + f"{self.api_url}/system/screenshot did not return an image " + f"({content_type or 'no content type'}, {len(response.content)} bytes)" + ) + raise NodeApiError(msg) + return response.content + + async def reboot(self) -> RebootAck: + """Ask the Node to reboot, returning its acknowledgement. + + The Node schedules the reboot and answers before it dies, so this returns while the + machine is still up. Whether it comes back is a separate question, answered by + polling. + """ + payload = await self._send_json("POST", "/system/reboot") + if not isinstance(payload, dict) or "scheduled" not in payload: + msg = f"{self.api_url}/system/reboot did not acknowledge the reboot: {str(payload)[:100]}" + raise NodeApiError(msg) + try: + return RebootAck.model_validate(payload) + except ValidationError as e: + msg = f"unexpected /system/reboot response: {e}" + raise NodeApiError(msg) from e + async def get_config(self) -> NodeConfigResponse: """Ask the Node for its name and follower address.""" payload = await self._send_json("GET", "/node/config") diff --git a/src/pqn_whobot/whobot.py b/src/pqn_whobot/whobot.py index b12898d..ad6e4c9 100644 --- a/src/pqn_whobot/whobot.py +++ b/src/pqn_whobot/whobot.py @@ -78,9 +78,11 @@ async def node_info(self, node: Node) -> Report: ... import asyncio import logging +import time from abc import ABC from abc import abstractmethod from collections.abc import Coroutine +from pathlib import Path from typing import Any from typing import ClassVar @@ -97,6 +99,7 @@ async def node_info(self, node: Node) -> Report: ... from pqn_whobot.actions import action from pqn_whobot.actions import prefill from pqn_whobot.actions import scan_actions +from pqn_whobot.config import REBOOT_TIMEOUT_S from pqn_whobot.config import WhobotSettings from pqn_whobot.node_client import NodeApiError from pqn_whobot.node_client import NodeClient @@ -109,6 +112,19 @@ async def node_info(self, node: Node) -> Report: ... SHUTDOWN_GRACE_S = 10.0 """How long a running Action gets to finish on shutdown before it is cancelled.""" +SCREENSHOT_TIMEOUT_S = 60.0 +"""Outer bound on one screenshot. The Node bounds the capture itself at 20s; the rest is +for a large PNG crossing the VPN.""" + +REBOOT_SETTLE_S = 15.0 +"""How long to wait after a reboot is acknowledged before polling starts. + +The Node answers and *then* begins shutting down, so a poll sent immediately reaches the API +that is about to die and reports a machine that never went away.""" + +REBOOT_POLL_INTERVAL_S = 5.0 +"""How often a rebooting Node is asked whether it is back.""" + class Whobot(ABC): """The platform-independent half of Whobot: its Actions and the flow that runs them. @@ -266,6 +282,155 @@ async def _availability_prefill(self, node: Node) -> dict[str, object]: availability = await self._client(node).get_availability() return dict(availability.model_dump()) + @action( + label="Screenshot", + description="A picture of what a Node's display is showing.", + scope=Scope.ONE, + timeout_s=SCREENSHOT_TIMEOUT_S, + ) + async def screenshot(self, node: Node) -> Report: + """Capture the Node's display and hand the image back for the reply to carry. + + This is the one thing no API probe can tell you: a Node whose every endpoint answers + correctly can still be sitting in front of a crashed kiosk or a login screen. + """ + if self.settings.debug_screenshot_path is not None: + return self._debug_screenshot(node, self.settings.debug_screenshot_path) + + try: + image = await self._client(node).get_screenshot() + except NodeApiError as e: + return Report( + status=Status.FAIL, + title=f"Screenshot — {node.name}", + summary="The Node did not return a screenshot.", + sections=[Section(error=str(e))], + ) + + return Report( + status=Status.OK, + title=f"Screenshot — {node.name}", + summary=f"{node.api_url} — {len(image) / 1024:,.0f} KB", + image=image, + ) + + @staticmethod + def _debug_screenshot(node: Node, path: Path) -> Report: + """Answer with a file from disk instead of calling the Node. + + Screenshot is the one Action that cannot be exercised without a Node in front of a + real display, so this exists to drive the whole path — the Action, the image on the + result, the upload — from a laptop. + + It says so loudly. An image that is not of the Node is worse than no image at all if + anyone mistakes it for one, so the report is a ``WARN`` naming the setting that + produced it. + """ + try: + image = path.read_bytes() + except OSError as e: + return Report( + status=Status.FAIL, + title=f"Screenshot — {node.name}", + summary="debug_screenshot_path is set in whobot.toml, and that file could not be read.", + sections=[Section(error=str(e))], + ) + + return Report( + status=Status.WARN, + title=f"Screenshot — {node.name}", + summary="This is not the Node's display. Whobot answered from a file and never called the Node.", + image=image, + notes=[f"debug_screenshot_path = {path}. Remove it from whobot.toml to screenshot the Node itself."], + ) + + @action( + label="Reboot", + description="Reboot a Node's host, then wait for its API to answer again.", + scope=Scope.ONE, + destructive=True, + timeout_s=REBOOT_TIMEOUT_S, + ) + async def reboot(self, node: Node) -> Report: + """Reboot the Node's host and report whether it came back. + + Recovery is unattended: the machine autologs in, KDE autostart runs the Node's start + script, and the API and kiosk come back on their own. The report is the point — an + operator who asked for a reboot and got only "requested" has learnt nothing they + could not have assumed, so this waits and says either how long it took or that it is + still down. + """ + title = f"Reboot — {node.name}" + try: + ack = await self._client(node).reboot() + except NodeApiError as e: + return Report( + status=Status.FAIL, + title=title, + summary="The Node did not accept the reboot, so nothing was rebooted.", + sections=[Section(error=str(e))], + ) + + waited = self.settings.reboot_wait_s + elapsed = await self._wait_until_back(node) + if elapsed is None: + return Report( + status=Status.FAIL, + title=title, + summary=f"The Node has not answered in the {waited / 60:.0f} minutes since it was rebooted.", + sections=[ + Section( + fields=[ + Field(name="Reboot", value=ack.detail or "scheduled", status=Status.OK), + Field(name="Node API", value=f"still down after {waited:.0f}s", status=Status.FAIL), + ] + ) + ], + notes=["A reboot does not restart the Router or the Instrument Providers; those are another machine."], + ) + + return Report( + status=Status.OK, + title=title, + summary=f"{node.api_url} is back, {elapsed:.0f}s after the reboot was requested.", + sections=[ + Section( + fields=[ + Field(name="Reboot", value=ack.detail or "scheduled", status=Status.OK), + Field(name="Node API", value=f"answering after {elapsed:.0f}s", status=Status.OK), + ] + ) + ], + ) + + async def _wait_until_back(self, node: Node) -> float | None: + """Poll a rebooting Node until it answers, returning how long that took. + + ``None`` means it never did within ``reboot_wait_s``, which is what the guardrail is + actually for: the confirm step protects against rebooting the wrong Node, and this + protects against one that does not return. How long to wait is configuration because + it is site-specific; the settle period and the poll interval are not, because they + describe how a host shuts down rather than how long an operator is willing to wait. + + Each poll is bounded by ``reachability_timeout_s`` rather than the Action's timeout, + because "are you there?" is exactly the question, and a host that is off drops + packets rather than refusing them — an unbounded poll would hang until the whole + invocation timed out. + """ + started = time.monotonic() + await asyncio.sleep(REBOOT_SETTLE_S) + client = self._client(node, self.settings.reachability_timeout_s) + + while time.monotonic() - started < self.settings.reboot_wait_s: + try: + await client.get_config() + except NodeApiError: + await asyncio.sleep(REBOOT_POLL_INTERVAL_S) + continue + return time.monotonic() - started + + return None + # ---------------------------------------------------------------------------------- # The flow. The single entry point from any Chat Platform. # ---------------------------------------------------------------------------------- @@ -412,9 +577,13 @@ async def shutdown(self, grace_s: float = SHUTDOWN_GRACE_S) -> None: # Helpers. None of these is an Action, so none can be invoked from a Chat Platform. # ---------------------------------------------------------------------------------- - def _client(self, node: Node) -> NodeClient: - """Open a client for one Node, bounded by the timeout an Action's calls get.""" - return NodeClient(node.api_url, self.settings.node_timeout_s) + def _client(self, node: Node, timeout_s: float | None = None) -> NodeClient: + """Open a client for one Node, bounded by the timeout an Action's calls get. + + ``timeout_s`` overrides that where a call is asking a different question: polling a + rebooting Node wants the "are you there?" bound, not the Action's. + """ + return NodeClient(node.api_url, self.settings.node_timeout_s if timeout_s is None else timeout_s) def _menu(self) -> list[Action]: """Every Action, in the order they are declared. The menu is the class body.""" diff --git a/src/pqn_whobot/whobot_slack.py b/src/pqn_whobot/whobot_slack.py index ed6ba85..4472570 100644 --- a/src/pqn_whobot/whobot_slack.py +++ b/src/pqn_whobot/whobot_slack.py @@ -54,6 +54,13 @@ FIELDS_PER_SECTION = 10 """Slack's cap on a section's ``fields`` grid. Actions emit one Section; this splits it.""" +IMAGE_SUFFIXES = ((b"\x89PNG\r\n\x1a\n", "png"), (b"GIF8", "gif"), (b"\xff\xd8\xff", "jpg")) +"""Magic numbers, so an upload can be named after what it actually is. + +Slack decides how to display a file from its *filename*, so a GIF sent as ``.png`` arrives +broken. A screenshot is a PNG, but ``Report.image`` is only ``bytes``, and the debug +screenshot makes it whatever file an operator pointed the config at.""" + STATUS_EMOJI = { Status.OK: ":white_check_mark:", Status.WARN: ":warning:", @@ -113,6 +120,11 @@ def _context(text: str) -> Block: return {"type": "context", "elements": [{"type": "mrkdwn", "text": text}]} +def _image_suffix(image: bytes) -> str: + """Name an uploaded image after what its bytes say it is, defaulting to PNG.""" + return next((suffix for magic, suffix in IMAGE_SUFFIXES if image.startswith(magic)), "png") + + def _target_label(node: Node) -> str: """Every rendered target shows name and address, resolved from the current registry.""" return f"{node.name} — {node.api_url}" @@ -440,7 +452,7 @@ async def post_result(self, act: Action, result: ActionResult, reply: ReplyHandl channel=slack.channel, thread_ts=slack.thread_ts, file=image, - filename=f"{act.name}.png", + filename=f"{act.name}.{_image_suffix(image)}", title=act.label, ) diff --git a/tests/pytest/test_whobot_config.py b/tests/pytest/test_whobot_config.py index 7916b59..d8eee93 100644 --- a/tests/pytest/test_whobot_config.py +++ b/tests/pytest/test_whobot_config.py @@ -136,6 +136,30 @@ def test_invalid_values_name_the_offending_key(tmp_path: Path, body: str, expect WhobotSettings() +def test_a_reboot_wait_the_action_cannot_outlast_is_rejected(tmp_path: Path) -> None: + """A wait longer than the Action's own timeout loses the report it exists to produce. + + ``execute`` would cut the run off at the Action's ``timeout_s`` and post "Timed out", + instead of the "still down after N minutes" that tells an operator to go and look at the + machine. Refusing it at load is the only place that can be said, since an Action's + ``timeout_s`` is fixed when the class is created. + """ + (tmp_path / "whobot.toml").write_text("reboot_wait_s = 600\n", encoding="utf-8") + + with pytest.raises(ValidationError, match=r"reboot_wait_s.*no time to report"): + WhobotSettings() + + +def test_the_reboot_wait_is_measured_against_the_calls_around_it(tmp_path: Path) -> None: + """The wait shares the Action's budget with the reboot call and the last poll of the wait.""" + (tmp_path / "whobot.toml").write_text( + "reboot_wait_s = 331\nnode_timeout_s = 20\nreachability_timeout_s = 10\n", encoding="utf-8" + ) + + with pytest.raises(ValidationError, match="at most 330s"): + WhobotSettings() + + def test_malformed_toml_is_a_parse_error(tmp_path: Path) -> None: (tmp_path / "whobot.toml").write_text("schedule_hour = \n", encoding="utf-8") diff --git a/tests/pytest/test_whobot_flow.py b/tests/pytest/test_whobot_flow.py index c40294b..e8a6c35 100644 --- a/tests/pytest/test_whobot_flow.py +++ b/tests/pytest/test_whobot_flow.py @@ -167,8 +167,8 @@ class ClientSpy(FlowSpy): handler: Callable[[httpx.Request], httpx.Response] - def _client(self, node: Node) -> NodeClient: - return NodeClient(node.api_url, 5.0, transport=httpx.MockTransport(self.handler)) + def _client(self, node: Node, timeout_s: float | None = None) -> NodeClient: + return NodeClient(node.api_url, timeout_s or 5.0, transport=httpx.MockTransport(self.handler)) # -------------------------------------------------------------------------------------- @@ -200,8 +200,8 @@ async def fake(settings: WhobotSettings) -> list[Node]: FLEET[:] = [] -def settings_for(*urls: str) -> WhobotSettings: - return WhobotSettings(nodes=[NodeEntry(api_url=url) for url in urls]) +def settings_for(*urls: str, **overrides: object) -> WhobotSettings: + return WhobotSettings(nodes=[NodeEntry(api_url=url) for url in urls], **overrides) def spy(*urls: str, nodes: list[Node] | None = None) -> FlowSpy: @@ -485,8 +485,8 @@ def test_a_node_that_answers_without_a_name_is_a_warning_not_a_failure() -> None # -------------------------------------------------------------------------------------- -def with_node_api(handler: Callable[[httpx.Request], httpx.Response], *urls: str) -> ClientSpy: - bot = ClientSpy(settings_for(*urls)) +def with_node_api(handler: Callable[[httpx.Request], httpx.Response], *urls: str, **settings: object) -> ClientSpy: + bot = ClientSpy(settings_for(*urls, **settings)) bot.handler = handler return bot @@ -599,6 +599,189 @@ def test_the_form_asks_about_every_game() -> None: assert asked == set(GamesAvailability.model_fields) +# -------------------------------------------------------------------------------------- +# screenshot: the image, and the debug file that stands in for a display. +# -------------------------------------------------------------------------------------- + + +PNG = b"\x89PNG\r\n\x1a\n" + b"not really a PNG, but it starts like one" * 20 +GIF = b"GIF89a" + b"nor is this a GIF" * 20 + + +def serving_an_image(request: httpx.Request) -> httpx.Response: # noqa: ARG001 + return httpx.Response(200, content=PNG, headers={"content-type": "image/png"}) + + +def test_screenshot_carries_the_image_on_the_result() -> None: + """``Report.image`` gets its first real exercise here; nothing else produces one.""" + bot = with_node_api(serving_an_image, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="screenshot", node_url=ALICE))) + result = only_report(bot) + assert result.status is Status.OK + assert result.image == PNG + + +def test_a_node_that_cannot_capture_says_why() -> None: + """The Node's own reason must survive the trip, or the operator reads its logs to learn it.""" + + def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001 + return httpx.Response(503, json={"detail": "'maim' is not installed on this Node"}) + + bot = with_node_api(handler, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="screenshot", node_url=ALICE))) + result = only_report(bot) + assert result.status is Status.FAIL + assert result.image is None + assert result.sections[0].error is not None + assert "maim" in result.sections[0].error + + +def test_something_that_is_not_an_image_is_not_uploaded_as_a_screenshot() -> None: + """A captive portal answering 200 with HTML must not become a picture of a Node.""" + + def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001 + return httpx.Response(200, text="sign in to continue") + + bot = with_node_api(handler, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="screenshot", node_url=ALICE))) + result = only_report(bot) + assert result.status is Status.FAIL + assert result.image is None + + +def debug_bot(path: Path, *urls: str) -> ClientSpy: + """Build a Whobot answering Screenshot from a file, whose Node API refuses every call.""" + + def never(request: httpx.Request) -> httpx.Response: + msg = f"the Node was called: {request.url}" + raise AssertionError(msg) + + bot = ClientSpy(settings_for(*urls, debug_screenshot_path=path)) + bot.handler = never + return bot + + +def test_the_debug_screenshot_answers_from_a_file_without_calling_the_node(tmp_path: Path) -> None: + """The point of the setting: the whole Action path runs on a laptop that has no display.""" + image = tmp_path / "spongebob.gif" + image.write_bytes(GIF) + + bot = debug_bot(image, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="screenshot", node_url=ALICE))) + assert only_report(bot).image == GIF + + +def test_the_debug_screenshot_says_it_is_not_the_nodes_display(tmp_path: Path) -> None: + """A picture mistaken for a Node's display is worse than no picture at all.""" + image = tmp_path / "spongebob.gif" + image.write_bytes(GIF) + + bot = debug_bot(image, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="screenshot", node_url=ALICE))) + result = only_report(bot) + assert result.status is Status.WARN + assert result.summary is not None + assert "not the Node's display" in result.summary + assert any("debug_screenshot_path" in note for note in result.notes) + + +def test_a_debug_screenshot_path_that_cannot_be_read_is_a_failure(tmp_path: Path) -> None: + """Silently falling back to the Node would answer a laptop's screenshot with a 503 instead.""" + bot = debug_bot(tmp_path / "deleted.gif", ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="screenshot", node_url=ALICE))) + result = only_report(bot) + assert result.status is Status.FAIL + assert result.image is None + assert result.summary is not None + assert "debug_screenshot_path" in result.summary + + +# -------------------------------------------------------------------------------------- +# reboot: the confirm step, and the poll that says whether the Node came back. +# -------------------------------------------------------------------------------------- + + +@pytest.fixture +def _reboot_without_the_waiting(monkeypatch: pytest.MonkeyPatch) -> None: + """Collapse the settle period and the poll interval, keeping the sequence intact.""" + monkeypatch.setattr("pqn_whobot.whobot.REBOOT_SETTLE_S", 0.0) + monkeypatch.setattr("pqn_whobot.whobot.REBOOT_POLL_INTERVAL_S", 0.0) + + +def rebooting(comes_back_after: int | None) -> Callable[[httpx.Request], httpx.Response]: + """Build a Node that acks a reboot, then refuses ``comes_back_after`` polls before answering. + + ``None`` is the Node that never comes back — the failure the poll exists to catch. + """ + polls = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal polls + if request.url.path == "/system/reboot": + return httpx.Response(200, json={"scheduled": True, "detail": "Rebooting in 1s"}) + polls += 1 + if comes_back_after is None or polls <= comes_back_after: + msg = "Connection refused" + raise httpx.ConnectError(msg, request=request) + return httpx.Response(200, json={"node_name": "uiuc-public-left"}) + + return handler + + +def test_reboot_asks_to_confirm_first() -> None: + """The first shipped destructive Action: picking it from a dropdown must not reboot anything.""" + bot = run(spy(ALICE), PendingInvocation(action="reboot", node_url=ALICE)) + assert bot.drawn.calls == ["ask_to_confirm"] + + +@pytest.mark.usefixtures("_reboot_without_the_waiting") +def test_a_confirmed_reboot_polls_until_the_node_answers() -> None: + bot = with_node_api(rebooting(comes_back_after=2), ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="reboot", node_url=ALICE, confirmed=True))) + result = only_report(bot) + assert result.status is Status.OK + assert result.summary is not None + assert "is back" in result.summary + assert [f.status for f in result.sections[0].fields] == [Status.OK, Status.OK] + + +@pytest.mark.usefixtures("_reboot_without_the_waiting") +def test_a_node_that_never_comes_back_is_reported_as_still_down() -> None: + """The real guardrail: the confirm step protects the wrong Node, this protects against a lost one.""" + bot = with_node_api(rebooting(comes_back_after=None), ALICE, reboot_wait_s=0.05) + asyncio.run(dispatched(bot, PendingInvocation(action="reboot", node_url=ALICE, confirmed=True))) + result = only_report(bot) + assert result.status is Status.FAIL + assert result.sections[0].fields[-1].status is Status.FAIL + assert "still down" in result.sections[0].fields[-1].value + + +def test_a_node_that_refuses_the_reboot_is_not_polled_for() -> None: + """Nothing was rebooted, so waiting five minutes to say so would be five wasted minutes.""" + + def handler(request: httpx.Request) -> httpx.Response: + msg = "Connection refused" + raise httpx.ConnectError(msg, request=request) + + bot = with_node_api(handler, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="reboot", node_url=ALICE, confirmed=True))) + result = only_report(bot) + assert result.status is Status.FAIL + assert result.summary is not None + assert "nothing was rebooted" in result.summary + + +def test_an_answer_that_does_not_acknowledge_a_reboot_is_refused() -> None: + """Something else on that port must not be read as a Node that is now rebooting.""" + + def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001 + return httpx.Response(200, json={"hello": "world"}) + + bot = with_node_api(handler, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="reboot", node_url=ALICE, confirmed=True))) + assert only_report(bot).status is Status.FAIL + + # -------------------------------------------------------------------------------------- # Neutrality: nothing an Action produces may carry platform markup. # -------------------------------------------------------------------------------------- @@ -617,6 +800,13 @@ def _human_strings(report: Report) -> list[str]: return strings +def assert_no_markup(report: Report) -> None: + for text in _human_strings(report): + assert "*" not in text, text + assert "`" not in text, text + assert not SHORTCODE.search(text), text + + @pytest.mark.parametrize( "pending", [ @@ -629,8 +819,22 @@ def _human_strings(report: Report) -> list[str]: ) def test_no_action_output_contains_platform_markup(pending: PendingInvocation) -> None: """An Action describes what happened; decoration is the Chat Platform's business.""" - bot = run(spy(ALICE, BOB), pending) - for text in _human_strings(only_report(bot)): - assert "*" not in text, text - assert "`" not in text, text - assert not SHORTCODE.search(text), text + assert_no_markup(only_report(run(spy(ALICE, BOB), pending))) + + +@pytest.mark.usefixtures("_reboot_without_the_waiting") +def test_the_reboot_report_carries_no_platform_markup() -> None: + """The Action most tempted by decoration — a Node's address in backticks — must resist it.""" + bot = with_node_api(rebooting(comes_back_after=0), ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="reboot", node_url=ALICE, confirmed=True))) + assert_no_markup(only_report(bot)) + + +def test_the_debug_screenshot_report_carries_no_platform_markup(tmp_path: Path) -> None: + """It names a filesystem path, which is where a backtick would feel most natural.""" + image = tmp_path / "spongebob.gif" + image.write_bytes(GIF) + + bot = debug_bot(image, ALICE) + asyncio.run(dispatched(bot, PendingInvocation(action="screenshot", node_url=ALICE))) + assert_no_markup(only_report(bot)) diff --git a/tests/pytest/test_whobot_slack.py b/tests/pytest/test_whobot_slack.py index f5a99f5..edf4ba8 100644 --- a/tests/pytest/test_whobot_slack.py +++ b/tests/pytest/test_whobot_slack.py @@ -29,6 +29,7 @@ from pqn_whobot.whobot_slack import STATUS_EMOJI from pqn_whobot.whobot_slack import SlackReply from pqn_whobot.whobot_slack import WhobotSlack +from pqn_whobot.whobot_slack import _image_suffix from pqn_whobot.whobot_slack import _option_value ALICE = "http://node-a.invalid:9000" @@ -211,6 +212,29 @@ class Unrenderable(ActionResult): bot._render(Unrenderable()) # noqa: SLF001 +# -------------------------------------------------------------------------------------- +# Uploading an image. +# -------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + ("image", "suffix"), + [ + (b"\x89PNG\r\n\x1a\nrest of a screenshot", "png"), + (b"GIF89a and the rest", "gif"), + (b"\xff\xd8\xff\xe0 and the rest", "jpg"), + ], +) +def test_an_upload_is_named_after_what_its_bytes_are(image: bytes, suffix: str) -> None: + """Slack picks how to display a file from its filename, so a GIF sent as .png arrives broken.""" + assert _image_suffix(image) == suffix + + +def test_an_unrecognised_image_is_uploaded_as_a_png() -> None: + """Every Action that produces one produces a PNG; the fallback must not be an error.""" + assert _image_suffix(b"something else entirely") == "png" + + # -------------------------------------------------------------------------------------- # The payload guards. # -------------------------------------------------------------------------------------- From 4a358c01563d09faca64aa78cfc67f5710dd19f6 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 29 Jul 2026 12:43:45 -0500 Subject: [PATCH 5/7] Added digest support with `NodeDigest` and `DigestResult` types, updated Slack form renderer for `float` parameters, implemented block limits for Slack messages, adjusted digest configuration logic, and added comprehensive tests. --- configs/whobot_example.toml | 27 +- src/pqn_whobot/actions.py | 83 ++++- src/pqn_whobot/config.py | 51 +-- src/pqn_whobot/node_client.py | 104 ++++-- src/pqn_whobot/registry.py | 14 +- src/pqn_whobot/whobot.py | 396 +++++++++++++++++++++-- src/pqn_whobot/whobot_slack.py | 138 ++++++-- tests/pytest/test_whobot_config.py | 68 ++-- tests/pytest/test_whobot_flow.py | 463 ++++++++++++++++++++++++++- tests/pytest/test_whobot_registry.py | 114 ++++++- tests/pytest/test_whobot_slack.py | 154 ++++++++- 11 files changed, 1439 insertions(+), 173 deletions(-) diff --git a/configs/whobot_example.toml b/configs/whobot_example.toml index fa44fca..2dc59b2 100644 --- a/configs/whobot_example.toml +++ b/configs/whobot_example.toml @@ -37,24 +37,29 @@ schedule_timezone = "America/Chicago" schedule_hour = 7 schedule_minute = 0 -# Per-Node bounds for the serial digest. Nodes are probed one at a time and each one runs -# its Games for real, so total runtime scales with Node count — hence per-Node timeouts -# rather than one global bound. -per_node_timeout_s = 900 +# How the digest measures a Node. The address is resolved on the Node, so 127.0.0.1 means each +# Node's own host and one value serves the fleet; the port is the Node API's (9000 in +# production, 8000 from a dev checkout). basis is the CHSH measurement angles. +timetagger_address = "127.0.0.1:9000" +basis = [0.0, 22.5] + +# How long one Game may take. Everything longer is derived from it: a Node's bound is its two +# Games plus the two calls around them, and the digest's is that once per Node in the registry. +# So adding a Node widens the digest's budget on its own, and no two settings can disagree +# about the same wait. per_game_timeout_s = 600 # How long a mere "are you there?" call waits. Far shorter than the digest budget, so # `whobot nodes` reports a dead address in seconds instead of appearing to hang. reachability_timeout_s = 5 -# Bound on one Node API call made by an Action from Slack. Neither key above fits: 5s is -# for "are you there?", and 900s is the digest's whole budget for a Node. +# Bound on one Node API call that does work — reading a config, writing availability, capturing +# a screenshot. Neither key above fits: 5s is for "are you there?", 600s is for a whole Game. node_timeout_s = 30 -# How long a rebooted Node has to answer again before Whobot reports it as still down. Raise -# it for a machine with a slow POST or one that fscks on boot. It shares the Reboot Action's -# 360s budget with node_timeout_s and reachability_timeout_s, and Whobot refuses to start if -# the three together leave the Action no time to report what it found. +# How long a rebooted Node has to answer again before Whobot reports it as still down. Raise it +# for a machine with a slow POST or one that fscks on boot; the Reboot Action's own budget is +# worked out from this, so it always has time to report what it found. reboot_wait_s = 300 # Development only. Screenshot is the one Action that needs a Node in front of a real @@ -67,7 +72,7 @@ reboot_wait_s = 300 # The Node Registry. Whobot knows about exactly these Nodes — adding one is an edit here, # not a code change. Use each Node's address on the VPN; production Nodes listen on 9000. # Node *names* are deliberately not listed: Whobot reads them from each Node's -# GET /node/config, so this file cannot drift out of date. +# GET /node/config, so this file cannot drift out of date. An address is all an entry holds. [[nodes]] api_url = "http://xx.xx.xx.xx:9000" diff --git a/src/pqn_whobot/actions.py b/src/pqn_whobot/actions.py index faa045c..b93fee2 100644 --- a/src/pqn_whobot/actions.py +++ b/src/pqn_whobot/actions.py @@ -35,6 +35,7 @@ from typing import Any from typing import TypeVar +from pqn_whobot.config import WhobotSettings from pqn_whobot.registry import Node logger = logging.getLogger(__name__) @@ -135,6 +136,37 @@ class Report(ActionResult): notes: list[str] = dataclass_field(default_factory=list) +@dataclass(frozen=True) +class NodeDigest: + """One Node's check-up. Not an ``ActionResult``: it is part of one. + + Holds ordinary ``Section``s, so every rule about how a result renders is inherited rather + than restated. What it adds is the Node the sections belong to. + """ + + name: str + api_url: str + status: Status + sections: list[Section] = dataclass_field(default_factory=list) + + +@dataclass(frozen=True) +class DigestResult(ActionResult): + """One or more Nodes' check-ups in one result. + + The one result ``Report`` cannot express, because Node x (hardware checklist + Games) is a + level of nesting deeper than a flat list of Sections. The grouping has to be structural: + with a flat list, the only way to say which Node a Section belongs to is to write the name + into the Section's label, and then nothing can tell where one Node ends and the next begins. + """ + + status: Status + title: str + summary: str | None = None + nodes: list[NodeDigest] = dataclass_field(default_factory=list) + notes: list[str] = dataclass_field(default_factory=list) + + # -------------------------------------------------------------------------------------- # Declaring an Action. # -------------------------------------------------------------------------------------- @@ -161,13 +193,27 @@ class Scope(StrEnum): _PREFILL_ATTR = "_whobot_prefill" DEFAULT_TIMEOUT_S = 120.0 +"""What an Action is allowed when it does not say. Enough for any single Node API call.""" + +TimeoutSpec = float | Callable[[WhobotSettings], float] +"""How long an Action may take: a number, or the settings it should be worked out from. -WIDGET_TYPES: tuple[type, ...] = (bool,) -"""Parameter types the form generator can render. ``bool`` becomes a checkbox. +A budget belongs with the configuration it spends. ``reboot`` may take the reboot call plus the +wait plus one poll, and the digest may take one per-Node budget for every Node in the registry — +so both are stated as arithmetic over ``WhobotSettings`` rather than as a constant that a config +edit can silently contradict. That is why there is no validator checking a timeout against the +config: an Action's bound *is* the sum of what it spends, so it cannot disagree with it. + +Resolved by ``Action.timeout_for`` at call time, which is the only moment the number is needed — +the scan stores the declaration and never looks inside it. +""" + +WIDGET_TYPES: tuple[type, ...] = (bool, float) +"""Parameter types the form generator can render: ``bool`` a checkbox, ``float`` a number input. The scan rejects any other type by name, so an unsupported parameter fails at import with a -message rather than producing an empty form. Adding ``str``, ``int``/``float`` or -``Literal``/enum is one entry here and one branch in the Chat Platform's form renderer. +message rather than producing an empty form. Adding ``str`` or ``Literal``/enum is one entry +here and one branch in the Chat Platform's form renderer. """ @@ -175,9 +221,9 @@ class Scope(StrEnum): class Parameter: """One question the form asks, parsed from an Action's signature. - ``default`` is always set: a checkbox is ticked or it is not, so a ``bool`` with no - signature default starts unticked. A widget for which "no value" differs from a default - will need a ``required`` flag here. + ``default`` is always set: a checkbox is ticked or it is not, and a number input left empty + is the same as not answering, so both fall back to what the signature says. A widget for + which "no value" must be distinguished from a default will need a ``required`` flag here. """ name: str @@ -193,7 +239,7 @@ class ActionMeta: description: str | None = None scope: Scope = Scope.NONE destructive: bool = False - timeout_s: float = DEFAULT_TIMEOUT_S + timeout_s: TimeoutSpec = DEFAULT_TIMEOUT_S @dataclass(frozen=True) @@ -211,10 +257,25 @@ class Action: description: str | None scope: Scope destructive: bool - timeout_s: float + timeout_s: TimeoutSpec parameters: tuple[Parameter, ...] prefill_name: str | None = None + def timeout_for(self, settings: WhobotSettings) -> float: + """How long this Action may take, given the configuration it will spend. + + A declaration that cannot be worked out falls back to the default rather than stopping + the Action: an unresolvable budget is a bug in one Action's arithmetic, and refusing to + run would break the promise that every announcement is followed by a result. + """ + if not callable(self.timeout_s): + return self.timeout_s + try: + return float(self.timeout_s(settings)) + except Exception: + logger.exception("%s: could not work out its timeout; allowing the default", self.name) + return DEFAULT_TIMEOUT_S + async def call(self, owner: object, node: Node | None, params: dict[str, object] | None) -> ActionResult: """Run the Action against ``owner``, passing the Node only when its scope declares one. @@ -264,10 +325,12 @@ def action( description: str | None = None, scope: Scope = Scope.NONE, destructive: bool = False, - timeout_s: float = DEFAULT_TIMEOUT_S, + timeout_s: TimeoutSpec = DEFAULT_TIMEOUT_S, ) -> Callable[[F], F]: """Mark a method as an Action, recording what its signature cannot say. + ``timeout_s`` may be a number or a function of the settings — see ``TimeoutSpec``. + The method is returned unchanged: this staples metadata onto it rather than wrapping it, so an Action stays an ordinary method and ``@prefill`` can link to it by identity. """ diff --git a/src/pqn_whobot/config.py b/src/pqn_whobot/config.py index b26d244..03cf10c 100644 --- a/src/pqn_whobot/config.py +++ b/src/pqn_whobot/config.py @@ -13,21 +13,11 @@ from pydantic import ConfigDict from pydantic import Field from pydantic import field_validator -from pydantic import model_validator from pydantic_settings import BaseSettings from pydantic_settings import PydanticBaseSettingsSource from pydantic_settings import SettingsConfigDict from pydantic_settings import TomlConfigSettingsSource -REBOOT_TIMEOUT_S = 360.0 -"""Outer bound on a whole Reboot invocation, as ``@action`` declares it. - -It lives here rather than beside the Action because it is the ceiling ``reboot_wait_s`` is -checked against, and this module may not import ``whobot.py`` — the dependency runs the other -way. An Action's ``timeout_s`` is read by the scan while the class is being created, before -any settings exist, so it cannot itself come from configuration. -""" - class NodeEntry(BaseModel): """One entry of the Node Registry: a Node's address. @@ -63,20 +53,26 @@ class WhobotSettings(BaseSettings): schedule_hour: int = Field(default=7, ge=0, le=23) schedule_minute: int = Field(default=0, ge=0, le=59) - # Bounds for the serial digest, per Node rather than one bound for the whole run. - per_node_timeout_s: float = Field(default=900.0, gt=0) + # How the digest measures a Node. The address is resolved *on the Node*, so 127.0.0.1 + # means each Node's own host and one value serves the fleet; the port is the Node API's. + timetagger_address: str = "127.0.0.1:9000" + basis: tuple[float, float] = (0.0, 22.5) + + # How long one Game may take. The digest's per-Node and whole-run bounds are derived from + # this rather than configured beside it, so no two keys can disagree about the same wait. per_game_timeout_s: float = Field(default=600.0, gt=0) - # Bound for a single "are you there?" call, well under the digest's per-Node budget. + # Bound for a single "are you there?" call, so listing Nodes cannot stall on a dead one. reachability_timeout_s: float = Field(default=5.0, gt=0) - # Bound for one Node API call made by an Action. Neither existing key fits: 5s is for - # "are you there?", and 900s is the digest's whole budget for a Node. + # Bound for one Node API call that does work — reading a config, writing availability, + # capturing a screenshot. Longer than "are you there?", far shorter than a Game. node_timeout_s: float = Field(default=30.0, gt=0) # How long a rebooted Node has to answer again before it is reported as still down. # Site-specific, which is why it is here: a machine with a slow POST, or one that fscks - # on boot, legitimately takes longer than one that does not. + # on boot, legitimately takes longer than one that does not. The Reboot Action's own bound + # is worked out from this, so raising it cannot cut the report it exists to produce short. reboot_wait_s: float = Field(default=300.0, gt=0) # Development only: answer the Screenshot Action with this file instead of calling a @@ -124,29 +120,6 @@ def _require_known_timezone(cls, value: str) -> str: raise ValueError(msg) from e return value - @model_validator(mode="after") - def _reboot_must_be_able_to_report_itself(self) -> "WhobotSettings": - """Keep the reboot wait inside what the Reboot Action is allowed to take. - - A wait that outlasts the Action's ``timeout_s`` is worse than a shorter one: - ``execute`` cuts the run off and posts "Timed out after 360s", losing the "still down - after 5 minutes — go and look at the machine" report the wait exists to produce. - - The three keys are added because they are what one invocation spends: the call that - asks for the reboot, the wait, and the last poll of that wait. - """ - budget = self.node_timeout_s + self.reboot_wait_s + self.reachability_timeout_s - if budget > REBOOT_TIMEOUT_S: - msg = ( - f"reboot_wait_s ({self.reboot_wait_s:.0f}s) leaves the Reboot Action no time to report: " - f"with node_timeout_s ({self.node_timeout_s:.0f}s) and reachability_timeout_s " - f"({self.reachability_timeout_s:.0f}s) it needs {budget:.0f}s of the {REBOOT_TIMEOUT_S:.0f}s " - f"the Action is allowed. Lower reboot_wait_s to at most " - f"{REBOOT_TIMEOUT_S - self.node_timeout_s - self.reachability_timeout_s:.0f}s." - ) - raise ValueError(msg) - return self - @property def timezone(self) -> ZoneInfo: """The zone ``schedule_hour`` and ``schedule_minute`` are interpreted in.""" diff --git a/src/pqn_whobot/node_client.py b/src/pqn_whobot/node_client.py index a453169..be59e2d 100644 --- a/src/pqn_whobot/node_client.py +++ b/src/pqn_whobot/node_client.py @@ -2,6 +2,11 @@ Every call carries a timeout, and every failure — refused connection, HTTP error, unparseable body — arrives as a ``NodeApiError``. + +``HealthStatus`` and ``ChshResult`` are imported from the route modules that produce them, so +there is one definition of each rather than a copy that can drift. The cost is that importing +this pulls in a FastAPI route module and everything it imports; extracting the shared response +models into a module of their own is a later refactor. """ import logging @@ -10,6 +15,8 @@ from pydantic import BaseModel from pydantic import ValidationError +from pqn_node.api.routes.chsh import ChshResult +from pqn_node.api.routes.health import HealthStatus from pqn_node.core.config import GamesAvailability logger = logging.getLogger(__name__) @@ -57,26 +64,36 @@ def _detail(response: httpx.Response) -> str: class NodeClient: - """Talks to one Node, applying ``timeout_s`` to every call. + """Talks to one Node. Every call states how long it may take. + + The bound belongs to the call rather than to the client, because the same Node answers + "are you there?" in milliseconds and runs a CHSH for ten minutes. A client that fixed one + timeout would need to be rebuilt to ask a different question of the same machine. Each call opens and closes its own connection; Nodes are probed minutes apart at most, so there is nothing for a pooled connection to save. """ - def __init__(self, api_url: str, timeout_s: float, transport: httpx.AsyncBaseTransport | None = None) -> None: + def __init__(self, api_url: str, transport: httpx.AsyncBaseTransport | None = None) -> None: self.api_url = api_url.rstrip("/") - self.timeout_s = timeout_s self._transport = transport def __repr__(self) -> str: - return f"NodeClient({self.api_url!r}, timeout_s={self.timeout_s})" - - async def _request(self, method: str, path: str, body: object | None = None) -> httpx.Response: + return f"NodeClient({self.api_url!r})" + + async def _request( + self, + method: str, + path: str, + timeout_s: float, + body: object | None = None, + params: dict[str, str] | None = None, + ) -> httpx.Response: """Make one call, turning every way it can fail into a ``NodeApiError``.""" url = f"{self.api_url}{path}" try: - async with httpx.AsyncClient(timeout=self.timeout_s, transport=self._transport) as client: - response = await client.request(method, url, json=body) + async with httpx.AsyncClient(timeout=timeout_s, transport=self._transport) as client: + response = await client.request(method, url, json=body, params=params) response.raise_for_status() return response except httpx.HTTPStatusError as e: @@ -89,8 +106,15 @@ async def _request(self, method: str, path: str, body: object | None = None) -> logger.warning("%s %s failed: %s", method, url, msg) raise NodeApiError(msg) from e - async def _send_json(self, method: str, path: str, body: object | None = None) -> object: - response = await self._request(method, path, body) + async def _send_json( + self, + method: str, + path: str, + timeout_s: float, + body: object | None = None, + params: dict[str, str] | None = None, + ) -> object: + response = await self._request(method, path, timeout_s, body, params) try: return response.json() except ValueError as e: # a 200 that isn't JSON: something other than a Node answered @@ -110,32 +134,32 @@ def _parse_availability(self, payload: object) -> GamesAvailability: msg = f"{self.api_url} did not answer with a Games availability: {e}" raise NodeApiError(msg) from e - async def get_availability(self) -> GamesAvailability: + async def get_availability(self, timeout_s: float) -> GamesAvailability: """Ask the Node which Games it currently offers. This is the Node's *effective* availability — its configuration gated by the last hardware probe — because that is what the endpoint returns. A Game switched on in config still reads as unavailable while the router it needs is unreachable. """ - return self._parse_availability(await self._send_json("GET", "/games/availability")) + return self._parse_availability(await self._send_json("GET", "/games/availability", timeout_s)) - async def set_availability(self, availability: GamesAvailability) -> GamesAvailability: + async def set_availability(self, availability: GamesAvailability, timeout_s: float) -> GamesAvailability: """Set which Games the Node offers, persistently and without a restart. Returns what the Node reports *afterwards*, which is not necessarily what was asked for: the endpoint answers with effective availability, so a Game switched on here still reads as unavailable if its hardware is unreachable. """ - payload = await self._send_json("PUT", "/games/availability", availability.model_dump()) + payload = await self._send_json("PUT", "/games/availability", timeout_s, availability.model_dump()) return self._parse_availability(payload) - async def get_screenshot(self) -> bytes: + async def get_screenshot(self, timeout_s: float) -> bytes: """Capture the Node's display, returning the image bytes as they arrived. The endpoint answers ``image/png``. The content type is checked because a proxy or a captive portal on the way in would otherwise be uploaded to Slack as a screenshot. """ - response = await self._request("GET", "/system/screenshot") + response = await self._request("GET", "/system/screenshot", timeout_s) content_type = response.headers.get("content-type", "") if not content_type.startswith("image/") or not response.content: msg = ( @@ -145,14 +169,14 @@ async def get_screenshot(self) -> bytes: raise NodeApiError(msg) return response.content - async def reboot(self) -> RebootAck: + async def reboot(self, timeout_s: float) -> RebootAck: """Ask the Node to reboot, returning its acknowledgement. The Node schedules the reboot and answers before it dies, so this returns while the machine is still up. Whether it comes back is a separate question, answered by polling. """ - payload = await self._send_json("POST", "/system/reboot") + payload = await self._send_json("POST", "/system/reboot", timeout_s) if not isinstance(payload, dict) or "scheduled" not in payload: msg = f"{self.api_url}/system/reboot did not acknowledge the reboot: {str(payload)[:100]}" raise NodeApiError(msg) @@ -162,9 +186,49 @@ async def reboot(self) -> RebootAck: msg = f"unexpected /system/reboot response: {e}" raise NodeApiError(msg) from e - async def get_config(self) -> NodeConfigResponse: + def _validated[T: BaseModel](self, model: type[T], payload: object, what: str) -> T: + """Parse a response into a model, naming what was being read when it doesn't fit.""" + try: + return model.model_validate(payload) + except ValidationError as e: + msg = f"{self.api_url} did not answer with {what}: {e}" + raise NodeApiError(msg) from e + + async def get_health(self, timeout_s: float) -> HealthStatus: + """Probe the Node's hardware: its router, devices, rotary encoder and follower. + + The endpoint takes no parameters — it reads the follower's address from the Node's own + settings — so there is nothing for a caller to get wrong here. + """ + payload = await self._send_json("GET", "/health/", timeout_s) + return self._validated(HealthStatus, payload, "a health status") + + async def run_chsh(self, timetagger_address: str, basis: tuple[float, float], timeout_s: float) -> ChshResult: + """Run one CHSH measurement at these angles, and return what it measured. + + Minutes of hardware work, which is why the caller says how many. + """ + params = {"timetagger_address": timetagger_address} + payload = await self._send_json("POST", "/chsh/", timeout_s, list(basis), params) + return self._validated(ChshResult, payload, "a CHSH result") + + async def run_fortune(self, timetagger_address: str, timeout_s: float) -> list[int]: + """Run one Quantum Fortune, returning the number each channel drew. + + ``fortune_size`` and ``channels`` are deliberately not sent: the Node falls back to its + own ``rng_settings``, and an unattended run must not override per-Node calibration. + """ + payload = await self._send_json( + "GET", "/rng/fortune", timeout_s, params={"timetagger_address": timetagger_address} + ) + if not isinstance(payload, list) or not all(isinstance(drawn, int) for drawn in payload): + msg = f"{self.api_url}/rng/fortune is not a fortune per channel: {str(payload)[:100]}" + raise NodeApiError(msg) + return payload + + async def get_config(self, timeout_s: float) -> NodeConfigResponse: """Ask the Node for its name and follower address.""" - payload = await self._send_json("GET", "/node/config") + payload = await self._send_json("GET", "/node/config", timeout_s) # *Any* of the fields will do here, unlike availability: a Node running code from # before node_name existed answers with only the follower address, and that is out # of date rather than unreachable. Both fields are optional, so without this check diff --git a/src/pqn_whobot/registry.py b/src/pqn_whobot/registry.py index d0696e2..0277d1a 100644 --- a/src/pqn_whobot/registry.py +++ b/src/pqn_whobot/registry.py @@ -36,11 +36,15 @@ class Node: latency_ms: float | None = None -async def resolve_node(client: NodeClient) -> Node: - """Ask one Node for its name, timing the call. Unreachable is a result, not an exception.""" +async def resolve_node(client: NodeClient, timeout_s: float) -> Node: + """Ask one Node for its name, timing the call. Unreachable is a result, not an exception. + + The bound is "are you there?" rather than the digest's per-Node budget: a listing must + report a dead address in seconds instead of appearing to hang. + """ started = time.perf_counter() try: - config = await client.get_config() + config = await client.get_config(timeout_s) except NodeApiError as e: return Node(api_url=client.api_url, reachable=False, error=str(e)) return Node( @@ -58,5 +62,5 @@ async def resolve_nodes(settings: WhobotSettings) -> list[Node]: Concurrent because ``/node/config`` touches no hardware, so there is nothing for two Nodes to contend for — unlike the digest, which runs Games and so must be serial. """ - clients = [NodeClient(entry.api_url, settings.reachability_timeout_s) for entry in settings.nodes] - return list(await asyncio.gather(*(resolve_node(client) for client in clients))) + clients = [NodeClient(entry.api_url) for entry in settings.nodes] + return list(await asyncio.gather(*(resolve_node(client, settings.reachability_timeout_s) for client in clients))) diff --git a/src/pqn_whobot/whobot.py b/src/pqn_whobot/whobot.py index ad6e4c9..e6097dd 100644 --- a/src/pqn_whobot/whobot.py +++ b/src/pqn_whobot/whobot.py @@ -23,7 +23,8 @@ async def node_info(self, node: Node) -> Report: ... ``scope=Scope.ONE`` means the Action acts on one Node, which the operator picks and which arrives as the method's first argument; ``Scope.NONE`` means it acts on the Network and takes no Node. Every remaining parameter is keyword-only and becomes a question in the -parameter form: one checkbox per ``bool``, which is the only widget mapping there is. +parameter form: a checkbox per ``bool`` and a number input per ``float``, which are the only +widget mappings there are. An Action returns an ``ActionResult``, usually a ``Report``, describing *what happened*. It must not emit platform markup: deciding what a result looks like belongs to the subclass, @@ -86,10 +87,14 @@ async def node_info(self, node: Node) -> Report: ... from typing import Any from typing import ClassVar +from pqn_node.api.routes.health import ComponentStatus +from pqn_node.api.routes.health import HealthStatus from pqn_node.core.config import GamesAvailability from pqn_whobot.actions import Action from pqn_whobot.actions import ActionResult +from pqn_whobot.actions import DigestResult from pqn_whobot.actions import Field +from pqn_whobot.actions import NodeDigest from pqn_whobot.actions import PendingInvocation from pqn_whobot.actions import ReplyHandle from pqn_whobot.actions import Report @@ -99,7 +104,6 @@ async def node_info(self, node: Node) -> Report: ... from pqn_whobot.actions import action from pqn_whobot.actions import prefill from pqn_whobot.actions import scan_actions -from pqn_whobot.config import REBOOT_TIMEOUT_S from pqn_whobot.config import WhobotSettings from pqn_whobot.node_client import NodeApiError from pqn_whobot.node_client import NodeClient @@ -126,6 +130,54 @@ async def node_info(self, node: Node) -> Report: ... """How often a rebooting Node is asked whether it is back.""" +def one_game_budget(settings: WhobotSettings) -> float: + """How long an Action that plays one Game may take: the Game, plus the call that starts it.""" + return settings.node_timeout_s + settings.per_game_timeout_s + + +def whole_digest_budget(settings: WhobotSettings) -> float: + """How long a whole-fleet digest may take: one Node's budget for every registered Node. + + This is why an Action's bound is a function of the settings rather than a constant. The + digest's duration grows with the registry, so a constant would be wrong for every fleet but + one — and when it fired, ``execute`` would post "Timed out" and throw away every section the + run had already gathered. Adding a Node now widens this on its own. + + The extra Node call is for resolving the registry before any Node is checked. + """ + return len(settings.nodes) * one_node_budget(settings) + settings.node_timeout_s + + +def one_node_budget(settings: WhobotSettings) -> float: + """How long checking one Node over may take: two Node calls, then both Games it may play. + + Derived rather than configured. A ``per_node_timeout_s`` key would be a second statement of + the same thing, free to disagree with it — and it did: the default was 900s while two Games + at 600s each need 1200s, so a Node whose Games were merely slow lost both measurements to a + budget nobody had chosen. + """ + return 2 * settings.node_timeout_s + 2 * settings.per_game_timeout_s + + +DIGEST_TITLE = "Daily Digest" +"""What the fleet-wide report is called, whether it was scheduled or asked for by hand.""" + +BELL_CLASSICAL_LIMIT = 2.0 +"""An S above this is the Bell inequality being violated, which is the point of the exercise.""" + +# FIXME: wrong home, and doing two jobs. What a Game is *called* is Node-domain knowledge — +# `GamesAvailability` carries these names in comments — and `set_availability` names the same +# three Games differently (`game.upper()`), so there are two namings in two places. +# `_play_games` also iterates this as the list-of-Games, so display order silently decides +# section order. Wants a home for Game domain facts, shared with `set_availability`. +GAME_TITLES = { + "chsh": "CHSH — Verify Quantum Link", + "qf": "Quantum Fortune", + "ssm": "Share a Secret Message", +} +"""What each Game in ``GamesAvailability`` is called in front of an operator.""" + + class Whobot(ABC): """The platform-independent half of Whobot: its Actions and the flow that runs them. @@ -192,7 +244,7 @@ async def list_nodes(self) -> Report: async def node_info(self, node: Node) -> Report: """Report what a Node says about itself, read fresh rather than from the registry.""" try: - config = await self._client(node).get_config() + config = await self._client(node).get_config(self.settings.node_timeout_s) except NodeApiError as e: return Report( status=Status.FAIL, @@ -233,7 +285,7 @@ async def set_availability(self, node: Node, *, chsh: bool = True, qf: bool = Tr """ games = GamesAvailability(chsh=chsh, qf=qf, ssm=ssm) try: - applied = await self._client(node).set_availability(games) + applied = await self._client(node).set_availability(games, self.settings.node_timeout_s) except NodeApiError as e: return Report( status=Status.FAIL, @@ -279,9 +331,106 @@ async def _availability_prefill(self, node: Node) -> dict[str, object]: ``model_dump`` keys this by Game name, which is what the form asks for, and is what keeps this method from naming the Games itself. """ - availability = await self._client(node).get_availability() + availability = await self._client(node).get_availability(self.settings.node_timeout_s) return dict(availability.model_dump()) + @action( + label="Run CHSH", + description="Measure one Node's quantum link now, at the angles you choose.", + scope=Scope.ONE, + timeout_s=one_game_budget, + ) + async def run_chsh(self, node: Node, *, angle_a: float = 0.0, angle_b: float = 22.5) -> Report: + """Run one CHSH measurement and report what it measured. + + The angles are asked for because they are a choice: an operator running this by hand is + usually running it *at* something. The digest, which nobody is watching, takes them from + configuration instead. + """ + section = await self._play_chsh(node, (angle_a, angle_b)) + return Report(status=section.status or Status.OK, title=f"CHSH — {node.name}", sections=[section]) + + @prefill(run_chsh) + async def _chsh_angles_prefill(self, _node: Node) -> dict[str, object]: + """Open the form on the angles an unattended run would use, so config is the starting point.""" + angle_a, angle_b = self.settings.basis + return {"angle_a": angle_a, "angle_b": angle_b} + + @action( + label="Run Quantum Fortune", + description="Draw one number per channel from a Node's quantum randomness.", + scope=Scope.ONE, + timeout_s=one_game_budget, + ) + async def run_fortune(self, node: Node) -> Report: + """Run one Quantum Fortune and report what each channel drew. + + No parameters: ``fortune_size`` and ``channels`` are the Node's own calibration, and + overriding them from Slack would make one run incomparable with the next. + """ + section = await self._play_fortune(node) + return Report(status=section.status or Status.OK, title=f"Quantum Fortune — {node.name}", sections=[section]) + + @action( + label="Run digest now", + description="Check every Node in the registry, one after another.", + timeout_s=whole_digest_budget, + ) + async def run_digest(self) -> DigestResult: + """Check every registered Node over, and report the fleet in one message. + + Nodes are checked **one at a time**, and that is a requirement rather than simplicity: + in two-Node CHSH one Node acts as follower for another, so concurrent runs would contend + for the same follower and the same timetagger, and the numbers would be worthless. + """ + nodes = await resolve_nodes(self.settings) + if not nodes: + return DigestResult( + status=Status.WARN, + title=DIGEST_TITLE, + summary="No Nodes are registered. Add a [[nodes]] entry to whobot.toml.", + ) + + budget = one_node_budget(self.settings) + digests = [] + for node in nodes: + try: + digests.append(await asyncio.wait_for(self._probe_node(node), budget)) + except TimeoutError: + # Bounded per Node so that one machine cannot spend the whole fleet's time. The + # sections that Node had already produced are lost with it; what survives is + # every *other* Node's, which is the point of the bound. + logger.warning("%s took longer than its %ss budget", node.api_url, budget) + digests.append(_timed_out(node, budget)) + + healthy = sum(1 for digest in digests if digest.status is Status.OK) + return DigestResult( + status=Status.overall(digest.status for digest in digests), + title=DIGEST_TITLE, + summary=f"{healthy} of {len(digests)} Nodes reported no problems", + nodes=digests, + ) + + @action( + label="Check one Node", + description="One Node's full check-up: its hardware, then the Games it offers.", + scope=Scope.ONE, + timeout_s=one_node_budget, + ) + async def check_node(self, node: Node) -> DigestResult: + """Check one Node the way the Daily Digest checks every Node. + + The same ``_probe_node`` the digest fans out over, so what an operator sees here is + exactly what the unattended run would have reported about this machine. + """ + digest = await self._probe_node(node) + return DigestResult( + status=digest.status, + title=f"Check-up — {node.name}", + summary=f"{node.api_url} — hardware and Games", + nodes=[digest], + ) + @action( label="Screenshot", description="A picture of what a Node's display is showing.", @@ -298,7 +447,7 @@ async def screenshot(self, node: Node) -> Report: return self._debug_screenshot(node, self.settings.debug_screenshot_path) try: - image = await self._client(node).get_screenshot() + image = await self._client(node).get_screenshot(self.settings.node_timeout_s) except NodeApiError as e: return Report( status=Status.FAIL, @@ -349,7 +498,8 @@ def _debug_screenshot(node: Node, path: Path) -> Report: description="Reboot a Node's host, then wait for its API to answer again.", scope=Scope.ONE, destructive=True, - timeout_s=REBOOT_TIMEOUT_S, + # The call that asks for the reboot, the wait for the machine, and the last poll of it. + timeout_s=lambda s: s.node_timeout_s + s.reboot_wait_s + s.reachability_timeout_s, ) async def reboot(self, node: Node) -> Report: """Reboot the Node's host and report whether it came back. @@ -362,7 +512,7 @@ async def reboot(self, node: Node) -> Report: """ title = f"Reboot — {node.name}" try: - ack = await self._client(node).reboot() + ack = await self._client(node).reboot(self.settings.node_timeout_s) except NodeApiError as e: return Report( status=Status.FAIL, @@ -419,11 +569,11 @@ async def _wait_until_back(self, node: Node) -> float | None: """ started = time.monotonic() await asyncio.sleep(REBOOT_SETTLE_S) - client = self._client(node, self.settings.reachability_timeout_s) + client = self._client(node) while time.monotonic() - started < self.settings.reboot_wait_s: try: - await client.get_config() + await client.get_config(self.settings.reachability_timeout_s) except NodeApiError: await asyncio.sleep(REBOOT_POLL_INTERVAL_S) continue @@ -482,13 +632,16 @@ async def execute( happened, and has to go and check by hand. So every way out of the call posts something, including cancellation. """ + # Worked out before anything is announced, because it depends on configuration that a + # long-running process may have had reloaded under it. + timeout_s = act.timeout_for(self.settings) reply = await self.announce_start(act, pending, handle) try: - result = await asyncio.wait_for(act.call(self, node, pending.params), act.timeout_s) + result = await asyncio.wait_for(act.call(self, node, pending.params), timeout_s) except TimeoutError: - logger.warning("%s timed out after %ss", act.name, act.timeout_s) - result = Report(status=Status.FAIL, title=act.label, summary=f"Timed out after {act.timeout_s:.0f}s.") + logger.warning("%s timed out after %ss", act.name, timeout_s) + result = Report(status=Status.FAIL, title=act.label, summary=f"Timed out after {timeout_s:.0f}s.") except asyncio.CancelledError: # Posting from inside a cancelled coroutine cannot be awaited here — the await # would be cancelled too. Hand it to a task nothing cancels, which shutdown @@ -577,18 +730,127 @@ async def shutdown(self, grace_s: float = SHUTDOWN_GRACE_S) -> None: # Helpers. None of these is an Action, so none can be invoked from a Chat Platform. # ---------------------------------------------------------------------------------- - def _client(self, node: Node, timeout_s: float | None = None) -> NodeClient: - """Open a client for one Node, bounded by the timeout an Action's calls get. - - ``timeout_s`` overrides that where a call is asking a different question: polling a - rebooting Node wants the "are you there?" bound, not the Action's. - """ - return NodeClient(node.api_url, self.settings.node_timeout_s if timeout_s is None else timeout_s) + def _client(self, node: Node) -> NodeClient: + """Open a client for one Node. How long a call may take is stated at the call.""" + return NodeClient(node.api_url) def _menu(self) -> list[Action]: """Every Action, in the order they are declared. The menu is the class body.""" return list(self.actions.values()) + # ---------------------------------------------------------------------------------- + # Checking one Node over. Shared by "Check one Node", the two Game Actions, and the + # Daily Digest, which fans this out across the registry. + # ---------------------------------------------------------------------------------- + + async def _probe_node(self, node: Node) -> NodeDigest: + """Check one Node's hardware, then the Games it offers. + + Every failure is a section rather than an exception, because a digest of four Nodes must + not be lost to one of them being unplugged. Whoever calls this bounds it per Node. + """ + if not node.reachable: + # Already known from resolving the registry, so there is nothing to gain by spending + # both Games' timeouts finding it out again. + return NodeDigest( + name=node.name, + api_url=node.api_url, + status=Status.FAIL, + sections=[_failed_section("Node API", "Whobot cannot reach this Node.", node.error or "unreachable")], + ) + + try: + sections = [_hardware_section(await self._client(node).get_health(self.settings.node_timeout_s))] + except NodeApiError as e: + sections = [_failed_section("Hardware", "The hardware probe failed.", str(e))] + + sections += await self._play_games(node) + + return NodeDigest( + name=node.name, + api_url=node.api_url, + status=Status.overall(section.status for section in sections), + sections=sections, + ) + + async def _play_games(self, node: Node) -> list[Section]: + """Ask the Node which Games it offers, and play the ones it does. + + Availability that cannot be read skips every Game rather than assuming all of them are + on. Whether a Game may run is the *Node's* answer to give: assuming is how an unattended + run plays a Game an operator deliberately switched off. + """ + try: + availability = await self._client(node).get_availability(self.settings.node_timeout_s) + except NodeApiError as e: + reason = f"Whobot could not read this Node's Game availability: {e}" + return [_skipped_section(title, reason) for title in GAME_TITLES.values()] + + sections = [] + if availability.chsh: + sections.append(await self._play_chsh(node, self.settings.basis)) + else: + sections.append(_skipped_section(GAME_TITLES["chsh"], "Not available on this Node.")) + + if availability.qf: + sections.append(await self._play_fortune(node)) + else: + sections.append(_skipped_section(GAME_TITLES["qf"], "Not available on this Node.")) + + # SSM is never played, whether it is available or not — hence no branch on availability. + sections.append( + _skipped_section( + GAME_TITLES["ssm"], "Needs an interactive coordination dance, so an unattended run cannot play it." + ) + ) + return sections + + async def _play_chsh(self, node: Node, basis: tuple[float, float]) -> Section: + """Run one CHSH at these angles, and describe what it measured or why it could not.""" + label = GAME_TITLES["chsh"] + started = time.monotonic() + try: + result = await self._client(node).run_chsh( + self.settings.timetagger_address, basis, self.settings.per_game_timeout_s + ) + except NodeApiError as e: + return _failed_section(label, "The Game did not complete.", str(e), time.monotonic() - started) + + verdict = _bell_verdict(result.chsh_value) + return Section( + label=label, + status=verdict.status, + fields=[ + verdict, + # Written out rather than dumped from the model: the error belongs beside the + # value it qualifies, and "S" is what a physicist calls this. + Field(name="S", value=f"{result.chsh_value:.4f} ± {result.chsh_error:.4f}"), + Field(name="Basis", value=f"{basis[0]:.1f}°, {basis[1]:.1f}°"), + Field(name="Expectation values", value=_angles(result.expectation_values)), + Field(name="Sign-fixed expectations", value=_angles(result.expectation_values_sign_fixed)), + Field(name="Expectation errors", value=_angles(result.expectation_errors)), + ], + note=f"{time.monotonic() - started:.1f}s", + ) + + async def _play_fortune(self, node: Node) -> Section: + """Draw one Quantum Fortune, and describe what each channel got or why it could not.""" + label = GAME_TITLES["qf"] + started = time.monotonic() + try: + drawn = await self._client(node).run_fortune( + self.settings.timetagger_address, self.settings.per_game_timeout_s + ) + except NodeApiError as e: + return _failed_section(label, "The Game did not complete.", str(e), time.monotonic() - started) + + return Section( + label=label, + status=Status.OK, + fields=[Field(name="Fortune per channel", value=", ".join(str(number) for number in drawn) or "nothing")], + note=f"{time.monotonic() - started:.1f}s", + ) + async def _initial_params(self, act: Action, node: Node | None) -> dict[str, object]: """Work out what a parameter form should open on. @@ -602,3 +864,97 @@ async def _initial_params(self, act: Action, node: Node | None) -> dict[str, obj except Exception: logger.exception("%s: prefill failed, opening the form on its defaults", act.name) return defaults + + +# -------------------------------------------------------------------------------------- +# Pure helpers for a Node's check-up. Free functions rather than methods, because nothing +# here needs a Node client — which keeps their tests the cheapest in the package. +# -------------------------------------------------------------------------------------- + + +def _angles(values: list[float]) -> str: + """Render a row of measured numbers, four decimals each, one convention for all of them.""" + return ", ".join(f"{value:.4f}" for value in values) + + +def _bell_verdict(chsh_value: float) -> Field: + """State whether the Bell inequality was violated. The digest's one judgement. + + A ``WARN`` rather than a ``FAIL`` when it was not: the hardware answered and the Game ran, + so nothing is broken in the sense the rest of the checklist means. What stopped is the + demonstration of anything quantum, which is a different thing to go and look into. + """ + if chsh_value > BELL_CLASSICAL_LIMIT: + return Field( + name="Bell inequality", + value=f"violated, S = {chsh_value:.4f} is above the classical limit of {BELL_CLASSICAL_LIMIT}", + status=Status.OK, + ) + return Field( + name="Bell inequality", + value=f"not violated, S = {chsh_value:.4f} is within the classical limit of {BELL_CLASSICAL_LIMIT}", + status=Status.WARN, + ) + + +def _component_field(label: str, component: ComponentStatus) -> Field: + """Describe one probed thing as one checklist row, carrying its own status. + + Per-row and not per-section, because a Node with one dead device and nine live ones has to + say which one is dead. + """ + if component.reachable: + value = "reachable" if component.latency_ms is None else f"reachable, {component.latency_ms:.0f}ms" + return Field(name=label, value=value, status=Status.OK) + return Field(name=label, value=component.error or "unreachable", status=Status.FAIL) + + +def _hardware_section(health: HealthStatus) -> Section: + """Describe a Node's hardware, one row per probed thing.""" + fields = [_component_field("Router", health.router)] + fields += [_component_field(f"{d.provider}/{d.name} ({d.purpose})", d) for d in health.devices] + if health.rotary_encoder is None: + # Not probed rather than broken: a Node with a virtual encoder has nothing to probe. + fields.append(Field(name="Rotary encoder", value="virtual, not probed", status=Status.SKIPPED)) + else: + fields.append(_component_field("Rotary encoder", health.rotary_encoder)) + if health.follower_node is not None: + fields.append(_component_field("Follower Node", health.follower_node)) + + return Section(label="Hardware", status=Status.overall(f.status for f in fields), fields=fields) + + +def _failed_section(label: str, summary: str, error: str, elapsed_s: float | None = None) -> Section: + """Describe something that was attempted and did not work.""" + return Section( + label=label, + status=Status.FAIL, + fields=[Field(name="Result", value=summary, status=Status.FAIL)], + note=None if elapsed_s is None else f"{elapsed_s:.1f}s", + error=error, + ) + + +def _timed_out(node: Node, budget_s: float) -> NodeDigest: + """Describe a Node that outlasted the time the digest could give it.""" + return NodeDigest( + name=node.name, + api_url=node.api_url, + status=Status.FAIL, + sections=[ + _failed_section( + "Check-up", + f"This Node did not finish within the {budget_s:.0f}s a digest allows it.", + "The run was cut off, so whatever it had already measured was lost with it.", + ) + ], + ) + + +def _skipped_section(label: str, reason: str) -> Section: + """Describe something that never ran. + + ``SKIPPED`` is not ``WARN``: one means it did not happen, the other that it did and looked + wrong. + """ + return Section(label=label, status=Status.SKIPPED, fields=[Field(name="Skipped", value=reason)]) diff --git a/src/pqn_whobot/whobot_slack.py b/src/pqn_whobot/whobot_slack.py index 4472570..fdcddad 100644 --- a/src/pqn_whobot/whobot_slack.py +++ b/src/pqn_whobot/whobot_slack.py @@ -29,6 +29,9 @@ from pqn_whobot.actions import Action from pqn_whobot.actions import ActionResult +from pqn_whobot.actions import DigestResult +from pqn_whobot.actions import NodeDigest +from pqn_whobot.actions import Parameter from pqn_whobot.actions import PayloadError from pqn_whobot.actions import PendingInvocation from pqn_whobot.actions import ReplyHandle @@ -54,6 +57,13 @@ FIELDS_PER_SECTION = 10 """Slack's cap on a section's ``fields`` grid. Actions emit one Section; this splits it.""" +BLOCK_LIMIT = 50 +"""Slack's cap on the blocks in one message, and the only result that can reach it is the digest. + +Its size grows with the fleet, and Slack answers an over-long message with a bare +``invalid_blocks`` — so without this the whole digest is lost rather than its tail. Four Nodes +fit comfortably; ten do not.""" + IMAGE_SUFFIXES = ((b"\x89PNG\r\n\x1a\n", "png"), (b"GIF8", "gif"), (b"\xff\xd8\xff", "jpg")) """Magic numbers, so an upload can be named after what it actually is. @@ -84,6 +94,11 @@ PARAMS_BLOCK = "whobot_params_block" +def _param_block(name: str) -> str: + """Name the block a single-parameter widget lives in; Slack echoes it back as the answer's key.""" + return f"whobot_param_{name}" + + @dataclass(frozen=True) class SlackReply(ReplyHandle): """Where a reply goes, and how. The base class never opens one of these. @@ -216,8 +231,8 @@ async def _form_submitted(ack: Any, body: dict[str, Any]) -> None: # the form against, so let dispatch re-render with its note. await self._safely(replace(pending, params=None), reply) return - values = view.get("state", {}).get("values", {}).get(PARAMS_BLOCK, {}) - await self._safely(replace(pending, params=self._read_checkboxes(act, values)), reply) + values = view.get("state", {}).get("values", {}) + await self._safely(replace(pending, params=self._read_form(act, values)), reply) async def _from_interaction(self, body: dict[str, Any]) -> None: """Decode the widget the operator just used and continue the flow.""" @@ -243,21 +258,31 @@ def _reply_from(body: dict[str, Any], *, replace: bool = False) -> SlackReply: ) @staticmethod - def _read_checkboxes(act: Action, values: dict[str, Any]) -> dict[str, object]: - """Read a checkbox group back as one boolean per declared parameter. + def _read_form(act: Action, values: dict[str, Any]) -> dict[str, object]: + """Read a submitted form back as one value per declared parameter. - Slack reports only the *ticked* boxes, so an unticked one arrives as an absence + Slack reports only the *ticked* checkboxes, so an unticked one arrives as an absence rather than a ``False``. A submitted view echoes nothing else either — in particular **not** the ``initial_options`` it was rendered with — so the False floor has to come from what the Action declares. Without it a cleared box is missing from the payload, takes its default in ``coerce_params``, and switches the flag back *on*. + + A number input needs no floor: an empty one is genuinely "no answer", and leaving it out + lets ``coerce_params`` supply the default. """ params: dict[str, object] = { parameter.name: False for parameter in act.parameters if parameter.annotation is bool } - for element in values.values(): + for element in values.get(PARAMS_BLOCK, {}).values(): for option in element.get("selected_options", []) or []: params[option["value"]] = True + + for parameter in act.parameters: + if parameter.annotation is not float: + continue + typed = (values.get(_param_block(parameter.name), {}).get(_param_block(parameter.name)) or {}).get("value") + if typed: + params[parameter.name] = typed return params async def _safely(self, pending: PendingInvocation, reply: SlackReply) -> None: @@ -356,21 +381,31 @@ async def ask_for_params( "submit": {"type": "plain_text", "text": "Run"}, "close": {"type": "plain_text", "text": "Cancel"}, "private_metadata": metadata, - "blocks": [self._checkbox_block(act, initial)], + "blocks": self._form_blocks(act, initial), }, ) - @staticmethod - def _checkbox_block(act: Action, initial: dict[str, object]) -> Block: - """Render every parameter as a checkbox. The only widget mapping that exists. + @classmethod + def _form_blocks(cls, act: Action, initial: dict[str, object]) -> list[Block]: + """Generate the whole form from the Action's parameters. - The scan has already refused any parameter type without a mapping, so reaching here - with something other than a ``bool`` is a bug in the scan rather than a bad Action. + Every ``bool`` shares one checkbox group, because "which of these are on" is one + question; every ``float`` gets an input of its own. The scan has already refused any + parameter type without a mapping, so a parameter reaching here that is neither is a bug + in the scan rather than a bad Action. """ - options = [_option(parameter.name.upper(), parameter.name) for parameter in act.parameters] + booleans = [p for p in act.parameters if p.annotation is bool] + blocks = [cls._checkbox_block(booleans, initial)] if booleans else [] + blocks += [cls._number_block(p, initial) for p in act.parameters if p.annotation is float] + return blocks + + @staticmethod + def _checkbox_block(parameters: list[Parameter], initial: dict[str, object]) -> Block: + """Render the ``bool`` parameters as one checkbox group.""" + options = [_option(parameter.name.upper(), parameter.name) for parameter in parameters] ticked = [ _option(parameter.name.upper(), parameter.name) - for parameter in act.parameters + for parameter in parameters if initial.get(parameter.name, parameter.default) ] element: Block = {"type": "checkboxes", "action_id": PARAMS_BLOCK, "options": options} @@ -385,6 +420,26 @@ def _checkbox_block(act: Action, initial: dict[str, object]) -> Block: "element": element, } + @staticmethod + def _number_block(parameter: Parameter, initial: dict[str, object]) -> Block: + """Render one ``float`` parameter as a number input, opened on its starting value. + + Optional, so an operator who clears it gets the Action's default rather than a form + that refuses to submit. + """ + return { + "type": "input", + "block_id": _param_block(parameter.name), + "optional": True, + "label": {"type": "plain_text", "text": parameter.name.replace("_", " ").title()}, + "element": { + "type": "number_input", + "action_id": _param_block(parameter.name), + "is_decimal_allowed": True, + "initial_value": str(initial.get(parameter.name, parameter.default)), + }, + } + async def ask_to_confirm(self, act: Action, pending: PendingInvocation, handle: ReplyHandle) -> None: """Name the target before anything happens. A dropdown pick must never act.""" reply = self._slack(handle) @@ -468,16 +523,7 @@ def _render(self, result: ActionResult) -> list[Block]: @_render.register def _render_report(self, result: Report) -> list[Block]: - blocks: list[Block] = [ - { - "type": "header", - "text": { - "type": "plain_text", - "text": f"{self._emoji(result.status)} {result.title}"[:HEADER_LIMIT], - "emoji": True, - }, - } - ] + blocks: list[Block] = [self._header(result.status, result.title)] if result.summary: blocks.append(_section(_escape(result.summary))) for section in result.sections: @@ -486,6 +532,50 @@ def _render_report(self, result: Report) -> list[Block]: blocks.append(_context("\n".join(_escape(note) for note in result.notes))) return blocks + @_render.register + def _render_digest(self, result: DigestResult) -> list[Block]: + """Render the digest: a divider and a Node line, then that Node's ordinary Sections. + + Everything about how a Section looks is inherited, so this adds only the grouping a flat + result cannot express. A second header block per Node was tried and reads badly — Slack + headers are all one size, so four Nodes look like four messages glued together. + """ + blocks: list[Block] = [self._header(result.status, result.title)] + if result.summary: + blocks.append(_section(_escape(result.summary))) + footer = [_context("\n".join(_escape(note) for note in result.notes))] if result.notes else [] + + # One spare block for saying what was dropped, which must itself fit inside the limit. + budget = BLOCK_LIMIT - len(footer) - 1 + for position, node in enumerate(result.nodes): + rendered = self._render_node(node) + if len(blocks) + len(rendered) > budget: + omitted = [n.name for n in result.nodes[position:]] + logger.warning("digest over Slack's %s-block limit; omitted %s", BLOCK_LIMIT, omitted) + blocks.append( + _context(f"{len(omitted)} Nodes omitted, over Slack's message limit: {', '.join(omitted)}") + ) + break + blocks += rendered + + return blocks + footer + + def _render_node(self, node: NodeDigest) -> list[Block]: + """One Node's part of the digest: a rule, a line naming it, and what was checked.""" + return [ + {"type": "divider"}, + _section(f"{self._emoji(node.status)} *{_escape(node.name)}* — {_escape(node.api_url)}"), + *(block for section in node.sections for block in self._render_section(section)), + ] + + @classmethod + def _header(cls, status: Status, title: str) -> Block: + """Slack truncates nothing itself: an over-long header is rejected, not shortened.""" + return { + "type": "header", + "text": {"type": "plain_text", "text": f"{cls._emoji(status)} {title}"[:HEADER_LIMIT], "emoji": True}, + } + @classmethod def _render_section(cls, section: Section) -> list[Block]: """Render one Section, splitting its measurements across Slack's ten-field cap.""" diff --git a/tests/pytest/test_whobot_config.py b/tests/pytest/test_whobot_config.py index d8eee93..99bd31c 100644 --- a/tests/pytest/test_whobot_config.py +++ b/tests/pytest/test_whobot_config.py @@ -10,6 +10,7 @@ import pytest from pydantic import ValidationError +from pqn_whobot.config import NodeEntry from pqn_whobot.config import WhobotSettings from pqn_whobot.config import config_path @@ -23,7 +24,6 @@ schedule_hour = 7 # morning digest schedule_minute = 0 -per_node_timeout_s = 900 per_game_timeout_s = 600 # The Node Registry. @@ -111,6 +111,42 @@ def test_defaults_are_host_agnostic() -> None: assert "localhost" not in settings.model_dump_json() +def test_how_a_node_is_measured_is_one_answer_for_the_whole_network(tmp_path: Path) -> None: + """Not per-Node: a registry entry holds an address and nothing about measuring it. + + ``timetagger_address`` is resolved on the Node — it builds + ``http://{timetagger_address}/timetagger/...`` — so one value serves every Node, and + ``basis`` is a measurement choice that belongs to the Network rather than to a machine. + """ + (tmp_path / "whobot.toml").write_text( + 'timetagger_address = "10.0.0.5:9000"\nbasis = [11.0, 33.5]\n' + '\n[[nodes]]\napi_url = "http://node-a.invalid:9000"\n', + encoding="utf-8", + ) + + settings = WhobotSettings() + + assert settings.timetagger_address == "10.0.0.5:9000" + assert settings.basis == (11.0, 33.5) + assert set(NodeEntry.model_fields) == {"api_url"} + + +def test_the_timetagger_default_is_the_nodes_own_host_not_whobots(tmp_path: Path) -> None: + """The one loopback default in Whobot, and it is not a host assumption. + + Whobot never dials this address: it hands the string to a Node, which resolves it. So + 127.0.0.1 means *that Node's* host, and a fleet of Nodes each measuring with their own + timetagger needs no per-Node configuration at all. Nothing here may assume which machine + Whobot runs on, and this does not. + """ + (tmp_path / "whobot.toml").write_text("", encoding="utf-8") + + settings = WhobotSettings() + + assert settings.timetagger_address == "127.0.0.1:9000" + assert "localhost" not in settings.model_dump_json() + + def test_unknown_timezone_is_rejected(tmp_path: Path) -> None: (tmp_path / "whobot.toml").write_text('schedule_timezone = "Mars/Olympus_Mons"\n', encoding="utf-8") @@ -123,9 +159,13 @@ def test_unknown_timezone_is_rejected(tmp_path: Path) -> None: [ ("schedule_hour = 24\n", "schedule_hour"), ("schedule_minute = -1\n", "schedule_minute"), - ("per_node_timeout_s = 0\n", "per_node_timeout_s"), + ("per_game_timeout_s = 0\n", "per_game_timeout_s"), ('slack_bot_tokn = "typo"\n', "slack_bot_tokn"), ('[[nodes]]\napi_url = "node-a.invalid:9000"\n', "api_url"), + # `POST /chsh/` declares `basis: tuple[float, float]`, so a third angle is a typo that + # would otherwise be found by the Node refusing an unattended run at 07:00. + ("basis = [0.0, 22.5, 45.0]\n", "basis"), + ("basis = [0.0]\n", "basis"), ], ) def test_invalid_values_name_the_offending_key(tmp_path: Path, body: str, expected: str) -> None: @@ -136,27 +176,15 @@ def test_invalid_values_name_the_offending_key(tmp_path: Path, body: str, expect WhobotSettings() -def test_a_reboot_wait_the_action_cannot_outlast_is_rejected(tmp_path: Path) -> None: - """A wait longer than the Action's own timeout loses the report it exists to produce. +def test_a_removed_key_is_refused_rather_than_ignored(tmp_path: Path) -> None: + """`per_node_timeout_s` is derived now, so a file still setting it must say so, not be ignored. - ``execute`` would cut the run off at the Action's ``timeout_s`` and post "Timed out", - instead of the "still down after N minutes" that tells an operator to go and look at the - machine. Refusing it at load is the only place that can be said, since an Action's - ``timeout_s`` is fixed when the class is created. + ``extra="forbid"`` is what makes this loud: a key Whobot no longer reads would otherwise sit + in the file looking like it was doing something. """ - (tmp_path / "whobot.toml").write_text("reboot_wait_s = 600\n", encoding="utf-8") - - with pytest.raises(ValidationError, match=r"reboot_wait_s.*no time to report"): - WhobotSettings() - - -def test_the_reboot_wait_is_measured_against_the_calls_around_it(tmp_path: Path) -> None: - """The wait shares the Action's budget with the reboot call and the last poll of the wait.""" - (tmp_path / "whobot.toml").write_text( - "reboot_wait_s = 331\nnode_timeout_s = 20\nreachability_timeout_s = 10\n", encoding="utf-8" - ) + (tmp_path / "whobot.toml").write_text("per_node_timeout_s = 900\n", encoding="utf-8") - with pytest.raises(ValidationError, match="at most 330s"): + with pytest.raises(ValidationError, match="per_node_timeout_s"): WhobotSettings() diff --git a/tests/pytest/test_whobot_flow.py b/tests/pytest/test_whobot_flow.py index e8a6c35..a15ae40 100644 --- a/tests/pytest/test_whobot_flow.py +++ b/tests/pytest/test_whobot_flow.py @@ -14,23 +14,31 @@ """ import asyncio +import json import re from collections.abc import Callable from collections.abc import Iterator from dataclasses import dataclass from dataclasses import field +from dataclasses import replace from pathlib import Path +from typing import ClassVar import httpx import pytest +from pqn_node.api.routes.health import HealthStatus from pqn_node.core.config import GamesAvailability +from pqn_whobot.actions import DEFAULT_TIMEOUT_S from pqn_whobot.actions import Action from pqn_whobot.actions import ActionResult +from pqn_whobot.actions import DigestResult +from pqn_whobot.actions import NodeDigest from pqn_whobot.actions import PendingInvocation from pqn_whobot.actions import ReplyHandle from pqn_whobot.actions import Report from pqn_whobot.actions import Scope +from pqn_whobot.actions import Section from pqn_whobot.actions import Status from pqn_whobot.actions import action from pqn_whobot.actions import prefill @@ -38,7 +46,12 @@ from pqn_whobot.config import WhobotSettings from pqn_whobot.node_client import NodeClient from pqn_whobot.registry import Node +from pqn_whobot.whobot import GAME_TITLES from pqn_whobot.whobot import Whobot +from pqn_whobot.whobot import _bell_verdict +from pqn_whobot.whobot import _hardware_section +from pqn_whobot.whobot import one_game_budget +from pqn_whobot.whobot import one_node_budget ALICE = "http://node-a.invalid:9000" BOB = "http://node-b.invalid:9000" @@ -167,8 +180,8 @@ class ClientSpy(FlowSpy): handler: Callable[[httpx.Request], httpx.Response] - def _client(self, node: Node, timeout_s: float | None = None) -> NodeClient: - return NodeClient(node.api_url, timeout_s or 5.0, transport=httpx.MockTransport(self.handler)) + def _client(self, node: Node) -> NodeClient: + return NodeClient(node.api_url, transport=httpx.MockTransport(self.handler)) # -------------------------------------------------------------------------------------- @@ -790,18 +803,36 @@ def handler(request: httpx.Request) -> httpx.Response: # noqa: ARG001 SHORTCODE = re.compile(r":[a-z0-9_+-]+:") -def _human_strings(report: Report) -> list[str]: - """Every string an operator reads, except ``Section.error``, which may hold a traceback.""" - strings = [report.title, *([report.summary] if report.summary else []), *report.notes] - for section in report.sections: +def _section_strings(sections: list[Section]) -> list[str]: + strings = [] + for section in sections: strings += [text for text in (section.label, section.note) if text] strings += [f.name for f in section.fields] strings += [f.value for f in section.fields] return strings -def assert_no_markup(report: Report) -> None: - for text in _human_strings(report): +def _human_strings(result: ActionResult) -> list[str]: + """Every string an operator reads, except ``Section.error``, which may hold a traceback. + + Takes an ``ActionResult`` and walks whichever shape it is, so the rule stays stated in one + place however many result shapes the package grows. It asserts on a shape it has no walker + for, which is what stops a third result type quietly escaping the rule. + """ + if isinstance(result, Report): + strings = [result.title, *([result.summary] if result.summary else []), *result.notes] + return strings + _section_strings(result.sections) + if isinstance(result, DigestResult): + strings = [result.title, *([result.summary] if result.summary else []), *result.notes] + for node in result.nodes: + strings += [node.name, node.api_url, *_section_strings(node.sections)] + return strings + msg = f"no walker for {type(result).__name__}, so the no-markup rule would silently skip it" + raise AssertionError(msg) + + +def assert_no_markup(result: ActionResult) -> None: + for text in _human_strings(result): assert "*" not in text, text assert "`" not in text, text assert not SHORTCODE.search(text), text @@ -822,6 +853,21 @@ def test_no_action_output_contains_platform_markup(pending: PendingInvocation) - assert_no_markup(only_report(run(spy(ALICE, BOB), pending))) +def test_a_check_ups_output_carries_no_platform_markup() -> None: + """The result with the most strings in it, and the only one with two levels of them.""" + assert_no_markup(checked(with_node_api(node_api(), ALICE))) + + +def test_the_rule_refuses_a_result_shape_it_cannot_walk() -> None: + """Otherwise a third result type would pass every neutrality test by not being read.""" + + class Bespoke(ActionResult): + pass + + with pytest.raises(AssertionError, match="no walker"): + assert_no_markup(Bespoke()) + + @pytest.mark.usefixtures("_reboot_without_the_waiting") def test_the_reboot_report_carries_no_platform_markup() -> None: """The Action most tempted by decoration — a Node's address in backticks — must resist it.""" @@ -838,3 +884,404 @@ def test_the_debug_screenshot_report_carries_no_platform_markup(tmp_path: Path) bot = debug_bot(image, ALICE) asyncio.run(dispatched(bot, PendingInvocation(action="screenshot", node_url=ALICE))) assert_no_markup(only_report(bot)) + + +# -------------------------------------------------------------------------------------- +# Checking a Node over: the Games, the hardware checklist, and the whole check-up that the +# Daily Digest fans out. Driven through the Actions, because that is how they are reached. +# -------------------------------------------------------------------------------------- + +HEALTHY = { + "router": {"reachable": True, "latency_ms": 3.2}, + "devices": [{"reachable": True, "provider": "prov", "name": "tagger", "purpose": "counting", "latency_ms": 8.0}], + "rotary_encoder": None, + "follower_node": {"reachable": True, "latency_ms": 12.0}, +} + +CHSH = { + "chsh_value": 2.6134919, + "chsh_error": 0.04, + "expectation_values": [0.7, -0.65], + "expectation_errors": [0.01, 0.01], + "expectation_values_sign_fixed": [0.7, 0.65], +} + +ALL_GAMES_ON = {"chsh": True, "qf": True, "ssm": True} + + +def node_api( + *, + availability: object = ALL_GAMES_ON, + refuse: dict[str, int] | None = None, + seen: list[httpx.Request] | None = None, +) -> Callable[[httpx.Request], httpx.Response]: + """Answer every endpoint a check-up calls, with any of them able to refuse instead.""" + refusals = refuse or {} + answers: dict[str, object] = { + "/health/": HEALTHY, + "/games/availability": availability, + "/chsh/": CHSH, + "/rng/fortune": [42, 137], + } + + def handler(request: httpx.Request) -> httpx.Response: + if seen is not None: + seen.append(request) + path = request.url.path + if path in refusals: + return httpx.Response(refusals[path], json={"detail": f"{path} is not having it"}) + if path not in answers: + return httpx.Response(404) + return httpx.Response(200, json=answers[path]) + + return handler + + +def checked(bot: ClientSpy, action_name: str = "check_node", **params: object) -> DigestResult: + """Run one check-up Action and return the digest it posted.""" + act = bot.actions[action_name] + node_url = ALICE if act.scope is Scope.ONE else None + pending = PendingInvocation(action=action_name, node_url=node_url, params=params or None) + asyncio.run(dispatched(bot, pending)) + assert len(bot.drawn.results) == 1 + result = bot.drawn.results[0] + assert isinstance(result, DigestResult) + return result + + +def sections_of(digest: DigestResult) -> dict[str, Section]: + assert len(digest.nodes) == 1 + return {section.label or "": section for section in digest.nodes[0].sections} + + +# --- the pure helpers, which need no Node at all --- + + +def test_every_probed_thing_gets_its_own_row() -> None: + """A Node with one dead device among nine live ones has to say which one is dead.""" + unhealthy = HealthStatus.model_validate( + { + **HEALTHY, + "devices": [ + {"reachable": True, "provider": "prov", "name": "tagger", "purpose": "counting"}, + {"reachable": False, "provider": "prov", "name": "hwp", "purpose": "rotating", "error": "timed out"}, + ], + } + ) + + hardware = _hardware_section(unhealthy) + rows = {f.name: f for f in hardware.fields} + + assert hardware.status is Status.FAIL + assert rows["prov/tagger (counting)"].status is Status.OK + assert rows["prov/hwp (rotating)"].status is Status.FAIL + assert "timed out" in rows["prov/hwp (rotating)"].value + + +def test_a_virtual_rotary_encoder_is_skipped_not_failed() -> None: + """Nothing was probed, so it is neither working nor broken, and a skip is not bad news.""" + hardware = _hardware_section(HealthStatus.model_validate(HEALTHY)) + + assert next(f for f in hardware.fields if f.name == "Rotary encoder").status is Status.SKIPPED + assert hardware.status is Status.OK + + +def test_a_violated_bell_inequality_is_the_good_news() -> None: + verdict = _bell_verdict(2.61) + + assert verdict.status is Status.OK + assert "violated" in verdict.value + + +def test_an_unviolated_bell_inequality_warns_rather_than_fails() -> None: + """The hardware answered and the Game ran; what stopped is the demonstration itself.""" + verdict = _bell_verdict(1.94) + + assert verdict.status is Status.WARN + assert "not violated" in verdict.value + + +# --- one Node's whole check-up --- + + +def test_a_healthy_node_reports_its_hardware_and_both_games() -> None: + digest = checked(with_node_api(node_api(), ALICE)) + + assert digest.status is Status.OK + assert list(sections_of(digest)) == ["Hardware", *GAME_TITLES.values()] + chsh = sections_of(digest)[GAME_TITLES["chsh"]] + assert next(f.value for f in chsh.fields if f.name == "S") == "2.6135 ± 0.0400" + assert chsh.note is not None, "a Game reports how long it took" + + +def test_ssm_is_always_skipped_and_says_why() -> None: + """It needs an interactive coordination dance, so no unattended run can play it.""" + ssm = sections_of(checked(with_node_api(node_api(), ALICE)))[GAME_TITLES["ssm"]] + + assert ssm.status is Status.SKIPPED + assert "coordination dance" in ssm.fields[0].value + + +def test_a_failed_hardware_probe_does_not_stop_the_games() -> None: + """The Games are what prove the physics still works, so they are still worth attempting.""" + digest = checked(with_node_api(node_api(refuse={"/health/": 500}), ALICE)) + + assert digest.status is Status.FAIL + assert sections_of(digest)["Hardware"].error is not None + assert sections_of(digest)[GAME_TITLES["chsh"]].status is Status.OK + + +def test_a_failed_game_carries_the_nodes_own_reason() -> None: + digest = checked(with_node_api(node_api(refuse={"/chsh/": 503}), ALICE)) + + chsh = sections_of(digest)[GAME_TITLES["chsh"]] + assert chsh.status is Status.FAIL + assert "is not having it" in str(chsh.error) + assert chsh.note is not None, "how long it ran before failing is worth knowing" + assert sections_of(digest)[GAME_TITLES["qf"]].status is Status.OK, "one Game failing does not stop the next" + + +def test_a_game_the_node_does_not_offer_is_skipped_rather_than_attempted() -> None: + digest = checked(with_node_api(node_api(availability={"chsh": False, "qf": True, "ssm": True}), ALICE)) + + assert sections_of(digest)[GAME_TITLES["chsh"]].status is Status.SKIPPED + assert digest.status is Status.OK, "a Game switched off on purpose is not bad news" + + +def test_availability_that_cannot_be_read_skips_every_game() -> None: + """Whether a Game may run is the Node's answer to give. + + Assuming all-enabled is how an unattended run plays a Game an operator deliberately + switched off, so the error is reported on each Game instead. + """ + digest = checked(with_node_api(node_api(refuse={"/games/availability": 500}), ALICE)) + + for title in GAME_TITLES.values(): + skipped = sections_of(digest)[title] + assert skipped.status is Status.SKIPPED + assert "could not read" in sections_of(digest)[GAME_TITLES["chsh"]].fields[0].value + + +def test_an_unreachable_node_is_one_section_and_no_calls() -> None: + """Probe nothing on a Node the registry already reported as unreachable. + + Spending both Games' timeouts to rediscover it would make one unplugged Node the slowest + part of the whole digest. + """ + seen: list[httpx.Request] = [] + bot = with_node_api(node_api(seen=seen), ALICE) + FLEET[:] = [Node(api_url=ALICE, reachable=False, error="ConnectError: refused")] + + digest = checked(bot) + + assert seen == [] + assert digest.status is Status.FAIL + assert len(digest.nodes[0].sections) == 1 + assert "refused" in str(digest.nodes[0].sections[0].error) + + +def test_the_configured_measurement_values_are_what_an_unattended_run_uses() -> None: + """`timetagger_address` and `basis` come from Whobot's config, not from the Node.""" + seen: list[httpx.Request] = [] + bot = with_node_api(node_api(seen=seen), ALICE, timetagger_address="10.0.0.5:9000", basis=(11.0, 33.5)) + + checked(bot) + + chsh = next(r for r in seen if r.url.path == "/chsh/") + fortune = next(r for r in seen if r.url.path == "/rng/fortune") + assert chsh.url.params["timetagger_address"] == "10.0.0.5:9000" + assert fortune.url.params["timetagger_address"] == "10.0.0.5:9000" + assert json.loads(chsh.content) == [11.0, 33.5] + + +# --- the two Game Actions an operator drives by hand --- + + +def test_running_chsh_by_hand_measures_at_the_angles_asked_for() -> None: + """The whole reason the Action takes parameters: an interactive run is aimed at something.""" + seen: list[httpx.Request] = [] + bot = with_node_api(node_api(seen=seen), ALICE, basis=(0.0, 22.5)) + + pending = PendingInvocation(action="run_chsh", node_url=ALICE, params={"angle_a": 15.0, "angle_b": 60.0}) + asyncio.run(dispatched(bot, pending)) + + assert json.loads(next(r for r in seen if r.url.path == "/chsh/").content) == [15.0, 60.0] + assert only_report(bot).status is Status.OK + + +def test_the_chsh_form_opens_on_the_angles_an_unattended_run_would_use() -> None: + """So the operator sees what the digest measures, and changes it deliberately.""" + bot = with_node_api(node_api(), ALICE, basis=(11.0, 33.5)) + + asyncio.run(dispatched(bot, PendingInvocation(action="run_chsh", node_url=ALICE))) + + assert bot.drawn.initial == {"angle_a": 11.0, "angle_b": 33.5} + + +def test_running_a_fortune_by_hand_reports_what_each_channel_drew() -> None: + bot = with_node_api(node_api(), ALICE) + + asyncio.run(dispatched(bot, PendingInvocation(action="run_fortune", node_url=ALICE))) + + result = only_report(bot) + assert result.status is Status.OK + assert result.sections[0].fields[0].value == "42, 137" + + +# -------------------------------------------------------------------------------------- +# What an Action is allowed to take, which is worked out from the configuration it spends +# rather than fixed when the class is created. +# -------------------------------------------------------------------------------------- + + +def test_a_declared_number_is_what_the_action_gets() -> None: + """Most Actions want a plain bound, and those keep working unchanged.""" + act = FlowSpy.actions["screenshot"] + + assert act.timeout_for(settings_for()) == pytest.approx(60.0) + + +def test_a_budget_follows_the_configuration_it_spends() -> None: + """Raise the wait and the Reboot Action is allowed longer, with nothing else to edit. + + This is what replaced the validator: the Action's bound *is* the sum of the calls it makes, + so a config edit cannot leave it too small to report what it found. + """ + act = FlowSpy.actions["reboot"] + patient = settings_for(reboot_wait_s=1800.0) + + assert act.timeout_for(settings_for()) == pytest.approx(30.0 + 300.0 + 5.0) + assert act.timeout_for(patient) == pytest.approx(30.0 + 1800.0 + 5.0) + + +def test_a_game_action_is_allowed_the_game_plus_the_call_that_starts_it() -> None: + generous = settings_for(per_game_timeout_s=900.0) + + assert FlowSpy.actions["run_chsh"].timeout_for(generous) == pytest.approx(930.0) + assert one_game_budget(generous) == pytest.approx(930.0) + + +def test_checking_a_node_is_allowed_both_its_games() -> None: + """The disagreement this deletes: 900s of budget for 1200s of Games, chosen by nobody.""" + settings = settings_for() + + assert one_node_budget(settings) == pytest.approx(2 * 30.0 + 2 * 600.0) + assert FlowSpy.actions["check_node"].timeout_for(settings) == pytest.approx(one_node_budget(settings)) + assert one_node_budget(settings) >= 2 * settings.per_game_timeout_s, "a Node's Games must fit its budget" + + +def test_a_budget_that_cannot_be_worked_out_falls_back_rather_than_refusing_to_run() -> None: + """An announcement must always be followed by a result, including when the arithmetic is wrong.""" + + def broken(_settings: WhobotSettings) -> float: + raise ZeroDivisionError + + act = replace(FlowSpy.actions["screenshot"], timeout_s=broken) + + assert act.timeout_for(settings_for()) == pytest.approx(DEFAULT_TIMEOUT_S) + + +# -------------------------------------------------------------------------------------- +# The whole-fleet digest. +# -------------------------------------------------------------------------------------- + + +def test_the_digest_reports_every_registered_node() -> None: + digest = checked(with_node_api(node_api(), ALICE, BOB), "run_digest") + + assert [node.api_url for node in digest.nodes] == [ALICE, BOB] + assert digest.summary == "2 of 2 Nodes reported no problems" + assert digest.status is Status.OK + + +def test_nodes_are_checked_one_at_a_time() -> None: + """A requirement, not simplicity: in two-Node CHSH one Node is another's follower. + + Concurrent runs would contend for the same follower and the same timetagger, so the numbers + would be worthless. One Node's check-up must therefore *finish* before the next one starts. + + Instrumented around ``_probe_node`` rather than around the HTTP calls, because the mock + transport answers without yielding to the event loop — so a digest rewritten as + ``asyncio.gather`` would still produce perfectly grouped requests and a test watching those + would pass. The ``sleep(0)`` is what makes the difference observable: under ``gather`` the + order becomes start, start, end, end. + """ + + class Recording(ClientSpy): + order: ClassVar[list[str]] = [] + + async def _probe_node(self, node: Node) -> NodeDigest: + self.order.append(f"start {node.name}") + await asyncio.sleep(0) + digest = await super()._probe_node(node) + self.order.append(f"end {node.name}") + return digest + + bot = Recording(settings_for(ALICE, BOB)) + bot.handler = node_api() + + checked(bot, "run_digest") + + assert Recording.order == [ + "start uiuc-public-left", + "end uiuc-public-left", + "start ufl-public-right", + "end ufl-public-right", + ] + + +def test_one_unreachable_node_does_not_stop_the_others() -> None: + """The whole reason a failure is a section rather than an exception.""" + bot = with_node_api(node_api(), ALICE, BOB) + FLEET[:] = [Node(api_url=ALICE, reachable=False, error="ConnectError: refused"), REACHABLE[1]] + + digest = checked(bot, "run_digest") + + assert [node.status for node in digest.nodes] == [Status.FAIL, Status.OK] + assert digest.summary == "1 of 2 Nodes reported no problems" + assert digest.status is Status.FAIL + + +def test_a_node_that_outlasts_its_budget_is_one_section_and_the_run_continues() -> None: + """One machine must not be able to spend the whole fleet's time.""" + + class SlowFirstNode(ClientSpy): + async def _probe_node(self, node: Node) -> NodeDigest: + if node.api_url == ALICE: + await asyncio.sleep(10) + return await super()._probe_node(node) + + bot = SlowFirstNode(settings_for(ALICE, BOB, node_timeout_s=0.001, per_game_timeout_s=0.001)) + bot.handler = node_api() + + digest = checked(bot, "run_digest") + + assert digest.nodes[0].status is Status.FAIL + assert "did not finish" in str(digest.nodes[0].sections[0].fields[0].value) + assert digest.nodes[1].status is Status.OK, "the next Node is still checked" + + +def test_a_digest_with_no_nodes_says_so_rather_than_reporting_nothing() -> None: + bot = ClientSpy(settings_for()) + bot.handler = node_api() + + digest = checked(bot, "run_digest") + + assert digest.status is Status.WARN + assert digest.nodes == [] + assert "No Nodes are registered" in str(digest.summary) + + +def test_the_digests_budget_grows_with_the_registry() -> None: + """What the callable timeout is for: adding a Node must not need a constant revisited.""" + act = FlowSpy.actions["run_digest"] + + two = act.timeout_for(settings_for(ALICE, BOB)) + four = act.timeout_for(settings_for(ALICE, BOB, "http://c.invalid:9000", "http://d.invalid:9000")) + + assert two == pytest.approx(2 * one_node_budget(settings_for()) + 30.0) + assert four - two == pytest.approx(2 * one_node_budget(settings_for())) + + +def test_the_digests_output_carries_no_platform_markup() -> None: + assert_no_markup(checked(with_node_api(node_api(), ALICE, BOB), "run_digest")) diff --git a/tests/pytest/test_whobot_registry.py b/tests/pytest/test_whobot_registry.py index 71f5d7e..9361782 100644 --- a/tests/pytest/test_whobot_registry.py +++ b/tests/pytest/test_whobot_registry.py @@ -6,6 +6,7 @@ """ import asyncio +import json from collections.abc import Callable from pathlib import Path @@ -60,8 +61,8 @@ def resolve_all(settings: WhobotSettings, transport: httpx.MockTransport) -> lis """Resolve a registry with every client's transport swapped for the mock.""" async def run() -> list[Node]: - clients = [NodeClient(entry.api_url, settings.reachability_timeout_s, transport) for entry in settings.nodes] - return list(await asyncio.gather(*(resolve_node(client) for client in clients))) + clients = [NodeClient(entry.api_url, transport) for entry in settings.nodes] + return list(await asyncio.gather(*(resolve_node(c, settings.reachability_timeout_s) for c in clients))) return asyncio.run(run()) @@ -162,21 +163,120 @@ def hangs(request: httpx.Request) -> httpx.Response: def test_the_client_reads_the_nodes_own_config() -> None: - client = NodeClient(ALICE, timeout_s=5.0, transport=node_api(_by_name)) + client = NodeClient(ALICE, transport=node_api(_by_name)) - config = asyncio.run(client.get_config()) + config = asyncio.run(client.get_config(5.0)) assert config.node_name == "uiuc-public-left" assert config.follower_node_address is None def test_the_client_raises_one_error_type_for_every_failure() -> None: - client = NodeClient(DEAD, timeout_s=5.0, transport=node_api(_by_name)) + client = NodeClient(DEAD, transport=node_api(_by_name)) with pytest.raises(NodeApiError): - asyncio.run(client.get_config()) + asyncio.run(client.get_config(5.0)) def test_the_client_normalises_a_trailing_slash() -> None: """So paths can be appended without producing a double slash.""" - assert NodeClient(f"{ALICE}/", timeout_s=5.0).api_url == ALICE + assert NodeClient(f"{ALICE}/").api_url == ALICE + + +# The three calls the digest makes: a hardware probe, and the two Games it runs for real. + +HEALTHY = { + "router": {"reachable": True, "latency_ms": 3.2}, + "devices": [{"reachable": True, "provider": "prov", "name": "tagger", "purpose": "counting", "latency_ms": 8.0}], + "rotary_encoder": None, + "follower_node": {"reachable": True, "latency_ms": 12.0}, +} + +CHSH = { + "chsh_value": 2.61, + "chsh_error": 0.04, + "expectation_values": [0.7, -0.65, 0.66, 0.6], + "expectation_errors": [0.01, 0.01, 0.01, 0.01], + "expectation_values_sign_fixed": [0.7, -0.65, 0.66, -0.6], +} + + +def client_for(handler: Callable[[httpx.Request], httpx.Response]) -> NodeClient: + return NodeClient(ALICE, transport=node_api(handler)) + + +def test_the_client_reads_a_hardware_health_probe() -> None: + """`GET /health/` takes no parameters — the Node reads its follower's address itself.""" + seen: list[httpx.Request] = [] + + def probe(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json=HEALTHY) + + health = asyncio.run(client_for(probe).get_health(30.0)) + + assert seen[0].url.path == "/health/" + assert not seen[0].url.params + assert health.all_ok is True + assert health.devices[0].purpose == "counting" + + +def test_running_chsh_sends_the_basis_as_the_body_and_the_timetagger_as_a_parameter() -> None: + """Which is what the endpoint's own signature asks for; getting it wrong is a 422.""" + seen: list[httpx.Request] = [] + + def measure(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json=CHSH) + + result = asyncio.run(client_for(measure).run_chsh("10.0.0.5:9000", (0.0, 22.5), 30.0)) + + assert seen[0].method == "POST" + assert seen[0].url.path == "/chsh/" + assert seen[0].url.params["timetagger_address"] == "10.0.0.5:9000" + assert json.loads(seen[0].content) == [0.0, 22.5] + assert result.chsh_value == pytest.approx(2.61) + + +def test_running_a_fortune_leaves_the_nodes_own_calibration_alone() -> None: + """`fortune_size` and `channels` are the Node's to choose; an unattended run must not override them.""" + seen: list[httpx.Request] = [] + + def draw(request: httpx.Request) -> httpx.Response: + seen.append(request) + return httpx.Response(200, json=[42, 137]) + + drawn = asyncio.run(client_for(draw).run_fortune("10.0.0.5:9000", 30.0)) + + assert dict(seen[0].url.params) == {"timetagger_address": "10.0.0.5:9000"} + assert drawn == [42, 137] + + +@pytest.mark.parametrize( + ("call", "payload", "expected"), + [ + ("get_health", {"nothing": "expected"}, "did not answer with a health status"), + ("run_chsh", {"chsh_value": "not a number"}, "did not answer with a CHSH result"), + ("run_fortune", {"fortune": [1, 2]}, "is not a fortune per channel"), + ("run_fortune", ["not", "numbers"], "is not a fortune per channel"), + ], +) +def test_a_node_answering_with_the_wrong_shape_is_one_error_type(call: str, payload: object, expected: str) -> None: + """A 200 of the wrong shape is as unusable as a refusal, and must read like one.""" + client = client_for(lambda _request: httpx.Response(200, json=payload)) + arguments: dict[str, tuple[object, ...]] = { + "get_health": (30.0,), + "run_chsh": ("10.0.0.5:9000", (0.0, 22.5), 30.0), + "run_fortune": ("10.0.0.5:9000", 30.0), + } + + with pytest.raises(NodeApiError, match=expected): + asyncio.run(getattr(client, call)(*arguments[call])) + + +def test_a_game_that_fails_on_the_node_carries_the_nodes_own_reason() -> None: + """So Slack shows what the Node said, rather than sending an operator to its logs.""" + refused = lambda _request: httpx.Response(503, json={"detail": "follower_node_address not configured"}) # noqa: E731 + + with pytest.raises(NodeApiError, match="follower_node_address not configured"): + asyncio.run(client_for(refused).run_chsh("10.0.0.5:9000", (0.0, 22.5), 30.0)) diff --git a/tests/pytest/test_whobot_slack.py b/tests/pytest/test_whobot_slack.py index edf4ba8..c3bf5ea 100644 --- a/tests/pytest/test_whobot_slack.py +++ b/tests/pytest/test_whobot_slack.py @@ -13,7 +13,9 @@ import pytest from pqn_whobot.actions import ActionResult +from pqn_whobot.actions import DigestResult from pqn_whobot.actions import Field +from pqn_whobot.actions import NodeDigest from pqn_whobot.actions import PendingInvocation from pqn_whobot.actions import Report from pqn_whobot.actions import Section @@ -22,6 +24,7 @@ from pqn_whobot.config import NodeEntry from pqn_whobot.config import WhobotSettings from pqn_whobot.registry import Node +from pqn_whobot.whobot_slack import BLOCK_LIMIT from pqn_whobot.whobot_slack import FIELDS_PER_SECTION from pqn_whobot.whobot_slack import HEADER_LIMIT from pqn_whobot.whobot_slack import OPTION_VALUE_LIMIT @@ -31,6 +34,7 @@ from pqn_whobot.whobot_slack import WhobotSlack from pqn_whobot.whobot_slack import _image_suffix from pqn_whobot.whobot_slack import _option_value +from pqn_whobot.whobot_slack import _param_block ALICE = "http://node-a.invalid:9000" @@ -169,6 +173,94 @@ def test_slack_control_characters_in_a_value_are_escaped(bot: WhobotSlack) -> No assert "a < b & c" in texts(bot._render(report)) # noqa: SLF001 +# -------------------------------------------------------------------------------------- +# Digest rendering: the one result with a level of nesting Report has no room for. +# -------------------------------------------------------------------------------------- + + +def a_node_digest(name: str, *, status: Status = Status.OK, sections: int = 2) -> NodeDigest: + return NodeDigest( + name=name, + api_url=f"http://{name}.invalid:9000", + status=status, + sections=[ + Section(label=f"Check {i}", status=Status.OK, fields=[Field(name="Router", value="up", status=Status.OK)]) + for i in range(sections) + ], + ) + + +def a_digest(*nodes: NodeDigest, notes: list[str] | None = None) -> DigestResult: + return DigestResult( + status=Status.overall(node.status for node in nodes), + title="Daily Digest", + summary=f"{len(nodes)} Nodes", + nodes=list(nodes), + notes=notes or [], + ) + + +def test_each_node_is_separated_by_a_divider_and_named(bot: WhobotSlack) -> None: + """The grouping is the whole reason this result type exists rather than a flat Report.""" + nodes = [a_node_digest("alice"), a_node_digest("bob")] + + blocks = bot._render(a_digest(*nodes)) # noqa: SLF001 + + assert [block["type"] for block in blocks].count("divider") == len(nodes) + assert "*alice*" in texts(blocks) + assert "http://bob.invalid:9000" in texts(blocks) + + +def test_a_nodes_own_status_is_marked_on_its_line(bot: WhobotSlack) -> None: + """So a fleet of four can be read at a glance without opening every section.""" + blocks = bot._render(a_digest(a_node_digest("alice"), a_node_digest("bob", status=Status.FAIL))) # noqa: SLF001 + + named = [json.dumps(block) for block in blocks if "*alice*" in json.dumps(block) or "*bob*" in json.dumps(block)] + assert STATUS_EMOJI[Status.OK] in named[0] + assert STATUS_EMOJI[Status.FAIL] in named[1] + + +def test_a_nodes_sections_render_by_the_same_rules_as_any_other_result(bot: WhobotSlack) -> None: + """Nothing about Sections is re-implemented here, so a status-carrying Field is a line.""" + digest = a_digest(a_node_digest("alice", sections=1)) + + blocks = bot._render(digest) # noqa: SLF001 + + lines = [block.get("text", {}).get("text", "") for block in blocks] + assert f"{STATUS_EMOJI[Status.OK]} Router — up" in lines + + +def test_a_fleet_too_large_for_one_message_loses_its_tail_and_says_so(bot: WhobotSlack) -> None: + """Slack refuses an over-long message outright, so the whole digest would be lost. + + Losing the tail and being told which Nodes went missing is recoverable; losing all of it, + to a bare ``invalid_blocks``, is not. + """ + blocks = bot._render(a_digest(*(a_node_digest(f"node-{i}", sections=4) for i in range(20)))) # noqa: SLF001 + + assert len(blocks) <= BLOCK_LIMIT + assert "Nodes omitted" in texts(blocks) + assert "node-19" in texts(blocks), "the omitted Nodes are named, or nobody knows what is missing" + + +def test_a_digest_that_fits_is_not_truncated(bot: WhobotSlack) -> None: + """Four Nodes is the fleet this is built for, and must not trip the guard.""" + blocks = bot._render(a_digest(*(a_node_digest(f"node-{i}") for i in range(4)), notes=["a footnote"])) # noqa: SLF001 + + assert "omitted" not in texts(blocks) + assert "a footnote" in texts(blocks) + + +def test_the_footer_survives_truncation(bot: WhobotSlack) -> None: + """It is budgeted for, because the notes say things like why a Game was skipped.""" + blocks = bot._render( # noqa: SLF001 + a_digest(*(a_node_digest(f"node-{i}", sections=4) for i in range(20)), notes=["a footnote"]) + ) + + assert len(blocks) <= BLOCK_LIMIT + assert "a footnote" in texts(blocks) + + # -------------------------------------------------------------------------------------- # Every result type must be renderable. # -------------------------------------------------------------------------------------- @@ -267,24 +359,68 @@ def availability_action() -> Any: return scan_actions(WhobotSlack)["set_availability"] -def test_a_bool_parameter_becomes_a_checkbox(bot: WhobotSlack) -> None: - block = bot._checkbox_block(availability_action(), {}) # noqa: SLF001 +def chsh_action() -> Any: + return scan_actions(WhobotSlack)["run_chsh"] + + +def checkboxes(act: Any, initial: dict[str, object]) -> Block: + return next(b for b in WhobotSlack._form_blocks(act, initial) if b["block_id"] == PARAMS_BLOCK) # noqa: SLF001 + + +def test_a_bool_parameter_becomes_a_checkbox() -> None: + block = checkboxes(availability_action(), {}) assert block["element"]["type"] == "checkboxes" assert [o["value"] for o in block["element"]["options"]] == ["chsh", "qf", "ssm"] -def test_the_form_opens_ticked_on_the_values_it_was_given(bot: WhobotSlack) -> None: +def test_the_form_opens_ticked_on_the_values_it_was_given() -> None: """The prefill's whole purpose: the form shows the Node's flags, not the defaults.""" - block = bot._checkbox_block(availability_action(), {"chsh": False, "qf": True, "ssm": False}) # noqa: SLF001 + block = checkboxes(availability_action(), {"chsh": False, "qf": True, "ssm": False}) assert [o["value"] for o in block["element"]["initial_options"]] == ["qf"] -def test_a_form_with_nothing_ticked_omits_initial_options(bot: WhobotSlack) -> None: +def test_a_form_with_nothing_ticked_omits_initial_options() -> None: """Slack rejects an empty initial_options outright rather than treating it as none.""" - block = bot._checkbox_block(availability_action(), {"chsh": False, "qf": False, "ssm": False}) # noqa: SLF001 + block = checkboxes(availability_action(), {"chsh": False, "qf": False, "ssm": False}) assert "initial_options" not in block["element"] +def test_a_float_parameter_becomes_a_number_input() -> None: + """The second widget mapping, and the reason a CHSH run can be aimed from Slack.""" + blocks = WhobotSlack._form_blocks(chsh_action(), {"angle_a": 11.0, "angle_b": 33.5}) # noqa: SLF001 + + assert [b["element"]["type"] for b in blocks] == ["number_input", "number_input"] + assert [b["element"]["initial_value"] for b in blocks] == ["11.0", "33.5"] + assert all(b["element"]["is_decimal_allowed"] for b in blocks), "angles are not whole degrees" + assert [b["label"]["text"] for b in blocks] == ["Angle A", "Angle B"] + + +def test_an_action_with_no_booleans_renders_no_checkbox_group() -> None: + """Slack rejects a checkbox element with no options, so an empty group must not be sent.""" + blocks = WhobotSlack._form_blocks(chsh_action(), {}) # noqa: SLF001 + + assert all(b["block_id"] != PARAMS_BLOCK for b in blocks) + + +def test_a_number_input_reads_back_as_the_actions_float() -> None: + """Slack sends a number input's value as a string, and the Action declared a float.""" + act = chsh_action() + state = { + _param_block("angle_a"): {_param_block("angle_a"): {"type": "number_input", "value": "11.5"}}, + _param_block("angle_b"): {_param_block("angle_b"): {"type": "number_input", "value": "33"}}, + } + + assert act.coerce_params(WhobotSlack._read_form(act, state)) == {"angle_a": 11.5, "angle_b": 33.0} # noqa: SLF001 + + +def test_a_number_input_left_empty_falls_back_to_the_actions_default() -> None: + """Unlike a checkbox, an empty number input is genuinely no answer rather than a zero.""" + act = chsh_action() + state = {_param_block("angle_a"): {_param_block("angle_a"): {"type": "number_input", "value": ""}}} + + assert act.coerce_params(WhobotSlack._read_form(act, state)) == {"angle_a": 0.0, "angle_b": 22.5} # noqa: SLF001 + + def submitted(*ticked: str) -> dict[str, Any]: """Build a view submission's state as Slack really sends it. @@ -297,7 +433,7 @@ def submitted(*ticked: str) -> dict[str, Any]: def test_an_unticked_checkbox_comes_back_as_false_rather_than_missing(bot: WhobotSlack) -> None: """Slack reports only what is ticked, so absence has to be reconstructed as False.""" - read = bot._read_checkboxes(availability_action(), submitted("qf")[PARAMS_BLOCK]) # noqa: SLF001 + read = bot._read_form(availability_action(), submitted("qf")) # noqa: SLF001 assert read == {"chsh": False, "qf": True, "ssm": False} @@ -307,14 +443,14 @@ def test_a_form_submitted_with_everything_unticked_reads_as_all_false(bot: Whobo Every default on ``set_availability`` is True, so a payload of ``{}`` plus ``coerce_params`` used to turn "switch everything off" into "switch everything on". """ - read = bot._read_checkboxes(availability_action(), submitted()[PARAMS_BLOCK]) # noqa: SLF001 + read = bot._read_form(availability_action(), submitted()) # noqa: SLF001 assert read == {"chsh": False, "qf": False, "ssm": False} def test_a_cleared_box_survives_coerce_params_as_false(bot: WhobotSlack) -> None: """The floor is only worth anything if it reaches the Action's arguments.""" act = availability_action() - arguments = act.coerce_params(bot._read_checkboxes(act, submitted("qf")[PARAMS_BLOCK])) # noqa: SLF001 + arguments = act.coerce_params(bot._read_form(act, submitted("qf"))) # noqa: SLF001 assert arguments == {"chsh": False, "qf": True, "ssm": False} From 83784acbf5a126e0ff79ef6e717f3f2a0669a1ed Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 29 Jul 2026 14:29:45 -0500 Subject: [PATCH 6/7] Added scheduling logic for the Daily Digest with the `Schedule` class, integrated Slack validation for digest channels, updated form renderer for `int` parameters, and added tests for scheduling behavior across various conditions. --- configs/whobot_example.toml | 23 ++- src/pqn_whobot/actions.py | 5 +- src/pqn_whobot/cli.py | 5 + src/pqn_whobot/config.py | 70 +++++++ src/pqn_whobot/schedule.py | 58 ++++++ src/pqn_whobot/whobot.py | 218 +++++++++++++++++++- src/pqn_whobot/whobot_slack.py | 70 ++++++- tests/pytest/test_whobot_config.py | 124 +++++++++++ tests/pytest/test_whobot_flow.py | 298 +++++++++++++++++++++++++++ tests/pytest/test_whobot_schedule.py | 123 +++++++++++ tests/pytest/test_whobot_slack.py | 105 ++++++++++ 11 files changed, 1071 insertions(+), 28 deletions(-) create mode 100644 src/pqn_whobot/schedule.py create mode 100644 tests/pytest/test_whobot_schedule.py diff --git a/configs/whobot_example.toml b/configs/whobot_example.toml index 2dc59b2..f90ae46 100644 --- a/configs/whobot_example.toml +++ b/configs/whobot_example.toml @@ -18,14 +18,18 @@ # # Socket Mode toggle on, generate an app-level token with `connections:write`. # That token is slack_app_token, and starts with "xapp-". -# OAuth & Permissions bot token scopes `chat:write`, `commands`, `files:write` (the -# last is for screenshot upload). Install to the workspace. That -# token is slack_bot_token, and starts with "xoxb-". +# OAuth & Permissions bot token scopes `chat:write`, `commands`, `files:write` (for the +# screenshot upload) and `channels:read` — NOT `channels:history`, +# which sits next to it and does not work: Whobot never reads +# messages, it checks that digest_channel exists and that it is in +# it. Add `groups:read` too if the digest goes to a private channel. +# Install to the workspace. That token is slack_bot_token, "xoxb-". # Slash Commands create /whobot. No Request URL is needed under Socket Mode. # Interactivity toggle on. Again no Request URL. # # Finally invite the bot to the channel the digest goes to, and put that channel's ID in -# digest_channel below. +# digest_channel below — `whobot serve` refuses to start until both are true, since otherwise +# the first sign of trouble is a digest that silently never arrives. slack_bot_token = "xoxb-..." slack_app_token = "xapp-..." digest_channel = "C0123456789" # channel ID the Daily Digest is posted to @@ -69,6 +73,13 @@ reboot_wait_s = 300 # Leave it out in production. # debug_screenshot_path = "/path/to/some-image.png" +# Whobot writes these two back itself after each digest run; leave them out of a fresh file. +# They must stay ABOVE the [[nodes]] tables below. A plain key written after a table belongs to +# that table, so moving these to the end of the file makes them fields of the last Node, and +# Whobot then refuses to start. +# last_run_at = 2026-01-01T07:00:00-06:00 +# last_result = "ok" + # The Node Registry. Whobot knows about exactly these Nodes — adding one is an edit here, # not a code change. Use each Node's address on the VPN; production Nodes listen on 9000. # Node *names* are deliberately not listed: Whobot reads them from each Node's @@ -78,7 +89,3 @@ api_url = "http://xx.xx.xx.xx:9000" [[nodes]] api_url = "http://xx.xx.xx.xx:9000" - -# Whobot writes these back itself after each digest run; leave them out of a fresh file. -# last_run_at = 2026-01-01T07:00:00-06:00 -# last_result = "ok" diff --git a/src/pqn_whobot/actions.py b/src/pqn_whobot/actions.py index b93fee2..7e8e6ea 100644 --- a/src/pqn_whobot/actions.py +++ b/src/pqn_whobot/actions.py @@ -208,8 +208,9 @@ class Scope(StrEnum): the scan stores the declaration and never looks inside it. """ -WIDGET_TYPES: tuple[type, ...] = (bool, float) -"""Parameter types the form generator can render: ``bool`` a checkbox, ``float`` a number input. +WIDGET_TYPES: tuple[type, ...] = (bool, float, int) +"""Parameter types the form generator can render: ``bool`` a checkbox, ``float`` and ``int`` a +number input — which for an ``int`` refuses decimals, since an hour of the day has none. The scan rejects any other type by name, so an unsupported parameter fails at import with a message rather than producing an empty form. Adding ``str`` or ``Literal``/enum is one entry diff --git a/src/pqn_whobot/cli.py b/src/pqn_whobot/cli.py index 38da88a..2c6d86d 100644 --- a/src/pqn_whobot/cli.py +++ b/src/pqn_whobot/cli.py @@ -125,6 +125,11 @@ async def run() -> None: # raising, so without this a bad token looks like a bot that started and then # quietly never answered. await bot.check_credentials() + # Then where the digest goes, which otherwise fails at the next scheduled run. + problem = await bot.check_digest_channel() + if problem is not None: + typer.echo(problem, err=True) + raise typer.Exit(code=1) typer.echo(f"Connected. {len(settings.nodes)} Node(s) registered. Ctrl-C to stop.") await bot.serve() diff --git a/src/pqn_whobot/config.py b/src/pqn_whobot/config.py index 03cf10c..20da0fc 100644 --- a/src/pqn_whobot/config.py +++ b/src/pqn_whobot/config.py @@ -4,8 +4,12 @@ so callers that need a real config check that the file exists first. """ +import os +import tempfile +from collections.abc import Mapping from datetime import datetime from pathlib import Path +from typing import Any from zoneinfo import ZoneInfo from zoneinfo import ZoneInfoNotFoundError @@ -18,6 +22,12 @@ from pydantic_settings import SettingsConfigDict from pydantic_settings import TomlConfigSettingsSource +from pqn_node.core.config import write_config + + +class ConfigWriteError(ValueError): + """A write to ``whobot.toml`` was rolled back because the result would not load.""" + class NodeEntry(BaseModel): """One entry of the Node Registry: a Node's address. @@ -130,3 +140,63 @@ def config_path() -> Path: """Return the file settings are loaded from, relative to the working directory.""" # pydantic-settings types this as "one path, or a list of them, or None"; ours is one path. return Path(WhobotSettings.model_config["toml_file"]) # type: ignore[arg-type] + + +def _restore(path: Path, content: bytes | None) -> None: + """Put a file back exactly as it was, atomically, after a write that must not stand.""" + if content is None: + path.unlink(missing_ok=True) + return + fd, temp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp") + temp_path = Path(temp_name) + try: + with os.fdopen(fd, "wb") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) + temp_path.replace(path) + except BaseException: + temp_path.unlink(missing_ok=True) + raise + + +def update_config(settings: WhobotSettings, updates: Mapping[str, Any]) -> None: + """Write settings back to ``whobot.toml`` and apply them to the live object. + + The counterpart of ``pqn_node``'s ``update_config``, which does the same for a Node, and + built on the same ``write_config`` — so comments survive and the file holding the Slack + tokens is replaced by an atomic rename rather than truncated. + + File first, so a failed write leaves memory matching disk. Values are applied unvalidated, + as a Node's are. Key names *are* checked, since an unknown one is written happily and then + refused by ``extra="forbid"``. + + And the result is **read back**, because the file being written is the one holding the Slack + tokens: if it does not load, Whobot cannot start again, and discovering that at 07:00 is the + worst moment. The failure this has actually caught is a *hand edit* made while Whobot was + running — a mutable key moved below the ``[[nodes]]`` tables, where TOML reads it as a field + of that Node and ``extra="forbid"`` refuses it. Whobot cannot repair the file, but it can + leave it exactly as it found it and say what is wrong, at the write rather than at the next + start. + """ + unknown = [key for key in updates if key not in WhobotSettings.model_fields] + if unknown: + msg = f"not a WhobotSettings field: {', '.join(sorted(unknown))}" + raise KeyError(msg) + + path = config_path() + before = path.read_bytes() if path.is_file() else None + write_config(path, updates) + + try: + WhobotSettings() + except (OSError, ValueError) as e: + _restore(path, before) + msg = ( + f"{path} does not load after that write, so it has been put back as it was:\n{e}\n" + "If a mutable key sits below a [[nodes]] table, move it above them: it belongs to that Node there." + ) + raise ConfigWriteError(msg) from e + + for key, value in updates.items(): + setattr(settings, key, value) diff --git a/src/pqn_whobot/schedule.py b/src/pqn_whobot/schedule.py new file mode 100644 index 0000000..62f6fd5 --- /dev/null +++ b/src/pqn_whobot/schedule.py @@ -0,0 +1,58 @@ +"""When the Daily Digest is due. Nothing here runs one; the loop is a method on ``Whobot``.""" + +from dataclasses import dataclass +from datetime import date +from datetime import datetime +from datetime import time +from datetime import timedelta +from zoneinfo import ZoneInfo + +from pqn_whobot.config import WhobotSettings + + +@dataclass(frozen=True) +class Schedule: + """A wall-clock time of day in one IANA timezone, and the instants it falls on. + + Days are advanced on the *local date* rather than by moving an instant, so a 07:00 digest + stays at 07:00 across a DST boundary — where consecutive runs are 23 or 25 real hours apart. + """ + + hour: int + minute: int + timezone: ZoneInfo + + @classmethod + def from_settings(cls, settings: WhobotSettings) -> "Schedule": + """Read the configured schedule. Rebuilt per tick, which is how a change re-arms.""" + return cls(hour=settings.schedule_hour, minute=settings.schedule_minute, timezone=settings.timezone) + + def _on(self, day: date) -> datetime: + """Return this time of day on one local day, as an aware datetime.""" + return datetime.combine(day, time(self.hour, self.minute), tzinfo=self.timezone) + + def next_after(self, instant: datetime) -> datetime: + """Return the first scheduled run strictly after ``instant``. + + Strictly, so a just-fired run computes tomorrow rather than itself. + """ + local_day = instant.astimezone(self.timezone).date() + candidate = self._on(local_day) + if candidate <= instant: + candidate = self._on(local_day + timedelta(days=1)) + return candidate + + def previous_at_or_before(self, instant: datetime) -> datetime: + """Return the most recent scheduled run at or before ``instant``. + + A digest is overdue exactly when its last recorded run is older than this. + """ + local_day = instant.astimezone(self.timezone).date() + candidate = self._on(local_day) + if candidate > instant: + candidate = self._on(local_day - timedelta(days=1)) + return candidate + + def __str__(self) -> str: + """Describe the schedule as an operator states it, zone included.""" + return f"{self.hour:02d}:{self.minute:02d} {self.timezone.key}" diff --git a/src/pqn_whobot/whobot.py b/src/pqn_whobot/whobot.py index e6097dd..c00dfc3 100644 --- a/src/pqn_whobot/whobot.py +++ b/src/pqn_whobot/whobot.py @@ -23,8 +23,8 @@ async def node_info(self, node: Node) -> Report: ... ``scope=Scope.ONE`` means the Action acts on one Node, which the operator picks and which arrives as the method's first argument; ``Scope.NONE`` means it acts on the Network and takes no Node. Every remaining parameter is keyword-only and becomes a question in the -parameter form: a checkbox per ``bool`` and a number input per ``float``, which are the only -widget mappings there are. +parameter form: a checkbox per ``bool`` and a number input per ``float`` or ``int``, which are +the only widget mappings there are. An Action returns an ``ActionResult``, usually a ``Report``, describing *what happened*. It must not emit platform markup: deciding what a result looks like belongs to the subclass, @@ -69,10 +69,11 @@ async def node_info(self, node: Node) -> Report: ... What a Chat Platform implements ------------------------------- -Six abstract methods: the four steps that can be asked for — ``show_menu``, -``ask_for_target``, ``ask_for_params``, ``ask_to_confirm`` — and the two halves of a run, -``announce_start`` and ``post_result``. A subclass implements those and nothing else. It -declares no Actions today, though the scan runs on every subclass and would find any it did. +Seven abstract methods: the four steps that can be asked for — ``show_menu``, +``ask_for_target``, ``ask_for_params``, ``ask_to_confirm`` — the two halves of a run, +``announce_start`` and ``post_result``, and ``scheduled_handle``, which says where a run +nobody clicked for is posted. A subclass implements those and nothing else. It declares no +Actions today, though the scan runs on every subclass and would find any it did. This module must not reference a Chat Platform. """ @@ -83,9 +84,12 @@ async def node_info(self, node: Node) -> Report: ... from abc import ABC from abc import abstractmethod from collections.abc import Coroutine +from datetime import UTC +from datetime import datetime from pathlib import Path from typing import Any from typing import ClassVar +from zoneinfo import ZoneInfo from pqn_node.api.routes.health import ComponentStatus from pqn_node.api.routes.health import HealthStatus @@ -104,12 +108,15 @@ async def node_info(self, node: Node) -> Report: ... from pqn_whobot.actions import action from pqn_whobot.actions import prefill from pqn_whobot.actions import scan_actions +from pqn_whobot.config import ConfigWriteError from pqn_whobot.config import WhobotSettings +from pqn_whobot.config import update_config from pqn_whobot.node_client import NodeApiError from pqn_whobot.node_client import NodeClient from pqn_whobot.registry import UNKNOWN_NAME from pqn_whobot.registry import Node from pqn_whobot.registry import resolve_nodes +from pqn_whobot.schedule import Schedule logger = logging.getLogger(__name__) @@ -129,6 +136,13 @@ async def node_info(self, node: Node) -> Report: ... REBOOT_POLL_INTERVAL_S = 5.0 """How often a rebooting Node is asked whether it is back.""" +SCHEDULER_TICK_S = 60.0 +"""Longest one tick of the scheduler sleeps. + +Capped rather than sleeping straight to the next run, so a schedule changed from a Chat +Platform re-arms on its own and a suspended host cannot wake up still asleep past a fire +time. When the target is nearer than this, the tick sleeps exactly as long as is left.""" + def one_game_budget(settings: WhobotSettings) -> float: """How long an Action that plays one Game may take: the Game, plus the call that starts it.""" @@ -159,6 +173,10 @@ def one_node_budget(settings: WhobotSettings) -> float: return 2 * settings.node_timeout_s + 2 * settings.per_game_timeout_s +SCHEDULE_BOUNDS = {"hour": (0, 23), "minute": (0, 59)} +"""What counts as a time of day. ``WhobotSettings`` declares the same bounds on its own fields, +and ``test_the_schedule_action_refuses_what_the_config_would`` keeps the two in step.""" + DIGEST_TITLE = "Daily Digest" """What the fleet-wide report is called, whether it was scheduled or asked for by hand.""" @@ -197,9 +215,10 @@ def __init_subclass__(cls, **kwargs: object) -> None: def __init__(self, settings: WhobotSettings) -> None: self.settings = settings - self._tasks: set[asyncio.Task[None]] = set() + self._tasks: set[asyncio.Task[object]] = set() # Interruption replies, which must survive the cancellation that caused them. self._finalisers: set[asyncio.Task[None]] = set() + self._scheduler: asyncio.Task[None] | None = None self._accepting = True # ---------------------------------------------------------------------------------- @@ -411,6 +430,87 @@ async def run_digest(self) -> DigestResult: nodes=digests, ) + @action(label="Digest status", description="When the Daily Digest last ran, and when it runs next.") + async def digest_status(self) -> Report: + """Report the schedule, the next run, and what the last one did. + + Overdue is derived rather than stored: the last recorded run being older than the most + recent scheduled one means at least one was missed. + """ + schedule = Schedule.from_settings(self.settings) + now = datetime.now(UTC) + due, last_run_at = schedule.previous_at_or_before(now), self.settings.last_run_at + overdue = last_run_at is None or last_run_at < due + armed = self._scheduler is not None + + notes = [] + if not armed: + notes.append("The scheduler is not running, because whobot.toml sets no digest_channel.") + if overdue: + notes.append(f"The {_local(due, schedule.timezone)} run did not happen; a missed run is never replayed.") + + return Report( + status=Status.OK if armed and not overdue else Status.WARN, + title=DIGEST_TITLE, + summary=f"Scheduled for {schedule}.", + sections=[ + Section( + fields=[ + Field(name="Next run", value=_local(schedule.next_after(now), schedule.timezone)), + Field( + name="Last run", + value=_local(last_run_at, schedule.timezone) if last_run_at else "never", + ), + Field(name="Last result", value=self.settings.last_result or "nothing recorded"), + ] + ) + ], + notes=notes, + ) + + @action(label="Change digest schedule", description="Set the time of day the Daily Digest runs.") + async def set_digest_schedule(self, *, hour: int = 7, minute: int = 0) -> Report: + """Persist the schedule, which the running scheduler picks up on its next tick. + + Validated here rather than left to the widget: the file must never come to hold a time + that ``WhobotSettings`` would refuse to load on the next start. + """ + was = Schedule.from_settings(self.settings) + wrong = _not_a_time_of_day(hour, minute) + if wrong is not None: + return Report( + status=Status.FAIL, + title=DIGEST_TITLE, + summary=f"{wrong}. Still scheduled for {was}.", + ) + + try: + update_config(self.settings, {"schedule_hour": hour, "schedule_minute": minute}) + except (ConfigWriteError, OSError) as e: + # A hand-edited file that no longer loads is worth saying out loud, rather than + # letting execute turn it into "the Action raised an unhandled error". + return Report( + status=Status.FAIL, + title=DIGEST_TITLE, + summary=f"The schedule could not be saved. Still scheduled for {was}.", + sections=[Section(error=str(e))], + ) + + now = Schedule.from_settings(self.settings) + return Report( + status=Status.OK, + title=DIGEST_TITLE, + summary=f"Scheduled for {now}, was {was}.", + sections=[ + Section(fields=[Field(name="Next run", value=_local(now.next_after(datetime.now(UTC)), now.timezone))]) + ], + ) + + @prefill(set_digest_schedule) + async def _schedule_prefill(self) -> dict[str, object]: + """Open the form on the schedule in force, not on the signature's defaults.""" + return {"hour": self.settings.schedule_hour, "minute": self.settings.schedule_minute} + @action( label="Check one Node", description="One Node's full check-up: its hardware, then the Games it offers.", @@ -623,7 +723,9 @@ async def execute( pending: PendingInvocation, node: Node | None, handle: ReplyHandle, - ) -> None: + *, + announce: bool = True, + ) -> ActionResult: """Announce the run, run it, and post the outcome — whatever the outcome is. The invariant that makes the bot trustworthy is that **every announcement is @@ -631,11 +733,14 @@ async def execute( with no reply is worse than a clear failure: the operator cannot tell whether it happened, and has to go and check by hand. So every way out of the call posts something, including cancellation. + + ``announce=False`` is for a run nobody clicked for: a scheduled digest posts one + message rather than a reply under an "is running" nobody was waiting to read. """ # Worked out before anything is announced, because it depends on configuration that a # long-running process may have had reloaded under it. timeout_s = act.timeout_for(self.settings) - reply = await self.announce_start(act, pending, handle) + reply = await self.announce_start(act, pending, handle) if announce else handle try: result = await asyncio.wait_for(act.call(self, node, pending.params), timeout_s) @@ -656,6 +761,8 @@ async def execute( result = Report(status=Status.FAIL, title=act.label, summary="The Action raised an unhandled error.") await self.post_result(act, result, reply) + # Returned as well as posted, so a scheduled run can record what it did. + return result # ---------------------------------------------------------------------------------- # What a Chat Platform must provide. The whole abstract surface. @@ -683,11 +790,75 @@ async def announce_start(self, act: Action, pending: PendingInvocation, handle: @abstractmethod async def post_result(self, act: Action, result: ActionResult, reply: ReplyHandle) -> None: ... + @abstractmethod + def scheduled_handle(self) -> ReplyHandle: + """Where an unattended run posts, there being no interaction to reply to. + + The one hook that is not ``async``: it builds a value rather than sending anything. + """ + # ---------------------------------------------------------------------------------- # Running work, and stopping. # ---------------------------------------------------------------------------------- - def _spawn(self, coro: Coroutine[Any, Any, None]) -> None: + def start_scheduler(self) -> None: + """Arm the Daily Digest, if there is anywhere for it to be posted.""" + if not self.settings.digest_channel: + logger.warning("no digest_channel is set, so no Daily Digest will be posted on a schedule") + return + self._scheduler = asyncio.create_task(self.run_scheduler()) + + async def run_scheduler(self) -> None: + """Fire the Daily Digest at its scheduled time, for as long as the process runs. + + **A missed run is never replayed.** The first target is computed strictly after + startup, so a schedule that came and went while Whobot was down is simply gone; the + Digest status Action is what reports the gap. A run that is merely *late* — the host + was suspended over its slot — still fires, because the digest measures the fleet as it + is now, which makes it late rather than wrong. + """ + schedule = Schedule.from_settings(self.settings) + target = schedule.next_after(datetime.now(UTC)) + logger.info("Daily Digest at %s; next run %s", schedule, target) + + while True: + remaining = (target - datetime.now(UTC)).total_seconds() + await asyncio.sleep(min(max(remaining, 0.0), SCHEDULER_TICK_S)) + + current = Schedule.from_settings(self.settings) + if current != schedule: + schedule, target = current, current.next_after(datetime.now(UTC)) + logger.info("Daily Digest moved to %s; next run %s", schedule, target) + continue + + if datetime.now(UTC) >= target: + try: + await self._run_scheduled_digest() + except Exception: + # A digest that could not even be posted must not take tomorrow's with it. + logger.exception("the scheduled Daily Digest failed") + target = schedule.next_after(datetime.now(UTC)) + logger.info("next Daily Digest %s", target) + + async def _run_scheduled_digest(self) -> None: + """Run the digest with nobody watching, and record what it did. + + Recorded only after it has been posted, so a digest nobody could be told about still + counts as missed — which is the honest answer to "did the morning report arrive?". + """ + act = self.actions[Whobot.run_digest.__name__] + result = await self.execute( + act, PendingInvocation(action=act.name), None, self.scheduled_handle(), announce=False + ) + # Read off the result with getattr: the marker base declares no fields, and a digest + # that timed out comes back as a Report rather than a DigestResult. + status, summary = getattr(result, "status", Status.WARN), getattr(result, "summary", None) + update_config( + self.settings, + {"last_run_at": datetime.now(UTC), "last_result": f"{status} — {summary}" if summary else status}, + ) + + def _spawn(self, coro: Coroutine[Any, Any, object]) -> None: """Run an Action without waiting for it, keeping a reference so it survives. Python garbage-collects a task nobody holds a reference to, so the set is @@ -716,6 +887,13 @@ async def shutdown(self, grace_s: float = SHUTDOWN_GRACE_S) -> None: """ self._accepting = False + if self._scheduler is not None: + # Cancelled outright rather than given the grace period: it is asleep almost + # always, and when it is not, a digest takes minutes and would use it all. + self._scheduler.cancel() + await asyncio.wait({self._scheduler}, timeout=grace_s) + self._scheduler = None + if self._tasks: _, running = await asyncio.wait(set(self._tasks), timeout=grace_s) for task in running: @@ -735,7 +913,11 @@ def _client(self, node: Node) -> NodeClient: return NodeClient(node.api_url) def _menu(self) -> list[Action]: - """Every Action, in the order they are declared. The menu is the class body.""" + """Every Action there is. The menu is the class body, so no menu code is ever edited. + + Ordered by method name, since that is how ``inspect.getmembers`` sorts what the scan + walks — not by declaration order. + """ return list(self.actions.values()) # ---------------------------------------------------------------------------------- @@ -872,6 +1054,20 @@ async def _initial_params(self, act: Action, node: Node | None) -> dict[str, obj # -------------------------------------------------------------------------------------- +def _local(instant: datetime, timezone: ZoneInfo) -> str: + """Render an instant in the digest's zone, the only one an operator thinks in.""" + return instant.astimezone(timezone).strftime("%Y-%m-%d %H:%M %Z") + + +def _not_a_time_of_day(hour: int, minute: int) -> str | None: + """Name the first value that is out of range, or ``None`` if both are in it.""" + for name, value in (("hour", hour), ("minute", minute)): + low, high = SCHEDULE_BOUNDS[name] + if not low <= value <= high: + return f"{name} must be between {low} and {high}, not {value}" + return None + + def _angles(values: list[float]) -> str: """Render a row of measured numbers, four decimals each, one convention for all of them.""" return ", ".join(f"{value:.4f}" for value in values) diff --git a/src/pqn_whobot/whobot_slack.py b/src/pqn_whobot/whobot_slack.py index fdcddad..6e0bac5 100644 --- a/src/pqn_whobot/whobot_slack.py +++ b/src/pqn_whobot/whobot_slack.py @@ -41,6 +41,7 @@ from pqn_whobot.actions import decode from pqn_whobot.actions import encode from pqn_whobot.config import WhobotSettings +from pqn_whobot.config import config_path from pqn_whobot.registry import Node from pqn_whobot.whobot import Whobot @@ -64,6 +65,9 @@ ``invalid_blocks`` — so without this the whole digest is lost rather than its tail. Four Nodes fit comfortably; ten do not.""" +NUMBER_TYPES: tuple[type, ...] = (float, int) +"""Parameter types rendered as a number input. An ``int`` disallows decimals; a ``float`` allows them.""" + IMAGE_SUFFIXES = ((b"\x89PNG\r\n\x1a\n", "png"), (b"GIF8", "gif"), (b"\xff\xd8\xff", "jpg")) """Magic numbers, so an upload can be named after what it actually is. @@ -278,7 +282,7 @@ def _read_form(act: Action, values: dict[str, Any]) -> dict[str, object]: params[option["value"]] = True for parameter in act.parameters: - if parameter.annotation is not float: + if parameter.annotation not in NUMBER_TYPES: continue typed = (values.get(_param_block(parameter.name), {}).get(_param_block(parameter.name)) or {}).get("value") if typed: @@ -390,13 +394,13 @@ def _form_blocks(cls, act: Action, initial: dict[str, object]) -> list[Block]: """Generate the whole form from the Action's parameters. Every ``bool`` shares one checkbox group, because "which of these are on" is one - question; every ``float`` gets an input of its own. The scan has already refused any - parameter type without a mapping, so a parameter reaching here that is neither is a bug - in the scan rather than a bad Action. + question; every number gets an input of its own, since Slack keys a submitted value by + the block it was in. The scan has already refused any parameter type without a mapping, + so a parameter reaching here that is neither is a bug in the scan rather than a bad Action. """ booleans = [p for p in act.parameters if p.annotation is bool] blocks = [cls._checkbox_block(booleans, initial)] if booleans else [] - blocks += [cls._number_block(p, initial) for p in act.parameters if p.annotation is float] + blocks += [cls._number_block(p, initial) for p in act.parameters if p.annotation in NUMBER_TYPES] return blocks @staticmethod @@ -422,7 +426,7 @@ def _checkbox_block(parameters: list[Parameter], initial: dict[str, object]) -> @staticmethod def _number_block(parameter: Parameter, initial: dict[str, object]) -> Block: - """Render one ``float`` parameter as a number input, opened on its starting value. + """Render one numeric parameter as a number input, opened on its starting value. Optional, so an operator who clears it gets the Action's default rather than a form that refuses to submit. @@ -435,7 +439,7 @@ def _number_block(parameter: Parameter, initial: dict[str, object]) -> Block: "element": { "type": "number_input", "action_id": _param_block(parameter.name), - "is_decimal_allowed": True, + "is_decimal_allowed": parameter.annotation is float, "initial_value": str(initial.get(parameter.name, parameter.default)), }, } @@ -491,6 +495,10 @@ async def announce_start(self, act: Action, pending: PendingInvocation, handle: ) return SlackReply(channel=channel, thread_ts=posted["ts"]) + def scheduled_handle(self) -> ReplyHandle: + """Post to the digest channel, with no thread: an unattended digest is its own message.""" + return SlackReply(channel=self.settings.digest_channel) + async def post_result(self, act: Action, result: ActionResult, reply: ReplyHandle) -> None: """Post an Action's outcome as a threaded reply under its announcement.""" slack = self._slack(reply) @@ -651,6 +659,53 @@ async def check_credentials(self) -> None: # The URL this returns is deliberately discarded; the handler opens its own. await AsyncWebClient().apps_connections_open(app_token=self.settings.slack_app_token) + async def check_digest_channel(self) -> str | None: + """Return what is wrong with ``digest_channel``, or ``None`` if nothing is. + + The tokens' preflight exists because a bad one looks like a bot that started and never + answered; a wrong channel is the same failure a day later — the digest simply doesn't + arrive, and only the log says why. Checked at startup instead. + + Every answer but "the channel is there and Whobot is in it" stops the bot from starting, + including a token that lacks the scope to look: an unverifiable channel is the state this + exists to rule out. Only an empty ``digest_channel`` passes, since that turns the + scheduled digest off deliberately. + + Reports rather than raises, so the CLI can say which setting is at fault instead of + offering the guidance for a rejected token. + """ + from slack_sdk.errors import SlackApiError # noqa: PLC0415 + + channel = self.settings.digest_channel + if not channel: + # A deliberate choice, not an error: start_scheduler already says the digest is off. + return None + + try: + info = await self.app.client.conversations_info(channel=channel) + except SlackApiError as e: + error = e.response.get("error", "unknown") + if error == "missing_scope": + # Refusing to start rather than warning: a check that can be skipped is a check + # nobody has, and the alternative is the failure it exists to prevent. The + # granted scopes are quoted back because `channels:read` and `channels:history` + # sit next to each other in Slack's picker and only the first one works here. + return ( + f"Whobot cannot verify digest_channel {channel!r}: its bot token lacks channels:read " + "(groups:read for a private channel).\n" + f" It currently has: {e.response.get('provided', 'nothing')}.\n" + " Add the scope under OAuth & Permissions, then Reinstall to Workspace." + ) + return ( + f"digest_channel {channel!r} in {config_path()} cannot be read: {error}. " + "The channel ID is at the bottom of the channel's About tab in Slack." + ) + + if not info["channel"].get("is_member"): + name = info["channel"].get("name", channel) + return f"Whobot is not in #{name}, so it cannot post the Daily Digest there. Invite it to the channel." + return None + async def serve(self) -> None: """Hold the Socket Mode connection until the process is asked to stop. @@ -662,6 +717,7 @@ async def serve(self) -> None: from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler # noqa: PLC0415 handler = AsyncSocketModeHandler(self.app, self.settings.slack_app_token) + self.start_scheduler() try: # slack_bolt ships no annotations for these two, and mypy is strict here. await handler.start_async() # type: ignore[no-untyped-call] diff --git a/tests/pytest/test_whobot_config.py b/tests/pytest/test_whobot_config.py index 99bd31c..9bbc7a5 100644 --- a/tests/pytest/test_whobot_config.py +++ b/tests/pytest/test_whobot_config.py @@ -4,15 +4,19 @@ """ import tomllib +from datetime import UTC +from datetime import datetime from datetime import timedelta from pathlib import Path import pytest from pydantic import ValidationError +from pqn_whobot.config import ConfigWriteError from pqn_whobot.config import NodeEntry from pqn_whobot.config import WhobotSettings from pqn_whobot.config import config_path +from pqn_whobot.config import update_config EXAMPLE_CONFIG = """\ # Slack credentials. @@ -207,3 +211,123 @@ def test_the_mutable_fields_round_trip_from_the_file(tmp_path: Path) -> None: assert settings.last_result == "ok" assert settings.last_run_at is not None assert settings.last_run_at.utcoffset() == timedelta(hours=-6) + + +# -------------------------------------------------------------------------------------- +# Writing back. `write_config`'s own guarantees — atomic rename, no stray temp file — are +# `pqn_node`'s and are tested in `test_config_updates.py`. +# -------------------------------------------------------------------------------------- + + +def test_a_persisted_schedule_survives_a_reload(config_file: Path) -> None: + settings = WhobotSettings() + + update_config(settings, {"schedule_hour": 9, "schedule_minute": 30}) + + reloaded = WhobotSettings() + assert (reloaded.schedule_hour, reloaded.schedule_minute) == (9, 30) + assert config_file.read_text(encoding="utf-8").count("schedule_hour") == 1 + + +def test_a_write_applies_to_the_live_settings_object(config_file: Path) -> None: + """No restart: the loop re-reads this object every tick, so it re-arms itself.""" + settings = WhobotSettings() + assert settings.schedule_hour == 7 # noqa: PLR2004 - the value in EXAMPLE_CONFIG + + update_config(settings, {"schedule_hour": 9}) + + assert settings.schedule_hour == 9 # noqa: PLR2004 - and the file agrees + assert "schedule_hour = 9" in config_file.read_text(encoding="utf-8") + + +def test_the_comments_and_the_tokens_survive_a_write(config_file: Path) -> None: + """Operators hand-write this file from a commented example, and it holds the tokens.""" + update_config(WhobotSettings(), {"schedule_hour": 9}) + + written = config_file.read_text(encoding="utf-8") + assert "# Slack credentials." in written + assert "schedule_hour = 9 # morning digest" in written + assert 'slack_app_token = "xapp-secret"' in written + assert "# The Node Registry." in written + assert [node.api_url for node in WhobotSettings().nodes] == [ + "http://node-a.invalid:9000", + "http://node-b.invalid:9000", + ] + + +def test_what_a_run_recorded_round_trips(config_file: Path) -> None: + """A digest writes an aware instant as a TOML timestamp and reads back the same instant. + + Both keys are absent until the first run writes them, and `[[nodes]]` is last in the file — + so a key appended in the wrong place lands inside it and takes the registry with it. + """ + ran_at = datetime(2026, 7, 29, 12, 0, tzinfo=UTC) + + update_config(WhobotSettings(), {"last_run_at": ran_at, "last_result": "ok — 2 of 2 Nodes reported no problems"}) + + reloaded = WhobotSettings() + assert reloaded.last_run_at == ran_at + assert reloaded.last_result == "ok — 2 of 2 Nodes reported no problems" + assert len(reloaded.nodes) == 2 # noqa: PLR2004 - both registry entries, still where they were + + written = config_file.read_text(encoding="utf-8") + assert "# Slack credentials." in written + # A quoted string would reload as a `datetime` too, so the round trip alone does not + # prove the file is readable by anything else that parses TOML. + assert "last_run_at = 2026-07-29T12:00:00Z" in written + assert written.index("last_run_at") < written.index("[[nodes]]") + + +def test_an_unknown_key_is_refused_before_the_file_is_touched(config_file: Path) -> None: + """A typo would write a key that `extra="forbid"` then refuses on the next start: the + bot keeps running and cannot come back up.""" # noqa: D205, D209 + before = config_file.read_text(encoding="utf-8") + + with pytest.raises(KeyError, match="schedule_hours"): + update_config(WhobotSettings(), {"schedule_hours": 9}) + + assert config_file.read_text(encoding="utf-8") == before + + +MUTABLE_KEY_BELOW_THE_REGISTRY = """\ +slack_bot_token = "xoxb-secret" + +[[nodes]] +api_url = "http://node-a.invalid:9000" + +last_result = "ok" +""" +"""A hand-edited file with `last_result` after the table, so TOML reads it as that Node's. + +`tomlkit` cannot see it there, so a write adds a second one at the top and leaves this behind — +and `extra="forbid"` then refuses the file, which is what `update_config` must not allow. +""" + + +def test_a_write_is_rolled_back_when_the_file_does_not_load(config_file: Path) -> None: + """The real timeline: Whobot is running, the file is hand-edited under it, then a digest writes. + + Whobot cannot mend the file, but it must not leave a *different* broken file behind, and it + must not report a run it could not record. + """ + settings = WhobotSettings() # loaded while the file was still good + config_file.write_text(MUTABLE_KEY_BELOW_THE_REGISTRY, encoding="utf-8") + + with pytest.raises(ConfigWriteError, match="does not load"): + update_config(settings, {"last_result": "warn"}) + + assert config_file.read_text(encoding="utf-8") == MUTABLE_KEY_BELOW_THE_REGISTRY + assert list(config_file.parent.iterdir()) == [config_file], "no temp file left behind" + # Memory and disk still agree, which is the point of writing before applying. + assert settings.last_result is None + + +def test_a_rolled_back_write_names_the_likely_cause(config_file: Path) -> None: + """Whoever reads this has to know where to look; pydantic's dump alone does not say.""" + settings = WhobotSettings() + config_file.write_text(MUTABLE_KEY_BELOW_THE_REGISTRY, encoding="utf-8") + + with pytest.raises(ConfigWriteError, match=r"move it above"): + update_config(settings, {"schedule_hour": 9}) + + assert settings.schedule_hour == 7 # noqa: PLR2004 - the default, unchanged diff --git a/tests/pytest/test_whobot_flow.py b/tests/pytest/test_whobot_flow.py index a15ae40..03177f5 100644 --- a/tests/pytest/test_whobot_flow.py +++ b/tests/pytest/test_whobot_flow.py @@ -15,17 +15,22 @@ import asyncio import json +import logging import re from collections.abc import Callable from collections.abc import Iterator from dataclasses import dataclass from dataclasses import field from dataclasses import replace +from datetime import UTC +from datetime import datetime from pathlib import Path from typing import ClassVar +from zoneinfo import ZoneInfo import httpx import pytest +from pydantic import ValidationError from pqn_node.api.routes.health import HealthStatus from pqn_node.core.config import GamesAvailability @@ -44,6 +49,7 @@ from pqn_whobot.actions import prefill from pqn_whobot.config import NodeEntry from pqn_whobot.config import WhobotSettings +from pqn_whobot.config import update_config from pqn_whobot.node_client import NodeClient from pqn_whobot.registry import Node from pqn_whobot.whobot import GAME_TITLES @@ -61,6 +67,9 @@ Node(api_url=BOB, name="ufl-public-right", reachable=True, latency_ms=24.0), ] +SCHEDULED = ReplyHandle() +"""Stands in for the digest channel: what a run nobody clicked for is handed.""" + DISTINCT_FAILURE_MODES = 3 """Raising, timing out and being interrupted: three failures an operator must tell apart.""" @@ -83,6 +92,7 @@ class Drawn: initial: dict[str, object] = field(default_factory=dict) notes: list[str | None] = field(default_factory=list) results: list[ActionResult] = field(default_factory=list) + replies: list[ReplyHandle] = field(default_factory=list) class WhobotSpy(Whobot): @@ -120,6 +130,10 @@ async def announce_start(self, act: Action, pending: PendingInvocation, handle: async def post_result(self, act: Action, result: ActionResult, reply: ReplyHandle) -> None: self.drawn.calls.append("post_result") self.drawn.results.append(result) + self.drawn.replies.append(reply) + + def scheduled_handle(self) -> ReplyHandle: + return SCHEDULED class FlowSpy(WhobotSpy): @@ -1285,3 +1299,287 @@ def test_the_digests_budget_grows_with_the_registry() -> None: def test_the_digests_output_carries_no_platform_markup() -> None: assert_no_markup(checked(with_node_api(node_api(), ALICE, BOB), "run_digest")) + + +# -------------------------------------------------------------------------------------- +# The scheduler. The clock is faked and the tick shortened, so nothing here waits on a +# real one. No Node is registered either: the digest's *content* is tested above, and an +# empty registry answers without touching the network. +# -------------------------------------------------------------------------------------- + +CHICAGO = ZoneInfo("America/Chicago") +SETTLE_S = 0.05 +"""Long enough for many ticks at the shortened interval below.""" + + +@dataclass +class FakeClock: + """A clock the test moves by hand, standing in for the `datetime` the loop reads.""" + + at: datetime + + def now(self, _tz: object = None) -> datetime: + return self.at + + +def scheduled_bot(clock: FakeClock, monkeypatch: pytest.MonkeyPatch, **overrides: object) -> FlowSpy: + monkeypatch.setattr("pqn_whobot.whobot.datetime", clock) + monkeypatch.setattr("pqn_whobot.whobot.SCHEDULER_TICK_S", 0.005) + return FlowSpy(settings_for(digest_channel="C0DIGEST", **overrides)) + + +async def settle() -> None: + await asyncio.sleep(SETTLE_S) + + +def test_a_scheduled_digest_posts_one_message_and_never_announces(monkeypatch: pytest.MonkeyPatch) -> None: + """No ack to reply under: nobody clicked, so there is nobody waiting to read one.""" + bot = scheduled_bot(FakeClock(at=datetime(2026, 7, 29, 7, 0, tzinfo=CHICAGO)), monkeypatch) + + asyncio.run(bot._run_scheduled_digest()) # noqa: SLF001 - the loop's one step, without the waiting + + assert bot.drawn.calls == ["post_result"] + assert bot.drawn.replies == [SCHEDULED] + + +def test_a_scheduled_digest_records_what_it_did(monkeypatch: pytest.MonkeyPatch) -> None: + ran_at = datetime(2026, 7, 29, 7, 0, tzinfo=CHICAGO) + bot = scheduled_bot(FakeClock(at=ran_at), monkeypatch) + + asyncio.run(bot._run_scheduled_digest()) # noqa: SLF001 + + assert bot.settings.last_run_at == ran_at + reloaded = WhobotSettings() + assert reloaded.last_run_at == ran_at + assert reloaded.last_result is not None + assert reloaded.last_result.startswith("warn") + + +def test_a_missed_run_is_not_replayed_on_startup(monkeypatch: pytest.MonkeyPatch) -> None: + """Whobot starting at 08:00 does not run the 07:00 digest it was down for.""" + clock = FakeClock(at=datetime(2026, 7, 29, 8, 0, tzinfo=CHICAGO)) + bot = scheduled_bot(clock, monkeypatch, schedule_hour=7, last_run_at=datetime(2026, 7, 20, 12, 0, tzinfo=UTC)) + + async def go() -> None: + bot.start_scheduler() + await settle() + await bot.shutdown(grace_s=1.0) + + asyncio.run(go()) + + assert bot.drawn.calls == [] + + +def test_a_run_fires_when_its_time_arrives(monkeypatch: pytest.MonkeyPatch) -> None: + clock = FakeClock(at=datetime(2026, 7, 29, 6, 59, tzinfo=CHICAGO)) + bot = scheduled_bot(clock, monkeypatch, schedule_hour=7) + + async def go() -> None: + bot.start_scheduler() + await settle() + assert bot.drawn.calls == [] # not yet due + clock.at = datetime(2026, 7, 29, 7, 0, 1, tzinfo=CHICAGO) + await settle() + await bot.shutdown(grace_s=1.0) + + asyncio.run(go()) + + # Once, and once only: after firing, the next target is tomorrow. + assert bot.drawn.calls == ["post_result"] + + +def test_a_changed_schedule_rearms_without_a_restart(monkeypatch: pytest.MonkeyPatch) -> None: + clock = FakeClock(at=datetime(2026, 7, 29, 6, 0, tzinfo=CHICAGO)) + bot = scheduled_bot(clock, monkeypatch, schedule_hour=7) + + async def go() -> None: + bot.start_scheduler() + await settle() + + update_config(bot.settings, {"schedule_hour": 6, "schedule_minute": 30}) + await settle() # the loop notices and re-arms for 06:30 while it is still 06:00 + + clock.at = datetime(2026, 7, 29, 6, 31, tzinfo=CHICAGO) + await settle() + await bot.shutdown(grace_s=1.0) + + asyncio.run(go()) + + # Nothing fires at 06:31 unless the running loop moved its target off 07:00. + assert bot.drawn.calls == ["post_result"] + + +def test_a_digest_that_cannot_be_posted_does_not_stop_tomorrows(monkeypatch: pytest.MonkeyPatch) -> None: + """The one thing that must not happen quietly: the loop dying and no digest ever again.""" + clock = FakeClock(at=datetime(2026, 7, 29, 6, 59, tzinfo=CHICAGO)) + bot = scheduled_bot(clock, monkeypatch, schedule_hour=7) + attempts = 0 + + async def refuse(*_: object) -> None: + nonlocal attempts + attempts += 1 + msg = "Slack said no" + raise RuntimeError(msg) + + monkeypatch.setattr(bot, "post_result", refuse) + + async def go() -> None: + bot.start_scheduler() + await settle() # let it arm for 07:00 today; a spawned task runs nothing until awaited + clock.at = datetime(2026, 7, 29, 7, 0, 1, tzinfo=CHICAGO) + await settle() + clock.at = datetime(2026, 7, 30, 7, 0, 1, tzinfo=CHICAGO) + await settle() + await bot.shutdown(grace_s=1.0) + + asyncio.run(go()) + + assert attempts == 2 # noqa: PLR2004 - today's and tomorrow's, so the loop outlived the failure + # And a digest nobody could be told about is not recorded, so it still reads as missed. + assert WhobotSettings().last_run_at is None + + +def test_shutdown_stops_the_scheduler(monkeypatch: pytest.MonkeyPatch) -> None: + clock = FakeClock(at=datetime(2026, 7, 29, 6, 59, tzinfo=CHICAGO)) + bot = scheduled_bot(clock, monkeypatch, schedule_hour=7) + + async def go() -> None: + bot.start_scheduler() + await settle() + await bot.shutdown(grace_s=1.0) + clock.at = datetime(2026, 7, 29, 7, 0, 1, tzinfo=CHICAGO) + await settle() + + asyncio.run(go()) + + assert bot.drawn.calls == [] + + +def test_no_digest_channel_means_no_scheduled_digest( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + """Said out loud, because the alternative is a bot that silently never reports.""" + clock = FakeClock(at=datetime(2026, 7, 29, 7, 0, 1, tzinfo=CHICAGO)) + monkeypatch.setattr("pqn_whobot.whobot.datetime", clock) + monkeypatch.setattr("pqn_whobot.whobot.SCHEDULER_TICK_S", 0.005) + bot = FlowSpy(settings_for(schedule_hour=7)) + + async def go() -> None: + bot.start_scheduler() + await settle() + await bot.shutdown(grace_s=1.0) + + with caplog.at_level(logging.WARNING): + asyncio.run(go()) + + assert bot.drawn.calls == [] + assert "digest_channel" in caplog.text + + +# -------------------------------------------------------------------------------------- +# The two schedule Actions. +# -------------------------------------------------------------------------------------- + + +def fields_of(report: Report) -> dict[str, str]: + return {f.name: f.value for f in report.sections[0].fields} + + +def test_digest_status_reports_a_run_that_happened(monkeypatch: pytest.MonkeyPatch) -> None: + clock = FakeClock(at=datetime(2026, 7, 29, 9, 0, tzinfo=CHICAGO)) + bot = scheduled_bot( + clock, monkeypatch, schedule_hour=7, last_run_at=datetime(2026, 7, 29, 7, 0, tzinfo=CHICAGO), last_result="ok" + ) + + async def go() -> Report: + bot.start_scheduler() + report = await bot.digest_status() + await bot.shutdown(grace_s=1.0) + return report + + report = asyncio.run(go()) + + assert report.status is Status.OK + assert report.notes == [] + assert report.summary == "Scheduled for 07:00 America/Chicago." + # Every time an operator is shown is in the digest's zone, never the host's. + assert fields_of(report) == { + "Next run": "2026-07-30 07:00 CDT", + "Last run": "2026-07-29 07:00 CDT", + "Last result": "ok", + } + + +def test_digest_status_reports_a_missed_run(monkeypatch: pytest.MonkeyPatch) -> None: + """Derived from the two timestamps: nothing records that a run was skipped.""" + clock = FakeClock(at=datetime(2026, 7, 29, 9, 0, tzinfo=CHICAGO)) + bot = scheduled_bot(clock, monkeypatch, schedule_hour=7, last_run_at=datetime(2026, 7, 27, 12, 0, tzinfo=UTC)) + + async def go() -> Report: + bot.start_scheduler() + report = await bot.digest_status() + await bot.shutdown(grace_s=1.0) + return report + + report = asyncio.run(go()) + + assert report.status is Status.WARN + assert report.notes == ["The 2026-07-29 07:00 CDT run did not happen; a missed run is never replayed."] + + +def test_digest_status_says_when_nothing_is_scheduled(monkeypatch: pytest.MonkeyPatch) -> None: + """A bot with no digest_channel has no scheduler, and must not look healthy.""" + clock = FakeClock(at=datetime(2026, 7, 29, 9, 0, tzinfo=CHICAGO)) + monkeypatch.setattr("pqn_whobot.whobot.datetime", clock) + bot = FlowSpy(settings_for(schedule_hour=7)) + + report = asyncio.run(bot.digest_status()) + + assert report.status is Status.WARN + assert fields_of(report)["Last run"] == "never" + assert fields_of(report)["Last result"] == "nothing recorded" + assert "no digest_channel" in report.notes[0] + + +def test_changing_the_schedule_persists_and_says_when_it_next_runs(monkeypatch: pytest.MonkeyPatch) -> None: + clock = FakeClock(at=datetime(2026, 7, 29, 9, 0, tzinfo=CHICAGO)) + bot = scheduled_bot(clock, monkeypatch, schedule_hour=7) + + report = asyncio.run(bot.set_digest_schedule(hour=21, minute=15)) + + assert report.status is Status.OK + assert report.summary == "Scheduled for 21:15 America/Chicago, was 07:00 America/Chicago." + assert fields_of(report) == {"Next run": "2026-07-29 21:15 CDT"} + assert (bot.settings.schedule_hour, bot.settings.schedule_minute) == (21, 15) + assert WhobotSettings().schedule_hour == 21 # noqa: PLR2004 - and it survives a restart + + +def test_the_schedule_form_opens_on_the_schedule_in_force(monkeypatch: pytest.MonkeyPatch) -> None: + clock = FakeClock(at=datetime(2026, 7, 29, 9, 0, tzinfo=CHICAGO)) + bot = scheduled_bot(clock, monkeypatch, schedule_hour=21, schedule_minute=15) + + run(bot, PendingInvocation(action="set_digest_schedule")) + + assert bot.drawn.calls == ["ask_for_params"] + assert bot.drawn.initial == {"hour": 21, "minute": 15} + + +@pytest.mark.parametrize(("hour", "minute"), [(24, 0), (-1, 0), (7, 60), (7, -1)]) +def test_the_schedule_action_refuses_what_the_config_would( + monkeypatch: pytest.MonkeyPatch, hour: int, minute: int +) -> None: + """Two places state what a time of day is, so this keeps them from drifting apart. + + The Action must refuse anything ``WhobotSettings`` would, or the write succeeds and Whobot + cannot load its own config on the next start. + """ + clock = FakeClock(at=datetime(2026, 7, 29, 9, 0, tzinfo=CHICAGO)) + bot = scheduled_bot(clock, monkeypatch, schedule_hour=7) + + report = asyncio.run(bot.set_digest_schedule(hour=hour, minute=minute)) + + with pytest.raises(ValidationError): + WhobotSettings(schedule_hour=hour, schedule_minute=minute) + assert report.status is Status.FAIL + assert (bot.settings.schedule_hour, bot.settings.schedule_minute) == (7, 0) + assert not Path("whobot.toml").exists(), "a refused schedule must not touch the file" diff --git a/tests/pytest/test_whobot_schedule.py b/tests/pytest/test_whobot_schedule.py new file mode 100644 index 0000000..d235075 --- /dev/null +++ b/tests/pytest/test_whobot_schedule.py @@ -0,0 +1,123 @@ +"""Tests for when the Daily Digest is due. Pure arithmetic: no clock, no config file.""" + +from datetime import UTC +from datetime import datetime +from datetime import timedelta +from pathlib import Path +from zoneinfo import ZoneInfo + +import pytest + +from pqn_whobot.config import WhobotSettings +from pqn_whobot.schedule import Schedule + +CHICAGO = ZoneInfo("America/Chicago") +AT_SEVEN = Schedule(hour=7, minute=0, timezone=CHICAGO) + + +def test_fires_later_today_when_the_time_has_not_passed() -> None: + fires_at = AT_SEVEN.next_after(datetime(2026, 7, 29, 6, 30, tzinfo=CHICAGO)) + + assert fires_at == datetime(2026, 7, 29, 7, 0, tzinfo=CHICAGO) + + +def test_fires_tomorrow_when_today_is_already_past() -> None: + fires_at = AT_SEVEN.next_after(datetime(2026, 7, 29, 7, 30, tzinfo=CHICAGO)) + + assert fires_at == datetime(2026, 7, 30, 7, 0, tzinfo=CHICAGO) + + +def test_a_run_that_has_just_fired_computes_tomorrow_not_itself() -> None: + """Strictly-after, or the loop would fire the same scheduled time repeatedly.""" + fired_at = datetime(2026, 7, 29, 7, 0, tzinfo=CHICAGO) + + assert AT_SEVEN.next_after(fired_at) == fired_at + timedelta(days=1) + + +def test_the_schedule_is_read_in_its_own_zone_not_the_callers() -> None: + """12:15 UTC is 07:15 in Chicago, so the 07:00 run is already past.""" + fires_at = AT_SEVEN.next_after(datetime(2026, 7, 29, 12, 15, tzinfo=UTC)) + + assert fires_at == datetime(2026, 7, 30, 7, 0, tzinfo=CHICAGO) + + +def _real_hours_between(first: datetime, second: datetime) -> timedelta: + """Measure elapsed time as a clock outside the zone would. + + Subtracting two datetimes sharing a `tzinfo` answers in wall clock, so a DST assertion made + that way reads 24 hours across every boundary and can never fail. + """ + return second.astimezone(UTC) - first.astimezone(UTC) + + +def test_the_digest_stays_at_seven_across_spring_forward() -> None: + """Chicago loses an hour on 8 March 2026, so that run is 23 real hours after the last. + + Advancing an instant instead of the local date drifts the digest to 08:00 until autumn. + """ + first = AT_SEVEN.next_after(datetime(2026, 3, 6, 7, 30, tzinfo=CHICAGO)) + second = AT_SEVEN.next_after(first) + + assert (first.day, second.day) == (7, 8) + assert (first.hour, second.hour) == (7, 7) + assert _real_hours_between(first, second) == timedelta(hours=23) + + +def test_the_digest_stays_at_seven_across_fall_back() -> None: + """Chicago gains an hour early on 1 November 2026, so that run is 25 real hours later.""" + first = AT_SEVEN.next_after(datetime(2026, 10, 30, 7, 30, tzinfo=CHICAGO)) + second = AT_SEVEN.next_after(first) + + assert (first.day, second.day) == (31, 1) + assert (first.hour, second.hour) == (7, 7) + assert _real_hours_between(first, second) == timedelta(hours=25) + + +def test_a_different_zone_moves_the_digest() -> None: + eastern = Schedule(hour=7, minute=0, timezone=ZoneInfo("America/New_York")) + + # 05:30 Central is 06:30 Eastern, so the Eastern 07:00 run is half an hour away. + fires_at = eastern.next_after(datetime(2026, 7, 29, 5, 30, tzinfo=CHICAGO)) + + assert fires_at == datetime(2026, 7, 29, 6, 0, tzinfo=CHICAGO) + + +def test_a_minute_other_than_zero_is_respected() -> None: + late = Schedule(hour=23, minute=45, timezone=CHICAGO) + + assert late.next_after(datetime(2026, 7, 29, 23, 44, tzinfo=CHICAGO)) == datetime( + 2026, 7, 29, 23, 45, tzinfo=CHICAGO + ) + + +def test_the_previous_run_is_earlier_today_once_the_time_has_passed() -> None: + ran_at = AT_SEVEN.previous_at_or_before(datetime(2026, 7, 29, 9, 0, tzinfo=CHICAGO)) + + assert ran_at == datetime(2026, 7, 29, 7, 0, tzinfo=CHICAGO) + + +def test_the_previous_run_is_yesterday_before_todays_time() -> None: + ran_at = AT_SEVEN.previous_at_or_before(datetime(2026, 7, 29, 6, 0, tzinfo=CHICAGO)) + + assert ran_at == datetime(2026, 7, 28, 7, 0, tzinfo=CHICAGO) + + +def test_the_scheduled_instant_itself_counts_as_the_previous_run() -> None: + """At-or-before, so a digest that has just run is not also reported as overdue.""" + fired_at = datetime(2026, 7, 29, 7, 0, tzinfo=CHICAGO) + + assert AT_SEVEN.previous_at_or_before(fired_at) == fired_at + + +def test_it_describes_itself_with_its_zone() -> None: + """What the status Action shows an operator; the zone is never left implied.""" + assert str(AT_SEVEN) == "07:00 America/Chicago" + assert str(Schedule(hour=0, minute=5, timezone=CHICAGO)) == "00:05 America/Chicago" + + +def test_it_is_built_from_the_configured_schedule(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The one test needing settings, so the one needing a working directory.""" + monkeypatch.chdir(tmp_path) + settings = WhobotSettings(schedule_timezone="America/New_York", schedule_hour=6, schedule_minute=30) + + assert Schedule.from_settings(settings) == Schedule(hour=6, minute=30, timezone=ZoneInfo("America/New_York")) diff --git a/tests/pytest/test_whobot_slack.py b/tests/pytest/test_whobot_slack.py index c3bf5ea..3ed0ce1 100644 --- a/tests/pytest/test_whobot_slack.py +++ b/tests/pytest/test_whobot_slack.py @@ -6,11 +6,13 @@ to a section — and the two rendering rules that come from the data rather than from a flag. """ +import asyncio import json from pathlib import Path from typing import Any import pytest +from slack_sdk.errors import SlackApiError from pqn_whobot.actions import ActionResult from pqn_whobot.actions import DigestResult @@ -363,6 +365,10 @@ def chsh_action() -> Any: return scan_actions(WhobotSlack)["run_chsh"] +def schedule_action() -> Any: + return scan_actions(WhobotSlack)["set_digest_schedule"] + + def checkboxes(act: Any, initial: dict[str, object]) -> Block: return next(b for b in WhobotSlack._form_blocks(act, initial) if b["block_id"] == PARAMS_BLOCK) # noqa: SLF001 @@ -395,6 +401,25 @@ def test_a_float_parameter_becomes_a_number_input() -> None: assert [b["label"]["text"] for b in blocks] == ["Angle A", "Angle B"] +def test_an_int_parameter_becomes_a_number_input_that_refuses_decimals() -> None: + """The third widget mapping. An hour of the day has no decimals, so the widget says so.""" + blocks = WhobotSlack._form_blocks(schedule_action(), {"hour": 21, "minute": 15}) # noqa: SLF001 + + assert [b["element"]["type"] for b in blocks] == ["number_input", "number_input"] + assert [b["element"]["initial_value"] for b in blocks] == ["21", "15"] + assert not any(b["element"]["is_decimal_allowed"] for b in blocks), "07:30 is not half past seven" + + +def test_an_int_number_input_reads_back_as_an_int() -> None: + act = schedule_action() + state = { + _param_block("hour"): {_param_block("hour"): {"type": "number_input", "value": "21"}}, + _param_block("minute"): {_param_block("minute"): {"type": "number_input", "value": "15"}}, + } + + assert act.coerce_params(WhobotSlack._read_form(act, state)) == {"hour": 21, "minute": 15} # noqa: SLF001 + + def test_an_action_with_no_booleans_renders_no_checkbox_group() -> None: """Slack rejects a checkbox element with no options, so an empty group must not be sent.""" blocks = WhobotSlack._form_blocks(chsh_action(), {}) # noqa: SLF001 @@ -478,3 +503,83 @@ def test_targets_are_labelled_with_both_name_and_address() -> None: assert _target_label(Node(api_url=ALICE, name="uiuc-public-left", reachable=True)) == f"uiuc-public-left — {ALICE}" assert _target_label(Node(api_url=ALICE, reachable=False)) == f"(unknown) — {ALICE}" + + +# -------------------------------------------------------------------------------------- +# The digest channel preflight. Same reasoning as the token preflight: a wrong channel is +# a bot that starts, looks healthy, and posts nothing at 07:00 the next morning. +# -------------------------------------------------------------------------------------- + + +def answering_conversations_info(bot: WhobotSlack, monkeypatch: pytest.MonkeyPatch, response: Any) -> None: + """Answer `conversations.info` with a canned response, or raise a canned Slack error.""" + + async def info(*, channel: str) -> Any: # noqa: ARG001 + if isinstance(response, Exception): + raise response + return response + + monkeypatch.setattr(bot.app.client, "conversations_info", info) + + +def slack_error(error: str, **extra: str) -> SlackApiError: + return SlackApiError(message=error, response={"ok": False, "error": error, **extra}) + + +def bot_for(channel: str) -> WhobotSlack: + return WhobotSlack(WhobotSettings(slack_bot_token="xoxb-not-a-real-token", digest_channel=channel)) # noqa: S106 + + +def test_a_channel_whobot_is_in_passes(monkeypatch: pytest.MonkeyPatch) -> None: + bot = bot_for("C0BLCR837LJ") + answering_conversations_info(bot, monkeypatch, {"channel": {"name": "pqn-ops", "is_member": True}}) + + assert asyncio.run(bot.check_digest_channel()) is None + + +def test_a_channel_id_that_does_not_exist_is_named(monkeypatch: pytest.MonkeyPatch) -> None: + """The example config ships a placeholder ID, so this is the likeliest way to get it wrong.""" + bot = bot_for("C0123456789") + answering_conversations_info(bot, monkeypatch, slack_error("channel_not_found")) + + problem = asyncio.run(bot.check_digest_channel()) + + assert problem is not None + assert "C0123456789" in problem + assert "channel_not_found" in problem + + +def test_a_channel_whobot_is_not_in_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + """`chat:write` alone cannot post to a channel the bot has not been invited to.""" + bot = bot_for("C0BLCR837LJ") + answering_conversations_info(bot, monkeypatch, {"channel": {"name": "pqn-ops", "is_member": False}}) + + problem = asyncio.run(bot.check_digest_channel()) + + assert problem is not None + assert "#pqn-ops" in problem + + +def test_a_token_that_cannot_look_is_refused(monkeypatch: pytest.MonkeyPatch) -> None: + """A check that can be skipped is a check nobody has, so an unverifiable channel stops the bot. + + The message has to name the scope and the reinstall, because that is the whole remedy. + """ + bot = bot_for("C0BLCR837LJ") + answering_conversations_info(bot, monkeypatch, slack_error("missing_scope", provided="channels:history,chat:write")) + + problem = asyncio.run(bot.check_digest_channel()) + + assert problem is not None + assert "channels:read" in problem + assert "Reinstall" in problem + # Quoting the granted scopes back is what tells `channels:history` from `channels:read`. + assert "channels:history,chat:write" in problem + + +def test_no_digest_channel_is_not_a_problem_to_report(monkeypatch: pytest.MonkeyPatch) -> None: + """Leaving it unset turns the scheduled digest off, which start_scheduler already says.""" + bot = bot_for("") + answering_conversations_info(bot, monkeypatch, slack_error("channel_not_found")) + + assert asyncio.run(bot.check_digest_channel()) is None From 4a8c7d03854ea0731abff21ab3e00057d10124e3 Mon Sep 17 00:00:00 2001 From: marcosf2 Date: Wed, 29 Jul 2026 17:02:53 -0500 Subject: [PATCH 7/7] Trimmed old code from original daily reporter --- README.md | 55 +++- configs/config_example.toml | 12 +- src/pqn_node/cli.py | 135 +--------- src/pqn_node/core/config.py | 13 +- src/pqn_node/cron_manager.py | 82 ------ src/pqn_node/daily_report.py | 486 ----------------------------------- 6 files changed, 61 insertions(+), 722 deletions(-) delete mode 100644 src/pqn_node/cron_manager.py delete mode 100644 src/pqn_node/daily_report.py diff --git a/README.md b/README.md index b5f1eef..16840c7 100644 --- a/README.md +++ b/README.md @@ -128,18 +128,61 @@ The endpoint returns before the machine goes down, so the caller gets a response > [!WARNING] > Neither route is authenticated, like every other Node API route — Nodes are expected to listen only on their VPN addresses, and membership of that network is the trust boundary. Any member of it can reboot any Node. -### Daily report +### Install the Web GUI + +See [pqn-gui](https://github.com/PublicQuantumNetwork/pqn-gui) for install and start instructions. + +## Whobot + +Whobot is how you operate a Network from Slack. It is the second deployable in this repo +(`pqn_whobot`), and **one** instance serves **every** Node, talking to each over the Node API. +It does not run on a Node — put it anywhere that can reach them. + +Type `/whobot` in Slack and pick from a menu: + +- **Daily Digest** — every Node's hardware health *and* a real CHSH and Quantum Fortune run, in + one scheduled message. Also runnable on demand, and its schedule is changeable from the menu. +- **List Nodes / Node Info / Check one Node** — what is out there, and is it well. +- **Screenshot / Reboot** — see a Node's screen, or restart it (with a confirm step). +- **Change Game availability** — turn Games on and off without touching `config.toml`. +- **Run CHSH / Run Quantum Fortune** — a single measurement, by hand. + +### Set up Whobot -Run or schedule the Slack health-report digest: +**1. Create the Slack app** at → *Create New App* → *From scratch*, +and name it Whobot. Then, in its settings: + +| Page | Do this | +|---|---| +| **Socket Mode** | Toggle on, and generate an app-level token with `connections:write`. This is `slack_app_token` (`xapp-…`). | +| **OAuth & Permissions** | Bot token scopes `chat:write`, `commands`, `files:write`, `channels:read` — plus `groups:read` if the digest goes to a private channel. Install to the workspace and copy the bot token (`xoxb-…`) as `slack_bot_token`. | +| **Slash Commands** | Create `/whobot`. Leave the Request URL blank. | +| **Interactivity & Shortcuts** | Toggle on. Request URL blank here too. | + +Socket Mode is why both URLs stay blank: Whobot dials **out** to Slack, so it needs no public +address, no certificate, and no inbound firewall rule. Watch the scope list — `channels:read` sits +next to `channels:history`, and the wrong one makes Whobot refuse to start. + +Finally, invite the bot to the channel the digest should go to, and copy that channel's ID from +the bottom of its *About* tab. + +**2. Configure and run.** `configs/whobot_example.toml` is a commented reference for every key. ```bash -uv run pqn-node daily-report run -uv run pqn-node daily-report schedule +cp configs/whobot_example.toml whobot.toml # fill in: both tokens, digest_channel, each Node's address +uv sync --extra whobot # the Slack transport; a Node itself does not need it +uv run whobot nodes # check: every Node listed, named, and reachable +uv run whobot serve # holds the Slack connection and fires the digest ``` -### Install the Web GUI +Whobot reads `whobot.toml` from the directory it is started in, and never a Node's `config.toml` — +its config is the Network's, not a machine's. `whobot serve` checks the tokens and the digest +channel before it starts, so a bad token or a channel the bot is not in fails immediately rather +than at 07:00. -See [pqn-gui](https://github.com/PublicQuantumNetwork/pqn-gui) for install and start instructions. +> [!NOTE] +> Whobot has no access control of its own: anyone who can see the bot can run any Action, +> including Reboot. The channel is the audit log. ## Acknowledgements diff --git a/configs/config_example.toml b/configs/config_example.toml index 4b057dc..06c09e6 100644 --- a/configs/config_example.toml +++ b/configs/config_example.toml @@ -52,14 +52,4 @@ dark_count = 0 # RNG / Quantum Fortune settings [rng_settings] channels = [1, 2] # Timetagger channels to sample for singles parity -fortune_size = 8 # Number of parity measurements per fortune run - -# Daily report settings (for automated Slack reporting of hardware + games) -[daily_report] -slack_webhook_url = "https://hooks.slack.com/services/YOUR/WEBHOOK/URL" # Get from https://api.slack.com/apps -api_url = "http://localhost:8000" # API endpoint (usually localhost if running on same machine) -timetagger_address = "127.0.0.1:8000" # TimeTagger address -follower_node_address = "192.168.1.100:9000" # Replace with actual follower node address -basis = [0, 22.5] # CHSH basis angles to use for daily measurements -overall_timeout_s = 1800 # Hard watchdog for the whole run (SIGALRM). Posts a Slack error and exits if exceeded. -per_game_timeout_s = 600 # Per-game HTTP timeout. \ No newline at end of file +fortune_size = 8 # Number of parity measurements per fortune run \ No newline at end of file diff --git a/src/pqn_node/cli.py b/src/pqn_node/cli.py index 61c2f3f..be97ba9 100644 --- a/src/pqn_node/cli.py +++ b/src/pqn_node/cli.py @@ -5,13 +5,7 @@ import typer from pqn_node.core.config import config_path -from pqn_node.core.config import get_settings from pqn_node.core.config import write_config -from pqn_node.cron_manager import describe_schedule -from pqn_node.cron_manager import get_daily_report_job -from pqn_node.cron_manager import remove_daily_report_job -from pqn_node.cron_manager import set_daily_report_schedule -from pqn_node.daily_report import run_daily_report # TODO: check if this way of handling logging from a command line script is ok. logging.basicConfig(level=logging.INFO) @@ -20,8 +14,16 @@ app = typer.Typer(no_args_is_help=True, help="CLI for pqn-node.") -daily_report_app = typer.Typer(no_args_is_help=True, help="Run and manage the daily health + Slack report.") -app.add_typer(daily_report_app, name="daily-report") + +@app.callback() +def main() -> None: + """ + Keep Typer in subcommand mode. + + With a single registered command and no callback, Typer collapses the app and + `pqn-node toggle-game chsh` becomes `pqn-node chsh`. This existed implicitly while the + `daily-report` subcommands were here; it has to be explicit now that they are gone. + """ @app.command() @@ -57,122 +59,5 @@ def toggle_game( logger.info("Games %s %s in %s. Restart the server for changes to take effect.", games, status, path) -@daily_report_app.command("run") -def daily_report_run() -> None: - """ - Run the daily health + games report and post the result to Slack. - - Reads the [daily_report] section from config.toml, probes hardware via the - running API (`/health`), exercises each enabled game (except SSM), and posts a - consolidated Slack digest. Exits non-zero if anything failed. - """ - report_config = get_settings().daily_report - if report_config is None: - logger.error("[daily_report] section missing from config.toml") - raise typer.Exit(code=1) - - raise typer.Exit(code=run_daily_report(report_config)) - - -@daily_report_app.command("status") -def daily_report_status() -> None: - """Show whether the daily report cron job is active and its schedule.""" - job = get_daily_report_job() - if job is None: - typer.echo("Daily report is not scheduled.") - else: - typer.echo(f"Daily report is active. Schedule: {describe_schedule(job)}") - - -_DOW_MAP = { - "monday": "1", - "tuesday": "2", - "wednesday": "3", - "thursday": "4", - "friday": "5", - "saturday": "6", - "sunday": "0", -} - - -def _prompt_hhmm() -> tuple[int, int]: - raw_time = typer.prompt("Time (HH:MM, 24-hour)") - try: - h_str, m_str = raw_time.strip().split(":") - hour, minute = int(h_str), int(m_str) - except ValueError: - typer.echo("Invalid time format. Use HH:MM (e.g. 09:00).", err=True) - raise typer.Exit(code=1) # noqa: B904 - if not (0 <= hour <= 23 and 0 <= minute <= 59): # noqa: PLR2004 - typer.echo("Hour must be 0-23 and minute 0-59.", err=True) - raise typer.Exit(code=1) - return hour, minute - - -def _prompt_dow() -> str: - raw_day = typer.prompt("Day of week (monday-sunday)").strip().lower() - if raw_day not in _DOW_MAP: - typer.echo(f"Invalid day '{raw_day}'.", err=True) - raise typer.Exit(code=1) - return _DOW_MAP[raw_day] - - -def _prompt_dom() -> str: - raw_dom = typer.prompt("Day of month (1-28)") - dom_int = int(raw_dom) - if not 1 <= dom_int <= 28: # noqa: PLR2004 - typer.echo("Day of month must be between 1 and 28.", err=True) - raise typer.Exit(code=1) - return str(dom_int) - - -@daily_report_app.command("schedule") -def daily_report_schedule() -> None: - """Interactively schedule the daily report cron job.""" - frequency = typer.prompt("Frequency (hourly/daily/weekly/monthly)").strip().lower() - valid = {"hourly", "daily", "weekly", "monthly"} - if frequency not in valid: - typer.echo(f"Invalid frequency '{frequency}'. Choose from: {', '.join(sorted(valid))}", err=True) - raise typer.Exit(code=1) - - minute: int - hour: int | str = "*" - dow = "*" - dom = "*" - - if frequency == "hourly": - raw_minute = typer.prompt("Minute past the hour (0-59)") - minute = int(raw_minute) - if not 0 <= minute <= 59: # noqa: PLR2004 - typer.echo("Minute must be between 0 and 59.", err=True) - raise typer.Exit(code=1) - else: - hour, minute = _prompt_hhmm() - if frequency == "weekly": - dow = _prompt_dow() - elif frequency == "monthly": - dom = _prompt_dom() - - try: - set_daily_report_schedule(minute=minute, hour=hour, dow=dow, dom=dom) - except RuntimeError as e: - typer.echo(str(e), err=True) - raise typer.Exit(code=1) # noqa: B904 - - job = get_daily_report_job() - description = describe_schedule(job) if job else "unknown" - typer.echo(f"Daily report scheduled. Schedule: {description}") - - -@daily_report_app.command("unschedule") -def daily_report_unschedule() -> None: - """Remove the daily report cron job.""" - removed = remove_daily_report_job() - if removed: - typer.echo("Daily report unscheduled.") - else: - typer.echo("Daily report was not scheduled.") - - if __name__ == "__main__": app() diff --git a/src/pqn_node/core/config.py b/src/pqn_node/core/config.py index 202e25f..bb110f3 100644 --- a/src/pqn_node/core/config.py +++ b/src/pqn_node/core/config.py @@ -23,16 +23,6 @@ logger = logging.getLogger(__name__) -class DailyReportConfig(BaseModel): - slack_webhook_url: str - follower_node_address: str - api_url: str = "http://localhost:8000" - timetagger_address: str = "127.0.0.1:8000" - basis: list[float] = Field(default_factory=lambda: [0.0, 22.5]) - overall_timeout_s: int = 1800 - per_game_timeout_s: int = 600 - - class RNGSettings(BaseModel): channels: list[int] = Field(default_factory=lambda: [1, 2]) fortune_size: int = 8 @@ -71,7 +61,6 @@ class Settings(BaseSettings): qkd_settings: QKDSettings = QKDSettings() rng_settings: RNGSettings = RNGSettings() bell_state: BellState = BellState.Phi_plus - daily_report: DailyReportConfig | None = None timetagger: tuple[str, str] | None = None # Name of the timetagger to use for the CHSH experiment. rotary_encoder_address: str = "/dev/ttyACM0" virtual_rotator: bool = False # If True, use terminal input instead of hardware rotary encoder @@ -82,7 +71,7 @@ class Settings(BaseSettings): toml_file="./config.toml", env_file=".env", env_file_encoding="utf-8", - extra="ignore", # Allow extra fields in config.toml (e.g., daily_report) + extra="ignore", ) @classmethod diff --git a/src/pqn_node/cron_manager.py b/src/pqn_node/cron_manager.py deleted file mode 100644 index 6322d3a..0000000 --- a/src/pqn_node/cron_manager.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Manage the PQN daily-report cron job via the system crontab. - -Reads and writes the user's crontab using `crontab -l` / `crontab -` so that -no extra dependencies are required. The managed entry is identified by the -inline tag `# PQN_DAILY_REPORT` appended to the cron line. -""" - -from __future__ import annotations - -import shutil -import subprocess - -CRON_TAG = "# PQN_DAILY_REPORT" - -_DOW_NAMES = ["sunday", "monday", "tuesday", "wednesday", "thursday", "friday", "saturday"] - - -def _read_crontab() -> list[str]: - result = subprocess.run(["crontab", "-l"], check=False, capture_output=True, text=True) # noqa: S607 - if result.returncode != 0: - return [] - return result.stdout.splitlines() - - -def _write_crontab(lines: list[str]) -> None: - content = "\n".join(lines) + "\n" - subprocess.run(["crontab", "-"], input=content, text=True, check=True) # noqa: S607 - - -def get_daily_report_job() -> str | None: - """Return the tagged cron line, or None if not present.""" - for line in _read_crontab(): - if line.endswith(CRON_TAG): - return line - return None - - -def describe_schedule(cron_line: str) -> str: - """Parse a tagged cron line and return a human-readable schedule string.""" - fields = cron_line.split() - if len(fields) < 5: # noqa: PLR2004 - return cron_line - - minute, hour, dom, _month, dow = fields[:5] - - if minute != "*" and hour == "*" and dom == "*" and dow == "*": - return f"Hourly at :{int(minute):02d}" - - if minute != "*" and hour != "*" and dom == "*" and dow == "*": - return f"Daily at {int(hour):02d}:{int(minute):02d}" - - if minute != "*" and hour != "*" and dom == "*" and dow != "*": - day_name = _DOW_NAMES[int(dow)].capitalize() - return f"Weekly on {day_name} at {int(hour):02d}:{int(minute):02d}" - - if minute != "*" and hour != "*" and dom != "*" and dow == "*": - return f"Monthly on day {dom} at {int(hour):02d}:{int(minute):02d}" - - return cron_line - - -def set_daily_report_schedule(minute: int, hour: int | str, dow: str, dom: str) -> None: - """Replace (or create) the tagged cron entry with the given schedule.""" - pqn = shutil.which("pqn") - if pqn is None: - msg = "Could not find 'pqn' executable on PATH" - raise RuntimeError(msg) - - new_line = f"{minute} {hour} {dom} * {dow} {pqn} daily-report run {CRON_TAG}" - lines = [line for line in _read_crontab() if not line.endswith(CRON_TAG)] - lines.append(new_line) - _write_crontab(lines) - - -def remove_daily_report_job() -> bool: - """Remove the tagged cron entry. Returns True if an entry was removed.""" - lines = _read_crontab() - filtered = [line for line in lines if not line.endswith(CRON_TAG)] - if len(filtered) == len(lines): - return False - _write_crontab(filtered) - return True diff --git a/src/pqn_node/daily_report.py b/src/pqn_node/daily_report.py deleted file mode 100644 index 39ff062..0000000 --- a/src/pqn_node/daily_report.py +++ /dev/null @@ -1,486 +0,0 @@ -"""Daily health + games report posted to Slack. - -Run via `pqn-node daily-report` (see pqn_node.cli). Loads `DailyReportConfig`, probes -hardware via `/health`, exercises each enabled game except SSM, and posts a -single consolidated Slack digest. -""" - -from __future__ import annotations - -import logging -import os -import signal -from dataclasses import dataclass -from dataclasses import field -from datetime import UTC -from datetime import datetime -from typing import TYPE_CHECKING -from typing import Any - -import httpx - -from pqn_node.api.routes.chsh import ChshResult -from pqn_node.api.routes.health import ComponentStatus -from pqn_node.api.routes.health import HealthStatus - -if TYPE_CHECKING: - from types import FrameType - - from pqn_node.core.config import DailyReportConfig - -logger = logging.getLogger(__name__) - -_SSM_SKIP_REASON = "SSM skipped in daily report (requires coordination dance; run manually)." -_BELL_CLASSICAL_LIMIT = 2.0 - -_watchdog_state: dict[str, Any] = {} - - -@dataclass -class GameResult: - name: str - title: str - status: str # "ok" | "failed" | "skipped" - data: dict[str, Any] | list[Any] | None = None - error: str | None = None - elapsed_s: float = 0.0 - emoji: str = "" - - -@dataclass -class ReportResult: - hardware: HealthStatus | None = None - hardware_error: str | None = None - games: list[GameResult] = field(default_factory=list) - skipped_notes: list[str] = field(default_factory=list) - - @property - def overall_ok(self) -> bool: - hw_ok = self.hardware_error is None and self.hardware is not None and self.hardware.all_ok - games_ok = all(g.status == "ok" for g in self.games) - return hw_ok and games_ok - - -def _watchdog_handler(_signum: int, _frame: FrameType | None) -> None: - """SIGALRM handler: post a Slack alert and force-kill the process. - - Uses os._exit instead of sys.exit so that atexit handlers, finally blocks, - and Python's own shutdown sequence can't delay or swallow the kill — a hung - process is precisely what we're trying to escape. Reads config from the - module-level dict because signal handlers can't accept arbitrary arguments. - """ - webhook = _watchdog_state.get("webhook_url") - timeout_s = _watchdog_state.get("timeout_s") - message = ( - f":x: *Daily report watchdog fired* — exceeded overall timeout of `{timeout_s}s`. " - "Process was killed; investigate the API / hardware." - ) - if webhook: - try: - _post_plain_slack(webhook, message) - except Exception: - logger.exception("Failed to post watchdog notification") - logger.error("Daily report watchdog fired after %ss; exiting", timeout_s) - os._exit(1) - - -def _arm_watchdog(config: DailyReportConfig) -> None: - """Install the SIGALRM watchdog for the entire run. - - SIGALRM is the right tool here: it fires even if the process is blocked in - a syscall (e.g. waiting on a socket), where threading-based timeouts can't - reach. Stash config in module state so the signal handler can read it. - """ - _watchdog_state["webhook_url"] = config.slack_webhook_url - _watchdog_state["timeout_s"] = config.overall_timeout_s - signal.signal(signal.SIGALRM, _watchdog_handler) - signal.alarm(config.overall_timeout_s) - - -def _disarm_watchdog() -> None: - """Cancel the watchdog once the run completes normally.""" - signal.alarm(0) - - -def _check_api(config: DailyReportConfig) -> bool: - """Return True if the API is reachable, False otherwise. - - Runs before any other probe so that a single "API is down" message replaces - the cascade of connection-refused errors that would otherwise fill the digest. - """ - try: - with httpx.Client(timeout=5.0) as client: - client.get(f"{config.api_url}/") - except httpx.ConnectError: - return False - return True - - -def _fetch_hardware(config: DailyReportConfig) -> tuple[HealthStatus | None, str | None]: - """Probe the node's hardware health and return (result, error). - - Returns a tuple rather than raising so that a failed hardware probe doesn't - abort the whole run — we still want to attempt the games and post a digest - that tells operators what's broken. - """ - url = f"{config.api_url}/health/" - try: - with httpx.Client(timeout=30.0) as client: - response = client.get(url, params={"follower_node_address": config.follower_node_address}) - response.raise_for_status() - return HealthStatus.model_validate(response.json()), None - except httpx.HTTPError as e: - logger.exception("Hardware health probe failed") - return None, f"{type(e).__name__}: {e}" - - -def _fetch_availability(config: DailyReportConfig) -> dict[str, bool]: - """Return which games are enabled according to the running node. - - Defaults to all-enabled on failure so a transient availability fetch error - doesn't silently skip every game — better to attempt and fail visibly than - to skip silently and miss the problem entirely. - """ - url = f"{config.api_url}/games/availability" - try: - with httpx.Client(timeout=10.0) as client: - response = client.get(url) - response.raise_for_status() - payload: dict[str, bool] = response.json() - except httpx.HTTPError: - logger.exception("Failed to fetch games availability; assuming all enabled") - return {"chsh": True, "qf": True, "ssm": True} - return payload - - -def _run_chsh(config: DailyReportConfig) -> GameResult: - """Run one CHSH measurement and return a structured result. - - Catches all exceptions (not just httpx) because pydantic validation errors - on an unexpected server response shape are equally fatal to the game result - and must be captured here rather than crashing the whole report. - """ - started = datetime.now(UTC) - try: - with httpx.Client(timeout=float(config.per_game_timeout_s)) as client: - response = client.post( - f"{config.api_url}/chsh/", - params={ - "follower_node_address": config.follower_node_address, - "timetagger_address": config.timetagger_address, - }, - json=config.basis, - ) - response.raise_for_status() - parsed = ChshResult.model_validate(response.json()) - except httpx.HTTPError as e: - return GameResult( - name="chsh", - title="CHSH — Verify Quantum Link", - status="failed", - error=f"{type(e).__name__}: {e}", - elapsed_s=(datetime.now(UTC) - started).total_seconds(), - ) - except Exception as e: # noqa: BLE001 - surface any parsing / validation error, don't crash the report - return GameResult( - name="chsh", - title="CHSH — Verify Quantum Link", - status="failed", - error=f"{type(e).__name__}: {e}", - elapsed_s=(datetime.now(UTC) - started).total_seconds(), - ) - - emoji = ":sparkles:" if parsed.chsh_value > _BELL_CLASSICAL_LIMIT else ":thinking_face:" - return GameResult( - name="chsh", - title="CHSH — Verify Quantum Link", - status="ok", - data=parsed.model_dump(), - elapsed_s=(datetime.now(UTC) - started).total_seconds(), - emoji=emoji, - ) - - -def _run_qf(config: DailyReportConfig) -> GameResult: - """Run one Quantum Fortune measurement and return a structured result. - - Omits channels and integration_time_s so the node falls back to its own - rng_settings — the daily report shouldn't override per-node calibration. - """ - started = datetime.now(UTC) - # channels and integration_time_s are omitted — the node uses its configured rng_settings defaults. - params: dict[str, str] = {"timetagger_address": config.timetagger_address} - try: - with httpx.Client(timeout=float(config.per_game_timeout_s)) as client: - response = client.get(f"{config.api_url}/rng/fortune", params=params) - response.raise_for_status() - payload = response.json() - except httpx.HTTPError as e: - return GameResult( - name="qf", - title="Quantum Fortune", - status="failed", - error=f"{type(e).__name__}: {e}", - elapsed_s=(datetime.now(UTC) - started).total_seconds(), - ) - - return GameResult( - name="qf", - title="Quantum Fortune", - status="ok", - data={"fortune_per_channel": payload}, - elapsed_s=(datetime.now(UTC) - started).total_seconds(), - emoji=":game_die:", - ) - - -def _format_value(value: Any) -> str: - """Render a scalar or numeric list as a human-readable string for Slack.""" - if isinstance(value, float): - return f"{value:.4f}" - if isinstance(value, list): - if all(isinstance(x, (int, float)) for x in value): - return "[" + ", ".join(f"{x:.4f}" if isinstance(x, float) else str(x) for x in value) + "]" - return str(value) - return str(value) - - -def _fields_from_dict(data: dict[str, Any]) -> list[dict[str, str]]: - """Convert an arbitrary result dict to Slack mrkdwn field blocks. - - Operates on the raw dict rather than a typed model so CHSH and QF results - share one formatting path without coupling this layer to specific schemas. - """ - fields: list[dict[str, str]] = [] - for key, value in data.items(): - label = key.replace("_", " ").title() - fields.append({"type": "mrkdwn", "text": f"*{label}:*\n`{_format_value(value)}`"}) - return fields - - -def _sections_from_fields(fields: list[dict[str, str]]) -> list[dict[str, Any]]: - """Slack section blocks cap fields at 10 per block; chunk accordingly.""" - chunk = 10 - return [{"type": "section", "fields": fields[i : i + chunk]} for i in range(0, len(fields), chunk)] - - -def _component_line(label: str, status: ComponentStatus) -> str: - """Format one hardware component as a single Slack line. - - Accepts the base ComponentStatus type so it works for both plain components - (router, rotary encoder) and DeviceStatus without needing an overload. - """ - emoji = ":white_check_mark:" if status.reachable else ":x:" - if status.reachable and status.latency_ms is not None: - suffix = f" _({status.latency_ms:.0f}ms)_" - elif status.error: - suffix = f" — `{status.error}`" - else: - suffix = "" - return f"{emoji} {label}{suffix}" - - -def _hardware_text(hardware: HealthStatus | None, error: str | None) -> str: - """Render the hardware section of the digest as a mrkdwn string. - - Takes the typed HealthStatus model (not a raw dict) so we can iterate - devices with their purpose labels and show the rotary encoder only when - it was actually probed (i.e. not virtual). - """ - if error is not None: - return f":x: *Hardware probe failed*\n```{error}```" - if hardware is None: - return ":x: *Hardware probe returned no data.*" - - lines = ["*Hardware Health*"] - lines.append(_component_line("Router", hardware.router)) - lines.extend(_component_line(f"`{d.provider}/{d.name}` _({d.purpose})_", d) for d in hardware.devices) - if hardware.rotary_encoder is not None: - lines.append(_component_line("Rotary encoder", hardware.rotary_encoder)) - else: - lines.append(":grey_question: Rotary encoder — _virtual (skipped)_") - if hardware.follower_node is not None: - lines.append(_component_line("Follower node", hardware.follower_node)) - - return "\n".join(lines) - - -def _game_blocks(game: GameResult) -> list[dict[str, Any]]: - """Build Block Kit blocks for one game result. - - Dispatches on status so skipped, failed, and successful games each get an - appropriate layout — skipped and failed collapse to a single block, while - a success expands to include the full data fields. - """ - header = f"{game.emoji} *{game.title}*" if game.emoji else f"*{game.title}*" - if game.status == "skipped": - return [{"type": "section", "text": {"type": "mrkdwn", "text": f":fast_forward: {header}\n_{game.error}_"}}] - - if game.status == "failed": - return [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f":x: {header}\n```{game.error}```\n_Elapsed: {game.elapsed_s:.1f}s_", - }, - } - ] - - blocks: list[dict[str, Any]] = [ - {"type": "section", "text": {"type": "mrkdwn", "text": f":white_check_mark: {header}"}}, - ] - if isinstance(game.data, dict): - blocks.extend(_sections_from_fields(_fields_from_dict(game.data))) - elif isinstance(game.data, list): - blocks.append( - { - "type": "section", - "text": {"type": "mrkdwn", "text": f"`{_format_value(game.data)}`"}, - } - ) - blocks.append( - { - "type": "context", - "elements": [{"type": "mrkdwn", "text": f":stopwatch: {game.elapsed_s:.1f}s"}], - } - ) - return blocks - - -def _build_digest(config: DailyReportConfig, result: ReportResult) -> dict[str, Any]: - """Assemble the full Block Kit digest payload. - - The header emoji distinguishes hardware failures from game failures so - operators can tell at a glance whether they need to check the hardware rack - or look at a software/timing issue. - """ - if result.overall_ok: - header_emoji = ":white_check_mark:" - header_text = "Daily Report — all systems nominal" - elif result.hardware_error is not None or result.hardware is None or not result.hardware.all_ok: - header_emoji = ":warning:" - header_text = "Daily Report — hardware issues detected" - else: - header_emoji = ":warning:" - header_text = "Daily Report — game failures detected" - - blocks: list[dict[str, Any]] = [ - { - "type": "header", - "text": {"type": "plain_text", "text": f"{header_emoji} {header_text}", "emoji": True}, - }, - { - "type": "section", - "text": {"type": "mrkdwn", "text": _hardware_text(result.hardware, result.hardware_error)}, - }, - ] - for game in result.games: - blocks.append({"type": "divider"}) - blocks.extend(_game_blocks(game)) - - if result.skipped_notes: - blocks.append({"type": "divider"}) - blocks.append( - { - "type": "context", - "elements": [{"type": "mrkdwn", "text": "\n".join(result.skipped_notes)}], - } - ) - - blocks.append( - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": ( - f":clock1: {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')} " - f"| Follower: `{config.follower_node_address}` " - f"| TimeTagger: `{config.timetagger_address}`" - ), - } - ], - } - ) - - return {"blocks": blocks} - - -def _post_plain_slack(webhook_url: str, text: str) -> None: - """Post a bare text message to Slack — used as a last-resort fallback.""" - with httpx.Client(timeout=10.0) as client: - client.post(webhook_url, json={"text": text}) - - -def _post_slack(webhook_url: str, message: dict[str, Any]) -> bool: - """Post a Block Kit message to Slack and return whether it was accepted. - - Falls back to a plain-text message if Slack rejects the Block Kit payload - so operators still get a notification even when the formatting is wrong, - rather than silently receiving nothing. - """ - try: - with httpx.Client(timeout=15.0) as client: - response = client.post(webhook_url, json=message) - except httpx.HTTPError: - logger.exception("Slack post failed") - return False - if response.text == "ok": - return True - logger.error("Slack rejected the digest: %s", response.text) - # Fallback: best-effort plain-text notice so the operator sees something. - try: - _post_plain_slack( - webhook_url, - f":warning: Daily report could not post Block Kit digest. Slack said: `{response.text[:200]}`", - ) - except httpx.HTTPError: - logger.exception("Fallback plain Slack post also failed") - return False - - -def run_daily_report(config: DailyReportConfig) -> int: - """Run the daily report end-to-end. Returns a process exit code.""" - _arm_watchdog(config) - try: - if not _check_api(config): - logger.error("API at %s is unreachable", config.api_url) - _post_plain_slack( - config.slack_webhook_url, - f":x: *Daily Report — API is down*\nCould not reach `{config.api_url}`. Start the server and re-run.", - ) - return 1 - - result = ReportResult() - - logger.info("Probing hardware via %s/health/", config.api_url) - hardware, hardware_error = _fetch_hardware(config) - result.hardware = hardware - result.hardware_error = hardware_error - - availability = _fetch_availability(config) - - if availability.get("chsh"): - logger.info("Running CHSH") - result.games.append(_run_chsh(config)) - else: - result.skipped_notes.append(":fast_forward: CHSH disabled in `games_availability`.") - - if availability.get("qf"): - logger.info("Running Quantum Fortune") - result.games.append(_run_qf(config)) - else: - result.skipped_notes.append(":fast_forward: Quantum Fortune disabled in `games_availability`.") - - if availability.get("ssm"): - result.skipped_notes.append(f":fast_forward: {_SSM_SKIP_REASON}") - - digest = _build_digest(config, result) - posted = _post_slack(config.slack_webhook_url, digest) - - if not posted: - return 1 - return 0 if result.overall_ok else 1 - finally: - _disarm_watchdog()