diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..f3f2799 --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,37 @@ +name: Check examples + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - run: uv sync --locked --group dev + - run: uv run --locked pytest -q + - run: uv run --locked ruff check . + - run: uv run --locked ruff format --check . + + typescript: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.3.14" + - working-directory: examples/typescript/task-runner + run: bun install --frozen-lockfile + - working-directory: examples/typescript/task-runner + run: bun run check diff --git a/.gitignore b/.gitignore index f2439d5..096063d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ __pycache__/ *.py[cod] .pytest_cache/ .coverage +htmlcov/ +.ruff_cache/ downloads/ node_modules/ dist/ diff --git a/README.md b/README.md index 52693ad..2333b21 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ | Language | Example | What it demonstrates | | --- | --- | --- | | TypeScript | [Embedded task runner](examples/typescript/task-runner/) | Server-side SDK calls, a live `@mobilerun/react` device stream, status polling, and cancellation | -| Python | [Signed webhook receiver with file download](examples/python/webhook-file-receiver/) | HMAC verification, replay protection, secure SDK downloads, and atomic file storage | +| Python | [Signed webhook receiver with file download](examples/python/webhook-file-receiver/) | HMAC verification, secure SDK downloads, atomic file storage, and a protected live showcase UI | Each example is self-contained, runnable, and documents the decisions you should revisit before taking it to production. @@ -59,14 +59,21 @@ Install the dependencies and run the test suite from this repository: ```bash uv sync -uv run python -m unittest discover \ - -s examples/python/webhook-file-receiver/tests \ - -p 'test_*.py' -v +uv run --group dev pytest -q ``` Then open the [webhook receiver guide](examples/python/webhook-file-receiver/README.md) for configuration and local run instructions. +## Check everything + +After installing [uv](https://docs.astral.sh/uv/) and +[Bun](https://bun.sh/), run the repository check from the root: + +```bash +./scripts/check.sh +``` + ## 📂 Repository layout ```text diff --git a/examples/python/webhook-file-receiver/.env.example b/examples/python/webhook-file-receiver/.env.example index 1370a63..66ddeaa 100644 --- a/examples/python/webhook-file-receiver/.env.example +++ b/examples/python/webhook-file-receiver/.env.example @@ -8,8 +8,23 @@ DOWNLOAD_DIR=./downloads # mobilerun file API MOBILERUN_BASE_URL=https://api.mobilerun.ai/v1 MOBILERUN_CLOUD_API_KEY=replace_with_your_api_key +# For a development key, use: +# MOBILERUN_BASE_URL=https://dev-api.mobilerun.ai/v1 # Optional limits MAX_WEBHOOK_BODY_BYTES=65536 MAX_FILE_BYTES=52428800 MAX_TIMESTAMP_AGE_SECONDS=300 + +# Optional live showcase UI (showcase.py) +PUBLIC_URL=https://replace-with-your-tunnel.ngrok.app +SHOWCASE_TRIGGER_ID=replace-with-your-custom-trigger-uuid +# SHOWCASE_ACCESS_TOKEN=replace-with-a-long-random-value +# SHOWCASE_PREVIEW_MAX_BYTES=100000 +# SHOWCASE_DELETE_WEBHOOK_ON_EXIT=true + +# To reuse an existing endpoint, set all three values together. When omitted, +# showcase.py creates a temporary endpoint and deletes it on shutdown. +# SHOWCASE_WEBHOOK_ID=replace-with-your-webhook-uuid +# SHOWCASE_WEBHOOK_PATH=/mobilerun/replace-with-at-least-24-random-characters +# SHOWCASE_WEBHOOK_SECRET=whsec_replace_with_the_one_time_secret diff --git a/examples/python/webhook-file-receiver/README.md b/examples/python/webhook-file-receiver/README.md index eb93fb1..cc8f9aa 100644 --- a/examples/python/webhook-file-receiver/README.md +++ b/examples/python/webhook-file-receiver/README.md @@ -48,12 +48,17 @@ runtime. ```text receiver.py # minimal executable entry point +showcase.py # protected live workflow demo server +showcase.html # presentation UI document +showcase.css # responsive visual design +showcase.js # session, launch, and live event client mobilerun_webhook_receiver/ config.py # environment validation security.py # HMAC and replay protection downloader.py # SDK request and atomic file storage application.py # webhook event processing http_server.py # HTTP transport + showcase_events.py # bounded event replay for the showcase ``` The layers are composed through small interfaces, so authentication, download, @@ -76,6 +81,9 @@ WEBHOOK_SECRET=whsec_... DOWNLOAD_DIR=./downloads ``` +Use `https://dev-api.mobilerun.ai/v1` as `MOBILERUN_BASE_URL` when testing +with a development API key. + `WEBHOOK_SECRET` is the one-time secret returned when the webhook endpoint is created; it is not the API key or the private URL path. @@ -99,6 +107,54 @@ ngrok http 3000 Configure the webhook endpoint URL as the ngrok HTTPS origin followed by `WEBHOOK_PATH`. +## Live showcase UI + +`showcase.py` adds a presentation-friendly workflow launcher, a live webhook +timeline, and an inline preview for small text files. Browser-facing APIs are +protected by a short-lived session cookie established from the access token in +the generated showcase URL. The webhook callback remains protected separately +by its Mobilerun HMAC signature. + +Start ngrok first, then launch the showcase with your custom trigger ID: + +```bash +ngrok http 3000 +PUBLIC_URL=https://your-tunnel.ngrok.app \ +SHOWCASE_TRIGGER_ID=your-custom-trigger-uuid \ +uv run python showcase.py +``` + +The process prints one JSON object: + +```json +{ + "showcaseUrl": "https://your-tunnel.ngrok.app/#access_token=...", + "webhookUrl": "https://your-tunnel.ngrok.app/mobilerun/showcase-...", + "webhookId": "webhook-uuid", + "temporaryWebhook": true +} +``` + +Before starting the workflow, set the `endpointId` of its `webhooks.send` +action to the printed `webhookId`. Then open the complete `showcaseUrl`; the +fragment contains the private browser access token and is removed from the +address bar after the session is established. + +The temporary endpoint is deleted when the process exits. The workflow action +will still contain its old ID, so update it with the newly printed ID on the +next run. To keep and reuse an endpoint instead, set `SHOWCASE_WEBHOOK_ID`, +`SHOWCASE_WEBHOOK_PATH`, and `SHOWCASE_WEBHOOK_SECRET` together; a reused +endpoint is never deleted by the showcase. + +The optional `SHOWCASE_TRIGGER_ID` is strongly recommended for shared demos. It +limits the UI to one custom trigger and rejects attempts to fire another. When +it is omitted, the UI lists all custom triggers available to the API key. + +Any verified `data.file` reference is downloaded with the same guarded, atomic +downloader as the minimal receiver. Small `text/*`, JSON, XML, and CSV files are +shown inline and retain a visible download button. Binary files remain +download-only. Configure the preview limit with `SHOWCASE_PREVIEW_MAX_BYTES`. + When a verified `workflow.step.emit` event contains `data.file`, the file is saved under `DOWNLOAD_DIR` as: @@ -106,6 +162,10 @@ saved under `DOWNLOAD_DIR` as: -- ``` +This is a single-process showcase, not a hosted multi-user application. For a +long-running deployment, use persistent event storage, server-side user +authentication, rate limiting, and a production HTTP server. + ## Expected file payload ```json @@ -138,7 +198,5 @@ X-Mobilerun-Signature: sha256= From the repository root: ```bash -uv run python -m unittest discover \ - -s examples/python/webhook-file-receiver/tests \ - -p 'test_*.py' -v +uv run --group dev pytest -q ``` diff --git a/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/application.py b/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/application.py index 4d656f9..b006f43 100644 --- a/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/application.py +++ b/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/application.py @@ -3,9 +3,10 @@ from __future__ import annotations import json +from collections.abc import Mapping from http import HTTPStatus from pathlib import Path -from typing import Any, Mapping, Protocol +from typing import Any, Protocol from .config import Config from .downloader import MobilerunFileDownloader @@ -45,7 +46,7 @@ def handle( if auth_error: return auth_error - event_id = headers.get(EVENT_ID_HEADER, "") + event_id = _header(headers, EVENT_ID_HEADER) event = self._parse_event(headers, raw_body, event_id) if isinstance(event, tuple): return event @@ -64,8 +65,8 @@ def _authenticate( try: verify_signature( raw_body, - headers.get(TIMESTAMP_HEADER, ""), - headers.get(SIGNATURE_HEADER, ""), + _header(headers, TIMESTAMP_HEADER), + _header(headers, SIGNATURE_HEADER), self.config.webhook_secret, max_age_ms=self.config.max_timestamp_age_ms, ) @@ -89,7 +90,7 @@ def _parse_event( return HTTPStatus.BAD_REQUEST, {"message": "event must be an object"} if event.get("id") != event_id: return HTTPStatus.BAD_REQUEST, {"message": "event id mismatch"} - event_type = headers.get(EVENT_TYPE_HEADER) + event_type = _header(headers, EVENT_TYPE_HEADER) if event_type and event.get("type") != event_type: return HTTPStatus.BAD_REQUEST, {"message": "event type mismatch"} return event @@ -147,3 +148,12 @@ def _file_data(event: Mapping[str, Any]) -> Mapping[str, Any] | None: if not isinstance(file_data, dict): raise PayloadError("event.data.file must be an object") return file_data + + +def _header(headers: Mapping[str, str], name: str) -> str: + """Read an HTTP header without depending on its wire-case spelling.""" + wanted = name.casefold() + for key, value in headers.items(): + if key.casefold() == wanted: + return value + return "" diff --git a/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/config.py b/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/config.py index 43c4f67..8062619 100644 --- a/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/config.py +++ b/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/config.py @@ -3,15 +3,15 @@ from __future__ import annotations import os +from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path -from typing import Mapping from urllib.parse import urlsplit from .errors import ConfigurationError -def _positive_int(env: Mapping[str, str], name: str, default: int) -> int: +def positive_int(env: Mapping[str, str], name: str, default: int) -> int: raw = env.get(name, str(default)) try: value = int(raw) @@ -36,7 +36,7 @@ class Config: max_timestamp_age_ms: int @classmethod - def from_env(cls, env: Mapping[str, str] | None = None) -> "Config": + def from_env(cls, env: Mapping[str, str] | None = None) -> Config: values = os.environ if env is None else env webhook_path = values.get("WEBHOOK_PATH", "") secret = values.get("WEBHOOK_SECRET", "") @@ -53,24 +53,24 @@ def from_env(cls, env: Mapping[str, str] | None = None) -> "Config": raise ConfigurationError("WEBHOOK_SECRET is required") if not api_key: raise ConfigurationError("MOBILERUN_CLOUD_API_KEY is required") - _validate_api_base_url(api_base_url) + validate_api_base_url(api_base_url) - age_seconds = _positive_int(values, "MAX_TIMESTAMP_AGE_SECONDS", 300) + age_seconds = positive_int(values, "MAX_TIMESTAMP_AGE_SECONDS", 300) return cls( host=values.get("HOST", "127.0.0.1"), - port=_positive_int(values, "PORT", 3000), + port=positive_int(values, "PORT", 3000), webhook_path=webhook_path, webhook_secret=secret.encode(), api_base_url=api_base_url, api_key=api_key, download_dir=Path(values.get("DOWNLOAD_DIR", "./downloads")), - max_body_bytes=_positive_int(values, "MAX_WEBHOOK_BODY_BYTES", 65_536), - max_file_bytes=_positive_int(values, "MAX_FILE_BYTES", 50 * 1024**2), + max_body_bytes=positive_int(values, "MAX_WEBHOOK_BODY_BYTES", 65_536), + max_file_bytes=positive_int(values, "MAX_FILE_BYTES", 50 * 1024**2), max_timestamp_age_ms=age_seconds * 1000, ) -def _validate_api_base_url(api_base_url: str) -> None: +def validate_api_base_url(api_base_url: str) -> None: parsed = urlsplit(api_base_url) if parsed.scheme not in {"http", "https"} or not parsed.netloc: raise ConfigurationError("MOBILERUN_BASE_URL must be an absolute HTTP(S) URL") diff --git a/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/downloader.py b/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/downloader.py index 2990a61..60d360d 100644 --- a/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/downloader.py +++ b/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/downloader.py @@ -5,10 +5,11 @@ import os import re import tempfile +from collections.abc import Mapping from contextlib import closing from http import HTTPStatus from pathlib import Path -from typing import Any, Mapping +from typing import Any from urllib.parse import urlsplit from uuid import UUID @@ -90,9 +91,13 @@ def _validate( return canonical_file_id, filename, download_path, expected_size def _open_api_or_storage(self, download_path: str) -> httpx.Response: + # The SDK base URL already ends in /v1. Webhook downloadPath values are + # API-root paths that also start with /v1, so passing them through + # verbatim would request /v1/v1/agents/files/... and return 404. + sdk_path = download_path.removeprefix("/v1") try: response = self.sdk_client.get( - download_path, + sdk_path, cast_to=httpx.Response, options={ "headers": {"Accept": "application/octet-stream"}, @@ -101,6 +106,15 @@ def _open_api_or_storage(self, download_path: str) -> httpx.Response: stream=True, ) except APIError as exc: + # Stainless raises for the intentional 302 to signed object + # storage before returning the raw httpx response. Preserve the + # redirect handling below without ever forwarding API auth. + error_response = getattr(exc, "response", None) + if ( + isinstance(error_response, httpx.Response) + and error_response.status_code in REDIRECT_STATUSES + ): + return self._follow_storage_redirect(error_response) raise DownloadError("mobilerun file API request failed") from exc if response.status_code == HTTPStatus.OK: @@ -198,6 +212,12 @@ def _required_string(data: Mapping[str, Any], key: str) -> str: def _safe_filename(filename: str) -> str: if filename in {".", ".."} or Path(filename).name != filename: raise PayloadError("file.filename must not contain a path") - if "/" in filename or "\\" in filename or "\x00" in filename: + if ( + "/" in filename + or "\\" in filename + or any( + ord(character) < 32 or ord(character) == 127 for character in filename + ) + ): raise PayloadError("file.filename contains an unsafe character") return filename diff --git a/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/http_server.py b/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/http_server.py index 27172f9..541464a 100644 --- a/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/http_server.py +++ b/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/http_server.py @@ -3,9 +3,10 @@ from __future__ import annotations import json +from collections.abc import Mapping from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Any, Mapping +from typing import Any from .application import WebhookApplication from .config import Config diff --git a/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/showcase_events.py b/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/showcase_events.py new file mode 100644 index 0000000..41b35c3 --- /dev/null +++ b/examples/python/webhook-file-receiver/mobilerun_webhook_receiver/showcase_events.py @@ -0,0 +1,45 @@ +"""Bounded in-memory event fan-out for the local showcase.""" + +from __future__ import annotations + +import threading +import time +from collections import deque +from typing import Any + + +class EventHub: + """Keep a small replay buffer and wake connected SSE clients.""" + + def __init__(self, maximum: int = 100) -> None: + self._events: deque[dict[str, Any]] = deque(maxlen=maximum) + self._sequence = 0 + self._condition = threading.Condition() + + def publish(self, event: dict[str, Any]) -> dict[str, Any]: + with self._condition: + self._sequence += 1 + record = { + "sequence": self._sequence, + "receivedAt": int(time.time() * 1000), + **event, + } + self._events.appendleft(record) + self._condition.notify_all() + return record + + def snapshot(self) -> list[dict[str, Any]]: + with self._condition: + return list(self._events) + + def wait_after(self, sequence: int, timeout: float = 15) -> list[dict[str, Any]]: + with self._condition: + self._condition.wait_for( + lambda: self._sequence > sequence, + timeout=timeout, + ) + return [ + event + for event in reversed(self._events) + if event["sequence"] > sequence + ] diff --git a/examples/python/webhook-file-receiver/receiver.py b/examples/python/webhook-file-receiver/receiver.py old mode 100644 new mode 100755 index d428841..3c780dd --- a/examples/python/webhook-file-receiver/receiver.py +++ b/examples/python/webhook-file-receiver/receiver.py @@ -3,6 +3,5 @@ from mobilerun_webhook_receiver.http_server import run_server - if __name__ == "__main__": run_server() diff --git a/examples/python/webhook-file-receiver/showcase.css b/examples/python/webhook-file-receiver/showcase.css new file mode 100644 index 0000000..fb4c8e5 --- /dev/null +++ b/examples/python/webhook-file-receiver/showcase.css @@ -0,0 +1,382 @@ +:root { + --ink: #12130f; + --paper: #f2f0e8; + --acid: #c8ff44; + --orange: #ff5c35; + --muted: #77786f; + --line: #cac8bd; +} + +* { + box-sizing: border-box; +} + +body { + min-height: 100vh; + margin: 0; + color: var(--ink); + background: var(--paper); + font-family: "Bricolage Grotesque", sans-serif; +} + +body::before { + position: fixed; + inset: 0; + content: ""; + opacity: 0.17; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 180 180' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='.2'/%3E%3C/svg%3E"); +} + +header { + position: relative; + display: flex; + align-items: center; + justify-content: space-between; + height: 78px; + padding: 0 32px; + border-bottom: 1px solid var(--ink); +} + +.brand { + display: flex; + gap: 14px; + align-items: center; + font-size: 22px; + font-weight: 800; + letter-spacing: -0.04em; +} + +.mark { + width: 30px; + height: 30px; + border: 8px solid var(--ink); + border-right-color: var(--orange); + transform: rotate(45deg); +} + +.live { + display: flex; + gap: 9px; + align-items: center; + font: 500 11px "IBM Plex Mono", monospace; + letter-spacing: 0.12em; +} + +.pulse { + width: 9px; + height: 9px; + background: var(--orange); + border-radius: 50%; + box-shadow: 0 0 0 0 rgb(255 92 53 / 50%); + animation: pulse 1.8s infinite; +} + +@keyframes pulse { + 70% { box-shadow: 0 0 0 9px rgb(255 92 53 / 0%); } +} + +main { + display: grid; + grid-template-columns: minmax(320px, 0.85fr) minmax(480px, 1.5fr); + min-height: calc(100vh - 78px); +} + +.control { + position: relative; + padding: 42px 34px; + border-right: 1px solid var(--ink); +} + +.eyebrow { + color: var(--muted); + font: 500 11px "IBM Plex Mono", monospace; + letter-spacing: 0.15em; + text-transform: uppercase; +} + +h1 { + max-width: 570px; + margin: 12px 0 8px; + font-size: clamp(42px, 6vw, 78px); + font-weight: 750; + line-height: 0.86; + letter-spacing: -0.075em; +} + +h1 i { + color: var(--orange); + font-style: normal; +} + +.lede { + max-width: 430px; + margin: 20px 0 36px; + color: #55574f; + font-size: 15px; + line-height: 1.45; +} + +label { + display: block; + margin: 18px 0 8px; + font: 500 10px "IBM Plex Mono", monospace; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +select, +textarea { + width: 100%; + padding: 13px 14px; + color: var(--ink); + background: rgb(255 255 255 / 35%); + border: 1px solid var(--ink); + border-radius: 0; + outline: none; + font: 13px "IBM Plex Mono", monospace; +} + +select:focus-visible, +textarea:focus-visible { + box-shadow: 4px 4px 0 var(--acid); +} + +textarea { + min-height: 116px; + line-height: 1.5; + resize: vertical; +} + +button { + display: flex; + justify-content: space-between; + width: 100%; + margin-top: 14px; + padding: 17px; + color: white; + background: var(--ink); + border: 1px solid var(--ink); + cursor: pointer; + font: 700 14px "Bricolage Grotesque", sans-serif; + transition: 0.18s ease; +} + +button:hover, +button:focus-visible { + color: var(--ink); + background: var(--acid); + box-shadow: 6px 6px 0 var(--ink); + transform: translate(-3px, -3px); +} + +button:disabled { + opacity: 0.45; + pointer-events: none; +} + +.url { + margin-top: 30px; + padding: 12px 0; + overflow-wrap: anywhere; + color: var(--muted); + border-top: 1px solid var(--line); + font: 10px/1.5 "IBM Plex Mono", monospace; +} + +.signal { + padding: 28px 32px 56px; + background: rgb(255 255 255 / 22%); +} + +.signal-head { + display: flex; + align-items: end; + justify-content: space-between; + margin-bottom: 18px; +} + +h2 { + margin: 5px 0 0; + font-size: 28px; + letter-spacing: -0.045em; +} + +.count { + padding: 7px 9px; + background: var(--acid); + border: 1px solid var(--ink); + font: 12px "IBM Plex Mono", monospace; +} + +.empty { + display: grid; + min-height: 330px; + color: var(--muted); + text-align: center; + border: 1px dashed #9f9f94; + place-content: center; +} + +.radar { + position: relative; + width: 88px; + height: 88px; + margin: 0 auto 18px; + border: 1px solid var(--line); + border-radius: 50%; +} + +.radar::after { + position: absolute; + top: 50%; + left: 50%; + width: 44px; + height: 1px; + content: ""; + background: var(--orange); + transform-origin: left; + animation: scan 2.5s linear infinite; +} + +@keyframes scan { + to { transform: rotate(360deg); } +} + +.events { + display: grid; + gap: 12px; +} + +.event { + background: var(--paper); + border: 1px solid var(--ink); + animation: arrive 0.45s cubic-bezier(0.2, 0.9, 0.2, 1) both; +} + +@keyframes arrive { + from { + opacity: 0; + transform: translateY(-12px) scale(0.98); + } +} + +.event-top { + display: grid; + grid-template-columns: 12px 1fr auto; + gap: 10px; + align-items: center; + padding: 13px 15px; + border-bottom: 1px solid var(--line); +} + +.event-dot { + width: 8px; + height: 8px; + background: var(--orange); + border-radius: 50%; +} + +.event-type { + font-weight: 700; + letter-spacing: -0.02em; +} + +time { + color: var(--muted); + font: 10px "IBM Plex Mono", monospace; +} + +.event-body { + display: grid; + grid-template-columns: 1fr auto; + gap: 12px; + align-items: start; + padding: 14px 15px; +} + +.event-body.has-file { + grid-template-columns: 1fr; +} + +.file-head { + display: flex; + gap: 12px; + align-items: center; + justify-content: space-between; +} + +pre { + max-height: 180px; + margin: 0; + overflow: auto; + overflow-wrap: anywhere; + color: #4e504a; + white-space: pre-wrap; + font: 11px/1.55 "IBM Plex Mono", monospace; +} + +.file-preview { + max-height: 240px; + padding: 13px; + background: white; + border: 1px solid var(--line); +} + +.download { + padding: 10px 12px; + color: var(--ink); + white-space: nowrap; + text-decoration: none; + background: var(--acid); + border: 1px solid var(--ink); + box-shadow: 3px 3px 0 var(--ink); + font: 600 11px "IBM Plex Mono", monospace; +} + +.download:focus-visible { + outline: 3px solid var(--orange); + outline-offset: 3px; +} + +.toast { + position: fixed; + bottom: 24px; + left: 50%; + z-index: 5; + padding: 12px 18px; + color: white; + background: var(--ink); + opacity: 0; + pointer-events: none; + transform: translate(-50%, 20px); + transition: 0.25s; + font: 12px "IBM Plex Mono", monospace; +} + +.toast.show { + opacity: 1; + transform: translate(-50%, 0); +} + +@media (width <= 840px) { + header { padding: 0 20px; } + main { grid-template-columns: 1fr; } + .control { + border-right: 0; + border-bottom: 1px solid var(--ink); + } + h1 { font-size: 58px; } + .signal { padding: 28px 20px; } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } +} diff --git a/examples/python/webhook-file-receiver/showcase.html b/examples/python/webhook-file-receiver/showcase.html new file mode 100644 index 0000000..5eb19bd --- /dev/null +++ b/examples/python/webhook-file-receiver/showcase.html @@ -0,0 +1,74 @@ + + + + + + + Mobilerun Signal Room + + + + + +
+
MOBILERUN / SIGNAL ROOM
+
+ CONNECTING +
+
+ +
+
+
Workflow launch console
+

Fire it.
Watch it land.

+

+ Start a real Mobilerun workflow. Signed webhook events appear on the + right; emitted files are authenticated, downloaded, previewed, and + ready to open. +

+ + + + + + + + + +
+ Webhook ingress
+ Preparing secure endpoint… +
+
+ +
+
+
+
Inbound signal
+

Webhook timeline

+
+ 00 EVENTS +
+
+
+
+
+ Listening for Mobilerun
+ Events will arrive here in real time. +
+
+
+
+
+ +
+ + diff --git a/examples/python/webhook-file-receiver/showcase.js b/examples/python/webhook-file-receiver/showcase.js new file mode 100644 index 0000000..3a425c8 --- /dev/null +++ b/examples/python/webhook-file-receiver/showcase.js @@ -0,0 +1,174 @@ +const byId = (id) => document.getElementById(id); +const state = { events: [] }; + +function escapeHtml(value) { + return String(value).replace( + /[&<>'"]/g, + (character) => ({ + "&": "&", + "<": "<", + ">": ">", + "'": "'", + '"': """, + })[character], + ); +} + +function toast(message) { + const element = byId("toast"); + element.textContent = message; + element.classList.add("show"); + window.setTimeout(() => element.classList.remove("show"), 2600); +} + +function render() { + byId("count").textContent = + `${String(state.events.length).padStart(2, "0")} EVENTS`; + if (!state.events.length) return; + + byId("events").innerHTML = state.events + .map(({ event, receivedAt, downloadUrl, fileContent }) => { + const type = event?.type || "webhook.received"; + const file = event?.data?.file; + const filePanel = downloadUrl + ? `
+ Downloaded file · ${escapeHtml(file?.sizeBytes ?? "?")} bytes + ↓ ${escapeHtml(file?.filename || "DOWNLOAD FILE")} +
+ ${fileContent == null ? "" : `
${escapeHtml(fileContent)}
`}` + : ""; + + return `
+
+ + ${escapeHtml(type)} + +
+
+
${escapeHtml(JSON.stringify(event?.data ?? event, null, 2))}
+ ${filePanel} +
+
`; + }) + .join(""); +} + +async function establishSession() { + const fragment = new URLSearchParams(window.location.hash.slice(1)); + const token = fragment.get("access_token"); + if (!token) return; + + const response = await fetch("/api/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); + window.history.replaceState( + null, + "", + window.location.pathname + window.location.search, + ); + if (!response.ok) throw new Error("This showcase link is not authorized"); +} + +async function load() { + await establishSession(); + const responses = await Promise.all([ + fetch("/api/config"), + fetch("/api/triggers"), + fetch("/api/events"), + ]); + if (responses.some((response) => response.status === 401)) { + throw new Error("Open the complete secure showcase URL from the terminal"); + } + if (responses.some((response) => !response.ok)) { + throw new Error("Could not load the showcase"); + } + + const [config, triggers, events] = await Promise.all( + responses.map((response) => response.json()), + ); + byId("webhook-url").textContent = config.webhookUrl; + + const select = byId("trigger"); + select.innerHTML = ""; + for (const trigger of triggers.items || []) { + const option = document.createElement("option"); + option.value = trigger.id; + option.textContent = trigger.name; + select.appendChild(option); + } + if (!select.options.length) { + const option = document.createElement("option"); + option.textContent = "No custom triggers found"; + select.appendChild(option); + byId("start").disabled = true; + } + + state.events = events.items || []; + render(); +} + +async function startWorkflow() { + let payload; + try { + payload = JSON.parse(byId("payload").value); + } catch { + toast("Payload is not valid JSON"); + return; + } + + const button = byId("start"); + button.disabled = true; + button.firstElementChild.textContent = "STARTING…"; + try { + const response = await fetch("/api/workflows/start", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + triggerId: byId("trigger").value, + payload, + }), + }); + const body = await response.json(); + if (!response.ok) throw new Error(body.message); + toast(`Workflow queued · ${body.enqueued_count ?? body.enqueuedCount ?? "✓"}`); + } catch (error) { + toast(error.message || "Could not start workflow"); + } finally { + button.disabled = false; + button.firstElementChild.textContent = "START WORKFLOW"; + } +} + +function connectEventStream() { + const stream = new EventSource("/api/events/stream"); + stream.onopen = () => { + byId("connection").textContent = "LIVE / SECURE"; + }; + stream.onerror = () => { + byId("connection").textContent = "RECONNECTING"; + }; + stream.addEventListener("webhook", (message) => { + const event = JSON.parse(message.data); + state.events = [ + event, + ...state.events.filter((item) => item.sequence !== event.sequence), + ].slice(0, 100); + render(); + if (event.downloadUrl) toast("File received and downloaded"); + }); +} + +function showSetupError(error) { + const message = error.message || "Setup failed"; + byId("connection").textContent = "ACCESS REQUIRED"; + byId("events").innerHTML = `
+ ${escapeHtml(message)}
+ Use the secure URL printed by showcase.py. +
`; + toast(message); +} + +byId("start").addEventListener("click", startWorkflow); +load().then(connectEventStream).catch(showSetupError); diff --git a/examples/python/webhook-file-receiver/showcase.py b/examples/python/webhook-file-receiver/showcase.py new file mode 100755 index 0000000..e30c1d2 --- /dev/null +++ b/examples/python/webhook-file-receiver/showcase.py @@ -0,0 +1,610 @@ +#!/usr/bin/env python3 +"""Run the interactive workflow and webhook file-download showcase.""" + +from __future__ import annotations + +import hmac +import json +import mimetypes +import os +import secrets +import signal +import threading +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from http import HTTPStatus +from http.cookies import SimpleCookie +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import quote, unquote, urlsplit +from uuid import UUID + +from mobilerun_sdk import Mobilerun +from mobilerun_webhook_receiver.application import WebhookApplication +from mobilerun_webhook_receiver.config import Config +from mobilerun_webhook_receiver.showcase_events import EventHub + +ROOT = Path(__file__).resolve().parent +DASHBOARD = (ROOT / "showcase.html").read_bytes() +STYLESHEET = (ROOT / "showcase.css").read_bytes() +JAVASCRIPT = (ROOT / "showcase.js").read_bytes() +SESSION_COOKIE = "mobilerun_showcase_session" +TEXT_PREVIEW_MIME_TYPES = { + "application/csv", + "application/json", + "application/xml", +} + + +def as_json(value: Any) -> Any: + """Convert Stainless/Pydantic SDK responses to plain JSON values.""" + if hasattr(value, "model_dump"): + return value.model_dump(mode="json") + if hasattr(value, "to_dict"): + return value.to_dict() + return value + + +def positive_int(env: Mapping[str, str], name: str, default: int) -> int: + try: + value = int(env.get(name, str(default))) + except ValueError as exc: + raise ValueError(f"{name} must be an integer") from exc + if value <= 0: + raise ValueError(f"{name} must be positive") + return value + + +def boolean(env: Mapping[str, str], name: str, default: bool) -> bool: + value = env.get(name) + if value is None: + return default + normalized = value.strip().lower() + if normalized in {"1", "true", "yes", "on"}: + return True + if normalized in {"0", "false", "no", "off"}: + return False + raise ValueError(f"{name} must be true or false") + + +@dataclass(frozen=True) +class ShowcaseSettings: + public_url: str + api_base_url: str + api_key: str + host: str + port: int + download_dir: Path + access_token: str + trigger_id: str | None + preview_max_bytes: int + webhook_id: str | None + webhook_path: str | None + webhook_secret: str | None + delete_webhook_on_exit: bool + + @classmethod + def from_env(cls, env: Mapping[str, str] | None = None) -> ShowcaseSettings: + values = os.environ if env is None else env + public_url = values.get("PUBLIC_URL", "").rstrip("/") + parsed = urlsplit(public_url) + if parsed.scheme != "https" or not parsed.netloc: + raise ValueError("PUBLIC_URL must be an HTTPS origin") + + api_key = values.get("MOBILERUN_CLOUD_API_KEY", "") + if not api_key: + raise ValueError("MOBILERUN_CLOUD_API_KEY is required") + + trigger_id = values.get("SHOWCASE_TRIGGER_ID") or None + if trigger_id: + trigger_id = str(UUID(trigger_id)) + + webhook_id = values.get("SHOWCASE_WEBHOOK_ID") or None + webhook_path = values.get("SHOWCASE_WEBHOOK_PATH") or None + webhook_secret = values.get("SHOWCASE_WEBHOOK_SECRET") or None + supplied = (webhook_id, webhook_path, webhook_secret) + if any(supplied) and not all(supplied): + raise ValueError( + "SHOWCASE_WEBHOOK_ID, SHOWCASE_WEBHOOK_PATH, and " + "SHOWCASE_WEBHOOK_SECRET must be set together" + ) + if webhook_id: + webhook_id = str(UUID(webhook_id)) + if not webhook_path.startswith("/") or len(webhook_path) < 24: + raise ValueError( + "SHOWCASE_WEBHOOK_PATH must start with '/' and contain " + "at least 24 characters" + ) + + return cls( + public_url=public_url, + api_base_url=values.get( + "MOBILERUN_BASE_URL", "https://api.mobilerun.ai/v1" + ).rstrip("/"), + api_key=api_key, + host=values.get("HOST", "127.0.0.1"), + port=positive_int(values, "PORT", 3000), + download_dir=Path(values.get("DOWNLOAD_DIR", str(ROOT / "downloads"))), + access_token=( + values.get("SHOWCASE_ACCESS_TOKEN") or secrets.token_urlsafe(32) + ), + trigger_id=trigger_id, + preview_max_bytes=positive_int( + values, "SHOWCASE_PREVIEW_MAX_BYTES", 100_000 + ), + webhook_id=webhook_id, + webhook_path=webhook_path, + webhook_secret=webhook_secret, + delete_webhook_on_exit=boolean( + values, "SHOWCASE_DELETE_WEBHOOK_ON_EXIT", True + ), + ) + + +class Showcase: + def __init__( + self, + settings: ShowcaseSettings, + config: Config, + client: Mobilerun, + webhook_id: str, + ) -> None: + self.settings = settings + self.config = config + self.client = client + self.webhook_id = webhook_id + self.receiver = WebhookApplication(config) + self.events = EventHub() + + @property + def webhook_url(self) -> str: + return f"{self.settings.public_url}{self.config.webhook_path}" + + def triggers(self) -> list[dict[str, Any]]: + if self.settings.trigger_id: + response = self.client.workflows.triggers.retrieve(self.settings.trigger_id) + payload = as_json(response).get("data", {}) + items = [payload] + else: + response = self.client.workflows.triggers.list( + activation="custom", + page_size=100, + ) + items = as_json(response).get("items", []) + + return [ + { + "id": item["id"], + "name": item["name"], + "description": item.get("description"), + "schema": item.get("custom_payload_schema") + or item.get("customPayloadSchema"), + } + for item in items + ] + + def start_workflow( + self, + trigger_id: str, + payload: dict[str, Any], + ) -> dict[str, Any]: + trigger_id = str(UUID(trigger_id)) + if self.settings.trigger_id and trigger_id != self.settings.trigger_id: + raise ValueError("trigger is not enabled for this showcase") + response = self.client.workflows.triggers.fire( + trigger_id, + payload=payload, + ) + return as_json(response) + + def downloaded_file(self, file_id: str) -> Path | None: + canonical = str(UUID(file_id)) + matches = list(self.config.download_dir.glob(f"{canonical}--*")) + if len(matches) != 1 or not matches[0].is_file(): + return None + return matches[0] + + def preview(self, file_data: Mapping[str, Any]) -> str | None: + mime_type = file_data.get("mimeType") + if not isinstance(mime_type, str) or not ( + mime_type.startswith("text/") or mime_type in TEXT_PREVIEW_MIME_TYPES + ): + return None + path = self.downloaded_file(str(file_data.get("fileId", ""))) + if path is None or path.stat().st_size > self.settings.preview_max_bytes: + return None + return path.read_text(encoding="utf-8", errors="replace") + + +def make_handler(showcase: Showcase): + class Handler(BaseHTTPRequestHandler): + server_version = "mobilerun-showcase/1" + + def do_GET(self) -> None: + path = urlsplit(self.path).path + if path == "/": + self._bytes(HTTPStatus.OK, DASHBOARD, "text/html; charset=utf-8") + return + if path == "/showcase.css": + self._bytes(HTTPStatus.OK, STYLESHEET, "text/css; charset=utf-8") + return + if path == "/showcase.js": + self._bytes( + HTTPStatus.OK, + JAVASCRIPT, + "text/javascript; charset=utf-8", + ) + return + if not self._authorized(): + self._json(HTTPStatus.UNAUTHORIZED, {"message": "unauthorized"}) + return + if path == "/api/config": + self._json( + HTTPStatus.OK, + { + "publicUrl": showcase.settings.public_url, + "webhookUrl": showcase.webhook_url, + "webhookId": showcase.webhook_id, + }, + ) + elif path == "/api/triggers": + self._sdk_call(lambda: {"items": showcase.triggers()}) + elif path == "/api/events": + self._json( + HTTPStatus.OK, + {"items": showcase.events.snapshot()}, + ) + elif path == "/api/events/stream": + self._event_stream() + elif path.startswith("/api/downloads/"): + self._download(unquote(path.removeprefix("/api/downloads/"))) + else: + self._json(HTTPStatus.NOT_FOUND, {"message": "not found"}) + + def do_POST(self) -> None: + path = urlsplit(self.path).path + if path == showcase.config.webhook_path: + self._webhook() + return + if path == "/api/session": + self._create_session() + return + if not self._authorized(): + self._json(HTTPStatus.UNAUTHORIZED, {"message": "unauthorized"}) + return + if path == "/api/workflows/start": + self._start_workflow() + else: + self._json(HTTPStatus.NOT_FOUND, {"message": "not found"}) + + def _create_session(self) -> None: + raw = self._read_body(4_096) + if raw is None: + return + try: + token = json.loads(raw).get("token", "") + except (AttributeError, UnicodeDecodeError, json.JSONDecodeError): + token = "" + if not isinstance(token, str) or not hmac.compare_digest( + token, + showcase.settings.access_token, + ): + self._json(HTTPStatus.UNAUTHORIZED, {"message": "unauthorized"}) + return + self._json( + HTTPStatus.NO_CONTENT, + {}, + extra_headers={ + "Set-Cookie": ( + f"{SESSION_COOKIE}={showcase.settings.access_token}; " + "Path=/; Max-Age=43200; Secure; HttpOnly; SameSite=Strict" + ) + }, + ) + + def _authorized(self) -> bool: + cookie = SimpleCookie(self.headers.get("Cookie", "")) + supplied = cookie.get(SESSION_COOKIE) + return supplied is not None and hmac.compare_digest( + supplied.value, + showcase.settings.access_token, + ) + + def _webhook(self) -> None: + raw = self._read_body(showcase.config.max_body_bytes) + if raw is None: + return + headers = {key: value for key, value in self.headers.items()} + status, response = showcase.receiver.handle(headers, raw) + try: + payload = json.loads(raw) + except (UnicodeDecodeError, json.JSONDecodeError): + payload = None + + if HTTPStatus.OK <= status < HTTPStatus.MULTIPLE_CHOICES: + result_file = ( + response.get("file") if isinstance(response, dict) else None + ) + event_file = None + if isinstance(payload, dict) and isinstance(payload.get("data"), dict): + candidate = payload["data"].get("file") + if isinstance(candidate, dict): + event_file = candidate + showcase.events.publish( + { + "status": int(status), + "event": payload, + "result": response, + "downloadUrl": ( + f"/api/downloads/{result_file['fileId']}" + if isinstance(result_file, dict) + else None + ), + "fileContent": ( + showcase.preview(event_file) + if event_file is not None and result_file is not None + else None + ), + } + ) + self._json(status, response) + + def _start_workflow(self) -> None: + raw = self._read_body(65_536) + if raw is None: + return + try: + body = json.loads(raw) + trigger_id = body["triggerId"] + payload = body.get("payload", {}) + if not isinstance(trigger_id, str) or not isinstance(payload, dict): + raise TypeError("triggerId and object payload are required") + result = showcase.start_workflow(trigger_id, payload) + except (KeyError, ValueError, json.JSONDecodeError) as exc: + self._json( + HTTPStatus.BAD_REQUEST, + {"message": str(exc) or "invalid request"}, + ) + return + except Exception: # noqa: BLE001 - hide SDK details from the browser + self._json( + HTTPStatus.BAD_GATEWAY, + {"message": "mobilerun API request failed"}, + ) + return + self._json(HTTPStatus.ACCEPTED, result) + + def _event_stream(self) -> None: + self.send_response(HTTPStatus.OK) + self._security_headers() + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache, no-transform") + self.send_header("Connection", "keep-alive") + self.send_header("X-Accel-Buffering", "no") + self.end_headers() + sequence = 0 + try: + while True: + events = showcase.events.wait_after(sequence) + if not events: + self.wfile.write(b": keepalive\n\n") + for event in events: + sequence = event["sequence"] + data = json.dumps(event, separators=(",", ":")).encode() + self.wfile.write(b"event: webhook\ndata: " + data + b"\n\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError): + return + + def _download(self, file_id: str) -> None: + try: + path = showcase.downloaded_file(file_id) + except ValueError: + path = None + if path is None: + self._json(HTTPStatus.NOT_FOUND, {"message": "file not found"}) + return + content_type = ( + mimetypes.guess_type(path.name)[0] or "application/octet-stream" + ) + original_name = path.name.split("--", 1)[-1].replace('"', "") + body = path.read_bytes() + self.send_response(HTTPStatus.OK) + self._security_headers() + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header( + "Content-Disposition", + f'attachment; filename="{original_name}"', + ) + self.end_headers() + self.wfile.write(body) + + def _sdk_call(self, fn: Callable[[], Mapping[str, Any]]) -> None: + try: + self._json(HTTPStatus.OK, fn()) + except Exception: # noqa: BLE001 - hide SDK details from the browser + self._json( + HTTPStatus.BAD_GATEWAY, + {"message": "mobilerun API request failed"}, + ) + + def _read_body(self, maximum: int) -> bytes | None: + try: + length = int(self.headers.get("Content-Length", "")) + except ValueError: + length = 0 + if length <= 0 or length > maximum: + self._json( + HTTPStatus.REQUEST_ENTITY_TOO_LARGE, + {"message": "invalid body size"}, + ) + return None + return self.rfile.read(length) + + def _json( + self, + status: int, + payload: Mapping[str, Any], + *, + extra_headers: Mapping[str, str] | None = None, + ) -> None: + body = ( + b"" if status == HTTPStatus.NO_CONTENT else json.dumps(payload).encode() + ) + self._bytes( + status, + body, + "application/json", + extra_headers=extra_headers, + ) + + def _bytes( + self, + status: int, + body: bytes, + content_type: str, + *, + extra_headers: Mapping[str, str] | None = None, + ) -> None: + self.send_response(status) + self._security_headers() + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + for key, value in (extra_headers or {}).items(): + self.send_header(key, value) + self.end_headers() + self.wfile.write(body) + + def _security_headers(self) -> None: + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Frame-Options", "DENY") + self.send_header( + "Content-Security-Policy", + "default-src 'self'; " + "style-src 'self' https://fonts.googleapis.com; " + "font-src https://fonts.gstatic.com; " + "img-src 'self' data:; " + "script-src 'self'; " + "connect-src 'self'; " + "base-uri 'none'; frame-ancestors 'none'; form-action 'none'", + ) + + def log_message(self, fmt: str, *args: object) -> None: + return + + return Handler + + +def build_showcase( + settings: ShowcaseSettings, +) -> tuple[Showcase, bool]: + client = Mobilerun( + api_key=settings.api_key, + base_url=settings.api_base_url, + ) + owns_webhook = settings.webhook_id is None + webhook_id: str | None = None + try: + if owns_webhook: + webhook_path = f"/mobilerun/showcase-{secrets.token_hex(16)}" + webhook_url = f"{settings.public_url}{webhook_path}" + created = client.webhooks.create( + url=webhook_url, + description="Mobilerun live showcase (temporary)", + ) + webhook = as_json(created)["data"] + webhook_id = webhook["id"] + webhook_secret = webhook["secret"] + else: + webhook_id = settings.webhook_id + webhook_path = settings.webhook_path + webhook_secret = settings.webhook_secret + + if not webhook_id or not webhook_path or not webhook_secret: + raise ValueError("showcase webhook configuration is incomplete") + + config = Config( + host=settings.host, + port=settings.port, + webhook_path=webhook_path, + webhook_secret=webhook_secret.encode(), + api_base_url=settings.api_base_url, + api_key=settings.api_key, + download_dir=settings.download_dir, + max_body_bytes=65_536, + max_file_bytes=50 * 1024**2, + max_timestamp_age_ms=300_000, + ) + config.download_dir.mkdir(parents=True, exist_ok=True) + return Showcase(settings, config, client, webhook_id), owns_webhook + except Exception: + if owns_webhook and webhook_id and settings.delete_webhook_on_exit: + _delete_webhook(client, webhook_id) + raise + + +def _delete_webhook(client: Mobilerun, webhook_id: str) -> None: + try: + client.webhooks.delete(webhook_id) + except Exception as exc: # noqa: BLE001 - cleanup must not mask shutdown + print( + json.dumps( + { + "warning": "temporary webhook cleanup failed", + "webhookId": webhook_id, + "error": type(exc).__name__, + } + ), + flush=True, + ) + + +def main() -> None: + try: + settings = ShowcaseSettings.from_env() + except ValueError as exc: + raise SystemExit(str(exc)) from exc + + showcase, owns_webhook = build_showcase(settings) + server: ThreadingHTTPServer | None = None + try: + server = ThreadingHTTPServer( + (settings.host, settings.port), + make_handler(showcase), + ) + server.daemon_threads = True + + def stop(*_: object) -> None: + threading.Thread(target=server.shutdown, daemon=True).start() + + signal.signal(signal.SIGINT, stop) + signal.signal(signal.SIGTERM, stop) + access_url = ( + f"{settings.public_url}/#access_token=" + f"{quote(settings.access_token, safe='')}" + ) + print( + json.dumps( + { + "showcaseUrl": access_url, + "webhookUrl": showcase.webhook_url, + "webhookId": showcase.webhook_id, + "temporaryWebhook": owns_webhook, + } + ), + flush=True, + ) + server.serve_forever() + finally: + if server is not None: + server.server_close() + if owns_webhook and settings.delete_webhook_on_exit: + _delete_webhook(showcase.client, showcase.webhook_id) + + +if __name__ == "__main__": + main() diff --git a/examples/python/webhook-file-receiver/tests/support.py b/examples/python/webhook-file-receiver/tests/support.py index c5cd3a4..cf09935 100644 --- a/examples/python/webhook-file-receiver/tests/support.py +++ b/examples/python/webhook-file-receiver/tests/support.py @@ -4,13 +4,9 @@ import hashlib import hmac -import sys from pathlib import Path -EXAMPLE_ROOT = Path(__file__).parents[1] -sys.path.insert(0, str(EXAMPLE_ROOT)) - -from mobilerun_webhook_receiver.config import Config # noqa: E402 +from mobilerun_webhook_receiver.config import Config SECRET = b"whsec_test" NOW_MS = 1_800_000_000_000 diff --git a/examples/python/webhook-file-receiver/tests/test_application.py b/examples/python/webhook-file-receiver/tests/test_application.py index 1717c03..354b442 100644 --- a/examples/python/webhook-file-receiver/tests/test_application.py +++ b/examples/python/webhook-file-receiver/tests/test_application.py @@ -8,8 +8,6 @@ from io import StringIO from pathlib import Path -from support import FILE_ID, FakeDownloader, config, signature - from mobilerun_webhook_receiver.application import WebhookApplication from mobilerun_webhook_receiver.security import ( EVENT_ID_HEADER, @@ -17,6 +15,7 @@ SIGNATURE_HEADER, TIMESTAMP_HEADER, ) +from support import FILE_ID, FakeDownloader, config, signature class ApplicationTests(unittest.TestCase): @@ -84,6 +83,26 @@ def test_rejects_header_body_event_id_mismatch(self): self.assertEqual(status, 400) self.assertEqual(result["message"], "event id mismatch") + def test_accepts_case_insensitive_webhook_headers(self): + with tempfile.TemporaryDirectory() as directory: + app = WebhookApplication( + config(Path(directory)), + downloader=FakeDownloader(Path(directory) / "unused"), + ) + body = b'{"id":"event-case","type":"webhook.test","data":{}}' + timestamp = str(int(time.time() * 1000)) + headers = { + "x-mobilerun-timestamp": timestamp, + "x-mobilerun-signature": signature(body, timestamp), + "x-mobilerun-event-id": "event-case", + "x-mobilerun-event-type": "webhook.test", + } + + status, result = app.handle(headers, body) + + self.assertEqual(status, 200) + self.assertEqual(result["eventId"], "event-case") + if __name__ == "__main__": unittest.main() diff --git a/examples/python/webhook-file-receiver/tests/test_downloader.py b/examples/python/webhook-file-receiver/tests/test_downloader.py index 23bd25e..9b5560b 100644 --- a/examples/python/webhook-file-receiver/tests/test_downloader.py +++ b/examples/python/webhook-file-receiver/tests/test_downloader.py @@ -5,11 +5,10 @@ from pathlib import Path import httpx - -from support import FILE_ID, config - +from mobilerun_sdk import APIStatusError from mobilerun_webhook_receiver.downloader import MobilerunFileDownloader from mobilerun_webhook_receiver.errors import PayloadError +from support import FILE_ID, config class RedirectingSdkClient: @@ -30,11 +29,21 @@ def get(self, path, **kwargs): ) +class RaisingRedirectSdkClient(RedirectingSdkClient): + def get(self, path, **kwargs): + response = super().get(path, **kwargs) + raise APIStatusError("redirect", response=response, body=None) + + class DownloaderTests(unittest.TestCase): def test_rejects_filename_traversal(self): with self.assertRaisesRegex(PayloadError, "must not contain"): MobilerunFileDownloader._safe_filename("../secret") + def test_rejects_filename_control_characters(self): + with self.assertRaisesRegex(PayloadError, "unsafe character"): + MobilerunFileDownloader._safe_filename("report.csv\r\nX-Test: injected") + def test_storage_redirect_does_not_receive_bearer_token(self): with tempfile.TemporaryDirectory() as directory: sdk_client = RedirectingSdkClient() @@ -63,8 +72,36 @@ def storage_handler(request): self.assertEqual(saved.read_bytes(), b"csv") self.assertEqual( sdk_client.calls[0][0], - f"/v1/agents/files/{FILE_ID}/download", + f"/agents/files/{FILE_ID}/download", + ) + self.assertNotIn("Authorization", storage_requests[0].headers) + + def test_sdk_redirect_exception_is_followed_without_bearer_token(self): + with tempfile.TemporaryDirectory() as directory: + sdk_client = RaisingRedirectSdkClient() + storage_requests = [] + + def storage_handler(request): + storage_requests.append(request) + return httpx.Response(200, content=b"csv", request=request) + + downloader = MobilerunFileDownloader( + config(Path(directory)), + sdk_client=sdk_client, + storage_client=httpx.Client( + transport=httpx.MockTransport(storage_handler) + ), ) + saved = downloader.download( + { + "fileId": FILE_ID, + "filename": "result.csv", + "sizeBytes": 3, + "downloadPath": f"/v1/agents/files/{FILE_ID}/download", + } + ) + + self.assertEqual(saved.read_bytes(), b"csv") self.assertNotIn("Authorization", storage_requests[0].headers) diff --git a/examples/python/webhook-file-receiver/tests/test_security.py b/examples/python/webhook-file-receiver/tests/test_security.py index b41849a..1f5aefe 100644 --- a/examples/python/webhook-file-receiver/tests/test_security.py +++ b/examples/python/webhook-file-receiver/tests/test_security.py @@ -2,10 +2,9 @@ import unittest -from support import NOW_MS, SECRET, signature - from mobilerun_webhook_receiver.errors import PayloadError from mobilerun_webhook_receiver.security import verify_signature +from support import NOW_MS, SECRET, signature class SignatureTests(unittest.TestCase): diff --git a/examples/python/webhook-file-receiver/tests/test_showcase.py b/examples/python/webhook-file-receiver/tests/test_showcase.py new file mode 100644 index 0000000..685389b --- /dev/null +++ b/examples/python/webhook-file-receiver/tests/test_showcase.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from showcase import Showcase, ShowcaseSettings +from support import FILE_ID, config + +TRIGGER_ID = "3241bf38-38c5-4e41-99e6-d17e7d076ae0" + + +def settings(download_dir: Path, **overrides) -> ShowcaseSettings: + values = { + "public_url": "https://showcase.example", + "api_base_url": "https://api.mobilerun.ai/v1", + "api_key": "dr_sk_test", + "host": "127.0.0.1", + "port": 3000, + "download_dir": download_dir, + "access_token": "showcase-token", + "trigger_id": TRIGGER_ID, + "preview_max_bytes": 100_000, + "webhook_id": None, + "webhook_path": None, + "webhook_secret": None, + "delete_webhook_on_exit": True, + } + values.update(overrides) + return ShowcaseSettings(**values) + + +class FakeTriggers: + def __init__(self): + self.fired = [] + + def fire(self, trigger_id, *, payload): + self.fired.append((trigger_id, payload)) + return {"enqueuedCount": 1} + + +class FakeWorkflows: + def __init__(self): + self.triggers = FakeTriggers() + + +class FakeClient: + def __init__(self): + self.workflows = FakeWorkflows() + + +class ShowcaseSettingsTests(unittest.TestCase): + def test_requires_complete_reusable_webhook_configuration(self): + with self.assertRaisesRegex(ValueError, "must be set together"): + ShowcaseSettings.from_env( + { + "PUBLIC_URL": "https://showcase.example", + "MOBILERUN_CLOUD_API_KEY": "dr_sk_test", + "SHOWCASE_WEBHOOK_ID": FILE_ID, + } + ) + + def test_generates_private_access_token(self): + result = ShowcaseSettings.from_env( + { + "PUBLIC_URL": "https://showcase.example", + "MOBILERUN_CLOUD_API_KEY": "dr_sk_test", + } + ) + + self.assertGreaterEqual(len(result.access_token), 32) + self.assertTrue(result.delete_webhook_on_exit) + + +class ShowcaseTests(unittest.TestCase): + def test_allows_only_configured_trigger(self): + with tempfile.TemporaryDirectory() as directory: + client = FakeClient() + showcase = Showcase( + settings(Path(directory)), + config(Path(directory)), + client, + FILE_ID, + ) + + result = showcase.start_workflow(TRIGGER_ID, {"source": "test"}) + + self.assertEqual(result["enqueuedCount"], 1) + self.assertEqual(client.workflows.triggers.fired[0][0], TRIGGER_ID) + with self.assertRaisesRegex(ValueError, "not enabled"): + showcase.start_workflow(FILE_ID, {}) + + def test_previews_small_text_files_only(self): + with tempfile.TemporaryDirectory() as directory: + download_dir = Path(directory) + path = download_dir / f"{FILE_ID}--result.csv" + path.write_text("name,value\nBattery,100%\n") + showcase = Showcase( + settings(download_dir), + config(download_dir), + FakeClient(), + FILE_ID, + ) + + text = showcase.preview({"fileId": FILE_ID, "mimeType": "text/csv"}) + binary = showcase.preview( + {"fileId": FILE_ID, "mimeType": "application/zip"} + ) + + self.assertIn("Battery,100%", text) + self.assertIsNone(binary) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/typescript/task-runner/.env.example b/examples/typescript/task-runner/.env.example index 9d53fb5..285cf36 100644 --- a/examples/typescript/task-runner/.env.example +++ b/examples/typescript/task-runner/.env.example @@ -1,4 +1,8 @@ # Keep this secret on the server. It is never sent to the browser. MOBILERUN_CLOUD_API_KEY=dr_sk_... +# Optional; defaults to https://api.mobilerun.ai/v1. Use the dev API when +# testing with a dev key. +# MOBILERUN_API_URL=https://dev-api.mobilerun.ai/v1 + PORT=3000 diff --git a/examples/typescript/task-runner/README.md b/examples/typescript/task-runner/README.md index 4349c10..6592b52 100644 --- a/examples/typescript/task-runner/README.md +++ b/examples/typescript/task-runner/README.md @@ -48,6 +48,12 @@ Set the server-side key: MOBILERUN_CLOUD_API_KEY=dr_sk_... ``` +For a development key, set the API base explicitly: + +```dotenv +MOBILERUN_API_URL=https://dev-api.mobilerun.ai/v1 +``` + The example always binds to `127.0.0.1`; it cannot accidentally expose its unauthenticated task and stream-credential routes to the local network. Put an authenticated application or reverse proxy in front of it before making it @@ -76,8 +82,8 @@ the example server. | `GET /api/health` | Report whether the server SDK is configured | The start route requires `deviceId` and intentionally accepts only `task`, -`llmModel`, `deviceId`, and `maxSteps`. Sensitive or expensive SDK options -cannot be passed through from the browser. +`deviceId`, and `maxSteps`. The server owns the model and output schema, so +sensitive or expensive SDK options cannot be passed through from the browser. The server adds a fixed output schema with a short `summary` and an `observations` list. Keeping the schema server-side prevents the browser from diff --git a/examples/typescript/task-runner/src/client/components/task-brief.tsx b/examples/typescript/task-runner/src/client/components/task-brief.tsx index feda2a5..dd9f425 100644 --- a/examples/typescript/task-runner/src/client/components/task-brief.tsx +++ b/examples/typescript/task-runner/src/client/components/task-brief.tsx @@ -36,7 +36,6 @@ export function TaskBrief({ state, run, cancel, reset }: TaskBriefProps) { event.preventDefault(); void run({ task, - llmModel: 'google/gemini-3.1-flash-lite', deviceId, maxSteps, }); diff --git a/examples/typescript/task-runner/src/client/use-task-runner.ts b/examples/typescript/task-runner/src/client/use-task-runner.ts index 0c97211..9075ca8 100644 --- a/examples/typescript/task-runner/src/client/use-task-runner.ts +++ b/examples/typescript/task-runner/src/client/use-task-runner.ts @@ -7,6 +7,8 @@ import { } from './runner-machine'; const POLL_INTERVAL_MS = 2_000; +const REQUEST_TIMEOUT_MS = 15_000; +const MAX_POLL_INTERVAL_MS = 15_000; function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); @@ -16,6 +18,13 @@ function isAbortError(error: unknown): boolean { return error instanceof DOMException && error.name === 'AbortError'; } +function requestSignal(parent: AbortSignal): AbortSignal { + return AbortSignal.any([ + parent, + AbortSignal.timeout(REQUEST_TIMEOUT_MS), + ]); +} + export function useTaskRunner() { const [state, dispatch] = useReducer( runnerReducer, @@ -40,7 +49,10 @@ export function useTaskRunner() { dispatch({ type: 'start-requested' }); try { - const started = await taskRunnerApi.start(input, controller.signal); + const started = await taskRunnerApi.start( + input, + requestSignal(controller.signal), + ); if (!controller.signal.aborted) { dispatch({ type: 'start-succeeded', run: started }); } @@ -87,23 +99,26 @@ export function useTaskRunner() { const controller = new AbortController(); let timer: number | undefined; + let pollInterval = POLL_INTERVAL_MS; const poll = async () => { try { const task = await taskRunnerApi.retrieve( state.runId as string, - controller.signal, + requestSignal(controller.signal), ); if (!controller.signal.aborted) { + pollInterval = POLL_INTERVAL_MS; dispatch({ type: 'poll-succeeded', task }); } } catch (error) { if (!isAbortError(error)) { + pollInterval = Math.min(pollInterval * 2, MAX_POLL_INTERVAL_MS); dispatch({ type: 'poll-failed', message: errorMessage(error) }); } } finally { if (!controller.signal.aborted) { - timer = window.setTimeout(poll, POLL_INTERVAL_MS); + timer = window.setTimeout(poll, pollInterval); } } }; diff --git a/examples/typescript/task-runner/src/server/api-app.test.ts b/examples/typescript/task-runner/src/server/api-app.test.ts index 8c4466c..ce617e1 100644 --- a/examples/typescript/task-runner/src/server/api-app.test.ts +++ b/examples/typescript/task-runner/src/server/api-app.test.ts @@ -58,6 +58,7 @@ describe('API contracts', () => { body: JSON.stringify({ task: 'Open Settings', deviceId: DEVICE_ID, + maxSteps: 30, credentials: [{ packageName: 'example', credentialNames: ['password'] }], }), }); diff --git a/examples/typescript/task-runner/src/server/mobilerun-adapter.ts b/examples/typescript/task-runner/src/server/mobilerun-adapter.ts index e7f61c1..d44aa05 100644 --- a/examples/typescript/task-runner/src/server/mobilerun-adapter.ts +++ b/examples/typescript/task-runner/src/server/mobilerun-adapter.ts @@ -18,11 +18,14 @@ const OUTPUT_SCHEMA = { additionalProperties: false, } as const; +const LLM_MODEL = 'google/gemini-3.1-flash-lite'; + export function createMobilerunTaskDeps(client: Mobilerun): TaskServiceDeps { return { async runTask(input: RunTaskRequest) { const result = await client.tasks.run({ ...input, + llmModel: LLM_MODEL, outputSchema: OUTPUT_SCHEMA, }); return { diff --git a/examples/typescript/task-runner/src/server/task-service.test.ts b/examples/typescript/task-runner/src/server/task-service.test.ts index f34a150..b8d0fa9 100644 --- a/examples/typescript/task-runner/src/server/task-service.test.ts +++ b/examples/typescript/task-runner/src/server/task-service.test.ts @@ -54,7 +54,6 @@ describe('task service', () => { test('starts a task and returns scoped stream credentials', async () => { const result = await createService().start({ task: 'Open Settings', - llmModel: 'google/gemini-3.1-flash-lite', deviceId: DEVICE_ID, maxSteps: 30, }); diff --git a/examples/typescript/task-runner/src/shared/contracts.ts b/examples/typescript/task-runner/src/shared/contracts.ts index bd35e81..2ecef68 100644 --- a/examples/typescript/task-runner/src/shared/contracts.ts +++ b/examples/typescript/task-runner/src/shared/contracts.ts @@ -16,12 +16,6 @@ export type TaskStatus = z.infer; export const runTaskRequestSchema = z .object({ task: z.string().trim().min(1).max(8_000), - llmModel: z - .string() - .trim() - .min(1) - .max(160) - .default('google/gemini-3.1-flash-lite'), deviceId: z.uuid(), maxSteps: z.number().int().min(1).max(100).default(30), }) diff --git a/pyproject.toml b/pyproject.toml index 82cf247..af7188b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,5 +8,13 @@ dependencies = ["mobilerun-sdk>=5.1.0,<6"] [tool.uv] package = false +[dependency-groups] +dev = [ + "pytest>=8.0,<9", + "pytest-cov>=5.0,<7", + "ruff>=0.9,<1", +] + [tool.pytest.ini_options] testpaths = ["examples/python/webhook-file-receiver/tests"] +pythonpath = ["examples/python/webhook-file-receiver"] diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..0d313c5 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +cd "$repo_root" +uv run --locked pytest -q +uv run --locked ruff check . +uv run --locked ruff format --check . + +cd "$repo_root/examples/typescript/task-runner" +bun install --frozen-lockfile +bun run check diff --git a/uv.lock b/uv.lock index 7ebad73..4cb9d76 100644 --- a/uv.lock +++ b/uv.lock @@ -43,6 +43,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/d0/55fe630f4cf94e3fcba868240fad8c8cdd1f764e2a932f8926347e6ec4cd/coverage-7.15.2.tar.gz", hash = "sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d", size = 927741, upload-time = "2026-07-15T18:56:19.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/3a/54536704f507d4573bf9161c4d0dd3dd59b6d85e48c664e901b6844d8e33/coverage-7.15.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036", size = 221414, upload-time = "2026-07-15T18:53:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d9/8ba925d29743e3577b21e4d8c11a702b76bc93c41e7fdfd1177af63d4b8d/coverage-7.15.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660", size = 221913, upload-time = "2026-07-15T18:53:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/a855f3aa0187f2b431ade4e4791b77b56282cfb5d201c83ec26a31b5b36a/coverage-7.15.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589", size = 252332, upload-time = "2026-07-15T18:53:55.467Z" }, + { url = "https://files.pythonhosted.org/packages/8e/d3/13ac97b4370640ba3452fc8559b06cc2f479ce3ba4a0b632a73e44c38a7d/coverage-7.15.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee", size = 254243, upload-time = "2026-07-15T18:53:57.055Z" }, + { url = "https://files.pythonhosted.org/packages/88/83/5eca144942d8d0659d3f55176517f4a59cdc65eefd17146a0770935a3ebd/coverage-7.15.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0", size = 256352, upload-time = "2026-07-15T18:53:58.83Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ba/d3db2e01a50fc88cdb4c0f19542bcf6f61489e34dc9aa3538413e2459a38/coverage-7.15.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487", size = 258313, upload-time = "2026-07-15T18:54:00.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/b3/aba83416e9177df28e5186d856c19158c59fc0e7e814aaa61a4a2354ad1b/coverage-7.15.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f", size = 252449, upload-time = "2026-07-15T18:54:02.456Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a5/4b00ecac0194431ab451b0f6710f8e2517d04cef60f821b14dec4637d575/coverage-7.15.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1", size = 254043, upload-time = "2026-07-15T18:54:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/75/b6/cfa209b4313ee7f1b34da47efcd789ea51c024ad35af390e00f5a3c10a2e/coverage-7.15.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5", size = 252107, upload-time = "2026-07-15T18:54:06.745Z" }, + { url = "https://files.pythonhosted.org/packages/36/67/e8cac5a6954038c98d7fe7eb9802afe7ab3ecb637bb7cc00e69b4148b56d/coverage-7.15.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0", size = 255873, upload-time = "2026-07-15T18:54:08.48Z" }, + { url = "https://files.pythonhosted.org/packages/2c/92/395cca9f330a86c3fe3471d73e2c102116c4c58fdc619dbbc125c6e93a54/coverage-7.15.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad", size = 251826, upload-time = "2026-07-15T18:54:10.083Z" }, + { url = "https://files.pythonhosted.org/packages/51/60/3e91b20295439652424f426b7086ec5bf4fbe3f604c73eda22b986c4fd6b/coverage-7.15.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db", size = 252735, upload-time = "2026-07-15T18:54:11.878Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/8c07839005e5e3c6b3877d3a6e2a80ce766589f31dd2b6882b78d59a7b8c/coverage-7.15.2-cp311-cp311-win32.whl", hash = "sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9", size = 223500, upload-time = "2026-07-15T18:54:13.525Z" }, + { url = "https://files.pythonhosted.org/packages/2e/98/59d83c257cd59f0fbaf9d9ddb26b744a576760dfd1ae16e516408894a02b/coverage-7.15.2-cp311-cp311-win_amd64.whl", hash = "sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688", size = 223973, upload-time = "2026-07-15T18:54:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/ea/09/2d285c8bef5c4f695d120c1c96dc11715638aa8e134069f210bb6a62a9fe/coverage-7.15.2-cp311-cp311-win_arm64.whl", hash = "sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934", size = 223519, upload-time = "2026-07-15T18:54:16.803Z" }, + { url = "https://files.pythonhosted.org/packages/6a/50/eb5bf42e531611a9f8d272556b1ed4de503f84a91413584094487cf69f8f/coverage-7.15.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9", size = 221587, upload-time = "2026-07-15T18:54:18.439Z" }, + { url = "https://files.pythonhosted.org/packages/06/d1/da99af464c335d4e023a6efcd7ec30f63b88a43c93745154ab74ffb31cea/coverage-7.15.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73", size = 221943, upload-time = "2026-07-15T18:54:20.062Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8a/13c42723d61ca447eafa18732e8141dd6a63f2732e1c7e1502c182dd88d7/coverage-7.15.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d", size = 253450, upload-time = "2026-07-15T18:54:21.765Z" }, + { url = "https://files.pythonhosted.org/packages/d7/29/99021303f98fbdcb63504b4d07bea4cc025b9b2dd907c4f07c85d50a0dab/coverage-7.15.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b", size = 256187, upload-time = "2026-07-15T18:54:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/f9/a8/fd503715ed6ca9c5d742923aa5209257340b367a867b2ced0c7d4ba8a0b9/coverage-7.15.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296", size = 257301, upload-time = "2026-07-15T18:54:25.183Z" }, + { url = "https://files.pythonhosted.org/packages/da/40/3f4b8fb409810036ebc2857d36adc0498c6e957b5df0290c5036b2e143f1/coverage-7.15.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6", size = 259562, upload-time = "2026-07-15T18:54:27.204Z" }, + { url = "https://files.pythonhosted.org/packages/0b/8a/9bdffbef47db77cce3d6b02a28f7e919b19f0106c4b080c2c2246040f885/coverage-7.15.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098", size = 253841, upload-time = "2026-07-15T18:54:29.134Z" }, + { url = "https://files.pythonhosted.org/packages/1b/1e/9031efde019d31a06646261fce6dfc5c3c74e951e27a71e5c9a424563178/coverage-7.15.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a", size = 255221, upload-time = "2026-07-15T18:54:31.142Z" }, + { url = "https://files.pythonhosted.org/packages/56/db/787acde872389fc84a9ef9d8cd1ccc658e391ab4cb5b28092a714426a394/coverage-7.15.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b", size = 253366, upload-time = "2026-07-15T18:54:32.886Z" }, + { url = "https://files.pythonhosted.org/packages/2f/9b/6f57bc4b93c842eef1695f8cdaf2318e35e7ba54f5ba80d84be213ab7858/coverage-7.15.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2", size = 257434, upload-time = "2026-07-15T18:54:34.7Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/b3186a21b2acc83e451118978905c81c7072c3333707804db09a78c096a2/coverage-7.15.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440", size = 252935, upload-time = "2026-07-15T18:54:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/20/c2/c9f3376b2e717ea69ed7a6e9a5fcab968fb0b290db6cf4bd9a1fc7541b75/coverage-7.15.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e", size = 254807, upload-time = "2026-07-15T18:54:38.296Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e1/dfc15401f4a8aaeb486e1ba3e9e3c40522a6e38bd0ecf0b3f29cb8082957/coverage-7.15.2-cp312-cp312-win32.whl", hash = "sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd", size = 223641, upload-time = "2026-07-15T18:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/91/40/81b6d809d320cd366ec5bdf8176575e897dcb8efe7fb4b489ef9e93e4d13/coverage-7.15.2-cp312-cp312-win_amd64.whl", hash = "sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40", size = 224172, upload-time = "2026-07-15T18:54:41.882Z" }, + { url = "https://files.pythonhosted.org/packages/ef/28/9f14ec438149f7de557f45518f09b4a7917b795cc37083aa7db482693f8c/coverage-7.15.2-cp312-cp312-win_arm64.whl", hash = "sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3", size = 223556, upload-time = "2026-07-15T18:54:43.674Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d5/f8c838e6b7282976f7c918884b792df7a0c42c5bba5d99c60ad2d221d56d/coverage-7.15.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8", size = 221606, upload-time = "2026-07-15T18:54:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/bf/37/97c926376364f66298cc44893b89cdf17b8bc406376497c4061ae4b8a8ff/coverage-7.15.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1", size = 221982, upload-time = "2026-07-15T18:54:47.341Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/a36050a6e83c2135ee0776f452ca3948224befc6d7f26acecc082d0c106a/coverage-7.15.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578", size = 252972, upload-time = "2026-07-15T18:54:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/31/d3/06b5f1daf95f0f15ab05bd75f26ba5f3c8b33d0bb72f3aaa3cf41d1bad3a/coverage-7.15.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1", size = 255569, upload-time = "2026-07-15T18:54:51.098Z" }, + { url = "https://files.pythonhosted.org/packages/81/1c/9afb3f8de2b8d36960391c48559a2e3ff96594b58099f115921549ea8d0d/coverage-7.15.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6", size = 256806, upload-time = "2026-07-15T18:54:53.145Z" }, + { url = "https://files.pythonhosted.org/packages/64/d8/b989f96061a5e32d82fddd1b1b9ff48a7c8f8ae7606f0e80fd9de54b1e33/coverage-7.15.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7", size = 258936, upload-time = "2026-07-15T18:54:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fa/f99771f5110457c7b511c1935ca49ddf288218eaa84322e028b9334146ae/coverage-7.15.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d", size = 253178, upload-time = "2026-07-15T18:54:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/f6/96/c098a6044d119c751ceede7be91035fa8310170ec24a6523aff72f0a5793/coverage-7.15.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026", size = 254934, upload-time = "2026-07-15T18:54:59.41Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a2/1457b3a7a50c8d77500103b97a046db863e2f59a1cf6d2f814595f349885/coverage-7.15.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa", size = 252898, upload-time = "2026-07-15T18:55:01.338Z" }, + { url = "https://files.pythonhosted.org/packages/6c/0e/76958874c471ecfcdde0d2b2747bb2c61bdbf34a40636f4ce9db9923e643/coverage-7.15.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d", size = 257056, upload-time = "2026-07-15T18:55:03.243Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7c/3d7c4e3bf58baa40327dc7edc2272b17cf02299366d52763db1b0ca1556a/coverage-7.15.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b", size = 252718, upload-time = "2026-07-15T18:55:05.029Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b8/1cecffed9ce14fb25be9ba42d37b6bb61485c9a3ddd43cd3dde36b6087d8/coverage-7.15.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188", size = 254490, upload-time = "2026-07-15T18:55:06.889Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2c/42984561bc7f4c045dca67516a0c50ee5ef8d84352dbeb5559dc86c4823e/coverage-7.15.2-cp313-cp313-win32.whl", hash = "sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050", size = 223647, upload-time = "2026-07-15T18:55:08.941Z" }, + { url = "https://files.pythonhosted.org/packages/41/9f/39c7c9245efc583beddf89a87683574e663ed93637f3afb6cd7b88405676/coverage-7.15.2-cp313-cp313-win_amd64.whl", hash = "sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c", size = 224190, upload-time = "2026-07-15T18:55:10.789Z" }, + { url = "https://files.pythonhosted.org/packages/c7/de/3a2883cf8a213659280ef4b403059e17a9acaeb7fc7fd4105e1226ff2e6d/coverage-7.15.2-cp313-cp313-win_arm64.whl", hash = "sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b", size = 223583, upload-time = "2026-07-15T18:55:12.678Z" }, + { url = "https://files.pythonhosted.org/packages/81/5f/aed265fd7a3551a394f36dfe41868aee709b7f95db4052205b4ad1563ac3/coverage-7.15.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a", size = 221650, upload-time = "2026-07-15T18:55:14.527Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2c/222ba12a545189017120f8eddfc1a0bd4616b47d5d4a8d99421edb2fe4c6/coverage-7.15.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2", size = 221988, upload-time = "2026-07-15T18:55:16.674Z" }, + { url = "https://files.pythonhosted.org/packages/aa/38/304b5877ab46e6c290b4292cfcf3fe28245f0e5597cad7f6acc91fc7e0a4/coverage-7.15.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138", size = 253029, upload-time = "2026-07-15T18:55:18.856Z" }, + { url = "https://files.pythonhosted.org/packages/6c/58/821b533b8db9e44cf1d8a97bd525149ced40dde1d0093da02cb78e715244/coverage-7.15.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f", size = 255536, upload-time = "2026-07-15T18:55:21.027Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f2/7aa06604c389d32ea7f0a6a988359a7eafc3cd3f8e7bc2e88cd2fdf0b877/coverage-7.15.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984", size = 256881, upload-time = "2026-07-15T18:55:23.125Z" }, + { url = "https://files.pythonhosted.org/packages/a2/4f/1ef342339c7916d0096bc5888cc0f653882cc7bc8f897d5cb89143287c9b/coverage-7.15.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7", size = 259196, upload-time = "2026-07-15T18:55:25.099Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f4/7ed055d7a9c5ec13b161773a115a5ccc6b0081d568c31fad830806306cc7/coverage-7.15.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3", size = 253036, upload-time = "2026-07-15T18:55:27.018Z" }, + { url = "https://files.pythonhosted.org/packages/14/79/ea82cca18c242a3a38b6c017da39726aa62dcb64aa635abf79b92009975c/coverage-7.15.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee", size = 254887, upload-time = "2026-07-15T18:55:29.084Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ba/a136db3c0d9562b00e10b72540dbf3a33cd3bc5b95060c9308e247494623/coverage-7.15.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1", size = 252852, upload-time = "2026-07-15T18:55:31.184Z" }, + { url = "https://files.pythonhosted.org/packages/17/17/ea334246b16b7d059953fad6fdefa11e33c68efbd3fe37b1098120a1fac2/coverage-7.15.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a", size = 257128, upload-time = "2026-07-15T18:55:33.163Z" }, + { url = "https://files.pythonhosted.org/packages/ed/c3/074fb66d46d607855f710876b117cbda562c5ab08363528e78820449f937/coverage-7.15.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145", size = 252668, upload-time = "2026-07-15T18:55:35.063Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c1/f620850ada9b36435921c9a3a8057013422b1d964eb4bf37fe138724d192/coverage-7.15.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446", size = 254325, upload-time = "2026-07-15T18:55:37.125Z" }, + { url = "https://files.pythonhosted.org/packages/cc/31/a729ca3689404493af82ef8e6ff70bd88bdda8da89aeef6ca9b387aeb2b4/coverage-7.15.2-cp314-cp314-win32.whl", hash = "sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243", size = 223844, upload-time = "2026-07-15T18:55:39.078Z" }, + { url = "https://files.pythonhosted.org/packages/c6/83/5d809dc808fb1698c671f3e372259bb9158e64b7ea526fc6ab7de64de9fe/coverage-7.15.2-cp314-cp314-win_amd64.whl", hash = "sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc", size = 224331, upload-time = "2026-07-15T18:55:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/16/4e/35e488548e952795829e129995c4174df33bf432b591d1aa42c8d9e4e7ad/coverage-7.15.2-cp314-cp314-win_arm64.whl", hash = "sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635", size = 223760, upload-time = "2026-07-15T18:55:43.518Z" }, + { url = "https://files.pythonhosted.org/packages/ed/49/dd2c86cd6374038f6e415fb5bfb86db5218553209c081384a020369dee79/coverage-7.15.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be", size = 222384, upload-time = "2026-07-15T18:55:45.569Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/173ff17a1c0808e5a438f549f6f145d5ac7528f2791310b63523e3200ac7/coverage-7.15.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072", size = 222647, upload-time = "2026-07-15T18:55:47.544Z" }, + { url = "https://files.pythonhosted.org/packages/84/f8/b8cba872162356fb44ac79c10309d987206a4461e32072fc29228dad7331/coverage-7.15.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328", size = 264013, upload-time = "2026-07-15T18:55:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/ee/67/a807a7586d0b8cae485308ddd55756f0806c92f8e0b411bacbf23c48edf3/coverage-7.15.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a", size = 266135, upload-time = "2026-07-15T18:55:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/cd78771dc985f7e4ebdcc82b1a96d9a932af9e806f01f2f91a89f4c72e80/coverage-7.15.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0", size = 268555, upload-time = "2026-07-15T18:55:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/18/3e/10134cf81275188c58568f324fc74aedff32c63ca4d5bbc513a91944a6f0/coverage-7.15.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5", size = 269674, upload-time = "2026-07-15T18:55:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/75/4a/771b77de446cba985dc414bbc5844bd21604da05dbc044286df8318a48a7/coverage-7.15.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743", size = 263101, upload-time = "2026-07-15T18:55:58.107Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b5/70a7011da15f4071943361183aefa27847f3e3aec4fd335f1cb3d3a622b1/coverage-7.15.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c", size = 266007, upload-time = "2026-07-15T18:56:00.468Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0d/f9547e804ce7ad49646ffeffac26699510efbe6c0f751b66fdc960c4e825/coverage-7.15.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071", size = 263611, upload-time = "2026-07-15T18:56:02.615Z" }, + { url = "https://files.pythonhosted.org/packages/ac/59/f576a396659c0efd351f5c1544f67c3560e89c7761cabf7f65e412beeda5/coverage-7.15.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a", size = 267344, upload-time = "2026-07-15T18:56:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5d/c2e4fce3579c0cb635024293f1a32bbe26df101b3e3a69f22243d1352b6c/coverage-7.15.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d", size = 262456, upload-time = "2026-07-15T18:56:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/956287d69436b66094bc4b57ac2da71e43bfd2a5524e958900b9f582fcf8/coverage-7.15.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c", size = 264771, upload-time = "2026-07-15T18:56:08.795Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5a/6f979530c2734c575de77cf58f5f28d51f7123a94b5030fd9156fe5f363c/coverage-7.15.2-cp314-cp314t-win32.whl", hash = "sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688", size = 224151, upload-time = "2026-07-15T18:56:10.856Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/27f6b2a74d484742f4017553e710b01e396b23d809df3e95ca0bb9a2824b/coverage-7.15.2-cp314-cp314t-win_amd64.whl", hash = "sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199", size = 224981, upload-time = "2026-07-15T18:56:12.928Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/284863423aa474240f6842bd00d680da22f4e6ea2e466618ef7c9c9e69a9/coverage-7.15.2-cp314-cp314t-win_arm64.whl", hash = "sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658", size = 224294, upload-time = "2026-07-15T18:56:15.156Z" }, + { url = "https://files.pythonhosted.org/packages/ec/82/32e3bd191d498e64f6f911ad55d14006a0861e54869d2d32452326399e65/coverage-7.15.2-py3-none-any.whl", hash = "sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c", size = 213375, upload-time = "2026-07-15T18:56:17.305Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -98,6 +196,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "mobilerun-examples" version = "0.1.0" @@ -106,9 +213,23 @@ dependencies = [ { name = "mobilerun-sdk" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + [package.metadata] requires-dist = [{ name = "mobilerun-sdk", specifier = ">=5.1.0,<6" }] +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0,<9" }, + { name = "pytest-cov", specifier = ">=5.0,<7" }, + { name = "ruff", specifier = ">=0.9,<1" }, +] + [[package]] name = "mobilerun-sdk" version = "5.1.0" @@ -126,6 +247,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/b6/36adb62838cb469877b06cb4da88a392bd58200de31ba33bc7a62021f2fc/mobilerun_sdk-5.1.0-py3-none-any.whl", hash = "sha256:46ab40d0ee00ff164af40057260597447903478e8d92f84417d59dfea9850f84", size = 332975, upload-time = "2026-06-23T17:57:14.065Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" @@ -243,6 +382,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pytest-cov" +version = "6.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/4c/f883ab8f0daad69f47efdf95f55a66b51a8b939c430dadce0611508d9e99/pytest_cov-6.3.0.tar.gz", hash = "sha256:35c580e7800f87ce892e687461166e1ac2bcb8fb9e13aea79032518d6e503ff2", size = 70398, upload-time = "2025-09-06T15:40:14.361Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/80/b4/bb7263e12aade3842b938bc5c6958cae79c5ee18992f9b9349019579da0f/pytest_cov-6.3.0-py3-none-any.whl", hash = "sha256:440db28156d2468cafc0415b4f8e50856a0d11faefa38f30906048fe490f1749", size = 25115, upload-time = "2025-09-06T15:40:12.44Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -252,6 +455,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"