diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1769f6e..7801398 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] env: # Fresh clones have no src/botlab/ui display frontend (gitignored, # regenerated with `botlab ui sync`); tests must pass without it. The diff --git a/.gitignore b/.gitignore index 0c32d1b..da22025 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ src/botlab/ui/ # Local verification artifacts (screenshots, logs, scratch scripts) tmp/ + +# Native library logs and stray session artifacts +MUJOCO_LOG.TXT +:memory:.ses diff --git a/README.md b/README.md index 142bd1d..9825f98 100644 --- a/README.md +++ b/README.md @@ -3,11 +3,11 @@ BotLab's Python SDK and command-line interface. Declare robot projects with `project()`, `Robot`, `Model`, `Sensor`, `Motion`, and `Scene` in `controller.py`, express control logic as composable signals, and run them -natively on your machine with one command — real MuJoCo physics and ONNX -Runtime inference in local CPython, with the browser as a read-only viewer. +in the browser with one command — MuJoCo WASM physics and ONNX Runtime Web +inference. Native CPython execution is available with `--native`. ```bash -pip install "botlab-python[runtime]" +pip install botlab-python cd my-robot-project botlab run ``` @@ -25,8 +25,8 @@ Watch, and Metrics panels, and the browser itself executes the project 3. Assemble an integrity-checked release in memory and serve it to the browser without writing an archive to disk. 4. Start a local static server and open the browser. The Vite-style startup - banner prints clickable `Local` and `Network` links; the server binds to - `0.0.0.0` by default so devices on the same network can connect. + banner prints a clickable `Local` link; browser runs bind to `127.0.0.1` + by default. Use `--host 0.0.0.0` for ordinary LAN access (HIL is disabled). The editor opens at `/`. Its localized `index.html` includes the `window.BOTLAB_LOCAL_PROJECT` flag, so it loads the current directory from @@ -52,12 +52,42 @@ The CLI is project-agnostic: it works with any BotLab project that follows the two-file `main.py` + `controller.py` contract and contains no robot- or policy-specific behavior. +## Hardware-in-the-loop + +Run `botlab run`, open **Target manager**, and add a Linux target over SSH. +BotLab can inspect a clean system, help configure a supported network interface, +and install its Agent plus matching Linux inference dependencies. The board needs +a booted Linux system, Python 3.10+ and reachable SSH; it does not need BotLab, +robot-specific programs, pip or Internet access preinstalled. New SSH host keys +require explicit verification; passwords are transient. + +Once prepared, choose the target and **Run on Target**. BotLab builds, deploys and +loads the current project's ONNX models before running them on the board, while +browser MuJoCo and the controller graph remain on the host. The separate run menu +provides Build → Deploy → Load project → Run: deployment uses the last successful +build, Load project directly connects that exact version after it is deployed to +the connected target, and Run starts only that connected version with matching +local source and models. Choose other projects or versions explicitly in Target +manager's Deployed projects tab. Stop and Unload remain separate actions. The +target manager keeps multiple projects and versions under `~/botlab/projects/`; one session executes +at a time. The CLI also supports `botlab target discover/add/inspect/provision`, +`botlab deploy --target `, and `botlab run --hil `. + +This unreleased implementation uses ONNX Runtime CPU on compatible Linux +ARM64/x86_64 targets over verified SSH forwarding. It is model-service HIL, not +full-controller deployment or hard real-time scheduling. RDK X5 is an example; +BPU/MCU support and per-project dependency environments are not implemented. +Install the matching SDK and browser runtime. See the [HIL guide](docs/hil.md) +for first connection, CLI commands, deployment layout and current limits. + ## Project structure ```text my-robot-project/ ├── main.py # Fixed four-line entry point ├── controller.py # Project declarations and control expressions +├── config/ # Optional: declaration data split into yaml files +│ └── runtime.yaml # referenced canonically via read_config("config/runtime.yaml") ├── robot.preset.yaml # Joint order, PD gains, initial state, and calibration └── assets/ # Project-relative MJCF, ONNX, meshes, and other assets ``` @@ -99,6 +129,24 @@ See the BotLab repository's `docs/api/` directory for the complete API contract, including signal expressions, `policy_bank`, state machines, and resource preset fields. +Declaration data (runtime settings, parameters, IO bindings, sensor configs, +control profiles) may live in `config/*.yaml` files referenced from +`controller.py` through `read_config("config/.yaml")` — the file is the +declaration data, the source is structure and wiring. The CLI collects them +recursively into the release `files` (same path rules as the browser editor, +so `botlab build` and a browser publish produce identical project digests): + +```python +from botlab import Model, PolicyController, Robot, concat, project, read_config + +runtime = read_config("config/runtime.yaml") +project( + runtime["name"], + control_hz=runtime["control_hz"], + physics_hz=runtime["physics_hz"], +) +``` + ## Commands | Command | Description | @@ -241,7 +289,7 @@ installation and make no runtime download through the CLI. For offline machines, import the exact matching archive first: ```bash -botlab ui fetch --archive botlab-browser-runtime-0.3.0.zip +botlab ui fetch --archive botlab-browser-runtime-0.4.0.zip ``` `botlab ui fetch --url ` allows a mirror. The default cache is @@ -259,7 +307,7 @@ npm run build npm run build:viewer # In botlab-python: botlab ui sync --botlab ../BotLab -botlab ui export --botlab ../BotLab --out tmp/botlab-browser-runtime-0.3.0.zip +botlab ui export --botlab ../BotLab --out tmp/botlab-browser-runtime-0.4.0.zip uv build --wheel --out-dir tmp/core-wheel ``` @@ -338,9 +386,9 @@ its real synchronized display frontend. Verify the separate browser runtime archive used for website assembly too. From this checkout, with `uv` on PATH: ```bash -python scripts/verify_wheel.py dist/pypi-0.3.0/botlab_python-0.3.0-py3-none-any.whl \ - --browser-runtime tmp/botlab-browser-runtime-0.3.0.zip \ - --out tmp/wheel-acceptance-0.3.0 +python scripts/verify_wheel.py dist/pypi-0.4.0/botlab_python-0.4.0-py3-none-any.whl \ + --browser-runtime tmp/botlab-browser-runtime-0.4.0.zip \ + --out tmp/wheel-acceptance-0.4.0 ``` Use a new output directory for each run. The command installs that wheel with @@ -372,3 +420,39 @@ only; this verification command does not upload an sdist or any other artifact. ## License Proprietary — same as the main BotLab repository. + +## Browser adapters for hardware-in-the-loop projects + +Projects that provide a browser controller and a hardware bridge can use the +standard launcher with an explicit project adapter: + +```bash +botlab run /path/to/project --browser-adapter hardware.web:run \ + --inference-endpoint 192.168.128.10:50051 +``` + +The selected callable receives `botlab.browser.BrowserRunContext` and returns an +exit code. The context contains `project_dir`, `ui_dir`, `host`, `port`, +`open_browser`, and optional `inference_endpoint`. The adapter runs with the +project on the Python import path and as the working directory. Selecting an +adapter executes its Python code; it is an explicit alternative to the usual +controller compilation/release path. It cannot be combined with native, +headless, release export, `--no-server`, or native report options. + +The SDK supplies its versioned, integrity-checked browser runtime in `ui_dir`; +the project serves its own controller, assets, and inference transport. Adapter +runs default to loopback and port 0 (choose an available port); adapters must use +the actual bound port in URLs. Physics still runs in the browser. The SDK has no +robot models, board addresses, or robot-specific protocol implementation. + +Browser-only fixes belong in BotLab's frontend source. Rebuild and distribute +the browser runtime archive alongside the compatible SDK release; Python SDK +source does not duplicate TypeScript fixes. For local/offline verification: + +```bash +botlab ui export --botlab /path/to/fixed/BotLab --out /tmp/runtime.zip +botlab ui fetch --archive /tmp/runtime.zip +``` + +These commands use a local build; they do not publish a release. Existing users +must install the corrected runtime as well as any required CLI changes. diff --git a/docs/control-assistant.md b/docs/control-assistant.md new file mode 100644 index 0000000..abe0870 --- /dev/null +++ b/docs/control-assistant.md @@ -0,0 +1,103 @@ +# BotLab Control CLI 部署 runbook(AI 助手专用) + +本文件是 BotLab 网页版「CLI 控制」面板提示词的**目标文档**:面板提示词只需一句话指向本文件。 +读者是用户委托的 AI 助手。按本文件执行即可把「CLI → 通用中间件 → 浏览器页面」链路跑通。 + +**目标**:让用户正在使用的 BotLab 网页(编辑器或托管页)连上本地通用中间件, +此后通过 `botlab control` 命令驱动页面里的机器人仿真。 + +## 0. 链路与前提 + +- 用户已有浏览器页面,浏览器侧**无需安装任何东西**;本地要安装的只有 + `botlab-python`(提供 `botlab` CLI 与通用中间件)。 +- 中间件是**通用的**:`botlab control serve` 一条命令启动,不含任何项目代码。 + 项目的能力声明(capabilities)、动作表与控制经验由**页面在配对时上报**, + 中间件按描述符构建——因此**不需要检出被控制项目的源码**。 +- 中间件是 WS 服务端,浏览器作为客户端拨入,因此必须跑在用户本地(或用户可达的机器上)。 +- 动手前向用户确认一件事:从哪个地址打开的 BotLab 页面(决定第 3 步的 `--origin`)。 +- 项目自带中间件(如 microduck 的 `python -m microduck_control serve`)仍受支持: + 按项目自己的说明启动即可,后续步骤不变;本文件默认走通用中间件。 + +## 1. 组件检查(只读) + +- `which botlab`;若存在,看脚本 shebang 定位所属 Python 环境,用对应方式查版本 + (`python3 -m pip show botlab-python`)。 +- 版本号不能区分是否含 `control`;以 `botlab control --help` 是否存在为准。 + +## 2. 安装 / 更新 + +- 源:`git+https://github.com/OpenLegged/botlab-python.git@feat/control-protocol-core` +- ⚠️ **git 源构建必须前缀 `BOTLAB_ALLOW_MISSING_UI=1`**:UI 快照(`src/botlab/ui`)是 + 生成产物不进 git,否则构建钩子拒绝出包。CLI 控制用不到 UI,此为官方支持用法。 +- 本地 checkout 若含 `src/botlab/ui`,可直接从该目录安装(同一提交、免开关、保留 UI,严格更优)。 +- 安装带 `serve` extra(中间件需要 aiohttp):`pip install "botlab-python[serve]"`; + 控制客户端本身标准库 only。缺 extra 时 `botlab control serve` 报 + `missing_dependency`(exit 2),按提示补装即可。 +- 系统 pip 受限先建 venv;Windows 用 `py -3 -m pip`。 + +## 3. 启动中间件并配对 + +```bash +botlab control serve --port 8776 --origin <本页 origin> + +# 取配对信息(websocket 地址 + 令牌): +botlab control connection --show-token --json +``` + +- 工作目录决定中间件的项目锚点(discovery 的 `project_key`);与页面项目无关, + 在哪个目录启动,之后就在哪个目录(或加 `--project` 指向它)调用其余命令。 +- 中间件默认只监听 127.0.0.1 且只接受 allowlist 的 origin。**网页版页面的 origin 不是 + 127.0.0.1**,必须加 `--origin <页面地址的 scheme://host[:port]>`(如 + `--origin https://botlab.example.com`),否则浏览器的 WS 连接被拒。 +- 每个中间件只接受一个浏览器运行实例;不要对同一浏览器同时启动两套服务。 +- 把 `connection --show-token` 输出的 websocket 地址与令牌**在对话中**交给用户, + 提醒其粘贴到浏览器「CLI 控制」面板并点「连接并启用外部控制」。 + 连接只启用外部指令,不会让机器人移动。 + +## 4. 验证 + +```bash +botlab control state --json +``` + +`connected` 与 `external_enabled` 均为 `true`、`run_state` 为 `running`、`ready` 为 +`true` 时链路就绪。`ready=false` 常见于页面隐藏或仿真暂停——保留配对,恢复后即可发令。 + +## 5. 控制规则(接管后) + +1. 控制前先拉项目经验与能力表(均由页面上报): + `botlab control experience --json` 与 `botlab control capabilities --json`; + 经验记录实测参数(如触发行走的最低速度)与坑,capabilities 声明不含这些。 +2. 每个动作:读 `state` → 发命令 → `wait --id` 直到 `terminal:true` → 核对物理反馈, + 再发下一条。 +3. 工具输出解析失败不等于命令未执行:先查 `state` 的 `active_command_id`, + 确认未接受后才可重发。 +4. 参数按 `capabilities` 声明的范围发送;动作别名来自页面声明,通用 CLI 不内置; + 定时移动不是精确导航,实际效果以位置与姿态反馈为准。 +5. 网络超时只表示结果未知:先 `wait` 原请求,必要时对同一 request-id 显式 `retry`; + 新的运动决定用新 ID。 +6. 退出码:`0` 为请求成功(含 `pending`)、`2` 为请求/连接错误、`3` 为任务失败/取消/过期。 + +## 6. 安全规则 + +令牌只在用户对话中转述,绝不写入文件、日志或提交;令牌经 `BOTLAB_CONTROL_TOKEN` +或隐藏输入传入时不入 argv、不落 shell 历史。请求收据目录(默认 +`~/.local/state/botlab/control/receipts/`)保持 0700/0600,不复制进项目日志,也不要 +删除收据来绕过冲突。 + +## 7. 出错纪律 + +贴完整报错、分析根因后再行动,不盲目换端口或重装。已知坑速查: + +- 浏览器面板连不上、中间件日志显示 origin 拒绝 → 缺 `--origin`,按第 3 步补页面 origin; +- `state` 显示 `connected:false` → 用户尚未在面板完成配对,重新走第 3 步交付流程; +- `capabilities` / `experience` / 发令返回 `no_project_paired` → 页面还没配对上报描述符, + 或页面过旧不支持下发;确认用户用的是新版页面并重新连接; +- `serve` 报 `missing_dependency` → 缺 serve extra,按第 2 步补装 aiohttp; +- 中间件重启后旧命令全部被拒 → 实例身份(instance_id)已更换,属预期;重新配对即可, + 不要重放旧 session / episode / request ID; +- CLI 安装报 `src/botlab/ui is missing` → git 源缺 UI 快照,安装命令加前缀 + `BOTLAB_ALLOW_MISSING_UI=1`; +- 端口相关报错 → 先 `lsof -nP -iTCP:8776` 查占用再决定,不要直接换端口。 + +协议与托管背景见 [control-protocol.md](control-protocol.md) 与 [control-hosting.md](control-hosting.md)。 diff --git a/docs/control-hosting.md b/docs/control-hosting.md new file mode 100644 index 0000000..f7c712d --- /dev/null +++ b/docs/control-hosting.md @@ -0,0 +1,176 @@ +# Hosting the Control API (Route A, milestone M0) + +This document freezes the contract decisions every later hosting milestone +(M1 remote control, M2 accounts, H1 web HIL, M3 platform) implements against. +It records *decisions*, not aspirations: each one is enforced by a named test +suite listed at the end. The hosting goal itself — browser editor served from +a URL, server-side middleware per session, outbound CLI — is out of scope here. + +## Architecture target + +Two independent chains, deliberately not merged: + +- **Control chain** (low-frequency commands, cloud relay acceptable): + one-shot CLI / external agent → HTTPS → cloud control service ← outbound + WSS ← the BotLab page (physics, signal graph, project Python stay in the + user's browser). +- **HIL chain** (in-loop inference, separate acceptance): page → device + gateway ← outbound WSS ← resident board agent running ONNX. The board still + runs an agent; the user's machine runs at most a browser and an optional CLI. + +## Version and extension strategy (frozen) + +- The v1 command envelope is **frozen at exactly eight fields** (see + `ENVELOPE_FIELDS` in `botlab/control/protocol.py`). Hosted identity, routing + and tenancy never add envelope fields; they travel in discovery descriptors + and `/info`. A middleware must reject unknown envelope fields (this is what + `validate_envelope` does), so an old middleware cleanly refuses a newer + client's extension instead of executing a partially understood command. +- `/info` declares `"extensions": [...]` — the negotiation surface. Clients + ignore extensions they do not understand; local v1 declares none + (`PROTOCOL_EXTENSIONS == ()`). Hosted features ride named extensions. +- `API_VERSION` bumps are reserved for incompatible envelope or semantic + changes, and require a full discovery→submit compatibility matrix across + old/new clients and middlewares. Adding an extension is not a version bump. + +## Hosted identity model (to be implemented in M1/M2) + +A hosted identity namespace binds, at minimum: **service environment + account +or tenant + project ID + execution instance**. Consequences: + +- Receipt directories are keyed by the full namespace, never by a bare project + name, so two deployments' same-named projects never share receipts. +- Descriptors need an explicit instance routing design (path segment, subdomain + or routing field): several descriptors sharing one public origin cannot + address an instance today. The stable public address is recorded in receipts. +- Local and hosted modes are selected explicitly; hosted mode must not require + downloading project source. +- `project_key` (path digest) remains the *local* identity; the hosted + `project_ref` is a separate, explicitly negotiated field. + +## Restart and deduplication semantics (frozen) + +Safety rests on three things holding together: CLI receipts on the user's +machine, server-side deduplication records, and instance identity changing on +restart. + +- A worker restart produces a **new `instance_id`**. Requests bound to the old + instance are refused (`original_instance_unavailable`); nothing is + auto-recovered or replayed. This is a feature, not a limitation. +- The in-memory task table may evict results under retention pressure; evicted + IDs keep tombstones and answer `result_evicted` — never a re-execution. +- **Prohibition:** an identity-preserving failover (same `instance_id` on a new + worker) must not be built without persistent deduplication, atomic accept + and executor mutual exclusion. A lost task table must never surface as a + resend-permitting `not_found`. +- Server-side task and audit records are *not* CLI receipts. CLI receipts stay + on the CLI machine (`0600`, never uploaded) — both statements hold at once. + +## Dependency rules + +- `botlab.control.protocol` and the client are **standard-library only**. + Server-side dependencies (aiohttp, later the middleware base class) never + enter the CLI import chain. +- Project middleware imports `botlab.control.protocol` directly; the resulting + package import cost (~80 ms) is accepted for now, deferring a lazy + `botlab/__init__` to a later milestone if it ever matters. +- The middleware base class (M3) unifies envelope validation, task tables and + state machines, but is **not** a third-party code isolation boundary: real + isolation needs separate restricted workers. First hosted phase supports + platform-maintained adapters only. + +## M1: remote control over HTTPS (implemented) + +M1 ships the remote transport without any account system, using **restricted +developer credentials** (the middleware token, shared out of band): + +- **Endpoint rules** (`botlab.control.discovery.endpoint`): loopback HTTP + keeps the explicit-port rule; HTTPS is accepted for any host with an + optional port. Plaintext HTTP never leaves the loopback. +- **Enrollment** (`botlab control enroll --endpoint https://…`): the CLI + probes `/info`, pins the reported `project_key`/`project` identity and the + `instance_id` into a `0600` discovery descriptor marked `remote: true`. + Every later command — including `wait`/`retry` — flows through the normal + descriptor, receipt and restart-refusal semantics with zero changes. + Identity comparison for remote descriptors uses the enrollment pin, not the + local checkout path (trust-on-first-use; M2 replaces this with accounts). + Re-enrolling the same endpoint replaces its previous descriptor; a second + endpoint reusing an instance identity fails with `instance_conflict`. +- **Credentials**: the token reaches the CLI via `BOTLAB_CONTROL_TOKEN` or a + hidden prompt — never argv. For private CAs set `BOTLAB_CONTROL_CA_BUNDLE` + to the CA file; TLS verification is never disabled. +- **Middleware exposure** (MicroDuck reference): `serve --host 0.0.0.0` + behind a TLS-terminating reverse proxy, `--public-host` to allowlist the + proxy's Host header, `--public-endpoint https://…` to record the public + origin in the descriptor, `--print-token` for the operator channel. The + browser panel connects to `wss:///runtime` with the same token. + +Acceptance for M1 is encoded in `tests/test_control_remote.py`: enrollment, +pinned-identity control, restart refusal until re-enrollment, identity-pin +mismatch, self-signed rejection without the CA, and multi-instance selection — +all over real TLS with a generated dev CA. Genuine cross-machine acceptance +(two physical machines) remains a manual step: same commands, `--endpoint` on +the other machine's origin. + +Explicitly **not** in M1: accounts and remote discovery (M2), instance pools +and isolation (M3), web HIL (H1 — separate chain, never rides these +semantics). + +## H1: web HIL device chain (implemented) + +HIL inference rides its own chain and inherits **no** Control-command +semantics: no receipts, no retry, no task table on the inference path. H1 +turns the board agent's inbound TCP listener into an **outbound** link so the +board never needs an inbound port: + +- **Gateway** (`botlab-hil-gateway`, `botlab.hil.gateway`): relays opaque HIL + v1 frames between a coordinator (`/device/`) and a resident agent that + dialed out (`/agent/`). It never parses HIL frames. One shared bearer + token (operator channel, generated at startup or via + `BOTLAB_HIL_GATEWAY_TOKEN`, revealed with `--print-token`); there is no + tokenless mode. Native clients present it in the `Authorization` header; + browsers cannot set handshake headers, so they send the same credential as + one leading `{"type": "gateway-auth", "token": ...}` text control frame + before the binary relay begins. `ws://` is loopback-dev only; remote gateways + are `wss://` (direct via `--tls-cert/--tls-key` or a TLS reverse proxy). + Compression is off and links dial directly (no ambient system proxy). +- **Agent** (`botlab-agent connect --gateway wss://… --device `): + registers, serves the same dispatch loop as TCP mode, and redials after + lease termination or link loss (`--retry-interval`, default 5 s). The token + arrives via `BOTLAB_HIL_TOKEN` or a hidden prompt — never argv. Private CAs + use `BOTLAB_HIL_CA_BUNDLE`; verification is never disabled. +- **Coordinator** (`transport: "gateway"` targets): `ws://` endpoints stay + loopback, `wss://` accepts any host — the same shape as the Control M1 + endpoint rule (`gateway_endpoint` in `botlab.hil.protocol`). The token is + never stored in target settings; the run server reads `BOTLAB_HIL_TOKEN`. +- **Pairing semantics**: one agent per device (re-registration takes over and + closes the displaced pairing — device restart), one coordinator per device + (a second gets `device busy`), and either side disappearing closes the + other, which releases the HIL lease exactly like a TCP disconnect. Stale + sessions are refused by the agent and the rejection terminates the lease; + nothing is replayed. + +**Acceptance is two tiers** (`tests/test_hil_gateway.py` covers the first): +functional — the model executes correctly through the gateway, exclusive +pairing, takeover and stale-result rejection, over loopback `ws://` and real +`wss://` with a generated dev CA; real-time — wall-clock loop rate at the +project's `control_hz` (50 Hz → 20 ms budget) measured on real hardware via +`botlab target bench --model policy.onnx --budget-ms 20`, which +reports RTT percentiles, effective Hz and a budget verdict. Serial +multi-model inference (one Promise chain in the browser) is the loop shape +that must fit the budget. + +Explicitly **not** in H1: per-device credentials and accounts (M2), multi- +tenant routing, and any receipt/retry semantics on the inference path. + +## Frozen by these suites + +- `tests/test_control_protocol.py` — frozen constants, envelope validation, + extension surface, `project_key`. +- `tests/test_control_cli.py` — client identity, retry and restart refusal + semantics (dedup, eviction, control generation, unsafe discovery). +- `tests/test_hil_gateway.py` — H1 functional tier: relay transparency, + exclusive pairing, takeover, stale-session rejection, endpoint rules, TLS. +- microduck-project `tests/test_control_api.py`, `tests/test_control_service.py` + — middleware conformance, including extension-surface and frozen-envelope + rejection tests. diff --git a/docs/control-protocol.md b/docs/control-protocol.md new file mode 100644 index 0000000..afdca0f --- /dev/null +++ b/docs/control-protocol.md @@ -0,0 +1,254 @@ +# BotLab Control API v1 + +`botlab control` is a robot-independent client. A project supplies its own middleware, +capability declarations and execution backend. The client uses Python's standard library; +it does not import project code, a robot adapter, aiohttp, MuJoCo or ONNX Runtime. +The browser execution protocol is separate and is not changed by this API. + +## CLI + +```sh +botlab control --project . instances --json +botlab control --project . capabilities --json +botlab control --project . experience --json +botlab control --project . sessions --json +botlab control --project . state --json +botlab control --project . call joint.position --args '{"positions":[0.1,-0.1]}' --request-id my-request --json +botlab control --project . wait --id my-request --timeout 1 --json +botlab control --project . retry --id my-request --json +botlab control --project . stop --request-id my-stop --json +botlab control serve --port 8776 --origin https://your-botlab-page.example +``` + +`call` accepts an object via `--args` or `--args-file`. `move --vx V --vy V +--yaw-rate W --duration SECONDS` requires the declared `motion.twist` capability and +submits `vx`, `vy`, `yaw_rate`, `duration_s`. `action NAME` resolves a unique alias +from capabilities. Neither command knows robot-specific limits or action names. +Unsupported capabilities return `unsupported_capability`; there is no fallback action. +The service is the authoritative validator. The CLI checks finite JSON and common +schema constraints (type, required/properties, additionalProperties=false, enum/const, +numeric bounds and array items/length), not all of JSON Schema. + +Options `--project`, `--endpoint`, `--instance`, `--session` and `--json` may appear +before or after the subcommand. The project defaults to the current working directory. +Multiple live instances require explicit selection. `connection` reports the selected +address and pairing guidance; only explicit `--show-token` exposes the pairing token. +`serve` starts the generic middleware below; apart from it the CLI does not start a +project middleware or manipulate a browser window. + +JSON stdout contains one object, including on argument errors. Exit codes are 0 for +success or pending, 2 for parsing/connection/request errors, and 3 for a cancelled, +failed or expired command. A pending wait does not cancel a command. Wait timeout is +0..30 seconds; network timeout leaves the execution outcome unknown. + +## Discovery and authentication + +Canonical project identity is `str(Path(project).resolve())`; `project_key` is the +first 16 hex characters of SHA-256 of its UTF-8 encoding. The default discovery root is +`Path(tempfile.gettempdir()) / ('botlab-control-' + user_id)`, where user_id is the +POSIX numeric UID, or SHA-256 of `getpass.getuser()` truncated to 12 hex characters. +`BOTLAB_CONTROL_RUNTIME_DIR` overrides the root. + +Each middleware writes `//.json`, with 0700 directories +and a 0600 file owned by the current user. Symbolic-link directories/files are rejected. +A descriptor contains: + +```json +{ + "schema": 1, + "api_version": 1, + "protocol": "botlab-control", + "project": "/absolute/project/path", + "project_key": "path-hash", + "instance_id": "new-uuid-for-each-service-start", + "endpoint": "http://127.0.0.1:8776", + "websocket": "ws://127.0.0.1:8776/runtime", + "token": "temporary-private-pairing-token", + "pid": 123 +} +``` + +All HTTP requests carry `Authorization: Bearer `. Credentials are never sent +in query strings. Only loopback HTTP origins with an explicit port are accepted; +URL credentials, paths, queries and fragments are refused. Environment HTTP proxies +and HTTP redirects are disabled. Explicit endpoints still require a matching private +descriptor for this project. Every descriptor is verified against `info` before use; +the CLI does not select targets by process start time. Stale descriptor files may be +listed as unavailable and are not removed by the client. + +## HTTP endpoints + +All paths are under `/control/v1`. Successful bodies contain `ok:true`; errors contain +`{"ok":false,"error":{"code":"stable_code","message":"description"}}`. +New optional fields are compatible; unknown API major versions are rejected. + +| Method / path | Meaning | +| --- | --- | +| GET `/info` | `api_version:1`, `protocol:'botlab-control'`, project, project_key, instance_id, backend `{id,kind}`, `extensions` | +| GET `/capabilities` | api_version, instance_id, capabilities_revision, capabilities array | +| GET `/experience` | instance_id, capabilities_revision and the project's text experience document | +| GET `/sessions` | instance_id and available sessions/targets; no FSM requirement | +| GET `/state` | instance_id, project_key and current state | +| POST `/commands` | submit the complete command envelope | +| GET `/commands/{request_id}?instance_id=...&timeout=1` | query or bounded wait for the original instance | +| POST `/stop` | submit the same envelope with `capability:'control.stop',args:{}` | + +`control.stop` is a mandatory protocol operation, also accepted at `/commands`. +The generic client uses `/commands` so stop shares the same request registry and retry +behavior. Control API v1 requires `command_id` to equal `request_id` for every +command. Submission responses and subsequent queries preserve this identity; queries +use that same ID. Querying a different instance +returns `wrong_instance`; unknown IDs return `not_found`; reclaimed results return +`result_evicted` without permitting replay. + +Each capability declares `id`, `version:1`, optional `aliases`, `input_schema`, `units` +(parameter-name to unit mapping), `frame` (string or null), `max_duration_s` (positive +number or null), and nonempty `completion_semantics` and `stop_semantics`. Unitless +actions use an empty units object and null frame. Current readiness is independent of +capability support. Services may publish only joint or end-effector capabilities, +without movement, sitting, a floating base, or a scalar policy state. + +The state envelope includes `connected`, `external_enabled`, `ready`, `run_state`, +`session_id`, `episode_id`, sampling/progress timestamps or steps, `state_age_ms`, +`progress_age_ms`, and `active_command_id` where applicable. Robot measurements belong +in `observations`, with schemas/frames supplied by the middleware. Connection heartbeats +do not count as advancing physics. The middleware enforces freshness and robot limits. + +## Command identity and retries + +```json +{ + "request_id": "client-generated-stable-id", + "instance_id": "service-start-id", + "session_id": "connected-runtime-session", + "episode_id": "current-reset-episode", + "capabilities_revision": "adapter-capabilities-revision", + "capability": "joint.position", + "args": {"positions": [0.1, -0.1]} +} +``` + +The service validates all identity fields and uses one deduplication registry for all +public and compatibility routes. An identical ID/content returns the original command; +different content conflicts. Service restarts create a new instance_id and never replay +old motion. Heavy results may be reclaimed, but lightweight ID tombstones must survive +for the instance lifetime. A service at its published identity limit rejects new tasks +instead of forgetting IDs and allowing duplicate motion. + +Before a first submission, the CLI persists the exact envelope, endpoint and normalized +intent under `~/.local/state/botlab/control/receipts//.json`. +When state supplies `control_generation`, the receipt preserves it separately from the +public command envelope as an additional retry precondition. +`BOTLAB_CONTROL_RECEIPTS_DIR` overrides the root. Directories are 0700; files are 0600. +Publication is atomic and exclusive, using a fully written/fsynced temporary file plus +a no-overwrite hard link. Filesystems lacking this operation cause submission to fail +before any command is sent. Receipts contain no token, are not auto-replayed, and are +not auto-deleted: deleting one removes the ability to safely retry that request. + +`retry --id` and repeated calls with an existing request ID read the original receipt, +verify its project/instance/endpoint and query the original task first. Terminal or +pending tasks are returned without submission. Only an explicit unknown-task response +from the same instance allows checking the original session, episode and capabilities +revision and resending the exact envelope. Missing receipts, replaced instances, +changed identities, unavailable services and reclaimed results refuse resubmission. +For an unknown task with a recorded control generation, the current generation must +still match; a changed or absent generation returns `state_changed` without resending. +This prevents a pre-pause request that never reached the service from executing after +resume. Middleware without this optional state field remains compatible. Existing task +results can still be queried after generation changes. +Timeout/error output after submission includes request_id and instance_id. A changed +intent cannot reuse the request ID. A new movement decision requires fresh state and a +new ID; `retry` is not a resume operation. + +## Completion and physical evidence + +Command responses contain `command_id`, `instance_id`, `session_id`, `episode_id`, +`status`, `terminal`, `initial_observation`, `completion_observation` and `current_state`. +Statuses are accepted, applied, completed, cancelled, failed and expired. +Wait responses add `pending`; accepted/applied are not completion. + +`initial_observation` labels its capture stage (typically request acceptance), not an +unverified physical start instant. `completion_observation` is a committed snapshot +matching the completion acknowledgment's episode and step; if unavailable it is null, +with an explicit absence reason. `current_state` is query-time feedback and must not +be substituted for completion evidence. Already-satisfied actions identify that basis. + +Completion is capability-specific: a bounded velocity command expiring and zeroing +does not prove arrival at a position; entering a posture state does not prove physical +stability. Stop cancels pending movement according to backend semantics and is not +necessarily an emergency freeze or interruption of a running dynamic action. Local +middleware/backend deadlines and leases, not model or CLI response timing, enforce +bounded execution. Pause/reset/disconnection never implicitly replay old commands. + +## Generic middleware: `botlab control serve` + +`pip install "botlab-python[serve]"` adds the generic middleware. The client CLI stays +standard-library-only: `serve` imports its server module lazily, and without the extra +reports `missing_dependency` (exit 2) instead of crashing. + +```sh +botlab control serve --port 8776 --origin http://127.0.0.1:5199 +``` + +`--origin` (repeatable) allowlists the BotLab page origin for browser pairing and is +required. Discovery registration, permissions (0700/0600), the pairing token +(`--print-token` or the descriptor) and remote enrollment (`--public-endpoint`, HTTPS +only) follow the rules above unchanged. `/info` declares `extensions: +["project-descriptor"]`; clients ignore extensions they do not know. + +The middleware itself is project-agnostic: the paired page uploads the project's +control semantics at connect time, and the middleware compiles them into capabilities. +Until then `/capabilities`, `/experience`, `/commands` and `/stop` answer +`no_project_paired` (HTTP 409), while `/info` and `/state` keep working. + +Pairing sequence over the `/runtime` WebSocket (the v1 message envelope is unchanged): + +``` +hello {token} page → middleware +welcome {profile: null, descriptor:{version:1}} middleware → page (negotiation bit) +descriptor {profile, motion, actions, states?, experience?} page → middleware +descriptor_ack {capabilities_revision, profile} middleware → page +... then state / command / renew / result exactly as before +``` + +A middleware without the `descriptor` negotiation bit never receives a descriptor +frame, so old pages and old middlewares interop unchanged. On reconnect the retained +descriptor is echoed in `welcome.profile` and re-uploads are idempotent (same revision); +a second descriptor on one connection closes it with `invalid_message`. Structural +validation failures close with `invalid_descriptor` (or `descriptor_too_large` for an +experience document over 32768 bytes); state frames before a descriptor are +`invalid_message`. The middleware performs structural validation only — cross-checks +against the actual project (the robot exists, the state node and parameters are +consumed, state codes cover the FSM) stay on the page, which owns the project manifest. + +Descriptor fields (frame ≤ 64 KiB): `profile` with `robot_id`, `state_node_id`, +`motion_parameters` mapping exactly `vx` and `yaw_rate` to signal names, +`event_parameter` and nonzero unique `event_values`; `motion` with `vx`/`yaw_rate` +`{min` unless overridden) carrying +`preconditions {allowed_state_codes, target_state_codes}`. The middleware self-checks +the compiled table against the frozen capability contract. +`capabilities_revision` is `"gen-" + sha256(canonical descriptor)[:16]`, so any +descriptor change forces clients to re-read capabilities. + +## Initial integration boundary + +MicroDuck is the first adapter; `botlab control serve` is the generic path for browser +projects, with the paired page supplying the project's semantics. Project-authored +adapter modules remain the escape hatch for headless/native execution, where no page +exists to upload a descriptor. MicroDuck's middleware retains the existing browser +WebSocket v1, old HTTP routes and `python -m microduck_cli` entry point. New and old +clients share one task registry. Installing the SDK alone does not upgrade a running +middleware; deploy the compatibility service first, then install the CLI and verify +actual feedback. +The joint-only HTTP fixture in `tests/test_control_cli.py` demonstrates protocol +independence; it is not a hardware driver or a validated physical robot backend. diff --git a/docs/hil-assistant.md b/docs/hil-assistant.md new file mode 100644 index 0000000..de3aea9 --- /dev/null +++ b/docs/hil-assistant.md @@ -0,0 +1,105 @@ +# BotLab HIL 设备链路部署 runbook(AI 助手专用) + +本文件是 BotLab 网页版「设备网关」面板提示词的**目标文档**:面板提示词只需一句话指向本文件。 +读者是用户委托的 AI 助手。按本文件执行即可把「浏览器 ↔ 网关 ↔ 设备 agent」链路跑通。 + +**目标**:让浏览器「设备网关」面板连上网关,模型推理下发到目标设备执行。 + +## 0. 先问场景,再动手 + +用一句话向用户确认处于哪种情况,确认前只做只读检查: + +- **A. 本机试跑(无板卡)**:同一台电脑起网关 + agent,浏览器连 `ws://127.0.0.1:50060`。 +- **B. 真实 Linux 板卡(板卡 HIL 需 Python ≥3.11,系统 Python 更旧时传独立 CPython,见第 4 步),网线/局域网连着用户本机且 SSH 可达**:网关 + agent 都跑在板卡回环,本机一条 `ssh -L` 隧道把网关投到本机回环,浏览器仍连 `ws://127.0.0.1:50060`。板卡装包一律**离线装**(第 4 步),不要求板卡出网。异地无法 SSH 直达的板卡才需要域名服务器方案(见 `hil.md`「Gateway targets」)。 + +## 1. 组件检查(只读) + +- `which botlab-hil-gateway botlab-agent botlab`;若存在,看脚本 shebang 定位所属 Python 环境(`~/.local/share/uv/tools//bin/python` = uv tool 安装),用对应方式查版本(`uv tool list` / `python3 -m pip show botlab-python`)。 +- 若本机有该仓库 checkout,`git fetch origin feat/control-protocol-core` 后 `git rev-list --count HEAD..origin/feat/control-protocol-core`,>0 才需要更新。 +- 用 `importlib.util.find_spec` 探测工具环境内 `websockets` / `numpy` / `onnxruntime` 是否齐备,缺哪个补哪个 extras。 + +## 2. 安装 / 更新 + +- 源:`git+https://github.com/OpenLegged/botlab-python.git@feat/control-protocol-core` +- ⚠️ **git 源构建必须前缀 `BOTLAB_ALLOW_MISSING_UI=1`**:UI 快照(`src/botlab/ui`)是生成产物不进 git,否则构建钩子拒绝出包。HIL 场景用不到 UI,此为官方支持用法。 +- 本地 checkout 若含 `src/botlab/ui`,可直接从该目录安装(同一提交、免开关、保留 UI,严格更优)。 +- extras:`[gateway]` = websockets(网关机);`[target]` = numpy + onnxruntime + websockets(设备端推理必需,缺了 agent 能注册但跑不了模型);`[runtime]` = 本地 MuJoCo 仿真,HIL 不需要。 +- **板卡等无外网机器一律离线装**:助手机构建本包 wheel + 按目标机标签下载依赖 wheel,scp 后 `pip install --no-index --find-links`(完整命令见第 4 步)。不要靠临时共享网络或蹭 Wi-Fi 在线装。 +- **HIL 硬门槛 Python ≥3.11**:`websockets>=17.1` 的 Requires-Python 是 ≥3.11,且拨号代码(`hil/wsstream.py`)传 17.1 才有的 `legacy=True`,降到 16.x 会直接 TypeError。板卡系统 Python 更旧时用独立 CPython(第 4 步)。离线装的 wheel 的 cp/ABI 标签必须与板卡 venv 实际 Python 一致(cp310 wheel 在 3.12 venv 里 pip 视而不见,报 `from versions: none`)。 +- 系统 pip 受限先建 venv;Windows 用 `py -3 -m pip`;uv 工具化环境用 `uv tool install --reinstall "botlab-python[...] @ <源>"`。 + +## 3. 场景 A 命令(本机试跑) + +```bash +# 启动前确认端口空闲:lsof -nP -iTCP:50060 +# 终端 1(令牌来自 BOTLAB_HIL_GATEWAY_TOKEN,未设则随机生成并由 --print-token 打印) +botlab-hil-gateway --host 127.0.0.1 --port 50060 --print-token +# 终端 2(省略 BOTLAB_HIL_TOKEN 则 getpass 交互输入,不落 shell 历史) +BOTLAB_HIL_TOKEN=<令牌> botlab-agent connect --gateway ws://127.0.0.1:50060 --device my-laptop +``` + +两个进程保持前台运行。验证标准:agent 日志出现 `BotLab HIL agent: registered as (onnxruntime)`,且 `lsof -iTCP:50060` 显示 ESTABLISHED。助手可代跑(会话后台任务),但必须告知用户进程随会话结束而停止,并附自行重启的两条终端命令。 + +## 4. 场景 B 命令(板卡直连本机 + SSH 隧道) + +两段 `ws://` 都不离开各自主机回环(浏览器拨本机回环、agent 拨板卡回环),跨网线 +流量由 SSH 加密,因此不需要域名、证书或 `wss://`。 + +```bash +# 助手机(有外网 + 本仓库 checkout):构建本包 wheel + 按板卡标签下载依赖 wheel +# --python-version/--abi 必须等于板卡 venv 将使用的 Python 版本(示例 3.12/aarch64) +uv build --wheel # 本地 checkout 含 src/botlab/ui,免 BOTLAB_ALLOW_MISSING_UI +python3 -m pip download -d botlab-pkgs --no-cache-dir --only-binary=:all: \ + --python-version 3.12 --abi cp312 --implementation cp \ + --platform manylinux2014_aarch64 --platform manylinux_2_17_aarch64 \ + --platform manylinux_2_27_aarch64 --platform manylinux_2_28_aarch64 \ + numpy "onnxruntime>=1.18" "websockets>=17.1" "pyyaml>=6.0" +cp dist/botlab_python-*.whl botlab-pkgs/ +scp -r botlab-pkgs <用户>@<板卡地址>:~/ + +# 板卡建 venv:系统 Python ≥3.11 时直接 +python3 -m venv ~/botlab-venv +# 系统 Python <3.11(websockets>=17.1 要求 ≥3.11)或 venv 报缺 ensurepip 时, +# 先由助手机从 astral-sh/python-build-standalone releases 下载 +# cpython-3.12.*-<板卡架构>-install_only.tar.gz(自带 ensurepip)scp 到板卡,然后: +mkdir -p ~/botlab-python && tar xzf cpython-*.tar.gz -C ~/botlab-python --strip-components=1 +~/botlab-python/bin/python3 -m venv ~/botlab-venv + +# 板卡:离线安装(全程不要求板卡出网) +~/botlab-venv/bin/pip install --no-index --find-links ~/botlab-pkgs "botlab-python[gateway,target]" + +# 板卡终端 1:网关(只绑板卡回环;未设 BOTLAB_HIL_GATEWAY_TOKEN 时随机生成并由 --print-token 打印) +~/botlab-venv/bin/botlab-hil-gateway --host 127.0.0.1 --port 50060 --print-token + +# 板卡终端 2:agent(网关就在板卡上,拨板卡回环) +BOTLAB_HIL_TOKEN=<令牌> ~/botlab-venv/bin/botlab-agent connect \ + --gateway ws://127.0.0.1:50060 --device <设备ID> + +# 本机终端 3:SSH 隧道(保持运行;断开后重跑同一条命令即可恢复) +ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=15 \ + -L 50060:127.0.0.1:50060 <用户>@<板卡地址> +``` + +验证标准:板卡终端 2 出现 `BotLab HIL agent: registered as (onnxruntime)`; +本机 `lsof -nP -iTCP:50060` 显示 ssh 监听 `127.0.0.1:50060`。 + +## 5. 安全规则 + +令牌只在用户对话中转述,绝不写入文件、日志或提交;`ws://` 仅限各自主机回环(场景 A 的本机回环、场景 B 的板卡回环),场景 B 的跨机流量只走 SSH 隧道;确需异地 `wss://` 网关时,强令牌一律 `secrets.token_urlsafe(32)`。 + +## 6. 交付 + +跑通后把「**网关端点 / 设备 ID / 令牌**」三项列表交给用户,并提醒:回浏览器「设备网关」面板粘贴三项后点「连接」。 + +## 7. 出错纪律 + +贴完整报错、分析根因后再行动,不盲目换端口或重装。已知坑速查: + +- `src/botlab/ui is missing` → git 源缺 UI 快照,安装命令加前缀 `BOTLAB_ALLOW_MISSING_UI=1`; +- 端口相关报错 → 先 `lsof -iTCP:` 查占用再决定,不要直接换端口; +- agent 能注册但推理报错 → 缺 `[target]` extras(numpy/onnxruntime),补装即可。 +- 离线装报 `from versions: none` → wheel 的 cp/ABI 标签与板卡 venv 的 Python 不一致,按 venv 实际版本重新 `pip download`。 +- 装不上 `websockets>=17.1`(17.x Requires-Python ≥3.11)→ 板卡 Python 太旧,用独立 CPython 建 venv;不要降级 16.x(拨号传 `legacy=True` 会 TypeError)。 +- 浏览器连不上但板卡 agent 已注册 → 本机 `ssh -L` 隧道断了或本机 50060 被占,`lsof -nP -iTCP:50060` 查明后重跑隧道命令。 + +架构与协议背景见 [hil.md](hil.md)。 diff --git a/docs/hil.md b/docs/hil.md new file mode 100644 index 0000000..5c44942 --- /dev/null +++ b/docs/hil.md @@ -0,0 +1,484 @@ +# Hardware-in-the-loop (HIL) + +BotLab can run a project's ONNX models on a Linux target while the browser runs +MuJoCo, the signal graph, project Python and keyboard controls. Project `Model` +declarations are reused; neither a robot-specific network service nor a desktop +is required on the target. + +The current execution type is **model-service**. It provides target preparation, +versioned model deployment and synchronous inference, not deployment of the +complete controller or a hard real-time scheduler. The feature is currently an +unreleased local development change. Install matching SDK and browser-runtime +builds; a published older wheel or cached editor may not contain these features. + +## Prepare a target from a clean system + +The built-in adapter supports Linux `aarch64` and `x86_64`, glibc 2.17 or later, +Python 3.10 or later, and an available SSH server. Compatible binary NumPy and +ONNX Runtime wheels must exist for the detected Python/glibc/architecture +combination. The Mac downloads these wheels; the board does not need Internet, +pip, virtualenv, BotLab, or project programs preinstalled. + +Ubuntu alone is not a compatibility specification. Inspection reports the OS, +architecture, Python, glibc, storage, memory, network interfaces and available +network-management capability. The RDK X5 profile only adds factory-address +hints to discovery; it does not change the project protocol or inference backend. + +In a project directory, start `botlab run` and open **Target manager** from the +compact HIL toolbar. The manager has three pages: **Target settings**, **Deployed +projects**, and **Network settings**. It manages configuration and deployed +projects; target connection is controlled from the main toolbar. + +1. On **Target settings**, add a target and enter its name, hostname/address and + SSH username. You can enter the SSH password to the right of the username, + then click **Save target**. The password is remembered by the current + `botlab run` host process and is not written to the target configuration or + returned to the browser. Leaving it blank preserves a password already + remembered for the same connection. Restarting `botlab run` clears this cache. + For key-based login, set the identity-file path under **Advanced**. SSH is the + default; Advanced also contains the port (22), + installation root (`~/botlab`), backend (`onnxruntime`) and the existing-Agent + TCP connection option. There is no board type to select: inspection detects + the architecture and compatible environment. +2. Close the manager and click **Connect** in the main toolbar. For the first SSH + connection, the connection dialog automatically checks the target using the + saved session credentials or SSH key and displays its result. If no usable + credentials are available or SSH authentication fails, a password dialog lets + you enter a password and retry the current operation. A wrong password keeps + the dialog open for another attempt; **Cancel** ends that operation. This + prompt also applies to SSH management actions such as scanning projects and + reading target network interfaces. Network failures and host-key errors are + reported separately rather than requesting a password. For a new SSH host, + verify the displayed fingerprint against a trusted source such as the board's + console, then accept the verified key; inspection continues automatically. + Existing trusted keys are also supported. +3. Review the check result. The dialog offers **Cancel** and **Connect**; when + the Agent is missing, the latter becomes **Install and connect**. Installation + uploads the Agent and matching Linux dependencies into the chosen BotLab root + and connects afterward. A successful connection does not require changing + network settings. +4. Choose **Run on Target** in the main toolbar. Its separate-operation menu + also provides separate build, deploy, load, run and unload actions. After + building and deploying to the connected target, **Load project** directly + connects that exact build. **Run** in this menu starts the connected version + without rebuilding, deploying or selecting another version. + In the manager's **Deployed projects** page, choose a target on the left to + automatically scan its projects. **Scan projects** refreshes the list; + expanding a project shows each version's deployment time, short build ID, + and loaded status. **Load version** selects an existing version of that + project, or switches to a version of another project, on the connected target. + Stop the running session before switching; selecting a version does not build + or deploy. **Delete version** removes only that deployment; deleting its last + version also removes the project. **Delete project** removes all its versions. + Both deletion actions require confirmation. Stop and unload the loaded version + before deleting it or its project; other inactive versions and projects can + be deleted while a version remains loaded. Browsing a target does not change + the main toolbar's selected target or active project. Connect the browsed + target from the main toolbar before loading one of its versions. + +Operation progress, completion and errors appear in the main **Output** terminal. +The manager does not repeat logs or completion banners. A failed connection shows +**Connection failed** on the main page; the Output terminal opens automatically +if hidden so the detailed error is visible. + +If the address is unknown or SSH is unreachable, use **Network settings** as +described below, then return to **Target settings**. + +Initialization uses the installed SDK's Python source and Linux dependency +wheels. It never copies the Mac's native modules or reuses an old robot-specific +`.deps` directory. Agent execution uses `python -S` with its own source and +dependency paths, excluding global Python site packages. Dependency import +checks complete before switching the active Agent release. + +The Agent binds `127.0.0.1:50052` on the target. The SDK opens an authenticated SSH +forward and holds a remote login session for the connection lifetime. Connecting +starts the already-installed Agent if needed and waits for an actual Agent hello +through the tunnel before reporting success. Only SSH needs to be reachable from +the host. + +### Network setup + +Skip this page if the target already connects. Its three sections act on different +things: + +- **Find a device** is read-only: list the Mac interfaces, select the USB/Ethernet + interface connected to the board, then discover SSH neighbors on that link and + choose an address. It does not change either device's IP or scan an entire subnet. +- **Mac network settings** changes the selected Mac network service's IPv4 mode + to DHCP or a static address. macOS presents its own administrator authorization + dialog; BotLab does not collect that password. Only the selected service is + changed. A USB/Ethernet link may need a host address in the board's factory subnet + before discovery can find it. This does not change the board's address. +- **Target network settings** changes the board's network. It requires SSH to be + reachable first. Use **Read target network interfaces** in this section; + BotLab uses saved session credentials or SSH keys, asks for a password when + needed, and requires verification of a new host fingerprint. You can also + enter the password in **Target settings** beforehand. Change the address only + when the result reports supported NetworkManager capability. It cannot repair + a device that has no usable SSH path. + +Both network-setting sections are advanced, collapsible operations. Choose DHCP +or static explicitly before applying a change. Remote changes also require an +active NetworkManager-managed connection and either a root SSH account or +passwordless sudo. Other network managers are rejected before modifying the +configuration. For a static target address, leaving the gateway empty clears the +existing gateway; enter the gateway explicitly if it must be retained. + +A target network change creates a 90-second NetworkManager checkpoint and applies +temporary settings. Reconnect through the new address and explicitly confirm to +make them persistent. Without confirmation, NetworkManager rolls back even if +the SSH session closes. For DHCP, discover and supply the new address before +confirming. Keep the SDK process alive while confirming; an expired transaction +must be retried from the restored address. + +BotLab still needs a physical and management path to the device. Flashing an OS, +serial-console bootstrap, Wi-Fi credential setup and vendor-specific USB drivers +are not implemented. The CLI's optional `--profile` discovery hints remain +supported; hiding board type in the manager does not remove that compatibility. + +### Command-line workflow + +Install the matching development artifacts on the host: + +```bash +pip install /path/to/botlab_python--py3-none-any.whl +botlab ui fetch --archive /path/to/botlab-browser-runtime-.zip +``` + +Stop and restart any existing `botlab run` process after updating the SDK so the +new host-side credential and project-management behavior is loaded. Reload the +browser after installing its matching runtime. + +Register and inspect a target. Replace placeholders with the actual SSH account, +address and host key; there is no built-in board password or automatic trust: + +```bash +# Show local adapters, then discover neighbors on the chosen adapter. +botlab target discover +botlab target discover --interface --profile generic-linux +# For an RDK X5, --profile rdk-x5 adds its factory-address hints. + +botlab target add my-board --ssh-host --user +botlab target identity my-board + +# After independently verifying the displayed SHA256 fingerprint, save it. +botlab target add my-board --ssh-host --user \ + --host-key 'SHA256:' + +botlab target inspect my-board --ask-password +botlab target provision my-board --ask-password +botlab target list +``` + +For key-based login, add `--identity-file /path/to/private-key` when registering +the target and omit `--ask-password`. Standard SSH-agent/configuration behavior +is available. Passwords are requested through a terminal prompt, not a command +argument, and are not saved in target profiles. Each separate CLI invocation +needs credentials again unless SSH key authentication is available. + +Deploy without running, inspect deployed projects, or launch the editor: + +```bash +botlab deploy /path/to/my-project --target my-board --ask-password +botlab target projects my-board --ask-password +cd /path/to/my-project +botlab run --hil my-board +``` + +`botlab deploy` builds and installs the current model-service artifact. The +existing `botlab build` command still creates the ordinary browser release ZIP; +it is not a separate HIL compile command. Use **Build** in the HIL run menu to +build without deploying. + +`botlab run --hil my-board` preselects the target. It does not immediately execute +or install anything. Connect from the main toolbar; optionally enter and save +an SSH password in **Target settings** first, or supply it when prompted. A normal +`botlab run` starts with local simulation selected and also provides the target +manager. + +```bash +# Remove only the local saved target; does not delete board files. +botlab target remove my-board +``` + +Target settings live in `~/.config/botlab/targets.json`; `BOTLAB_TARGETS_FILE` +overrides this location. They contain connection settings and accepted public +host-key fingerprints, never passwords. Deleting a deployed project and all its +versions is a separate, confirmed action in Target manager. + +Password caching lasts for the current host process, including browser reloads, +not across `botlab run` restarts. Long-term **Remember password** storage is not +implemented. A future OS credential-store integration could offer a similar user +experience to a browser password manager, but the current implementation does +not use macOS Keychain, Windows Credential Manager or a Linux secret service. + +The SSH helper currently writes a password into a permission-restricted `0600` +file in a temporary directory for `SSH_ASKPASS`. Ordinary SSH commands clean up +that directory when they finish; an SSH tunnel retains it until the tunnel closes. +This is temporary file-based authentication, not a guarantee that passwords never +touch disk. The password is excluded from target profiles and operation logs. + +## One-click and separate operations + +After preparing a target, **Run on Target** connects when necessary, builds the +current project, deploys it, loads that exact revision, and starts inference. +The Agent verifies and reuses an already-deployed revision. The current transport +still sends the artifact during deployment; it does not yet implement a +missing-file-only transfer protocol. + +| Action | Effect | +| --- | --- | +| Connect target | Open SSH forwarding and the Agent management connection. | +| Build | Validate the local project and package its declared models and manifest. | +| Deploy | Install the last successful build as an immutable target release without rebuilding or executing it. Requires a completed build and a connected target. | +| Load project | Directly load the last successful local build on the connected target and acquire its management ownership. That exact build must already be deployed there. Models load as the browser requests them during execution. | +| Run on Target | Build/deploy/load the current editor project and start the inference session. | +| Run (separate-operation menu) | Start only the currently loaded project/version, after checking that the local source and models match it. No build, deployment or project switch occurs. | +| Stop | End inference and release model execution resources; deployed files remain and project selection stays loaded. | +| Unload project | Release project ownership after stopping; the target connection remains. | +| Disconnect target | Close the connection and release its runtime/project ownership; deployed files remain. | +| Load version (Target manager) | Select an existing version on the connected target after stopping. May switch within one project or to another project; never builds or deploys. | +| Delete version | From Target manager, remove one inactive deployed version after confirmation. Deleting the last version also removes its project. Stop and unload first if this is the loaded version. | +| Delete project | From Target manager, remove the entire deployed project and all its versions after confirmation. Stop and unload first if this is the loaded project. | + +For step-by-step execution, choose **Build → Deploy → Load project → Run** +in the run menu. Deploy remains disabled until a build succeeds. It +sends that build even if local files subsequently change; choose Build again to +include those edits. Build artifacts live in the current host process, so +restarting `botlab run` requires another build before deploying. + +The run menu's **Load project** directly connects the current host process's +last successful build. Connect the target, then build and deploy that version +before loading it. Load project remains disabled until that exact version is +available on the connected target. If you build a newer version, deploy it before +loading again; this action does not fall back to an older deployment. + +To choose another project or a historical version, open Target manager's +**Deployed projects** tab, expand a project and choose **Load version** for that +exact deployment. Browsing or expanding a project does not connect it or change +the local editor files. The toolbar shows the connected project and its version. + +Several projects and versions can remain deployed, but only one project/session +can own execution at a time. The manager provides independent target browsing, +project scanning, version details, version/project switching, and deletion of a +single version or an entire project. Switching requires the browsed target to +be the connected target, and rejects stale target settings or deleted versions +without changing the current selection. Deletion refreshes both the manager and +the connected target's project inventory. Viewing or loading a deployed version +does not change the local editor source: **Run on Target** always prepares the current editor project. +**Run** in the separate-operation menu preserves the chosen version and requires matching +local source and model files; a mismatch leaves that selection intact and asks +you to open the corresponding local project revision. To reproduce an older +controller/model combination, restore the matching local project revision first. + +Model execution occurs in a separate worker process so a stalled native backend +can be terminated while the management Agent remains available. Timeouts are +failure bounds, not real-time scheduling guarantees. A disconnect stops the +session; this model-service mode does not continue a standalone controller on +the board. + +## Target files and project identity + +The default root is `~/botlab`; set another dedicated root when adding the target +with `--root`. Do not use a symlink or a general-purpose system directory. + +```text +~/botlab/ +├── agent/ +│ ├── releases// +│ │ ├── package/botlab/ # SDK Python source +│ │ ├── deps/ # Matching Linux binary/pure-Python wheels +│ │ └── manifest.json +│ ├── current -> releases/ +│ ├── start-agent.py +│ ├── agent.pid +│ └── agent.log +├── projects// +│ ├── project.json +│ ├── current # Selected build ID, stored as JSON +│ ├── releases// +│ │ ├── manifest.json +│ │ ├── .deployment.json +│ │ └── models/.onnx +│ └── runs/.jsonl # Run lifecycle events +├── staging/ # Incomplete uploads +├── cache/models/ # Legacy direct-model session cache +└── environments/ # Reserved; per-project environments not implemented +``` + +The first HIL build writes `.botlab/project.json` in the local project to give it +a stable ID. Preserve this file when updating that project; it distinguishes +same-name projects. Releases are content-addressed and checked before loading. +Deployment stages files, verifies their hashes and embedded weights, then +atomically creates the release directory. Failed uploads leave previous releases +available. Project deletion and local target removal are independent. + +All deployed model-service projects currently share the provisioned Agent's +fixed ONNX Runtime environment. Arbitrary project requirements and independent +per-project dependency environments are not yet supported. The `environments` +directory does not imply those features are available. Run event logs and Agent +logs are stored on disk. The main Output terminal shows host operation logs; +it does not tail the target Agent log file. + +If an existing systemd user manager is available, initialization installs and +enables a `botlab-agent-.service` user unit. It starts at user login; +startup at boot without a login requires user lingering to already be enabled. +BotLab reports `autostart` accurately and does not enable lingering implicitly. +Without lingering, the persistent SSH login keeps the user service alive while +BotLab is connected; it may stop after the last login ends. Reconnecting starts +the installed service again, without reinstalling or changing system permissions. +When the user manager is unavailable, provisioning starts a detached managed +process and reports `autostart: false`; a later connection can start the installed +launcher after reboot. A running Agent is reused and is not restarted by another +connection. Neither service mode automatically starts a deployed project. + +## Architecture and protocol + +```text +Browser: physics + controller graph + ↕ local authenticated HTTP +Mac: botlab-python Host, build/deploy, target inventory + ↕ verified SSH tunnel +Linux: BotLab Agent, project store, isolated inference worker + ↕ +ONNX Runtime CPU +``` + +- `botlab.hil.devices`: SSH trust/authentication, discovery, system inspection, + provisioning, network adapters and local forwarding. +- `botlab.hil.host`: local HTTP coordination, operations, sessions and metrics. +- `botlab.hil.artifacts`: deterministic builds, persistent releases and validation. +- `botlab.hil.agent` / `worker`: target management and bounded inference execution. +- `botlab.hil.backends`: model execution backend interface. +- Browser toolbar/runtime: source lives in the BotLab engine repository and is + exported as the SDK's separately installed runtime archive. + +The underlying wire format remains length-prefixed JSON protocol version 1, +with capabilities negotiated separately for project management. The Host and +updated Agent advertise `deploymentVersions: 1`; manager version switching and +individual version deletion require this Agent capability and otherwise ask you +to update the Agent first. +Whole-project deletion remains available with older project-management Agents. +A rejected project selection preserves its existing lease when the updated +Agent explicitly confirms that validation made no changes. Transport failures +and other operation errors retain the existing disconnect behavior. Models and +builds use SHA-256 identities. Named tensors carry dtype, shape and data; session and +sequence IDs reject stale inference results. Errors never silently switch to +local inference. Metrics measure real successful backend calls and latency. + +The generic Linux adapter can be reused across compatible boards. A new +accelerator requires a compatible build/conversion path and execution backend, +not just another IP address. The existing direct-agent backend interface is: + +```python +describe() # {backend, provider, dtypes, ...} +load(path) # (handle, input_ports, output_ports) +infer(handle, inputs) # named wire tensors +close(handle) +``` + +Factories can be supplied to a manually installed Agent as +`--backend vendor_module:create_backend`. Automatic provisioning and the current +persistent deployment format only support `onnxruntime`; custom accelerator +build/dependency plugins are not implemented by that extension point alone. + +### Compatibility with direct TCP targets + +Existing manually prepared targets still work: + +```bash +# On the target, with a matching SDK build and its [target] dependencies installed: +botlab agent serve --host --port 50052 --root ~/botlab +# On the host: +botlab target add legacy-board --endpoint :50052 +``` + +Direct TCP does not provide authentication or encryption. Use it only on a +trusted isolated development link; the SSH route is the standard onboarding path. +Direct targets cannot use SSH inspection, provisioning or network-management +operations. Older Agents without project capabilities retain direct model-session +behavior rather than gaining persistence automatically. + +### Gateway targets (outbound device link) + +The board can also dial out to a device gateway, so it never needs an inbound +port — the shape used for hosted/web HIL. The gateway relays opaque HIL v1 +frames; the protocol, browser UI and acceptance rules are unchanged, and the +inference path carries no receipts or retry semantics. + +```bash +# Wherever the gateway runs (loopback dev, or wss:// behind/direct TLS): +botlab-hil-gateway --host 127.0.0.1 --port 50060 --print-token +# Token via BOTLAB_HIL_GATEWAY_TOKEN; there is no tokenless mode. + +# On the board: resident agent registers and redials after link loss: +BOTLAB_HIL_TOKEN= botlab-agent connect \ + --gateway wss://boards.example.com --device rdk-x5-desk \ + --backend onnxruntime --root ~/botlab + +# On the host: +botlab target add desk-x5 --gateway wss://boards.example.com --device rdk-x5-desk +BOTLAB_HIL_TOKEN= botlab run --hil desk-x5 +``` + +Rules: `ws://` endpoints are loopback-only; remote gateways must be `wss://` +(private CA via `BOTLAB_HIL_CA_BUNDLE`, verification never disabled). The token +is never stored in target settings and never passed as a CLI argument. Native +clients send it in the `Authorization` header; browsers, which cannot set +handshake headers, send the same credential as one leading text control frame +`{"type": "gateway-auth", "token": ...}` before the binary relay begins (any +text frame after that is still a protocol error). One +agent per device (re-registration takes over, closing the displaced pairing — +device restart discards stale sessions), one coordinator per device (a second +gets `device busy`), and either side dropping releases the HIL lease exactly +like a TCP disconnect. + +Latency acceptance has two tiers: functional (the model executes correctly +through the gateway — `tests/test_hil_gateway.py`) and real-time (wall-clock +loop rate at the project's `control_hz`). Measure the latter with: + +```bash +BOTLAB_HIL_TOKEN= botlab target bench desk-x5 \ + --model policy.onnx --iterations 200 --budget-ms 20 +``` + +which reports RTT percentiles, effective Hz and whether the serial inference +loop fits the 20 ms budget of a 50 Hz project. + +Browser runs default to `127.0.0.1`. Explicit `--host 0.0.0.0` serves ordinary +local simulation on the LAN with HIL disabled. `--hil` requires loopback binding +and cannot be combined with native/headless/export/custom-adapter modes. The +local bridge uses a per-process token plus Host/Origin validation. + +## Current limits + +- Supported automatic target setup: Linux aarch64/x86_64 + glibc + Python 3.10+ + + SSH + compatible ONNX Runtime CPU wheels. No MCU/RTOS support or OS flashing. +- Static positive tensor dimensions, float32, named multi-input/output models. + Dynamic shapes, rank-zero graph tensors and ONNX external weight files are + unsupported. At most 32 model declarations and 32 MiB of unique model data per + deployment; aggregate project storage is limited to 512 MiB. The legacy model + cache has a separate 256 MiB limit. +- One active owner/session; all projects use the same provisioned runtime + dependencies. No arbitrary target-side project Python or `requirements.txt`. +- No BPU conversion, TensorRT build, full-controller deployment, hardware actuator + output, synchronized hardware clock or hard real-time deadline guarantee. +- Network changes support macOS host services and NetworkManager targets only; + fresh devices still need a reachable bootstrap channel and valid credentials. + +## Updating the browser engine + +After changing the HIL UI or browser model execution, build the engine, export +and install its runtime together with the matching SDK build: + +```bash +# In the engine checkout: +npm run build:dev +# With the updated SDK installed: +botlab ui export --botlab /path/to/BotLab --out /tmp/botlab-browser-runtime.zip +botlab ui fetch --archive /tmp/botlab-browser-runtime.zip +``` + +The archive contains `botlab-capabilities.json` with `hilProtocol: 1`. The CLI +rejects `--hil` with incompatible cached runtimes. Installing a Python wheel alone +does not update the browser code. No online BotLab deployment is needed. diff --git a/pyproject.toml b/pyproject.toml index c13bad7..e45ac89 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,10 +4,10 @@ build-backend = "hatchling.build" [project] name = "botlab-python" -version = "0.3.0" +version = "0.4.0" description = "Python-first robot simulation and control in the browser." readme = "README.md" -requires-python = ">=3.10" +requires-python = ">=3.11" license = { text = "Proprietary" } authors = [{ name = "OpenLegged" }] keywords = ["robotics", "mujoco", "onnx", "rl", "simulation", "sim2sim"] @@ -17,7 +17,6 @@ classifiers = [ "Intended Audience :: Science/Research", "Operating System :: OS Independent", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -34,8 +33,19 @@ Repository = "https://github.com/OpenLegged/botlab-python" [project.scripts] botlab = "botlab._cli:main" +botlab-agent = "botlab.hil.agent:main" +botlab-hil-gateway = "botlab.hil.gateway:main" [project.optional-dependencies] +# Target inference only; no native physics or browser dependencies. +# websockets is pure Python and only needed for gateway (outbound) mode. +# >=17.1: the long-lived (non-context-manager) dial needs the legacy flag. +target = ["numpy>=1.24", "onnxruntime>=1.18", "websockets>=17.1"] +# Run the device gateway standalone. +gateway = ["websockets>=17.1"] +# `botlab control serve`: the generic browser-pairing middleware. The client +# CLI stays stdlib-only; aiohttp enters the process only in the serve branch. +serve = ["aiohttp>=3.9"] # Native simulation via `botlab run`: native MuJoCo physics + # onnxruntime inference (CPU out of the box; GPU needs onnxruntime-gpu with # matching CUDA). Without these the CLI still compiles and packages projects. @@ -43,12 +53,13 @@ runtime = [ "numpy>=1.24", "mujoco>=3.2", "onnxruntime>=1.18", - "websockets>=12", + "websockets>=17.1", ] dev = [ "pytest>=8.0", "ruff>=0.6", "botlab-python[runtime]", + "botlab-python[serve]", # Enables the packaging-time ONNX asset validation in dev/CI; without it # botlab build packages model bytes as-is. "onnx>=1.16", @@ -76,7 +87,7 @@ include = [ [tool.ruff] line-length = 100 -target-version = "py310" +target-version = "py311" [tool.ruff.lint] select = ["E", "F", "W", "I", "UP", "B"] diff --git a/scripts/verify_wheel.py b/scripts/verify_wheel.py index af99b8f..781c278 100644 --- a/scripts/verify_wheel.py +++ b/scripts/verify_wheel.py @@ -76,7 +76,11 @@ def create_controller(sim): assert direct["url"] == Path(sys.argv[1]).as_uri(), direct assert "archive_info" in direct, direct scripts = {item.name: item.value for item in dist.entry_points if item.group == "console_scripts"} -assert scripts == {"botlab": "botlab._cli:main"}, scripts +assert scripts == { + "botlab": "botlab._cli:main", + "botlab-agent": "botlab.hil.agent:main", + "botlab-hil-gateway": "botlab.hil.gateway:main", +}, scripts for name in ("component-reference.md", "decision-table.md", "error-guide.md"): assert (root / "docs" / name).is_file(), name assert not any(root.rglob("*.wasm*")), "core wheel contains a WASM runtime" diff --git a/src/botlab/__init__.py b/src/botlab/__init__.py index b97e7e2..7fe4835 100644 --- a/src/botlab/__init__.py +++ b/src/botlab/__init__.py @@ -43,6 +43,7 @@ Signal, Simulation, absolute, + action, affine, all_of, any_of, @@ -50,8 +51,10 @@ clock, concat, constant, + control_profile, cos, cross, + declare_control, default_joint_positions, delay, derivative, @@ -81,6 +84,7 @@ quaternion_multiply, quaternion_slerp, quaternion_yaw, + read_config, reduce_max, reduce_min, reduce_sum, @@ -123,6 +127,7 @@ "Simulation", "StepRecord", "absolute", + "action", "affine", "all_of", "any_of", @@ -130,8 +135,10 @@ "clock", "concat", "constant", + "control_profile", "cos", "cross", + "declare_control", "default_joint_positions", "delay", "derivative", @@ -163,6 +170,7 @@ "quaternion_multiply", "quaternion_slerp", "quaternion_yaw", + "read_config", "reduce_max", "reduce_min", "reduce_sum", @@ -186,4 +194,4 @@ "where", ] -__version__ = "0.3.0" +__version__ = "0.4.0" diff --git a/src/botlab/_cli.py b/src/botlab/_cli.py index fe0a43f..c4ed5eb 100644 --- a/src/botlab/_cli.py +++ b/src/botlab/_cli.py @@ -22,6 +22,7 @@ from __future__ import annotations import argparse +import importlib import os import sys import tempfile @@ -63,9 +64,60 @@ def _set_verbose(args: argparse.Namespace) -> None: os.environ["BOTLAB_VERBOSE"] = "1" +def _run_browser_adapter(args: argparse.Namespace, project_dir: Path) -> int: + from .browser import BrowserRunContext + + if args.native or args.headless or args.out or args.no_server or args.report: + raise BotLabError( + "--browser-adapter requires a live browser run; it cannot be combined " + "with --native, --headless, --out, --no-server, or --report." + ) + module_name, separator, function_name = args.browser_adapter.partition(":") + if (not separator or not function_name.isidentifier() + or not all(part.isidentifier() for part in module_name.split("."))): + raise BotLabError("--browser-adapter must be MODULE:FUNCTION.") + if not project_dir.is_dir(): + raise BotLabError(f"project directory does not exist: {project_dir}") + previous_directory = Path.cwd() + previous_path = sys.path[:] + try: + os.chdir(project_dir) + sys.path.insert(0, str(project_dir)) + try: + module = importlib.import_module(module_name) + adapter = getattr(module, function_name) + except (ImportError, AttributeError) as error: + raise BotLabError(f"Cannot load browser adapter {args.browser_adapter}: {error}") from error + if not callable(adapter): + raise BotLabError(f"Browser adapter {args.browser_adapter} is not callable.") + context = BrowserRunContext( + project_dir=project_dir, + ui_dir=ensure_browser_runtime(), + host=args.host, + port=args.port, + open_browser=not args.no_browser, + inference_endpoint=args.inference_endpoint, + ) + return adapter(context) + finally: + sys.path[:] = previous_path + os.chdir(previous_directory) + + def _cmd_run(args: argparse.Namespace) -> int: _set_verbose(args) project_dir = Path(args.project_dir or ".").resolve() + adapter = getattr(args, "browser_adapter", None) + target = getattr(args, "hil", None) + if target is not None and (adapter or args.native or args.headless or args.out or args.no_server): + raise BotLabError("--hil requires the standard live browser run.") + args.host = args.host or ("0.0.0.0" if args.native or args.headless else "127.0.0.1") + if target is not None and args.host not in ("127.0.0.1", "localhost"): + raise BotLabError("HIL requires --host 127.0.0.1 or localhost.") + if adapter: + return _run_browser_adapter(args, project_dir) + if getattr(args, "inference_endpoint", None): + raise BotLabError("--inference-endpoint requires --browser-adapter.") if args.headless: if args.out or args.no_server: raise BotLabError("--headless cannot be combined with --out or --no-server.") @@ -137,6 +189,22 @@ def _cmd_run(args: argparse.Namespace) -> int: ) if args.no_server: return 0 + from .hil import HilHost + + runtime = ensure_browser_runtime() + if target is not None: + import json + + try: + capability = json.loads((runtime / "botlab-capabilities.json").read_text()) + except (OSError, ValueError): + capability = {} + if capability.get("hilProtocol") != 1: + raise BotLabError("The cached browser runtime does not support HIL v1. Install a matching runtime with `botlab ui fetch --archive `.") + try: + hil = HilHost(project_dir, selected_target=target) if args.host in ("127.0.0.1", "localhost") else None + except ValueError as error: + raise BotLabError(str(error)) from error serve_project( project_dir, None, @@ -144,7 +212,8 @@ def _cmd_run(args: argparse.Namespace) -> int: args.port, open_browser=not args.no_browser, release_provider=provider, - ui_dir=ensure_browser_runtime(), + ui_dir=runtime, + hil=hil, ) return 0 @@ -248,6 +317,165 @@ def _cmd_docs(args: argparse.Namespace) -> int: return 0 +def _hil_result(host, result): + """Wait for a CLI management job; the browser uses the same operations API.""" + import time + while isinstance(result, dict) and result.get("operation"): + operation = result["operation"] + if operation["status"] == "failed": + raise BotLabError(operation["error"]) + if operation["status"] == "succeeded": + return result.get("result", result) + time.sleep(0.2) + result = host.handle("GET", "/hil/operations/" + operation["id"]) + return result + + +def _bench_target(with_host, args) -> dict: + """Serial-inference latency report: the real-time acceptance tier. + + Functional acceptance (the model executes correctly) is covered by the + automated suites; this measures whether the whole loop fits the wall-clock + budget (e.g. 20 ms at control_hz=50). No receipts, no retry — inference + frames are never re-sent. + """ + import base64 + import hashlib + import random + import statistics + if not args.model: + raise BotLabError("target bench requires --model FILE") + _hil_result(with_host, with_host.handle("POST", "/hil/connect", {"target": args.name})) + session = with_host.handle("POST", "/hil/start", {})["session"] + content = Path(args.model).read_bytes() + digest = hashlib.sha256(content).hexdigest() + try: + model = with_host.handle("POST", "/hil/models", { + "session": session, "sha256": digest, "data": base64.b64encode(content).decode()}) + + def feed(): + inputs = {} + for spec in model["inputs"]: + shape = [n if type(n) is int and n > 0 else 1 for n in spec["shape"]] + size = 1 + for n in shape: + size *= n + inputs[spec["name"]] = {"dtype": "float32", "shape": shape, + "data": [random.uniform(-1, 1) for _ in range(size)]} + return inputs + + rtts, inference = [], [] + for seq in range(args.iterations): + result = with_host.handle("POST", "/hil/infer", { + "session": session, "seq": seq, "model": digest, "inputs": feed()}) + rtts.append(result["rtt_ms"]) + inference.append(result["inference_ms"]) + finally: + with_host.handle("POST", "/hil/stop", {"session": session}) + rtts.sort() + + def percentile(fraction): + return round(rtts[min(len(rtts) - 1, int(fraction * len(rtts)))], 3) + + mean = statistics.fmean(rtts) + return {"target": args.name, "model": digest, "iterations": args.iterations, + "rtt_ms": {"p50": percentile(0.50), "p95": percentile(0.95), + "p99": percentile(0.99), "max": round(rtts[-1], 3)}, + "inference_ms_mean": round(statistics.fmean(inference), 3), + "effective_hz": round(1000 / mean, 1), + "budget_ms": args.budget_ms, + "real_time_budget_met": mean <= args.budget_ms} + + +def _cmd_target(args: argparse.Namespace) -> int: + import json + + from .hil import HilHost + + with_host = HilHost(Path.cwd()) + try: + action = args.action + body = {"target": args.name} + if getattr(args, "ask_password", False): + import getpass + body["password"] = getpass.getpass("SSH password: ") + if getattr(args, "host_key", None): + body["host_key"] = args.host_key + if action == "list": + result = with_host.handle("GET", "/hil/state")["targets"] + elif action == "discover": + result = with_host.handle("POST", "/hil/devices/discover", { + "interface": args.interface, "profile": args.profile}) + elif action == "add": + if not args.name or not (args.endpoint or args.ssh_host or args.gateway): + raise BotLabError("target add requires NAME and (--ssh-host HOST --user USER " + "| --endpoint tcp://host:port | --gateway wss://… --device ID)") + settings = {"id": args.name, "name": args.name, "backend": args.backend} + if args.gateway: + settings.update(transport="gateway", endpoint=args.gateway, device=args.device) + elif args.ssh_host: + settings.update(transport="ssh", ssh={"host": args.ssh_host, "user": args.user, + "port": args.ssh_port}, root=args.root, profile=args.profile or "generic-linux") + if args.identity_file: + settings["ssh"]["identity_file"] = args.identity_file + if args.host_key: + settings["ssh"]["host_key"] = args.host_key + else: + settings["endpoint"] = args.endpoint + result = with_host.handle("POST", "/hil/targets", settings) + else: + if not args.name: + raise BotLabError("target " + action + " requires NAME") + target = next((t for t in with_host.targets if t["id"] == args.name), None) + if action == "remove": + result = with_host.handle("POST", "/hil/targets/remove", body) + elif action == "provision": + result = with_host.handle("POST", "/hil/devices/provision", body) + elif action == "identity": + result = with_host.handle("POST", "/hil/devices/host-key", body) + elif action == "inspect" and target and target.get("transport") == "ssh": + result = with_host.handle("POST", "/hil/devices/inspect", body) + elif action == "bench": + result = _bench_target(with_host, args) + else: + result = _hil_result(with_host, with_host.handle("POST", "/hil/connect", body)) + if action == "projects": + result = with_host.handle("GET", "/hil/projects") + print(json.dumps(_hil_result(with_host, result), ensure_ascii=False, indent=2)) + except (ValueError, OSError) as error: + raise BotLabError(str(error)) from error + finally: + with_host.close() + return 0 + + +def _cmd_deploy(args: argparse.Namespace) -> int: + import json + + from .hil import HilHost + host = HilHost(Path(args.project_dir or ".")) + body = {"target": args.target} + if args.ask_password: + import getpass + body["password"] = getpass.getpass("SSH password: ") + try: + _hil_result(host, host.handle("POST", "/hil/connect", body)) + _hil_result(host, host.handle("POST", "/hil/build", {})) + result = _hil_result(host, host.handle("POST", "/hil/deploy", {})) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + except (ValueError, OSError) as error: + raise BotLabError(str(error)) from error + finally: + host.close() + + +def _cmd_agent(args: argparse.Namespace) -> int: + from .hil.agent import main as agent_main + + return agent_main(args.agent_args) + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="botlab", @@ -280,7 +508,21 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="run natively without a viewer or HTTP server (supports custom Python controllers)", ) - run.add_argument("--host", default="0.0.0.0", help="bind address (default: 0.0.0.0, LAN-reachable)") + run.add_argument( + "--host", help="bind address (browser default: 127.0.0.1; native: 0.0.0.0)", + ) + run.add_argument( + "--hil", metavar="TARGET", + help="select a registered HIL target or tcp://host:port in the browser toolbar", + ) + run.add_argument( + "--browser-adapter", metavar="MODULE:FUNCTION", + help="run a project browser adapter with the SDK-managed web runtime", + ) + run.add_argument( + "--inference-endpoint", metavar="HOST:PORT", + help="remote inference address passed to the selected browser adapter", + ) run.add_argument("--port", type=int, default=0, help="bind port (default: random free port)") run.add_argument( "--out", @@ -333,6 +575,41 @@ def build_parser() -> argparse.ArgumentParser: ui.add_argument("--url", help="runtime archive URL override for ui fetch") ui.set_defaults(func=_cmd_ui) + target = subparsers.add_parser("target", help="manage HIL target devices") + target.add_argument("action", choices=["list", "add", "inspect", "remove", "discover", "identity", "provision", "projects", "bench"]) + target.add_argument("name", nargs="?") + target.add_argument("--endpoint") + target.add_argument("--gateway", help="outbound gateway origin, ws://loopback:port or wss://host[:port]") + target.add_argument("--device", help="device ID the board agent registered at the gateway") + target.add_argument("--model", type=Path, help="model file for target bench") + target.add_argument("--iterations", type=int, default=200, help="bench inference iterations") + target.add_argument("--budget-ms", type=float, default=20.0, + help="wall-clock budget per inference (control_hz=50 → 20)") + target.add_argument("--backend", default="onnxruntime") + target.add_argument("--ssh-host") + target.add_argument("--user") + target.add_argument("--ssh-port", type=int, default=22) + target.add_argument("--identity-file") + target.add_argument("--host-key", help="explicitly trusted SSH SHA256 fingerprint") + target.add_argument("--ask-password", action="store_true") + target.add_argument("--root", default="~/botlab") + target.add_argument("--interface") + target.add_argument("--profile", choices=["generic-linux", "rdk-x5"]) + target.set_defaults(func=_cmd_target) + + deploy = subparsers.add_parser("deploy", help="build and install a HIL application without running it") + deploy.add_argument("project_dir", nargs="?") + deploy.add_argument("--target", required=True) + deploy.add_argument("--ask-password", action="store_true") + deploy.set_defaults(func=_cmd_deploy) + + agent = subparsers.add_parser("agent", help="run the target-side HIL agent") + agent.add_argument("agent_args", nargs=argparse.REMAINDER) + agent.set_defaults(func=_cmd_agent) + + control = subparsers.add_parser("control", help="control project middleware through the generic Control API") + control.add_argument("control_args", nargs=argparse.REMAINDER) + docs = subparsers.add_parser( "docs", help="print where the bundled docs (component reference, decision table, error guide) live", @@ -343,6 +620,11 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + if argv and argv[0] == "control": + from .control.cli import main as control_main + + return control_main(argv[1:]) args = build_parser().parse_args(argv) try: return args.func(args) diff --git a/src/botlab/_debug.py b/src/botlab/_debug.py index 9ab2e5e..fcec3cb 100644 --- a/src/botlab/_debug.py +++ b/src/botlab/_debug.py @@ -24,7 +24,7 @@ # Directories that never describe project source state (caches, VCS, UI # snapshot data of other tools). -_IGNORED_DIRECTORIES = {"__pycache__", "node_modules"} +_IGNORED_DIRECTORIES = {"__pycache__", "node_modules", "tmp"} # Build outputs: writing one must not trigger a debug rebuild. _IGNORED_FILES = {"release.zip", ".botlab.zip"} diff --git a/src/botlab/_host.py b/src/botlab/_host.py index c700c57..8c71d1a 100644 --- a/src/botlab/_host.py +++ b/src/botlab/_host.py @@ -70,6 +70,32 @@ def _text(value: Any, label: str) -> str: return value +# 展示文案支持的界面语言;与 BotLab 前端 localizedText.ts、SDK _declaration.py 保持一致。 +_TEXT_LOCALES = ("en", "zh") + + +def _localized_text(value: Any, label: str) -> str | dict: + """Display text: a non-empty string, or {"en": ..., "zh": ...} with "en" as fallback.""" + if isinstance(value, str): + return _text(value, label) + if not isinstance(value, dict): + raise BotLabError(f'[botlab] {label} must be a non-empty string or a {{"en": ..., "zh": ...}} mapping') + unknown = sorted(set(value) - set(_TEXT_LOCALES)) + if unknown: + raise BotLabError(f"[botlab] {label} has unsupported locales {unknown} (expected {list(_TEXT_LOCALES)})") + if "en" not in value: + raise BotLabError(f'[botlab] {label} must include an "en" entry') + table = {} + for locale in _TEXT_LOCALES: + if locale not in value: + continue + text = value[locale] + if not isinstance(text, str) or not text.strip(): + raise BotLabError(f"[botlab] {label}.{locale} must be a non-empty string") + table[locale] = text.strip() + return table + + def _json_value(value: Any, label: str, _stack: frozenset[int] = frozenset()) -> Any: """Normalize a Python declaration value to its JS counterpart. @@ -144,7 +170,7 @@ def _declared_parameter(pid: str, value: Any) -> dict: for option in value["options"]: if not isinstance(option, dict) or set(option) != {"label", "value"}: raise BotLabError(f"[botlab] Parameter {pid!r} option requires only label and value") - label = _text(option["label"], f"Parameter {pid!r} option label") + label = _localized_text(option["label"], f"Parameter {pid!r} option label") number = _finite_number(option["value"], f"Parameter {pid!r} option value") if number != int(number) or abs(number) > 9007199254740991: raise BotLabError(f"[botlab] Parameter {pid!r} option value must be a safe integer") @@ -179,7 +205,7 @@ def _declared_parameter(pid: str, value: Any) -> dict: if step is not None: result["step"] = step if "label" in value: - result["label"] = _text(value["label"], f"Parameter {pid!r} label") + result["label"] = _localized_text(value["label"], f"Parameter {pid!r} label") if "unit" in value: result["unit"] = _text(value["unit"], f"Parameter {pid!r} unit") return result @@ -202,8 +228,11 @@ def _assert_robot_config(resource: dict) -> None: class HostSession: """One Apply: import controller.py, collect declarations, capture the plan.""" - def __init__(self, preset_lookup): + def __init__(self, preset_lookup, project_dir=None): self._preset_lookup = preset_lookup + # ``read_config`` reads project files by relative path; the declaration + # session is the one place that knows the directory they live in. + self._project_dir = None if project_dir is None else Path(project_dir) self._resources: list[dict] = [] self._resource_ids: set[str] = set() self._parameters: dict[str, dict] = {} @@ -403,6 +432,26 @@ def parameter_range(self, name): return None return [parameter["min"], parameter["max"]] + def read_project_file(self, path): + """The raw text of one project file — what ``read_config`` parses. + + Restricted to paths inside the project directory: no absolute paths, + no ``..``. The SDK additionally only ever asks for ``config/*.yaml``. + """ + name = str(path) + if not name: + raise BotLabError("[botlab] read_project_file needs a project-relative path") + if Path(name).is_absolute() or ".." in name.replace("\\", "/").split("/"): + raise BotLabError(f"[botlab] project file {name!r} must stay inside the project directory") + if self._project_dir is None: + raise BotLabError( + f"[botlab] project file {name!r} needs a project directory; no declaration is active" + ) + target = self._project_dir / name + if not target.is_file(): + raise BotLabError(f"[botlab] project file {name!r} was not found in {self._project_dir}") + return target.read_text(encoding="utf-8") + def log(self, level, source, message): # Info-level SDK chatter (sim.run(...) started) is compile detail the # debug loop keeps behind --verbose; warnings and errors always print. @@ -485,9 +534,9 @@ def _active() -> HostSession: return session -def begin_project_declaration(preset_lookup) -> HostSession: +def begin_project_declaration(preset_lookup, project_dir=None) -> HostSession: global ACTIVE - ACTIVE = HostSession(preset_lookup) + ACTIVE = HostSession(preset_lookup, project_dir) return ACTIVE @@ -524,7 +573,7 @@ def reset(self, directory=None): end_project_declaration() self.directory = directory if directory is not None: - begin_project_declaration(_preset_lookup(Path(directory))) + begin_project_declaration(_preset_lookup(Path(directory)), directory) def _session(self) -> HostSession: session = ACTIVE or RUN_SESSION @@ -609,6 +658,14 @@ def declareProject(name, control_hz, physics_hz, parameters, location=None): # _active().declare_project(name, control_hz, physics_hz, parameters) +def declareProjectControl(control, location=None): # noqa: N802 + del location + # The control declaration is a browser-pairing concern: the paired page + # uploads it to the middleware at connect time. The native collector has + # no page to pair with and drops it. + del control + + def requireResource(rid, kind): # noqa: N802 return _active().require_resource(rid, kind) @@ -623,6 +680,13 @@ def resourceConfig(rid, field): # noqa: N802 return active.require_resource(rid, active._kind_of(rid))["config"].get(str(field)) +def read_project_file(path): + # The one snake_case member of the botlab_host surface: every host (browser + # JS bridge, offline host, native collector) exposes it under this name so + # the shared SDK's ``read_config`` call site never branches per host. + return _active().read_project_file(path) + + # List-returning queries stay plain (no JsProxy-style wrapper): the SDK's # ``_host_value`` unwraps ``to_py`` when present and passes plain data through, # while the native runtime reads these directly and expects Python lists. diff --git a/src/botlab/_project.py b/src/botlab/_project.py index f296c5d..be7cb5a 100644 --- a/src/botlab/_project.py +++ b/src/botlab/_project.py @@ -13,7 +13,9 @@ import hashlib import importlib import json +import re import sys +import threading from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -37,8 +39,9 @@ SIMULATION_ENGINE_ABI_VERSION = 1 MAX_PYTHON_RELEASE_PACKAGE_BYTES = 200 * 1024 * 1024 -# Flat project files a release may carry besides main.py / controller.py. -SAFE_PROJECT_SUFFIXES = (".py", PRESET_FILE_SUFFIX) +# Declaration compilation uses process-global host state and imported modules. +# Browser release requests and asynchronous HIL builds must share this lock. +PROJECT_LOAD_LOCK = threading.RLock() def is_standard_python_main(source: str | None) -> bool: @@ -50,13 +53,88 @@ def _is_safe_archive_path(path: str) -> bool: return False if ":" in path.split("/")[0] and len(path.split("/")[0]) > 1: # scheme-ish prefix like "http:" — mirror the TS regex test. - import re - if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", path): return False return all(part not in ("", ".", "..") for part in path.split("/")) +# -- project text file paths ------------------------------------------------- +# +# is_project_text_file mirrors the editor's isProjectTextFilePath +# (src/domain/low-code/projectFilePaths.ts in BotLab): which project files are +# text declarations shipped in ``files`` and which stay binary assets. The +# browser publish path and this CLI must cut the same file set out of the same +# project or the release digests diverge — change both sides together. + +_NAME_SEGMENT = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") +_PRESET_BASE = re.compile(r"[A-Za-z_][A-Za-z0-9_-]*") +_PRESET_SUFFIX = PRESET_FILE_SUFFIX +_RESERVED_DIRECTORY_SEGMENTS = frozenset({"botlab"}) +_TEXT_FILE_EXTENSIONS = frozenset({ + "md", "markdown", "txt", "json", "jsonc", "yaml", "yml", "toml", "ini", + "cfg", "csv", "xml", "html", "css", "js", "jsx", "ts", "tsx", "sh", +}) + + +def _is_word_character(character: str) -> bool: + # TS \p{L}\p{N}: str.isalpha/str.isnumeric are exactly the Unicode + # letter and number categories. + return character.isalpha() or character.isnumeric() + + +def _is_python_source_path(relative: str) -> bool: + parts = relative.split("/") + file_name = parts.pop() + if file_name.endswith(".py"): + if not _NAME_SEGMENT.fullmatch(file_name[: -len(".py")]): + return False + elif file_name.endswith(_PRESET_SUFFIX): + if not _PRESET_BASE.fullmatch(file_name[: -len(_PRESET_SUFFIX)]): + return False + else: + return False + return all( + _NAME_SEGMENT.fullmatch(segment) and segment not in _RESERVED_DIRECTORY_SEGMENTS + for segment in parts + ) + + +def _is_text_directory_segment(segment: str) -> bool: + body = segment[1:] if segment.startswith(".") else segment + if not body or not (_is_word_character(body[0]) or body[0] == "_"): + return False + return all(_is_word_character(c) or c in ("_", "-") for c in body) + + +def _is_text_file_name(name: str) -> bool: + stem, dot, extension = name.rpartition(".") + if not dot or extension not in _TEXT_FILE_EXTENSIONS: + return False + if not (_is_word_character(name[0]) or name[0] == "_"): + return False + return all(_is_word_character(c) or c in ("_", " ", ".", "-") for c in stem) + + +def is_project_text_file(relative: str) -> bool: + """Editor isProjectTextFilePath, mirrored: .py/.preset.yaml follow the + Python module path rules, other text files a looser unicode name rule; + assets/ and botlab/ stay out of the text set.""" + if relative.endswith(".py") or relative.endswith(_PRESET_SUFFIX): + return _is_python_source_path(relative) + if not _is_safe_archive_path(relative) or relative == "botlab.project.json": + return False + parts = relative.split("/") + file_name = parts.pop() + if not _is_text_file_name(file_name): + return False + if parts and not all( + _is_text_directory_segment(segment) and segment not in _RESERVED_DIRECTORY_SEGMENTS + for segment in parts + ): + return False + return not parts or parts[0] not in ("assets", "botlab") + + @dataclass(frozen=True) class LoadedProject: project_dir: Path @@ -116,20 +194,48 @@ def lookup(preset_id: str): return lookup +def walk_project_files(project_dir: Path): + """Yield ``(relative_posix, path)`` for every real file under the project. + + Skips symlinks and local noise — dot entries at any depth, a root + ``release.zip``/``tmp``, ``__pycache__``/``node_modules`` anywhere — so + VCS metadata, the ``.botlab/`` project identity written between builds, + and editor scratch never reach classification. The source collector and + the local payload server share this walk: whatever the editor sees as + text files must be exactly what the release digest covers. + """ + for entry in sorted(project_dir.rglob("*")): + if entry.is_symlink() or not entry.is_file(): + continue + relative = entry.relative_to(project_dir) + parts = relative.parts + if any(part.startswith(".") for part in parts): + continue + if parts[0] in ("release.zip", "tmp") or any( + part in ("__pycache__", "node_modules") for part in parts + ): + continue + yield relative.as_posix(), entry + + def _project_source_files(project_dir: Path) -> dict[str, str]: + """Every project text file, collected recursively like the editor does. + + ``config/*.yaml`` declarations, nested Python packages and docs travel in + ``files`` next to main.py/controller.py; classification is + is_project_text_file's job. + """ files: dict[str, str] = {} - for entry in sorted(project_dir.iterdir()): - if not entry.is_file(): - continue - if entry.name == "botlab.yaml": + for posix, entry in walk_project_files(project_dir): + if posix == "botlab.yaml": if entry.stat().st_size > 0: raise BotLabError( "A non-empty botlab.yaml is the legacy browser format; the offline CLI " "only packages Python-declared projects (empty or missing YAML)." ) continue - if entry.name in ("main.py", "controller.py") or entry.name.endswith(SAFE_PROJECT_SUFFIXES): - files[entry.name] = entry.read_text(encoding="utf-8") + if is_project_text_file(posix): + files[posix] = entry.read_text(encoding="utf-8") if "main.py" not in files: raise BotLabError('A BotLab project requires "main.py".') if "controller.py" not in files: @@ -217,24 +323,31 @@ def python_release_project_digest(manifest: dict, files: dict[str, str]) -> str: def evict_project_modules(project_dir: Path) -> None: - """Drop cached top-level project modules from ``sys.modules``. + """Drop cached project modules (nested packages included) from ``sys.modules``. A previous load_project or run in this process leaves ``controller``/ ``main`` (and any sibling module) imported; re-importing the cache would run no declarations against a fresh session — the same eviction - pyodideRuntime.stageProjectModules performs before staging sources. - Every path that re-opens a project goes through this helper so the - eviction set cannot drift between callers. + pyodideRuntime.stageProjectModules performs before staging sources + (``pkg/helpers.py`` caches under ``pkg.helpers``). Every path that + re-opens a project goes through this helper so the eviction set cannot + drift between callers. """ for name in ("controller", "main"): sys.modules.pop(name, None) - for entry in project_dir.iterdir(): - if entry.is_file() and entry.suffix == ".py": - sys.modules.pop(entry.stem, None) + for entry in project_dir.rglob("*.py"): + if entry.is_symlink() or not entry.is_file(): + continue + parts = entry.relative_to(project_dir).parts + # ``pkg/__init__.py`` caches under the package name ``pkg``. + if entry.stem == "__init__" and len(parts) > 1: + sys.modules.pop(".".join(parts[:-1]), None) + else: + sys.modules.pop(".".join((*parts[:-1], entry.stem)), None) def _import_controller(project_dir: Path, source_files: dict[str, str]) -> _host.HostSession: - session = _host.begin_project_declaration(_preset_lookup(project_dir)) + session = _host.begin_project_declaration(_preset_lookup(project_dir), project_dir) sys.path.insert(0, str(project_dir)) try: evict_project_modules(project_dir) @@ -246,6 +359,11 @@ def _import_controller(project_dir: Path, source_files: dict[str, str]) -> _host def load_project(project_dir: Path) -> LoadedProject: """Apply the project: collect declarations, run main.py, capture the plan.""" + with PROJECT_LOAD_LOCK: + return _load_project(project_dir) + + +def _load_project(project_dir: Path) -> LoadedProject: project_dir = project_dir.resolve() if not project_dir.is_dir(): raise BotLabError(f"Project directory does not exist: {project_dir}") diff --git a/src/botlab/_release.py b/src/botlab/_release.py index d0945d0..ea4a795 100644 --- a/src/botlab/_release.py +++ b/src/botlab/_release.py @@ -13,6 +13,7 @@ import hashlib import json import zipfile +from datetime import UTC from pathlib import Path from ._host import MOTION_REQUIRED_FILES, BotLabError @@ -281,9 +282,9 @@ def create_release_package( def _release_timestamp() -> str: - from datetime import datetime, timezone + from datetime import datetime - return datetime.now(timezone.utc).isoformat() + return datetime.now(UTC).isoformat() def _optional_onnx_module(): diff --git a/src/botlab/_runtime/run.py b/src/botlab/_runtime/run.py index 4fbc6d7..705e940 100644 --- a/src/botlab/_runtime/run.py +++ b/src/botlab/_runtime/run.py @@ -359,10 +359,7 @@ def resolve(resource_source): str(error) for error in cleanup_errors ) if primary_error is not None: - if hasattr(primary_error, "add_note"): - primary_error.add_note(message) - else: # Python 3.9/3.10: preserve the primary traceback. - print(message, file=sys.stderr) + primary_error.add_note(message) elif len(cleanup_errors) == 1: raise cleanup_errors[0] else: diff --git a/src/botlab/_server.py b/src/botlab/_server.py index 93dac20..7e5cf06 100644 --- a/src/botlab/_server.py +++ b/src/botlab/_server.py @@ -24,12 +24,15 @@ import http.server import json +import secrets import socketserver import webbrowser from collections.abc import Callable from pathlib import Path -from ._host import PRESET_FILE_SUFFIX, BotLabError +from ._host import BotLabError +from ._project import is_project_text_file, walk_project_files +from .hil.protocol import error_details CROSS_ORIGIN_ISOLATION_HEADERS = { "Cross-Origin-Opener-Policy": "same-origin", @@ -73,33 +76,25 @@ def local_project_payload(project_dir: Path) -> dict: reload, mirroring the release provider's rebuild-on-change contract. Files map 1:1 onto ``/project/`` URLs: the editor's asset inventory uses base ``project``, so every path must be relative to the project root. - Source classification mirrors the two-file contract the editor and the - release packer share: flat ``*.py`` and ``*.preset.yaml`` at the project - root are sources; every other file (MJCF, ONNX, meshes, nested .py) is a - binary asset the tree shows at its real relative path. + Source classification mirrors the editor's project file tree and the + release packer: whatever ``is_project_text_file`` accepts (main.py / + controller.py, presets, nested Python packages, ``config/*.yaml``) is a + text source; every other file (MJCF, ONNX, meshes) is a binary asset the + tree shows at its real relative path. Local build noise (dot files, + ``release.zip``, caches, symlinks) stays out of the payload entirely. """ files = {} assets = [] - for child in sorted(project_dir.iterdir()): - if child.name in ("release.zip", ".botlab.zip"): - continue - if child.is_dir(): - if child.is_symlink() or child.name in ("__pycache__", "node_modules") or child.name.startswith("."): - continue - for item in sorted(child.rglob("*")): - if item.is_file(): - assets.append(item.relative_to(project_dir).as_posix()) - continue - relative = child.relative_to(project_dir).as_posix() - if child.suffix == ".py" or child.name.endswith(PRESET_FILE_SUFFIX): + for posix, entry in walk_project_files(project_dir): + if posix != "botlab.yaml" and is_project_text_file(posix): try: - files[relative] = child.read_text(encoding="utf-8") + files[posix] = entry.read_text(encoding="utf-8") except UnicodeDecodeError as error: raise BotLabError( - f'Project file "{relative}" is not UTF-8 text: {error}' + f'Project file "{posix}" is not UTF-8 text: {error}' ) from error else: - assets.append(relative) + assets.append(posix) return { "format": "botlab-local-project", "version": 1, @@ -122,6 +117,17 @@ def end_headers(self): def do_GET(self): server = self.server # type: ignore[attr-defined] + if getattr(server, "botlab_hil", None) is not None: + if not self._hil_host_allowed(): + self.send_error(403, "Invalid Host") + return + path = _request_path(self.path) + if path.startswith("hil/"): + self._serve_hil("GET", path) + return + if path in ("", "index.html"): + self._serve_hil_index() + return provider = getattr(server, "botlab_release_provider", None) request_path = _request_path(self.path) if provider is not None and request_path == "release.zip": @@ -132,6 +138,70 @@ def do_GET(self): return super().do_GET() + def _hil_host_allowed(self) -> bool: + return self.headers.get("Host") in self.server.botlab_hil_hosts + + def _hil_json(self, status: int, payload: dict) -> None: + data = json.dumps(payload, allow_nan=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _serve_hil_index(self) -> None: + server = self.server + bootstrap = json.dumps({ + "token": server.botlab_hil_token, + "selectedTarget": server.botlab_hil.handle("GET", "/hil/state")["selectedTarget"], + }).replace("<", "\\u003c") + source = (server.botlab_ui_dir / "index.html").read_text(encoding="utf-8") + injection = f"" + source = source.replace("", injection + "") if "" in source else injection + source + data = source.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _serve_hil(self, method: str, path: str) -> None: + server = self.server + token = self.headers.get("X-Botlab-Hil-Token", "") + origin = self.headers.get("Origin") + if (not self._hil_host_allowed() + or not secrets.compare_digest(token, server.botlab_hil_token) + or origin is not None and origin not in server.botlab_hil_origins): + self.close_connection = True + self._hil_json(403, {"error": "HIL requests require the local session token and origin."}) + return + try: + body = None + if method == "POST": + if self.headers.get_content_type() != "application/json": + raise ValueError("Expected application/json") + if self.headers.get("Transfer-Encoding"): + raise ValueError("Chunked requests are not supported") + size = int(self.headers.get("Content-Length", "0")) + if not 0 < size <= 48 * 1024 * 1024: + raise ValueError("Invalid HIL request size") + self.connection.settimeout(15) + body = json.loads(self.rfile.read(size)) + if not isinstance(body, dict): + raise ValueError("Expected a JSON object") + result = server.botlab_hil.handle(method, "/" + path, body) + self._hil_json(200, result) + except Exception as error: + self.close_connection = True + self._hil_json(400, {"error": str(error), **error_details(error)}) + + def do_POST(self): + path = _request_path(self.path) + if getattr(self.server, "botlab_hil", None) is None or not path.startswith("hil/"): + self.send_error(404, "Not found") + return + self._serve_hil("POST", path) + def _serve_local_project(self) -> None: server = self.server # type: ignore[attr-defined] project_dir = Path(server.botlab_project_dir) # type: ignore[attr-defined] @@ -174,11 +244,17 @@ def translate_path(self, path: str) -> str: relative = url_path[len("project/"):] root = Path(self.server.botlab_project_dir) # type: ignore[attr-defined] candidate = (root / relative).resolve() - if candidate == root or root in candidate.parents: + if ((candidate == root or root in candidate.parents) + and not any(part.startswith(".") for part in Path(relative).parts)): return str(candidate) self.send_error(403, "Invalid project asset path") return "" - return str(self.server.botlab_ui_dir / url_path) # type: ignore[attr-defined] + root = Path(self.server.botlab_ui_dir).resolve() + candidate = (root / url_path).resolve() + if candidate == root or root in candidate.parents: + return str(candidate) + self.send_error(403, "Invalid UI asset path") + return "" def list_directory(self, path: str): self.send_error(403, "Directory listings are disabled") @@ -257,6 +333,7 @@ def start_project_server( *, ui_dir: Path | None = None, release_provider: Callable[[], bytes] | None = None, + hil=None, ): """Build (but do not serve) the UI server; returns (httpd, base_url).""" if (release_zip is None) == (release_provider is None): @@ -268,6 +345,8 @@ def start_project_server( "Run `botlab ui sync --botlab ` first." ) + if hil is not None and host not in ("127.0.0.1", "localhost"): + raise BotLabError("HIL requires --host 127.0.0.1 or localhost.") handler = BotlabUIRequestHandler handler.directory = str(ui_dir) httpd = _run_server(host, port, handler) @@ -276,6 +355,11 @@ def start_project_server( httpd.botlab_release_provider = release_provider # type: ignore[attr-defined] httpd.botlab_project_dir = project_dir.resolve() # type: ignore[attr-defined] bound_host, bound_port = httpd.socket.getsockname() + if hil is not None: + httpd.botlab_hil = hil + httpd.botlab_hil_token = secrets.token_urlsafe(32) + httpd.botlab_hil_hosts = {f"127.0.0.1:{bound_port}", f"localhost:{bound_port}"} + httpd.botlab_hil_origins = {f"http://{value}" for value in httpd.botlab_hil_hosts} displayed_host = "localhost" if bound_host == "0.0.0.0" else bound_host return httpd, f"http://{displayed_host}:{bound_port}" @@ -290,6 +374,7 @@ def serve_project( ui_dir: Path | None = None, release_provider: Callable[[], bytes] | None = None, entry: str = "/", + hil=None, ) -> str: httpd, base_url = start_project_server( project_dir, @@ -298,6 +383,7 @@ def serve_project( port, ui_dir=ui_dir, release_provider=release_provider, + hil=hil, ) editor_url = f"{base_url}{entry}" print_startup_banner(project_dir.name, base_url, entry, host=host) @@ -308,4 +394,7 @@ def serve_project( httpd.serve_forever() except KeyboardInterrupt: print("\n[botlab] stopped") + finally: + if hil is not None: + hil.close() return editor_url diff --git a/src/botlab/browser.py b/src/botlab/browser.py new file mode 100644 index 0000000..832376d --- /dev/null +++ b/src/botlab/browser.py @@ -0,0 +1,22 @@ +"""Public host context for explicitly selected browser project adapters. + +An adapter is a callable ``run(context) -> int``. It serves project sources and +hardware bridges using ``context.ui_dir`` (the SDK-managed browser runtime). +Physics remains in the browser. Importing an adapter executes local Python code, +as importing a project's controller does; only select adapters you trust. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class BrowserRunContext: + project_dir: Path + ui_dir: Path + host: str + port: int + open_browser: bool + inference_endpoint: str | None = None diff --git a/src/botlab/control/__init__.py b/src/botlab/control/__init__.py new file mode 100644 index 0000000..21a18f6 --- /dev/null +++ b/src/botlab/control/__init__.py @@ -0,0 +1,6 @@ +"""Robot-independent clients for the BotLab Control API.""" + +from .client import ControlClient +from .protocol import ControlError + +__all__ = ["ControlClient", "ControlError"] diff --git a/src/botlab/control/__main__.py b/src/botlab/control/__main__.py new file mode 100644 index 0000000..eb53e2f --- /dev/null +++ b/src/botlab/control/__main__.py @@ -0,0 +1,3 @@ +from .cli import main + +raise SystemExit(main()) diff --git a/src/botlab/control/cli.py b/src/botlab/control/cli.py new file mode 100644 index 0000000..b708055 --- /dev/null +++ b/src/botlab/control/cli.py @@ -0,0 +1,137 @@ +"""Generic control command line. Robot-specific actions come from capabilities.""" + +from __future__ import annotations + +import argparse +import getpass +import json +import math +import os +import sys +from pathlib import Path + +from .client import ControlClient +from .protocol import FAILED, ControlError, parse_json + + +class _Parser(argparse.ArgumentParser): + def error(self, message): + raise ControlError("invalid_arguments", message) + + +def _common(parser: argparse.ArgumentParser, *, subcommand: bool = False) -> None: + default = argparse.SUPPRESS if subcommand else None + parser.add_argument("--project", default=argparse.SUPPRESS if subcommand else ".", help="project directory (default: cwd)") + parser.add_argument("--endpoint", default=default, + help="explicit middleware origin (loopback http, or https for enrolled remote middleware)") + parser.add_argument("--instance", default=default, help="bind a middleware instance") + parser.add_argument("--session", default=default, help="bind a runtime session") + parser.add_argument("--json", action="store_true", default=argparse.SUPPRESS if subcommand else False, + help="write only structured JSON to stdout") + + +def build_parser() -> argparse.ArgumentParser: + parser = _Parser(prog="botlab control", description="Use any project middleware implementing Control API v1.") + _common(parser) + subparsers = parser.add_subparsers(dest="command", required=True) + for name in ("instances", "capabilities", "experience", "sessions", "state", "connection", + "enroll", "call", "move", "action", "wait", "retry", "stop", "serve"): + sub = subparsers.add_parser(name) + if name == "serve": + # The generic middleware runs in this process; discovery flags do + # not apply and the page origin must be allowlisted explicitly. + sub.add_argument("--project", default=".", help="project directory anchoring discovery (default: cwd)") + sub.add_argument("--json", action="store_true", default=False, help=argparse.SUPPRESS) + sub.add_argument("--port", type=int, default=8776, help="loopback HTTP port (1024..65535)") + sub.add_argument("--host", default="127.0.0.1", help="bind address") + sub.add_argument("--origin", action="append", required=True, + help="allowlisted BotLab page origin for browser pairing (repeatable)") + sub.add_argument("--public-host", action="append", + help="additional Host header accepted behind a proxy (repeatable)") + sub.add_argument("--public-endpoint", + help="public HTTPS origin when serving behind TLS (enables remote enrollment)") + sub.add_argument("--print-token", action="store_true", + help="print the pairing token to stderr for out-of-band sharing") + continue + _common(sub, subcommand=True) + if name in {"call", "move", "action", "stop"}: + sub.add_argument("--request-id", help="stable ID for explicit retries") + if name in {"call", "action"}: + sub.add_argument("name", help="declared capability ID or action alias") + if name == "call": + args = sub.add_mutually_exclusive_group() + args.add_argument("--args", default="{}", help="capability arguments as JSON") + args.add_argument("--args-file", type=Path, help="read capability JSON from a file") + elif name == "move": + sub.add_argument("--vx", type=float, required=True) + sub.add_argument("--vy", type=float, default=0) + sub.add_argument("--yaw-rate", type=float, default=0) + sub.add_argument("--duration", type=float, required=True) + elif name in {"wait", "retry"}: + sub.add_argument("--id", required=True) + if name == "wait": + sub.add_argument("--timeout", type=float, default=1, help="bounded wait in seconds (0..30)") + elif name == "connection": + sub.add_argument("--show-token", action="store_true", help="explicitly reveal the temporary pairing token") + return parser + + +def run(args: argparse.Namespace) -> dict: + if args.command == "serve": + # Dispatch before any client construction: the middleware IS this + # process. serve_service imports aiohttp, which must stay out of the + # stdlib-only client import chain (mirrors botlab.hil.gateway). + try: + from .serve_service import ControlError as ServeError + from .serve_service import serve as serve_middleware + except ImportError as error: + raise ControlError("missing_dependency", + "botlab control serve needs the serve extra: pip install 'botlab-python[serve]'") from error + try: + return serve_middleware(args) + except ServeError as error: + raise ControlError(error.code, str(error)) from error + client = ControlClient(args.project, endpoint=args.endpoint, instance=args.instance, session=args.session) + if args.command in {"instances", "capabilities", "experience", "sessions", "state"}: + return getattr(client, args.command)() + if args.command == "connection": + return client.connection(args.show_token) + if args.command == "enroll": + if not args.endpoint: + raise ControlError("invalid_arguments", "enroll requires --endpoint .") + token = os.environ.get("BOTLAB_CONTROL_TOKEN") or getpass.getpass("Middleware token: ") + return client.enroll(args.endpoint, token) + if args.command == "retry": + return client.retry(args.id) + if args.command == "wait": + if not math.isfinite(args.timeout) or not 0 <= args.timeout <= 30: + raise ControlError("invalid_arguments", "--timeout must be finite and between 0 and 30 seconds.") + return client.wait(args.id, args.timeout) + if args.command == "call": + values = parse_json(args.args_file.read_text() if args.args_file else args.args) + return client.call(args.name, values, request_id=args.request_id) + if args.command == "action": + return client.call(args.name, {}, request_id=args.request_id, alias=True) + if args.command == "move": + values = {"vx": args.vx, "vy": args.vy, "yaw_rate": args.yaw_rate, "duration_s": args.duration} + return client.call("motion.twist", values, request_id=args.request_id) + return client.call("control.stop", {}, request_id=args.request_id) + + +def main(argv: list[str] | None = None) -> int: + argv = list(sys.argv[1:] if argv is None else argv) + json_output = "--json" in argv + try: + args = build_parser().parse_args(argv) + result = run(args) + code = 3 if result.get("command", {}).get("status") in FAILED else 0 + except ControlError as error: + result, code = error.payload(), 2 + except (OSError, UnicodeError) as error: + result, code = ControlError("local_io_error", "Cannot access the requested local control data.").payload(), 2 + if not json_output: + print(f"botlab control: {error.__class__.__name__}", file=sys.stderr) + if not json_output and not result.get("ok"): + print(f"botlab control: {result['error']['message']}", file=sys.stderr) + print(json.dumps(result, ensure_ascii=False, allow_nan=False, indent=None if json_output else 2)) + return code diff --git a/src/botlab/control/client.py b/src/botlab/control/client.py new file mode 100644 index 0000000..7e3471e --- /dev/null +++ b/src/botlab/control/client.py @@ -0,0 +1,339 @@ +"""Standard-library HTTP transport and identity-preserving command submission.""" + +from __future__ import annotations + +import json +import os +import ssl +import uuid +from http.client import HTTPException +from pathlib import Path +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode, urlsplit +from urllib.request import HTTPRedirectHandler, HTTPSHandler, ProxyHandler, Request, build_opener + +from .discovery import descriptors, private_directory, project_key, public_descriptor, runtime_dir +from .discovery import endpoint as normalize_endpoint +from .protocol import ( + API_PREFIX, + API_VERSION, + COMMAND_STATUSES, + PROTOCOL, + ControlError, + identifier, + parse_json, + validate_args, + validate_capabilities, +) +from .receipts import Receipts + + +class _NoRedirects(HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def request(descriptor: dict, path: str, body: dict | None = None, timeout: float = 5) -> dict: + origin = normalize_endpoint(descriptor["endpoint"]) + data = None if body is None else json.dumps(body, allow_nan=False).encode() + headers = {"Authorization": f"Bearer {descriptor['token']}", "Accept": "application/json"} + if data is not None: + headers["Content-Type"] = "application/json" + req = Request(origin + API_PREFIX + path, data=data, headers=headers) + handlers = [ProxyHandler({}), _NoRedirects()] + cafile = os.environ.get("BOTLAB_CONTROL_CA_BUNDLE") + if cafile: + handlers.append(HTTPSHandler(context=ssl.create_default_context(cafile=cafile))) + opener = build_opener(*handlers) + status = 200 + try: + try: + response = opener.open(req, timeout=timeout) + except HTTPError as error: + if 300 <= error.code < 400: + raise ControlError("redirect_refused", "Control API redirects are not permitted.") from None + response = error + status = error.code + with response: + raw = response.read(8 * 1024 * 1024 + 1) + if len(raw) > 8 * 1024 * 1024: + raise ControlError("invalid_protocol", "Control response is too large.") + payload = parse_json(raw.decode("utf-8")) + except TimeoutError as error: + raise ControlError("network_timeout", "Middleware did not respond; command outcome may be unknown.") from error + except (URLError, OSError, HTTPException) as error: + raise ControlError("connection_error", "Cannot reach the selected middleware; command outcome may be unknown.") from error + except (UnicodeError, ValueError) as error: + raise ControlError("invalid_protocol", "Middleware returned an invalid JSON response.") from error + if not isinstance(payload, dict) or not isinstance(payload.get("ok"), bool): + raise ControlError("invalid_protocol", "Middleware response must contain boolean ok.") + if payload["ok"] is False: + detail = payload.get("error", {}) + if not isinstance(detail, dict): + raise ControlError("invalid_protocol", "Malformed middleware error response.") + raise ControlError(str(detail.get("code", "request_failed")), str(detail.get("message", "Request failed."))) + if status >= 400: + raise ControlError("invalid_protocol", "Middleware returned success with an error HTTP status.") + return payload + + +class ControlClient: + def __init__(self, project: str | Path = ".", *, endpoint: str | None = None, + instance: str | None = None, session: str | None = None): + self.project = str(Path(project).resolve()) + self.project_key = project_key(project) + self.endpoint = normalize_endpoint(endpoint) if endpoint else None + self.instance = instance + self.session = session + self.receipts = Receipts(project) + self._selected = None + + def _info(self, descriptor: dict) -> dict: + info = request(descriptor, "/info") + if info.get("api_version") != API_VERSION or info.get("protocol") != PROTOCOL: + raise ControlError("unsupported_version", "Middleware does not implement Control API v1.") + if descriptor.get("remote") is True: + # Enrolled identity pin: compare against what /info reported at + # enrollment, not against this checkout's local path. + if (info.get("project_key") != descriptor.get("project_key") + or not isinstance(info.get("project"), str) or not info["project"]): + raise ControlError("wrong_project", "Middleware identity no longer matches the enrolled descriptor.") + elif info.get("project_key") != self.project_key or info.get("project") != self.project: + raise ControlError("wrong_project", "Middleware belongs to a different project.") + if info.get("instance_id") != descriptor["instance_id"]: + raise ControlError("wrong_instance", "Middleware instance changed; the original command must not be replayed.") + return info + + def instances(self) -> dict: + items = [] + for descriptor in descriptors(self.project): + item = public_descriptor(descriptor) + try: + item["info"] = self._info(descriptor) + item["available"] = True + except ControlError as error: + item["available"] = False + item["error"] = error.payload()["error"] + items.append(item) + return {"ok": True, "project_key": self.project_key, "instances": items} + + def enroll(self, target: str, token: str) -> dict: + """Bind a remote HTTPS middleware to this project by pinning the + identity /info reports into a private discovery descriptor.""" + origin = normalize_endpoint(target) + if urlsplit(origin).scheme != "https": + raise ControlError("invalid_endpoint", "Enrollment requires an HTTPS middleware origin.") + if not isinstance(token, str) or not token or "\n" in token or "\r" in token: + raise ControlError("invalid_credentials", "A nonempty middleware token is required.") + info = request({"endpoint": origin, "token": token}, "/info") + if info.get("api_version") != API_VERSION or info.get("protocol") != PROTOCOL: + raise ControlError("unsupported_version", "Middleware does not implement Control API v1.") + identifier(info.get("instance_id"), "instance_id") + pinned_key, pinned_project = info.get("project_key"), info.get("project") + if not isinstance(pinned_key, str) or not pinned_key or not isinstance(pinned_project, str) or not pinned_project: + raise ControlError("invalid_protocol", "Middleware did not report a project identity to pin.") + descriptor = {"schema": 1, "api_version": API_VERSION, "protocol": PROTOCOL, "remote": True, + "endpoint": origin, "websocket": "wss" + origin[len("https"):] + "/runtime", + "project": pinned_project, "project_key": pinned_key, + "instance_id": info["instance_id"], "token": token} + root, directory = runtime_dir(), runtime_dir() / self.project_key + private_directory(root, create=True) + private_directory(directory, create=True) + for path in sorted(directory.glob("*.json")): + try: + existing = parse_json(path.read_text()) + except (OSError, ControlError): + continue + # Re-enrollment replaces this endpoint's previous instance pin. + if isinstance(existing, dict) and existing.get("remote") is True and existing.get("endpoint") == origin: + path.unlink() + descriptor_path = directory / f"{descriptor['instance_id']}.json" + try: + fd = os.open(descriptor_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError as error: + raise ControlError("instance_conflict", + "A different endpoint already enrolled this instance identity; " + "remove that descriptor before enrolling this endpoint.") from error + with os.fdopen(fd, "w") as stream: + json.dump(descriptor, stream) + return {"ok": True, **public_descriptor(descriptor), "websocket": descriptor["websocket"], + "pairing": "Remote middleware enrolled; identity pinned to this project."} + + def select(self) -> dict: + if self._selected is not None: + return self._selected + candidates = [item for item in descriptors(self.project) + if (self.endpoint is None or item["endpoint"] == self.endpoint) + and (self.instance is None or item["instance_id"] == self.instance)] + live, errors = [], [] + for candidate in candidates: + try: + self._info(candidate) + live.append(candidate) + except ControlError as error: + errors.append(error) + if not live: + if len(errors) == 1: + raise errors[0] + raise ControlError("no_service", "No matching live middleware was discovered for this project.") + if len(live) != 1: + raise ControlError("ambiguous_instance", "Multiple middleware instances are available; select --instance or --endpoint.", + instances=[public_descriptor(item) for item in live]) + self._selected = live[0] + return self._selected + + def _read(self, path: str) -> dict: + descriptor = self.select() + payload = request(descriptor, path) + if payload.get("instance_id") != descriptor["instance_id"]: + raise ControlError("wrong_instance", "Middleware instance changed while reading state.") + return payload + + def capabilities(self) -> dict: + payload = self._read("/capabilities") + validate_capabilities(payload) + return payload + + def experience(self) -> dict: + """Project control experience the paired page uploaded at connect time.""" + payload = self._read("/experience") + if not isinstance(payload.get("experience"), str): + raise ControlError("invalid_protocol", "Middleware did not return a text experience document.") + return payload + + def state(self) -> dict: + payload = self._read("/state") + state = payload.get("state") + if not isinstance(state, dict): + raise ControlError("invalid_protocol", "Middleware did not return state.") + if self.session and state.get("session_id") != self.session: + raise ControlError("wrong_session", "Selected session is not the current middleware session.") + return payload + + def sessions(self) -> dict: + return self._read("/sessions") + + def connection(self, show_token: bool = False) -> dict: + descriptor = self.select() + result = {"ok": True, **public_descriptor(descriptor), "websocket": descriptor.get("websocket"), + "pairing": "Open the project's runtime, pair with this middleware, then enable external control."} + if show_token: + result["token"] = descriptor["token"] + return result + + def _query(self, descriptor: dict, command_id: str, timeout: float = 0) -> dict: + identifier(command_id, "command_id") + query = urlencode({"instance_id": descriptor["instance_id"], "timeout": timeout}) + payload = request(descriptor, f"/commands/{quote(command_id, safe='')}?{query}", timeout=timeout + 5) + self._check_command(payload, descriptor, command_id) + return payload + + @staticmethod + def _check_command(payload: dict, descriptor: dict, command_id: str) -> None: + command = payload.get("command") + if not isinstance(command, dict): + raise ControlError("invalid_protocol", "Middleware did not return a command.") + if command.get("instance_id") != descriptor["instance_id"]: + raise ControlError("wrong_instance", "Command response belongs to another middleware instance.") + if command.get("command_id") != command_id: + raise ControlError("wrong_command", "Response does not match the requested command ID.") + if command.get("status") not in COMMAND_STATUSES: + raise ControlError("invalid_protocol", "Middleware returned an unknown command status.") + terminal = command["status"] in {"completed", "cancelled", "failed", "expired"} + if command.get("terminal") is not terminal: + raise ControlError("invalid_protocol", "Command terminal flag disagrees with its status.") + + def _original(self, receipt: dict) -> dict: + envelope = receipt["envelope"] + if ((self.instance and self.instance != envelope["instance_id"]) + or (self.endpoint and self.endpoint != receipt["endpoint"]) + or (self.session and self.session != envelope["session_id"])): + raise ControlError("identity_conflict", "Explicit selection conflicts with the original request receipt.") + candidates = [item for item in descriptors(self.project) + if item["instance_id"] == envelope["instance_id"] and item["endpoint"] == receipt["endpoint"]] + if len(candidates) != 1: + raise ControlError("original_instance_unavailable", "Original middleware instance is unavailable; reassess current state before issuing a new request.") + self._info(candidates[0]) + return candidates[0] + + def wait(self, command_id: str, timeout: float = 1) -> dict: + receipt = self.receipts.load(command_id) + descriptor = self._original(receipt) if receipt else self.select() + return self._query(descriptor, command_id, timeout) + + def retry(self, request_id: str, *, expected_intent: dict | None = None) -> dict: + receipt = self.receipts.load(request_id) + if receipt is None: + raise ControlError("missing_receipt", "Original request receipt is missing; automatic rebinding is not permitted.", request_id=request_id) + envelope = receipt["envelope"] + try: + if expected_intent is not None and receipt["intent"] != expected_intent: + raise ControlError("request_conflict", "This request ID already belongs to a different intent.") + descriptor = self._original(receipt) + try: + return self._query(descriptor, request_id) + except ControlError as error: + if error.code not in {"not_found", "unknown_command"}: + raise + capabilities = request(descriptor, "/capabilities") + validate_capabilities(capabilities) + state_response = request(descriptor, "/state") + state = state_response.get("state", {}) + if (capabilities.get("instance_id") != envelope["instance_id"] + or state_response.get("instance_id") != envelope["instance_id"] + or capabilities["capabilities_revision"] != envelope["capabilities_revision"] + or state.get("session_id") != envelope["session_id"] + or state.get("episode_id") != envelope["episode_id"]): + raise ControlError("original_identity_changed", "Original session, episode or capabilities changed; the command was not resent.") + if ("control_generation" in receipt + and state.get("control_generation") != receipt["control_generation"]): + raise ControlError("state_changed", "Control generation changed since the original request; retry cannot resume cancelled control.") + return self._send(descriptor, envelope) + except ControlError as error: + error.context.update(request_id=request_id, instance_id=envelope["instance_id"]) + raise + + def _send(self, descriptor: dict, envelope: dict) -> dict: + try: + payload = request(descriptor, "/commands", envelope) + self._check_command(payload, descriptor, envelope["request_id"]) + command = payload["command"] + if any(command.get(key) != envelope[key] for key in ("session_id", "episode_id")): + raise ControlError("wrong_command", "Response does not match the submitted session/episode.") + return payload + except ControlError as error: + error.context.update(request_id=envelope["request_id"], instance_id=envelope["instance_id"]) + raise + + def call(self, name: str, args: dict, *, request_id: str | None = None, alias: bool = False) -> dict: + if not isinstance(args, dict): + raise ControlError("invalid_args", "Capability arguments must be a JSON object.") + validate_args(args, {"type": "object"}) + request_id = identifier(str(uuid.uuid4()) if request_id is None else request_id, "request_id") + intent = {"name": name, "alias": alias, "args": args} + if self.receipts.load(request_id) is not None: + return self.retry(request_id, expected_intent=intent) + descriptor = self.select() + capabilities = self.capabilities() + matches = [item for item in capabilities["capabilities"] + if (name in item.get("aliases", []) if alias else item["id"] == name)] + if name == "control.stop" and not alias: + capability = {"id": "control.stop", "input_schema": {"type": "object", "additionalProperties": False}} + elif len(matches) != 1: + raise ControlError("unsupported_capability", f"Middleware does not declare a unique capability for {name}.") + else: + capability = matches[0] + validate_args(args, capability["input_schema"]) + state = self.state()["state"] + envelope = {"request_id": request_id, "instance_id": descriptor["instance_id"], + "session_id": identifier(state.get("session_id"), "session_id"), + "episode_id": identifier(state.get("episode_id"), "episode_id"), + "capabilities_revision": capabilities["capabilities_revision"], + "capability": capability["id"], "args": args} + receipt, created = self.receipts.save( + envelope, descriptor["endpoint"], intent, + control_generation=state.get("control_generation"), + ) + if not created: + return self.retry(request_id, expected_intent=intent) + return self._send(descriptor, receipt["envelope"]) diff --git a/src/botlab/control/descriptor.py b/src/botlab/control/descriptor.py new file mode 100644 index 0000000..94ad197 --- /dev/null +++ b/src/botlab/control/descriptor.py @@ -0,0 +1,340 @@ +"""Project control descriptors: the page declares semantics at pairing time. + +A descriptor turns the middleware project-agnostic: the browser page owns the +project (manifest + compiled plan) and uploads its control surface — capability +tables, FSM actions, signal mapping and the experience document — right after +pairing. The middleware remains the wire-semantics authority: it only checks +structure here and compiles declared semantics into the same adapter surface +microduck's hand-written adapter implements (capabilities / from_capability / +normalize / prepare / decode_state / observations). + +Structural validation only. Cross-checks against the actual project (the robot +exists, the state node is a proc-state-machine, parameters are consumed) stay +on the page, which owns the manifest. +""" +from __future__ import annotations + +import hashlib +import json +import math +import re + +from .protocol import API_VERSION, ControlError, finite_json, identifier + +DESCRIPTOR_VERSION = 1 +MAX_EXPERIENCE_BYTES = 32768 +MAX_STATE_CODE = 2**31 - 1 +MIN_DURATION_S = 0.05 +MAX_DURATION_S = 10.0 +MOVE_TTL_MS = 500 +EVENT_TIMEOUT_MS = 10000 + +NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +CAPABILITY_PATTERN = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$") +RESERVED_CAPABILITIES = frozenset({"motion.twist", "control.stop"}) + +DESCRIPTOR_KEYS = frozenset({"v", "type", "profile", "motion", "actions", "states", "experience"}) +AXIS_KEYS = frozenset({"vx", "yaw_rate"}) +ACTION_KEYS = frozenset({"name", "event_value", "allowed_states", "target_states", + "capability", "idempotent"}) + +MOTION_COMPLETION = ("Bounded wall-clock motion ended and zero input was physically " + "committed; not distance arrival.") +ACTION_COMPLETION = "Target FSM state observed after physics commit; not proof of settled posture." +STOP_COMPLETION = "Zero motion confirmed by a committed physics snapshot." +STOP_SEMANTICS = "Zero motion and clear pending events; applied FSM actions may continue." + + +def _text(value, name: str, limit: int) -> str: + if not isinstance(value, str) or not value or len(value) > limit: + raise ControlError("invalid_descriptor", f"{name} must be a nonempty string (up to {limit} characters)") + return value + + +def _state_list(value, name: str) -> list[int]: + if not isinstance(value, list) or not value: + raise ControlError("invalid_descriptor", f"{name} must be a nonempty integer array") + for code in value: + if isinstance(code, bool) or not isinstance(code, int) or not 0 <= code <= MAX_STATE_CODE: + raise ControlError("invalid_descriptor", f"{name} codes must be integers in 0..{MAX_STATE_CODE}") + if len(set(value)) != len(value): + raise ControlError("invalid_descriptor", f"{name} must not repeat codes") + return list(value) + + +def _finite(value, name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ControlError("invalid_descriptor", f"{name} must be a finite number") + return float(value) + + +def _axis(value, name: str) -> dict: + if not isinstance(value, dict) or set(value) != {"min", "max", "unit"}: + raise ControlError("invalid_descriptor", f"motion.{name} must declare min, max and unit") + low, high = _finite(value["min"], f"motion.{name}.min"), _finite(value["max"], f"motion.{name}.max") + if not low < high: + raise ControlError("invalid_descriptor", f"motion.{name} requires min < max") + _text(value["unit"], f"motion.{name}.unit", 32) + return {"min": low, "max": high, "unit": value["unit"]} + + +def validate_descriptor(payload) -> dict: + """Structurally validate a project control descriptor; returns it unchanged.""" + if not isinstance(payload, dict) or set(payload) - DESCRIPTOR_KEYS: + raise ControlError("invalid_descriptor", "Descriptor must be an object with known fields only") + if payload.get("v") != DESCRIPTOR_VERSION or payload.get("type") != "descriptor": + raise ControlError("invalid_descriptor", "Expected a v1 descriptor frame") + try: + finite_json(payload) + except ControlError as error: + raise ControlError("invalid_descriptor", "Descriptor must contain finite JSON values") from error + + profile = payload.get("profile") + if not isinstance(profile, dict) or set(profile) != { + "robot_id", "state_node_id", "motion_parameters", "event_parameter", "event_values"}: + raise ControlError("invalid_descriptor", "profile must declare the full browser control profile") + identifier(profile["robot_id"], "profile.robot_id") + identifier(profile["state_node_id"], "profile.state_node_id") + parameters = profile["motion_parameters"] + if not isinstance(parameters, dict) or set(parameters) != AXIS_KEYS: + raise ControlError("invalid_descriptor", "motion_parameters must declare exactly vx and yaw_rate") + names = [identifier(parameters[axis], f"motion_parameters.{axis}") for axis in sorted(AXIS_KEYS)] + names.append(identifier(profile["event_parameter"], "profile.event_parameter")) + if len(set(names)) != len(names): + raise ControlError("invalid_descriptor", "motion and event parameter names must be distinct") + values = profile["event_values"] + if not isinstance(values, list) or not values: + raise ControlError("invalid_descriptor", "event_values must be a nonempty integer array") + for value in values: + if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= MAX_STATE_CODE: + raise ControlError("invalid_descriptor", "event_values must be nonzero integers") + if len(set(values)) != len(values): + raise ControlError("invalid_descriptor", "event_values must not repeat") + + motion = payload.get("motion") + if not isinstance(motion, dict) or set(motion) != AXIS_KEYS | {"allowed_states"}: + raise ControlError("invalid_descriptor", "motion must declare vx, yaw_rate and allowed_states") + motion["vx"] = _axis(motion["vx"], "vx") + motion["yaw_rate"] = _axis(motion["yaw_rate"], "yaw_rate") + _state_list(motion["allowed_states"], "motion.allowed_states") + + actions = payload.get("actions") + if not isinstance(actions, list): + raise ControlError("invalid_descriptor", "actions must be an array") + seen_names, seen_capabilities = set(), set() + for action in actions: + if not isinstance(action, dict) or set(action) - ACTION_KEYS \ + or not {"name", "event_value", "allowed_states", "target_states"} <= set(action): + raise ControlError("invalid_descriptor", "Each action must declare name, event_value, allowed_states, target_states") + name = action["name"] + if not isinstance(name, str) or not NAME_PATTERN.match(name): + raise ControlError("invalid_descriptor", f"Invalid action name: {name!r}") + if name in seen_names: + raise ControlError("invalid_descriptor", f"Duplicate action name: {name}") + seen_names.add(name) + value = action["event_value"] + if isinstance(value, bool) or not isinstance(value, int) or value not in profile["event_values"]: + raise ControlError("invalid_descriptor", f"action {name}: event_value must appear in profile.event_values") + # Two actions may share an event code (a toggle): the wire distinguishes + # them by allowed_states, so only names and capability ids must be unique. + _state_list(action["allowed_states"], f"action {name}.allowed_states") + _state_list(action["target_states"], f"action {name}.target_states") + capability = action.get("capability", f"action.{name}") + if not isinstance(capability, str) or not CAPABILITY_PATTERN.match(capability) \ + or capability in RESERVED_CAPABILITIES: + raise ControlError("invalid_descriptor", f"action {name}: invalid capability id {capability!r}") + if capability in seen_capabilities: + raise ControlError("invalid_descriptor", f"Duplicate capability id: {capability}") + seen_capabilities.add(capability) + if "idempotent" in action and not isinstance(action["idempotent"], bool): + raise ControlError("invalid_descriptor", f"action {name}: idempotent must be a boolean") + action["capability"] = capability + action.setdefault("idempotent", False) + + states = payload.get("states") + if states is not None: + if not isinstance(states, dict) or not states: + raise ControlError("invalid_descriptor", "states must map state codes to names") + for code, label in states.items(): + if not isinstance(code, str) or not code.isdigit() \ + or not 0 <= int(code) <= MAX_STATE_CODE or str(int(code)) != code: + raise ControlError("invalid_descriptor", "states keys must be canonical decimal state codes") + _text(label, f"states[{code}]", 64) + + experience = payload.get("experience") + if experience is not None: + if not isinstance(experience, str): + raise ControlError("invalid_descriptor", "experience must be a string") + if len(experience.encode("utf-8")) > MAX_EXPERIENCE_BYTES: + raise ControlError("descriptor_too_large", + f"experience exceeds {MAX_EXPERIENCE_BYTES} bytes") + return payload + + +def revision_of(descriptor: dict) -> str: + """Conservative revision: any descriptor change forces clients to re-read capabilities.""" + digest = hashlib.sha256(json.dumps(descriptor, sort_keys=True, separators=(",", ":")).encode()).hexdigest() + return f"gen-{digest[:16]}" + + +class DescriptorEngine: + """Adapter surface compiled from a validated descriptor (microduck parity).""" + + def __init__(self, descriptor: dict): + self.descriptor = descriptor + self.profile = descriptor["profile"] + self.motion = descriptor["motion"] + self.actions = {action["name"]: action for action in descriptor["actions"]} + self.by_capability = {action["capability"]: action for action in descriptor["actions"]} + self.states = {int(code): name for code, name in (descriptor.get("states") or {}).items()} + self.experience = descriptor.get("experience") + self.revision = revision_of(descriptor) + self._capabilities = self._compile_capabilities() + + def _compile_capabilities(self) -> list[dict]: + entries = [{ + "id": "motion.twist", "version": 1, "aliases": ["move"], + "input_schema": {"type": "object", "additionalProperties": False, + "properties": { + "vx": {"type": "number", "minimum": self.motion["vx"]["min"], + "maximum": self.motion["vx"]["max"], "default": 0}, + "vy": {"type": "number", "const": 0, "default": 0}, + "yaw_rate": {"type": "number", "minimum": self.motion["yaw_rate"]["min"], + "maximum": self.motion["yaw_rate"]["max"], "default": 0}, + "duration_s": {"type": "number", "minimum": MIN_DURATION_S, "maximum": MAX_DURATION_S}, + }, "required": ["duration_s"]}, + "units": {"vx": self.motion["vx"]["unit"], "vy": "m/s", + "yaw_rate": self.motion["yaw_rate"]["unit"], "duration_s": "s"}, + "frame": "body", "max_duration_s": MAX_DURATION_S, + "completion_semantics": MOTION_COMPLETION, "stop_semantics": STOP_SEMANTICS, + }] + for action in self.actions.values(): + entries.append({ + "id": action["capability"], "version": 1, "aliases": [action["name"]], + "input_schema": {"type": "object", "properties": {}, "additionalProperties": False}, + "units": {}, "frame": None, "max_duration_s": MAX_DURATION_S, + "completion_semantics": ACTION_COMPLETION, "stop_semantics": STOP_SEMANTICS, + "preconditions": {"allowed_state_codes": action["allowed_states"], + "target_state_codes": action["target_states"]}, + }) + entries.append({ + "id": "control.stop", "version": 1, "aliases": ["stop"], + "input_schema": {"type": "object", "properties": {}, "additionalProperties": False}, + "units": {}, "frame": None, "max_duration_s": 15, + "completion_semantics": STOP_COMPLETION, "stop_semantics": STOP_SEMANTICS, + }) + # Self-check: the compiled table must satisfy the frozen capability contract. + from .protocol import validate_capabilities + validate_capabilities({"api_version": API_VERSION, "capabilities_revision": self.revision, + "capabilities": entries}) + return entries + + def capabilities(self) -> list[dict]: + return json.loads(json.dumps(self._capabilities)) + + def _bounded(self, value, name: str, axis: dict) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ControlError("invalid_parameter", f"{name} must be a number") + number = float(value) + if not axis["min"] <= number <= axis["max"]: + raise ControlError("out_of_range", + f"{name} must be in [{axis['min']}, {axis['max']}]") + return number + + def normalize(self, body: dict) -> dict: + kind = body.get("kind") + if kind == "move": + return {"kind": kind, + "vx": self._bounded(body.get("vx", 0), "vx", self.motion["vx"]), + "vy": 0.0, + "yaw_rate": self._bounded(body.get("yaw_rate", 0), "yaw_rate", self.motion["yaw_rate"]), + "duration_s": float(_duration(body.get("duration_s")))} + if kind == "action": + name = body.get("name") + if not isinstance(name, str) or name not in self.actions: + raise ControlError("invalid_action", + f"Action must be one of: {', '.join(sorted(self.actions))}") + return {"kind": kind, "name": name} + if kind == "stop": + return {"kind": kind} + raise ControlError("invalid_request", "kind must be move, action or stop") + + def from_capability(self, capability: str, args) -> dict: + if not isinstance(args, dict): + raise ControlError("invalid_parameter", "args must be a JSON object") + if capability == "motion.twist": + if set(args) - {"vx", "vy", "yaw_rate", "duration_s"}: + raise ControlError("invalid_parameter", "Unknown motion argument") + return self.normalize({"kind": "move", **args}) + action = self.by_capability.get(capability) + if action is None and capability != "control.stop": + raise ControlError("unsupported_capability", f"Unsupported capability: {capability}") + if args: + raise ControlError("invalid_parameter", "This capability takes no arguments") + return {"kind": "action", "name": action["name"]} if action else {"kind": "stop"} + + def public_command(self, command: dict) -> tuple[str, dict]: + if command["kind"] == "move": + return "motion.twist", {key: command[key] for key in ("vx", "vy", "yaw_rate", "duration_s")} + if command["kind"] == "action": + return self.actions[command["name"]]["capability"], {} + return "control.stop", {} + + def _state_name(self, code) -> str: + return self.states.get(code, "UNKNOWN") + + def prepare(self, command: dict, state: dict) -> tuple[dict, float, bool]: + kind = command["kind"] + if kind == "move": + code = state.get("policy_state_code") + if code not in self.motion["allowed_states"]: + raise ControlError("invalid_state", + f"Movement requires {self._state_list_label(self.motion['allowed_states'])}") + return ({"kind": "move", + "values": {self.profile["motion_parameters"]["vx"]: command["vx"], + self.profile["motion_parameters"]["yaw_rate"]: command["yaw_rate"]}, + "duration_ms": command["duration_s"] * 1000, "ttl_ms": MOVE_TTL_MS, + "allowed_states": list(self.motion["allowed_states"])}, + command["duration_s"] + 10, False) + if kind == "action": + action = self.actions[command["name"]] + code = state.get("policy_state_code") + satisfied = action["idempotent"] and code in action["target_states"] + if not satisfied and code not in action["allowed_states"]: + raise ControlError("invalid_state", + f"{command['name']} is not allowed from {self._state_name(code)}") + return ({"kind": "event", "event_value": action["event_value"], + "allowed_states": list(action["allowed_states"]), + "target_states": list(action["target_states"]), + "timeout_ms": EVENT_TIMEOUT_MS}, 15, satisfied) + return {"kind": "stop"}, 15, False + + def _state_list_label(self, codes: list[int]) -> str: + return " or ".join(self._state_name(code) for code in codes) + + def state_available(self, snapshot: dict) -> bool: + return type(snapshot.get("policy_state_code")) is int + + def decode_state(self, snapshot: dict) -> dict: + result = dict(snapshot) + result["policy_state"] = self._state_name(result.get("policy_state_code")) + return result + + def observations(self, state: dict) -> dict: + return { + "base_pose": {"position_m": state.get("position_world_m"), + "orientation_wxyz": state.get("orientation_wxyz"), "frame": "world"}, + "base_velocity": {"values": state.get("base_velocity"), + "layout": "MuJoCo free-joint qvel: world linear xyz then body angular xyz", + "units": ["m/s"] * 3 + ["rad/s"] * 3}, + "policy": {"code": state.get("policy_state_code"), "name": state.get("policy_state"), + "steps": state.get("policy_state_steps")}, + "effective_parameters": dict(state.get("effective_parameters", {})), + } + + +def _duration(value) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)) \ + or not MIN_DURATION_S <= value <= MAX_DURATION_S: + raise ControlError("out_of_range", f"duration_s must be in [{MIN_DURATION_S}, {MAX_DURATION_S}]") + return float(value) diff --git a/src/botlab/control/discovery.py b/src/botlab/control/discovery.py new file mode 100644 index 0000000..ded341a --- /dev/null +++ b/src/botlab/control/discovery.py @@ -0,0 +1,111 @@ +"""Private, project-scoped local middleware discovery (never imports a project).""" + +from __future__ import annotations + +import getpass +import hashlib +import ipaddress +import os +import stat +import tempfile +from pathlib import Path +from urllib.parse import urlsplit + +from .protocol import API_VERSION, PROTOCOL, ControlError, identifier, parse_json, project_key + + +def runtime_dir() -> Path: + user = str(os.getuid()) if hasattr(os, "getuid") else hashlib.sha256(getpass.getuser().encode()).hexdigest()[:12] + return Path(os.environ.get("BOTLAB_CONTROL_RUNTIME_DIR", Path(tempfile.gettempdir()) / f"botlab-control-{user}")) + + +def endpoint(value: str) -> str: + """Accept loopback HTTP origins (local middleware) and HTTPS origins + (remote middleware behind TLS); plaintext HTTP never leaves the loopback.""" + try: + parsed = urlsplit(value) + host = parsed.hostname + if not host: + raise ValueError() + try: + loopback = host == "localhost" or ipaddress.ip_address(host).is_loopback + except ValueError: + loopback = False + if (parsed.username is not None or parsed.password is not None + or parsed.query or parsed.fragment or parsed.path not in ("", "/")): + raise ValueError() + if parsed.scheme == "http": + if not loopback or not parsed.port: + raise ValueError() + elif parsed.scheme == "https": + port = parsed.port # Accessing .port rejects malformed ports. + if port is not None and not 1 <= port <= 65535: + raise ValueError() + else: + raise ValueError() + except (ValueError, TypeError) as error: + raise ControlError("invalid_endpoint", + "Endpoint must be a loopback HTTP origin or an HTTPS origin, without " + "credentials, path, query or fragment.") from error + return value.rstrip("/") + + +def private_file(path: Path) -> None: + info = path.lstat() + if not stat.S_ISREG(info.st_mode) or (hasattr(os, "getuid") and info.st_uid != os.getuid()): + raise ControlError("unsafe_file", "Control metadata must be a regular file owned by this user.") + if os.name == "posix" and info.st_mode & 0o077: + raise ControlError("unsafe_file", "Control metadata must have private file permissions (0600).") + + +def private_directory(path: Path, *, create: bool = False) -> None: + if create: + path.mkdir(parents=True, mode=0o700, exist_ok=True) + info = path.lstat() + if not stat.S_ISDIR(info.st_mode) or (hasattr(os, "getuid") and info.st_uid != os.getuid()): + raise ControlError("unsafe_directory", "Control metadata directory must be owned by this user and not a symlink.") + if os.name == "posix" and info.st_mode & 0o077: + raise ControlError("unsafe_directory", "Control metadata directory must have private permissions (0700).") + + +def descriptors(project: str | Path) -> list[dict]: + root = runtime_dir() + directory = root / project_key(project) + if not directory.exists(): + return [] + private_directory(root) + private_directory(directory) + result = [] + for path in sorted(directory.glob("*.json")): + try: + private_file(path) + item = parse_json(path.read_text()) + if not isinstance(item, dict): + continue + if item.get("schema") != 1 or item.get("api_version") != API_VERSION or item.get("protocol") != PROTOCOL: + continue + remote = item.get("remote") is True + if remote: + # Enrolled remote descriptors pin the hosted identity that + # /info reported at enrollment; the client verifies the pin. + if (not isinstance(item.get("project_key"), str) or not item["project_key"] + or not isinstance(item.get("project"), str) or not item["project"]): + continue + elif item.get("project_key") != project_key(project) or item.get("project") != str(Path(project).resolve()): + continue + identifier(item.get("instance_id"), "instance_id") + item["endpoint"] = endpoint(item.get("endpoint")) + if remote and urlsplit(item["endpoint"]).scheme != "https": + continue + token = item.get("token") + if not isinstance(token, str) or not token or "\n" in token or "\r" in token: + continue + result.append(item) + except (OSError, ControlError): + # Partial, stale and insecure descriptors cannot grant credentials. + continue + return result + + +def public_descriptor(item: dict) -> dict: + return {key: item.get(key) for key in ("instance_id", "project", "project_key", "endpoint", "pid")} diff --git a/src/botlab/control/protocol.py b/src/botlab/control/protocol.py new file mode 100644 index 0000000..d697145 --- /dev/null +++ b/src/botlab/control/protocol.py @@ -0,0 +1,174 @@ +"""Public wire constants and small, dependency-free input checks.""" + +from __future__ import annotations + +import hashlib +import json +import math +from collections.abc import Callable +from pathlib import Path +from typing import Any + +API_VERSION = 1 +PROTOCOL = "botlab-control" +API_PREFIX = "/control/v1" +TERMINAL = frozenset({"completed", "cancelled", "failed", "expired"}) +FAILED = frozenset({"cancelled", "failed", "expired"}) +COMMAND_STATUSES = frozenset({"accepted", "applied"}) | TERMINAL + +# Frozen v1 submission envelope. Extensions never add fields here: hosted +# identity and routing information travels in discovery descriptors and /info +# instead, so v1 envelopes stay identical across client and middleware releases. +ENVELOPE_FIELDS = frozenset({"request_id", "instance_id", "session_id", "episode_id", + "capabilities_revision", "capability", "args"}) +IDENTITY_FIELDS = ENVELOPE_FIELDS - {"args"} +MAX_REQUEST_ID = 128 + +# Optional protocol extensions a middleware may declare in /info. Clients must +# ignore extensions they do not understand; local v1 declares none. +PROTOCOL_EXTENSIONS: tuple[str, ...] = () + + +class ControlError(Exception): + def __init__(self, code: str, message: str, **context: Any): + super().__init__(message) + self.code = code + self.context = context + + def payload(self) -> dict: + return {"ok": False, "error": {"code": self.code, "message": str(self)}, **self.context} + + +def finite_json(value: Any) -> None: + if isinstance(value, float) and not math.isfinite(value): + raise ControlError("invalid_args", "JSON numbers must be finite.") + if isinstance(value, dict): + for child in value.values(): + finite_json(child) + elif isinstance(value, list): + for child in value: + finite_json(child) + + +def parse_json(text: str) -> Any: + try: + value = json.loads(text) + finite_json(value) + return value + except (ValueError, RecursionError) as error: + raise ControlError("invalid_json", "Expected valid JSON.") from error + + +def identifier(value: Any, name: str) -> str: + if not isinstance(value, str) or not value or len(value) > 256: + raise ControlError("invalid_identity", f"{name} must be a nonempty string (up to 256 characters).") + return value + + +def project_key(project: str | Path) -> str: + """Stable short identity of a project checkout location.""" + return hashlib.sha256(str(Path(project).resolve()).encode()).hexdigest()[:16] + + +def validate_capabilities(payload: dict) -> list[dict]: + if payload.get("api_version") != API_VERSION: + raise ControlError("unsupported_version", "Unsupported Control API version.") + identifier(payload.get("capabilities_revision"), "capabilities_revision") + capabilities = payload.get("capabilities") + if not isinstance(capabilities, list): + raise ControlError("invalid_protocol", "capabilities must be an array.") + seen = set() + for capability in capabilities: + if not isinstance(capability, dict): + raise ControlError("invalid_protocol", "Each capability must be an object.") + name = identifier(capability.get("id"), "capability.id") + if name in seen: + raise ControlError("invalid_protocol", "Duplicate capability ID.") + seen.add(name) + if capability.get("version") != 1: + raise ControlError("unsupported_capability_version", f"Unsupported version of {name}.") + required = ("input_schema", "completion_semantics", "units", "frame", + "max_duration_s", "stop_semantics") + if any(key not in capability for key in required): + raise ControlError("invalid_protocol", f"Capability {name} is missing required metadata.") + if not isinstance(capability["input_schema"], dict): + raise ControlError("invalid_protocol", f"Capability {name} has no input schema.") + if not isinstance(capability["units"], dict): + raise ControlError("invalid_protocol", f"Capability {name} must declare parameter units.") + if capability["frame"] is not None and not isinstance(capability["frame"], str): + raise ControlError("invalid_protocol", f"Capability {name} has an invalid frame.") + for field in ("completion_semantics", "stop_semantics"): + if not isinstance(capability[field], str) or not capability[field]: + raise ControlError("invalid_protocol", f"Capability {name} has no {field}.") + duration = capability["max_duration_s"] + if duration is not None and (isinstance(duration, bool) + or not isinstance(duration, (float, int)) or duration <= 0): + raise ControlError("invalid_protocol", f"Capability {name} has invalid duration metadata.") + aliases = capability.get("aliases", []) + if not isinstance(aliases, list) or any(not isinstance(alias, str) for alias in aliases): + raise ControlError("invalid_protocol", f"Capability {name} has invalid aliases.") + return capabilities + + +def validate_args(value: Any, schema: dict, path: str = "args") -> None: + """Check common schema constraints; middleware remains the authoritative validator.""" + try: + _validate_args(value, schema, path) + except (TypeError, AttributeError, RecursionError) as error: + raise ControlError("invalid_protocol", "Middleware supplied a malformed input schema.") from error + + +def _validate_args(value: Any, schema: dict, path: str) -> None: + finite_json(value) + types = { + "object": isinstance(value, dict), "array": isinstance(value, list), + "string": isinstance(value, str), "boolean": isinstance(value, bool), + "number": isinstance(value, (int, float)) and not isinstance(value, bool), + "integer": isinstance(value, int) and not isinstance(value, bool), "null": value is None, + } + kind = schema.get("type") + if isinstance(kind, str) and kind in types and not types[kind]: + raise ControlError("invalid_args", f"{path} must have type {kind}.") + if "enum" in schema and value not in schema["enum"]: + raise ControlError("invalid_args", f"{path} is not an allowed value.") + if "const" in schema and value != schema["const"]: + raise ControlError("invalid_args", f"{path} does not match the required value.") + if isinstance(value, dict): + properties = schema.get("properties", {}) + for key in schema.get("required", []): + if key not in value: + raise ControlError("invalid_args", f"{path}.{key} is required.") + if schema.get("additionalProperties") is False and value.keys() - properties.keys(): + raise ControlError("invalid_args", f"{path} contains unknown fields.") + for key, child in value.items(): + if key in properties: + _validate_args(child, properties[key], f"{path}.{key}") + elif isinstance(value, list): + if len(value) < schema.get("minItems", 0) or len(value) > schema.get("maxItems", math.inf): + raise ControlError("invalid_args", f"{path} has the wrong number of items.") + if isinstance(schema.get("items"), dict): + for index, child in enumerate(value): + _validate_args(child, schema["items"], f"{path}[{index}]") + elif isinstance(value, (float, int)) and not isinstance(value, bool): + if value < schema.get("minimum", -math.inf) or value > schema.get("maximum", math.inf): + raise ControlError("invalid_args", f"{path} is outside the declared range.") + if value <= schema.get("exclusiveMinimum", -math.inf) or value >= schema.get("exclusiveMaximum", math.inf): + raise ControlError("invalid_args", f"{path} is outside the declared range.") + + +def validate_envelope(body: Any, *, error: Callable[..., ControlError] = ControlError) -> dict: + """Check the frozen v1 submission envelope: the complete field set, nonempty + identity strings, a bounded request_id and finite JSON values. ``error`` + lets a server integration raise its own exception type while the wire + contract stays single-sourced here.""" + if not isinstance(body, dict) or set(body) != set(ENVELOPE_FIELDS): + raise error("invalid_parameter", "Expected the complete control command envelope") + if any(not isinstance(body[key], str) or not body[key] for key in IDENTITY_FIELDS): + raise error("invalid_parameter", "Identity and capability fields must be nonempty strings") + if len(body["request_id"]) > MAX_REQUEST_ID: + raise error("invalid_parameter", f"request_id must be at most {MAX_REQUEST_ID} characters") + try: + json.dumps(body, allow_nan=False) + except (ValueError, TypeError) as envelope_error: + raise error("invalid_parameter", "Arguments must contain finite JSON values") from envelope_error + return body diff --git a/src/botlab/control/receipts.py b/src/botlab/control/receipts.py new file mode 100644 index 0000000..32a2913 --- /dev/null +++ b/src/botlab/control/receipts.py @@ -0,0 +1,89 @@ +"""Durable original request identities. No credential is ever written here.""" + +from __future__ import annotations + +import hashlib +import json +import os +import tempfile +from pathlib import Path + +from .discovery import endpoint, private_directory, private_file, project_key +from .protocol import ControlError, identifier, parse_json + + +class Receipts: + def __init__(self, project: str | Path): + self.project = str(Path(project).resolve()) + self.project_key = project_key(project) + self.root = Path(os.environ.get("BOTLAB_CONTROL_RECEIPTS_DIR", Path.home() / ".local/state/botlab/control/receipts")) + self.directory = self.root / self.project_key + + def path(self, request_id: str) -> Path: + identifier(request_id, "request_id") + return self.directory / (hashlib.sha256(request_id.encode()).hexdigest() + ".json") + + def load(self, request_id: str) -> dict | None: + path = self.path(request_id) + if not path.exists(): + return None + try: + private_directory(self.root) + private_directory(self.directory) + private_file(path) + receipt = parse_json(path.read_text()) + if (not isinstance(receipt, dict) or receipt.get("schema") != 1 + or receipt.get("project_key") != self.project_key + or receipt.get("project") != self.project): + raise ControlError("invalid_receipt", "Request receipt has invalid project identity.") + envelope = receipt.get("envelope", {}) + if envelope.get("request_id") != request_id: + raise ControlError("invalid_receipt", "Request receipt has invalid request identity.") + for key in ("instance_id", "session_id", "episode_id", "capabilities_revision", "capability"): + identifier(envelope.get(key), key) + if not isinstance(envelope.get("args"), dict) or not isinstance(receipt.get("intent"), dict): + raise ControlError("invalid_receipt", "Request receipt is incomplete.") + endpoint(receipt.get("endpoint")) + return receipt + except OSError as error: + raise ControlError("receipt_unavailable", "Cannot read the original request receipt.") from error + + def save(self, envelope: dict, origin: str, intent: dict, *, + control_generation: int | str | None = None) -> tuple[dict, bool]: + """Publish completely written data with a no-overwrite atomic hard link.""" + request_id = envelope["request_id"] + receipt = {"schema": 1, "project": self.project, "project_key": self.project_key, + "endpoint": endpoint(origin), "intent": intent, "envelope": envelope} + if control_generation is not None: + receipt["control_generation"] = control_generation + temporary = None + try: + private_directory(self.root, create=True) + private_directory(self.directory, create=True) + fd, temporary = tempfile.mkstemp(prefix=".request-", dir=self.directory) + with os.fdopen(fd, "w") as handle: + json.dump(receipt, handle, allow_nan=False, sort_keys=True, separators=(",", ":")) + handle.flush() + os.fsync(handle.fileno()) + try: + os.link(temporary, self.path(request_id)) + except FileExistsError: + existing = self.load(request_id) + if existing is None: + raise ControlError("receipt_unavailable", "Request receipt changed during publication.") from None + return existing, False + if os.name == "posix": + directory_fd = os.open(self.directory, os.O_RDONLY) + try: + os.fsync(directory_fd) + finally: + os.close(directory_fd) + return receipt, True + except OSError as error: + raise ControlError("receipt_unavailable", "Cannot persist request identity; no command was sent.") from error + finally: + if temporary is not None: + try: + os.unlink(temporary) + except OSError: + pass diff --git a/src/botlab/control/serve_service.py b/src/botlab/control/serve_service.py new file mode 100644 index 0000000..a4bd0b8 --- /dev/null +++ b/src/botlab/control/serve_service.py @@ -0,0 +1,599 @@ +"""Generic browser-pairing middleware: project semantics arrive at pairing time. + +Adapted from microduck's proven service semantics (task table, fingerprint +deduplication with tombstones, lease monitoring, completion observations, +boundary middleware, long-poll waits). The differences are deliberate: + +- the adapter starts empty; a validated project descriptor uploaded over the + paired /runtime connection compiles into a DescriptorEngine; +- only the public /control/v1 routes exist (no project legacy routes); +- the browser is the only execution backend, so pairing is always open. + +This module imports aiohttp at the top level and therefore must never enter +the client CLI import chain: ``botlab.control.cli`` imports it lazily in the +serve branch only (mirrors botlab.hil.gateway). +""" +from __future__ import annotations + +import asyncio +import json +import math +import os +import secrets +import sys +import time +import uuid +from collections import OrderedDict +from copy import deepcopy +from pathlib import Path + +from aiohttp import WSMsgType, web + +from .descriptor import DESCRIPTOR_VERSION, DescriptorEngine, validate_descriptor +from .discovery import private_directory +from .discovery import runtime_dir as discovery_runtime_dir +from .protocol import ( + API_VERSION, + COMMAND_STATUSES, + PROTOCOL, + TERMINAL, + project_key, + validate_envelope, +) +from .protocol import ControlError as ProtocolError + +STATUSES = COMMAND_STATUSES +WIRED_EXTENSIONS = ("project-descriptor",) +# The engine raises protocol.ControlError (stdlib package, no HTTP layer); +# these codes mirror microduck's explicit 400s, everything else is a task +# refusal (409). +BAD_REQUEST_CODES = frozenset({"invalid_parameter", "invalid_request", + "invalid_action", "invalid_args", "unsupported_capability"}) + + +class ControlError(Exception): + def __init__(self, code: str, message: str, status: int = 409): + super().__init__(message) + self.code, self.status = code, status + + +def finite(value, name: str, low: float, high: float) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ControlError("invalid_parameter", f"{name} must be a number", 400) + if not math.isfinite(value) or not low <= value <= high: + raise ControlError("out_of_range", f"{name} must be in [{low}, {high}]", 400) + return float(value) + + +class GenericControlService: + def __init__(self, token: str, origins: set[str], *, clock=time.monotonic, + project: str | Path | None = None, max_results: int = 256, + max_requests: int = 10000, public_hosts: set[str] | None = None): + self.token, self.origins, self.clock = token, origins, clock + self.public_hosts = frozenset(public_hosts or ()) + if max_results < 1 or max_requests < 1: + raise ValueError("Task retention limits must be positive") + self.project = Path(project or Path.cwd()).resolve() + self.project_key = project_key(self.project) + self.instance_id = str(uuid.uuid4()) + # The engine is compiled from the descriptor the page uploads at pairing; + # until then the service has no capabilities and refuses tasks. + self.adapter: DescriptorEngine | None = None + self.max_results, self.max_requests = max_results, max_requests + self.submit_lock = asyncio.Lock() + self.socket = None + self.snapshot: dict | None = None + self.received_at = 0.0 + self.progress_at = 0.0 + self.jobs: OrderedDict[str, dict] = OrderedDict() + self.fingerprints: dict[str, str] = {} + self.bindings: dict[str, tuple[str, str]] = {} + self.observations: OrderedDict[tuple, dict] = OrderedDict() + self.active_id: str | None = None + self.revision = asyncio.Condition() + self.monitor: asyncio.Task | None = None + self.retired_episodes: set[str] = set() + + @property + def connected(self) -> bool: + return self.socket is not None and not self.socket.closed + + def info(self) -> dict: + return {"ok": True, "api_version": API_VERSION, "protocol": PROTOCOL, + "project": str(self.project), "project_key": self.project_key, + "instance_id": self.instance_id, + "backend": {"id": "browser", "kind": "simulation"}, + "extensions": list(WIRED_EXTENSIONS), + "retention": {"results": self.max_results, "motion_requests": self.max_requests, + "deduplication": "instance_lifetime; stop remains available at motion capacity"}} + + def require_engine(self) -> DescriptorEngine: + if self.adapter is None: + raise ControlError("no_project_paired", + "Pair a project page over /runtime first; it uploads the control descriptor") + return self.adapter + + def capabilities(self) -> dict: + engine = self.require_engine() + return {"ok": True, "api_version": API_VERSION, "instance_id": self.instance_id, + "capabilities_revision": engine.revision, "capabilities": engine.capabilities()} + + def experience(self) -> dict: + engine = self.require_engine() + return {"ok": True, "instance_id": self.instance_id, + "capabilities_revision": engine.revision, "experience": engine.experience} + + async def changed(self): + async with self.revision: + self.revision.notify_all() + + def state(self) -> dict: + if self.snapshot is None: + return {"connected": self.connected, "external_enabled": False, + "run_state": "idle", "ready": False, "active_command_id": None} + result = self.adapter.decode_state(self.snapshot) + result.pop("type", None) + elapsed_ms = max(0, (self.clock() - self.received_at) * 1000) + result["state_age_ms"] = round(result.get("state_age_ms", 0) + elapsed_ms) + result["progress_age_ms"] = round(max(0, (self.clock() - self.progress_at) * 1000)) + result["connected"] = self.connected + result["active_command_id"] = self.active_id + result["ready"] = (result["connected"] and result.get("run_state") == "running" + and result.get("external_enabled") is True + and self.adapter.state_available(result) + and result["state_age_ms"] < 1000 and result["progress_age_ms"] < 1000) + return result + + def api_state(self) -> dict: + state = self.state() + state["observations"] = self.adapter.observations(state) if self.adapter else {} + return {"ok": True, "instance_id": self.instance_id, "project_key": self.project_key, "state": state} + + def require_state(self, requested_session: str | None = None) -> dict: + state = self.state() + if requested_session and requested_session != state.get("session_id"): + raise ControlError("wrong_session", "The requested browser session is not connected") + if not state["ready"]: + raise ControlError("not_ready", "Connect external control and run the simulation; fresh physics state is required") + return state + + async def send(self, message: dict): + if self.socket is None or self.socket.closed: + raise ControlError("disconnected", "Browser control connection is closed") + await asyncio.wait_for(self.socket.send_json({"v": 1, **message}), 1.0) + + @staticmethod + def envelope(state: dict) -> dict: + return {name: state[name] for name in ("session_id", "episode_id", "control_generation", "basis_id")} + + async def finish(self, job: dict, status: str, reason: str): + if job["status"] in TERMINAL: + return + job.update(status=status, reason=reason, finished_at=self.clock()) + self.capture_completion(job) + if self.active_id == job["command_id"]: + self.active_id = None + await self.changed() + + async def cancel_active(self, reason: str): + if self.active_id is not None: + await self.finish(self.jobs[self.active_id], "cancelled", reason) + + def check_instance(self, instance_id: str | None): + if instance_id is not None and instance_id != self.instance_id: + raise ControlError("wrong_instance", "Middleware instance changed; do not replay an old request") + + async def submit_public(self, body: dict) -> dict: + validate_envelope(body, error=lambda code, message: ControlError(code, message, 400)) + self.check_instance(body["instance_id"]) + engine = self.require_engine() + if body["capabilities_revision"] != engine.revision: + raise ControlError("capabilities_changed", "Read the current capabilities before a new task") + current = self.state() + if body["session_id"] != current.get("session_id"): + raise ControlError("wrong_session", "Read the current session before submitting a command") + if body["episode_id"] != current.get("episode_id"): + raise ControlError("wrong_episode", "Simulation episode changed; do not replay an old request") + command = engine.from_capability(body["capability"], body["args"]) + async with self.submit_lock: + await self._submit(body, command, engine, + binding=(body["session_id"], body["episode_id"])) + return self.api_job(self.jobs[body["request_id"]]) + + async def _submit(self, body: dict, command: dict, engine: DescriptorEngine, + binding: tuple[str, str]) -> dict: + request_id = body["request_id"] + if not isinstance(request_id, str) or not 1 <= len(request_id) <= 128: + raise ControlError("invalid_request", "request_id must be a nonempty string (max 128)", 400) + try: + json.dumps(body, allow_nan=False) + except (ValueError, TypeError) as error: + raise ControlError("invalid_request", "Request must contain finite JSON values", 400) from error + normalized = engine.normalize(command) + fingerprint = json.dumps(normalized, sort_keys=True, allow_nan=False) + if request_id in self.fingerprints: + known = self.bindings[request_id] + if (self.fingerprints[request_id] != fingerprint + or (body.get("session_id") is not None and body["session_id"] != known[0]) + or binding != known): + raise ControlError("request_conflict", "request_id already belongs to a different command or execution identity") + if request_id not in self.jobs: + raise ControlError("result_evicted", "Result was retired; request ID remains reserved and will not execute again") + return self.public_job(self.jobs[request_id]) + state = self.require_state(body.get("session_id")) + if binding[1] != state.get("episode_id"): + raise ControlError("wrong_episode", "Simulation episode changed; read state before a new task") + kind = normalized["kind"] + if kind != "stop" and len(self.fingerprints) >= self.max_requests: + raise ControlError("request_capacity", "Motion request capacity reached; stop remains available") + if self.active_id and kind != "stop": + raise ControlError("busy", f"Command {self.active_id} is still executing") + payload, timeout, already_satisfied = engine.prepare(normalized, state) + wire = {"type": "command", "command_id": request_id, **self.envelope(state), **payload} + if kind == "stop": + await self.cancel_active("stop_requested") + capability, args = engine.public_command(normalized) + job = {"command_id": request_id, "kind": kind, "request": deepcopy(body), + "instance_id": self.instance_id, "capability": capability, "args": args, + "capabilities_revision": engine.revision, + "session_id": state["session_id"], "episode_id": state["episode_id"], + "status": "accepted", "created_at": self.clock(), "deadline": self.clock() + timeout, + "initial_state": deepcopy(state), + "initial_observation": {**deepcopy(state), "capture_stage": "accepted"}, + "completion_observation": None, "completion_observation_reason": "not_terminal"} + self.jobs[request_id], self.fingerprints[request_id] = job, fingerprint + self.bindings[request_id] = (state["session_id"], state["episode_id"]) + if already_satisfied: + await self.finish(job, "completed", "already_satisfied") + else: + self.active_id = request_id + try: + await self.send(wire) + except (TimeoutError, ControlError, ConnectionError) as error: + await self.finish(job, "failed", str(error)) + while len(self.jobs) > self.max_results: + oldest = next((key for key, value in self.jobs.items() + if key != request_id and value["status"] in TERMINAL), None) + if oldest is None: + break + self.jobs.pop(oldest) + # Keep fingerprint and identity tombstones for this instance's lifetime. + await self.changed() + return self.public_job(job) + + def capture_completion(self, job: dict): + if job["status"] not in TERMINAL or job["completion_observation"] is not None: + return + if job.get("reason") == "already_satisfied": + job["completion_observation"] = {**deepcopy(job["initial_state"]), + "capture_stage": "already_satisfied"} + job["completion_observation_reason"] = None + return + result = job.get("browser_result", {}) + key = (job["session_id"], job["episode_id"], result.get("step")) + observed = self.observations.get(key) if result.get("status") in TERMINAL else None + if observed is not None: + job["completion_observation"] = {**deepcopy(observed), "capture_stage": "completed"} + job["completion_observation_reason"] = None + else: + job["completion_observation_reason"] = "no_matching_completion_snapshot" + + def public_job(self, job: dict) -> dict: + result = deepcopy({key: value for key, value in job.items() if key not in {"wire", "deadline"}}) + result["terminal"] = job["status"] in TERMINAL + result["elapsed_s"] = round((job.get("finished_at", self.clock()) - job["created_at"]), 3) + result["state"] = self.state() + return result + + def api_job(self, job: dict) -> dict: + result = self.public_job(job) + result.pop("state", None) + result["current_state"] = self.api_state()["state"] + return result + + def find_job(self, command_id: str) -> dict: + if command_id in self.jobs: + return self.jobs[command_id] + if command_id in self.fingerprints: + raise ControlError("result_evicted", "Result was retired; the request remains reserved") + raise ControlError("not_found", "Unknown command ID", 404) + + async def accept_message(self, message: dict): + if message.get("v") != 1: + raise ProtocolError("protocol_version", "Expected control protocol v1") + kind = message.get("type") + if kind == "descriptor": + descriptor = validate_descriptor(message) + self.adapter = DescriptorEngine(descriptor) + await self.send({"type": "descriptor_ack", "capabilities_revision": self.adapter.revision, + "profile": self.adapter.profile}) + await self.changed() + elif kind == "state": + if self.adapter is None: + raise ProtocolError("invalid_message", "Upload the project descriptor before state frames") + for name in ("session_id", "episode_id", "basis_id"): + if not isinstance(message.get(name), str) or not message[name]: + raise ProtocolError("bad_state", f"Missing state {name}") + if message.get("run_state") not in {"idle", "running", "paused", "error"}: + raise ProtocolError("bad_state", "Invalid run_state") + for name in ("step", "control_generation", "sim_time_s", "captured_mono_ms", "state_age_ms"): + finite(message.get(name), name, 0, 1e16) + if message["episode_id"] in self.retired_episodes: + return + previous = self.snapshot + if previous and message["session_id"] != previous["session_id"]: + raise ProtocolError("wrong_session", "Browser session changed within a connection") + new_episode = previous is None or previous["episode_id"] != message["episode_id"] + if previous and new_episode: + self.retired_episodes.add(previous["episode_id"]) + await self.cancel_active("episode_changed") + if not new_episode and message["step"] < previous["step"]: + return + now = self.clock() + if new_episode or message["step"] > previous["step"]: + self.progress_at = now + self.snapshot, self.received_at = deepcopy(message), now + key = (message["session_id"], message["episode_id"], message["step"]) + self.observations[key] = self.state() + while len(self.observations) > 64: + self.observations.popitem(last=False) + for job in self.jobs.values(): + self.capture_completion(job) + if message["run_state"] != "running": + await self.cancel_active(message["run_state"]) + elif not message.get("external_enabled", False): + await self.cancel_active("external_control_disabled") + await self.changed() + elif kind == "result": + job = self.jobs.get(message.get("command_id")) + if job is None or job["status"] in TERMINAL: + return + if (message.get("session_id") != job["session_id"] + or message.get("episode_id") != job["episode_id"]): + return + status = message.get("status") + if status not in STATUSES: + raise ProtocolError("bad_result", "Unknown command result") + if job["status"] == "applied" and status == "accepted": + return + job["browser_result"] = dict(message) + if status in TERMINAL: + await self.finish(job, status, str(message.get("reason", status))) + else: + job["status"] = status + await self.changed() + else: + raise ProtocolError("invalid_message", "Expected descriptor, state or result") + + async def monitor_jobs(self): + while True: + await asyncio.sleep(0.1) + if self.active_id is None: + continue + job = self.jobs[self.active_id] + if self.clock() > job["deadline"]: + await self.finish(job, "expired", "No completion before command deadline") + # Stop renewing: browser's independent lease will expire. + continue + if job["kind"] != "move": + continue + state = self.state() + if (not state["ready"] or state.get("session_id") != job["session_id"] + or state.get("episode_id") != job["episode_id"]): + await self.finish(job, "expired", "Physics feedback became stale") + continue + try: + await self.send({"type": "renew", "command_id": job["command_id"], + **self.envelope(state), "ttl_ms": 500}) + except (TimeoutError, ControlError, ConnectionError) as error: + await self.finish(job, "failed", str(error)) + + async def disconnected(self): + self.socket = None + await self.cancel_active("browser_disconnected") + self.snapshot = None + self.retired_episodes.clear() + self.observations.clear() + await self.changed() + + +def create_app(service: GenericControlService) -> web.Application: + @web.middleware + async def boundary(request, handler): + origin = request.headers.get("Origin") + cors = request.path.startswith("/control/") and origin in service.origins + try: + host = request.host.split(":")[0] + if host not in {"127.0.0.1", "localhost"} | service.public_hosts: + raise ControlError("forbidden_host", "Loopback Host required", 403) + if origin is not None and origin not in service.origins: + raise ControlError("forbidden_origin", "Browser Origin is not allowed", 403) + # CORS preflight: a page on an allowlisted origin fetches /control/v1 + # cross-port with Authorization + JSON Content-Type (both non-simple + # headers), so the browser sends OPTIONS first. The spec forbids + # credentials on preflight, which must be answered before the token + # comparison below. + if request.method == "OPTIONS" and request.path.startswith("/control/"): + if origin not in service.origins: + raise ControlError("forbidden_origin", "Browser Origin is not allowed", 403) + return web.Response(status=204, headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": "Authorization, Content-Type, Accept", + "Access-Control-Max-Age": "600", + }) + credential = request.headers.get("Authorization", "") + expected = "Bearer " + service.token + if request.path != "/runtime" and not secrets.compare_digest(credential, expected): + raise ControlError("unauthorized", "Valid local control token required", 401) + response = await handler(request) + except ControlError as error: + response = web.json_response({"ok": False, "error": {"code": error.code, "message": str(error)}}, status=error.status) + except ProtocolError as error: + response = web.json_response({"ok": False, "error": {"code": error.code, "message": str(error)}}, + status=400 if error.code in BAD_REQUEST_CODES else 409) + except (ValueError, TypeError, KeyError) as error: + response = web.json_response({"ok": False, "error": {"code": "invalid_request", "message": str(error)}}, status=400) + if cors: + # Echo the allowlisted Origin on every /control answer (errors + # included) so the page can read the body; originless CLI traffic + # gets no header. + response.headers["Access-Control-Allow-Origin"] = origin + return response + + app = web.Application(middlewares=[boundary], client_max_size=65536) + + async def api_info(request): + service.check_instance(request.query.get("instance_id")) + return web.json_response(service.info()) + + async def api_capabilities(request): + service.check_instance(request.query.get("instance_id")) + return web.json_response(service.capabilities()) + + async def api_experience(request): + service.check_instance(request.query.get("instance_id")) + return web.json_response(service.experience()) + + async def api_state(request): + service.check_instance(request.query.get("instance_id")) + return web.json_response(service.api_state()) + + async def api_sessions(request): + service.check_instance(request.query.get("instance_id")) + return web.json_response({"ok": True, "instance_id": service.instance_id, + "sessions": [service.api_state()["state"]] if service.snapshot else []}) + + async def api_submit(request): + body = await request.json() + if not isinstance(body, dict): + raise ControlError("invalid_request", "JSON object required", 400) + if request.path.endswith("/stop") and body.get("capability") != "control.stop": + raise ControlError("invalid_parameter", "Stop endpoint requires control.stop", 400) + return web.json_response({"ok": True, "command": await service.submit_public(body)}) + + async def command(request): + service.check_instance(request.query.get("instance_id")) + job = service.find_job(request.match_info["id"]) + timeout = finite(float(request.query.get("timeout", "0")), "timeout", 0, 30) + if timeout and job["status"] not in TERMINAL: + async with service.revision: + try: + await asyncio.wait_for(service.revision.wait_for(lambda: job["status"] in TERMINAL), timeout) + except TimeoutError: + pass + return web.json_response({"ok": True, "pending": job["status"] not in TERMINAL, + "command": service.api_job(job)}) + + async def runtime(request): + if request.headers.get("Origin") not in service.origins: + raise ControlError("forbidden_origin", "Explicit browser Origin required", 403) + ws = web.WebSocketResponse(max_msg_size=65536, heartbeat=10) + await ws.prepare(request) + owns_connection = False + descriptor_seen = False + try: + hello = await asyncio.wait_for(ws.receive_json(), 5) + if (not isinstance(hello, dict) or hello.get("v") != 1 or hello.get("type") != "hello" + or not isinstance(hello.get("token"), str) + or not secrets.compare_digest(hello["token"], service.token)): + await ws.close(code=1008, message=b"Authentication failed") + return ws + if service.socket is not None: + await ws.close(code=1008, message=b"A browser already owns this instance") + return ws + service.socket, owns_connection = ws, True + # The negotiation bit asks the page for its project descriptor; the + # profile round-trips back in descriptor_ack (or immediately here on + # reconnect, when a descriptor from an earlier pairing is retained). + await ws.send_json({"v": 1, "type": "welcome", + "profile": service.adapter.profile if service.adapter else None, + "descriptor": {"version": DESCRIPTOR_VERSION}}) + async for frame in ws: + if frame.type == WSMsgType.TEXT: + message = json.loads(frame.data) + if not isinstance(message, dict): + raise ProtocolError("invalid_message", "JSON object required") + if message.get("type") == "descriptor": + if descriptor_seen: + raise ProtocolError("invalid_message", + "A descriptor was already uploaded on this connection") + descriptor_seen = True + await service.accept_message(message) + elif frame.type in {WSMsgType.ERROR, WSMsgType.BINARY}: + break + except (TimeoutError, ProtocolError, ControlError, ValueError, TypeError) as error: + await ws.close(code=1008, message=str(error).encode()[:120]) + finally: + if owns_connection: + await service.disconnected() + return ws + + async def lifecycle(app): + service.monitor = asyncio.create_task(service.monitor_jobs()) + yield + service.monitor.cancel() + try: + await service.monitor + except asyncio.CancelledError: + pass + if service.socket is not None: + await service.socket.close(code=1001, message=b"Server stopping") + + app.cleanup_ctx.append(lifecycle) + app.router.add_get("/runtime", runtime) + app.router.add_get("/control/v1/info", api_info) + app.router.add_get("/control/v1/capabilities", api_capabilities) + app.router.add_get("/control/v1/experience", api_experience) + app.router.add_get("/control/v1/state", api_state) + app.router.add_get("/control/v1/sessions", api_sessions) + app.router.add_post("/control/v1/commands", api_submit) + app.router.add_post("/control/v1/stop", api_submit) + app.router.add_get("/control/v1/commands/{id}", command) + return app + + +def serve(args) -> dict: + """Run the generic middleware; blocks until shutdown. Returns a summary.""" + if not 1024 <= args.port <= 65535: + raise ControlError("invalid_port", "Port must be 1024..65535") + if not args.origin: + raise ControlError("invalid_parameter", + "--origin is required: allowlist the BotLab page origin for browser pairing") + if args.public_endpoint and not args.public_endpoint.startswith("https://"): + raise ControlError("invalid_parameter", "--public-endpoint must be an HTTPS origin") + service = GenericControlService(secrets.token_urlsafe(32), set(args.origin), + project=args.project, + public_hosts=set(args.public_host or [])) + app = create_app(service) + directory = discovery_runtime_dir() / service.project_key + private_directory(discovery_runtime_dir(), create=True) + private_directory(directory, create=True) + endpoint = args.public_endpoint or f"http://127.0.0.1:{args.port}" + info = {"schema": 1, "api_version": API_VERSION, "protocol": PROTOCOL, + "project": str(service.project), "project_key": service.project_key, + "instance_id": service.instance_id, + "endpoint": endpoint, + "websocket": "ws" + endpoint[4:] + "/runtime", + "token": service.token, "pid": os.getpid()} + descriptor_path = directory / f"{service.instance_id}.json" + created = False + try: + fd = os.open(descriptor_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + created = True + with os.fdopen(fd, "w") as stream: + json.dump(info, stream) + print(f"BotLab control: {info['endpoint']}\nInstance: {service.instance_id}", + file=sys.stderr, flush=True) + if args.public_endpoint: + # Operator channel: the remote user enrolls with this origin plus + # the token, shared out of band (revealed only with --print-token). + print(f"Remote enrollment: botlab control enroll --endpoint {info['endpoint']}", + file=sys.stderr, flush=True) + if args.print_token: + print(f"Token: {service.token}", file=sys.stderr, flush=True) + web.run_app(app, host=args.host, port=args.port, print=None, access_log=None) + finally: + if created: + descriptor_path.unlink(missing_ok=True) + return {"ok": True, "service": "stopped", "instance_id": service.instance_id} diff --git a/src/botlab/hil/__init__.py b/src/botlab/hil/__init__.py new file mode 100644 index 0000000..e1f099f --- /dev/null +++ b/src/botlab/hil/__init__.py @@ -0,0 +1,5 @@ +"""Generic hardware-in-the-loop connection and inference services.""" +from .host import HilHost +from .protocol import HilError + +__all__ = ["HilError", "HilHost"] diff --git a/src/botlab/hil/_onnx.py b/src/botlab/hil/_onnx.py new file mode 100644 index 0000000..d16d61c --- /dev/null +++ b/src/botlab/hil/_onnx.py @@ -0,0 +1,67 @@ +"""Reject external tensor files without requiring the ONNX Python compiler. + +This is a narrow wire-format traversal, not a model parser or validator. ORT +validates model semantics afterwards. Field IDs follow the public ONNX schema: +https://github.com/onnx/onnx/blob/main/onnx/onnx.proto +""" +from .protocol import HilError + +# Only messages that can contain TensorProto need recursive inspection. +_CHILDREN = { + "model": {7: "graph", 20: "training", 25: "function"}, + "training": {1: "graph", 2: "graph"}, + "function": {7: "node", 11: "attribute"}, + "graph": {1: "node", 5: "tensor", 15: "sparse"}, + "node": {5: "attribute"}, + "attribute": {5: "tensor", 6: "graph", 10: "tensor", 11: "graph", + 22: "sparse", 23: "sparse"}, + "sparse": {1: "tensor", 2: "tensor"}, + "tensor": {}, +} + + +def _varint(data, position): + value = 0 + for shift in range(0, 70, 7): + if position >= len(data): + break + byte = data[position] + position += 1 + value |= (byte & 127) << shift + if byte < 128: + return value, position + raise HilError("Invalid ONNX protobuf encoding") + + +def require_embedded_tensors(content: bytes): + def visit(data, kind, depth): + if depth > 64: + raise HilError("ONNX nesting exceeds the supported limit") + position = 0 + while position < len(data): + key, position = _varint(data, position) + field, wire = key >> 3, key & 7 + if not field: + raise HilError("Invalid ONNX protobuf field") + if kind == "tensor" and field == 13: + raise HilError("External ONNX tensor files are not supported; embed all weights") + if wire == 0: + value, position = _varint(data, position) + if kind == "tensor" and field == 14 and value != 0: + raise HilError("External ONNX tensor files are not supported; embed all weights") + elif wire == 2: + length, position = _varint(data, position) + end = position + length + if end > len(data): + raise HilError("Truncated ONNX protobuf field") + child = _CHILDREN[kind].get(field) + if child: + visit(data[position:end], child, depth + 1) + position = end + elif wire in (1, 5): + position += 8 if wire == 1 else 4 + if position > len(data): + raise HilError("Truncated ONNX protobuf field") + else: + raise HilError("Unsupported ONNX protobuf wire type") + visit(memoryview(content), "model", 0) diff --git a/src/botlab/hil/agent.py b/src/botlab/hil/agent.py new file mode 100644 index 0000000..bd69ffa --- /dev/null +++ b/src/botlab/hil/agent.py @@ -0,0 +1,349 @@ +"""Generic target agent for a trusted, isolated development network. + +This MVP transport has no network authentication or encryption. Bind to the +explicit development interface; do not expose the agent to untrusted networks. +""" +from __future__ import annotations + +import argparse +import base64 +import getpass +import hashlib +import os +import platform +import socket +import socketserver +import sys +import tempfile +import threading +import time +import uuid +from pathlib import Path + +from .artifacts import ProjectStore +from .protocol import MAX_MODEL_BYTES, SHA256, HilError, ports, receive, send, tensors + + +class AgentState: + def __init__(self, models_dir: Path, backend, *, root: Path | None = None): + self.models_dir = Path(models_dir).expanduser().resolve() + self.models_dir.mkdir(parents=True, exist_ok=True) + self.backend = backend + self.lock = threading.RLock() + self.owner = None + self.session = None + self.sequence = -1 + self.models = {} + self.calls = 0 + self.store = ProjectStore(root if root is not None else self.models_dir.parent / "botlab") + self.loaded = None + self.project_owner = None + + def device(self): + return {"hostname": platform.node(), "machine": platform.machine(), + "protocol": 1, "calls": self.calls, **self.backend.describe(), + "capabilities": {"projects": 1, "deploymentVersions": 1}, "loaded": self.loaded, + "state": "running" if self.session else "loaded" if self.loaded else "idle"} + + def _release(self): + session = self.session + models, self.models = self.models, {} + self.owner = self.session = None + self.sequence = -1 + if session and self.loaded: + try: + self.store.log(self.loaded["project_id"], session, "stopped", calls=self.calls) + except OSError: + pass # Disk-full logs must never prevent releasing the runtime. + if callable(getattr(self.backend, "reset", None)): + self.backend.reset() + return + for handle, _, _ in models.values(): + try: + self.backend.close(handle) + except Exception: + pass + + def disconnect(self, owner): + with self.lock: + if self.owner is owner: + self._release() + if self.project_owner is owner: + self.loaded = self.project_owner = None + + def dispatch(self, owner, request): + if request.get("op") == "infer": + return self._infer(owner, request) + with self.lock: + op = request.get("op") + if op == "hello": + return {"device": self.device()} + if op == "projects.list": + projects = self.store.list() + for project in projects: + if self.loaded and project["id"] == self.loaded["project_id"]: + project["state"] = "running" if self.session else "loaded" + return {"projects": projects, "loaded": self.loaded} + if isinstance(op, str) and op.startswith("projects."): + return self._project_operation(owner, request) + if op == "start": + if self.owner is not None: + raise HilError("Target already has an active session; stop it before starting another") + if self.project_owner is not None and self.project_owner is not owner: + raise HilError("Target project is loaded by another connection") + self.owner, self.session = owner, uuid.uuid4().hex + self.sequence = -1 + if self.loaded: + self.store.log(self.loaded["project_id"], self.session, "started", + build_id=self.loaded["build_id"]) + return {"session": self.session, "device": self.device()} + if self.owner is not owner or not self.session or request.get("session") != self.session: + raise HilError("Stale or inactive HIL session") + if op == "stop": + self._release() + return {"stopped": True} + if op == "load": + return self._load(request) + raise HilError("Unknown HIL operation") + + def _project_operation(self, owner, request): + if self.owner is not None: + raise HilError("Stop the active session before managing target projects") + op = request["op"] + # Independent inventory connections may clean up unused deployments. + # Only removal gets this exception; selection and execution keep their + # existing ownership lease. The loaded release is protected below. + if self.project_owner is not None and self.project_owner is not owner and op != "projects.remove": + raise HilError("Target project is loaded by another connection") + if op == "projects.deploy": + backend = self.backend.describe().get("backend") + manifest = request.get("artifact", {}).get("manifest", {}) + if manifest.get("backend") != backend: + raise HilError(f"Deployment backend does not match target backend {backend}") + return self.store.deploy(request.get("artifact")) + if op == "projects.load": + manifest = self.store.manifest(request.get("project_id"), request.get("build_id"), + verify_files=True) + if manifest["backend"] != self.backend.describe().get("backend"): + raise HilError("Deployment backend does not match target backend") + selection = {"project_id": manifest["project"]["id"], "build_id": manifest["build_id"], + "name": manifest["project"]["name"], "models": manifest["models"], + "project_digest": manifest["project_digest"]} + self.store.select(selection["project_id"], selection["build_id"]) + self.loaded, self.project_owner = selection, owner + return {"loaded": self.loaded} + if op == "projects.unload": + self.loaded = self.project_owner = None + return {"loaded": None} + if op == "projects.remove": + project_id, build_id = request.get("project_id"), request.get("build_id") + if self.loaded and self.loaded["project_id"] == project_id and ( + build_id is None or self.loaded["build_id"] == build_id + ): + raise HilError("Unload the project before deleting its loaded release") + return self.store.remove(project_id, build_id) + raise HilError("Unknown project operation") + + def _infer(self, owner, request): + with self.lock: + if self.owner is not owner or not self.session or request.get("session") != self.session: + raise HilError("Stale or inactive HIL session") + seq = request.get("seq") + if type(seq) is not int or seq < 0 or seq <= self.sequence: + raise HilError("Inference sequence must increase within a session") + model = request.get("model") + if model not in self.models: + raise HilError("Model is not loaded in this session") + handle, inputs, outputs = self.models[model] + feed = tensors(request.get("inputs"), inputs) + self.sequence = seq + session = self.session + # Native backend execution must never hold the management lock. The CLI + # backend is a killable process with a bounded inference deadline. + started = time.perf_counter() + result = self.backend.infer(handle, feed) + elapsed = (time.perf_counter() - started) * 1000 + with self.lock: + if self.session != session or self.owner is not owner: + raise HilError("Inference finished after its session was stopped") + result = tensors(result, outputs) + self.calls += 1 + return {"session": session, "seq": seq, "outputs": result, + "inference_ms": elapsed, "calls": self.calls} + + def _load(self, request): + digest, encoded = request.get("sha256"), request.get("data") + if not isinstance(digest, str) or not SHA256.fullmatch(digest): + raise HilError("Model identity must be a SHA-256 digest") + if digest in self.models: + _, inputs, outputs = self.models[digest] + return {"sha256": digest, "inputs": inputs, "outputs": outputs} + if len(self.models) >= 32: + raise HilError("A session may load at most 32 models") + if self.loaded: + entry = next((item for item in self.loaded["models"] if item["sha256"] == digest), None) + if entry is None: + raise HilError("Model is not part of the loaded deployment; build and deploy this project again") + self.store.manifest(self.loaded["project_id"], self.loaded["build_id"], verify_files=True) + path = self.store.release_path(self.loaded["project_id"], self.loaded["build_id"]) / entry["path"] + return self._load_path(digest, path) + if not isinstance(encoded, str) or len(encoded) > ((MAX_MODEL_BYTES + 2) // 3) * 4: + raise HilError("Model exceeds the 32 MiB limit") + try: + content = base64.b64decode(encoded, validate=True) + except ValueError as exc: + raise HilError("Invalid model base64") from exc + if not content or len(content) > MAX_MODEL_BYTES or hashlib.sha256(content).hexdigest() != digest: + raise HilError("Model SHA-256 verification failed") + path = self.models_dir / (digest + ".onnx") + if path.is_symlink(): + raise HilError("Model cache path must not be a symlink") + if not path.exists() or hashlib.sha256(path.read_bytes()).hexdigest() != digest: + usage = sum(p.stat().st_size for p in self.models_dir.glob("*.onnx") if not p.is_symlink()) + if usage + len(content) > 256 * 1024 * 1024: + raise HilError("Model cache exceeds 256 MiB; remove unused models on the target") + fd, temporary = tempfile.mkstemp(dir=self.models_dir, prefix=".upload-") + try: + with os.fdopen(fd, "wb") as stream: + stream.write(content) + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) + return self._load_path(digest, path) + + def _load_path(self, digest, path): + handle, inputs, outputs = self.backend.load(path) + try: + ports(inputs) + ports(outputs) + except HilError: + self.backend.close(handle) + raise + self.models[digest] = (handle, inputs, outputs) + return {"sha256": digest, "inputs": inputs, "outputs": outputs} + + +class AgentServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + def __init__(self, address, state: AgentState): + self.state = state + super().__init__(address, AgentHandler) + + +def serve_stream(state: AgentState, stream) -> None: + """Run the dispatch loop over any socket-shaped stream (TCP or gateway).""" + stream.settimeout(120) + while True: + try: + message = receive(stream) + except TimeoutError: + # Idle keep-alive: a coordinator may compile for minutes before its + # first frame. Transport pings own connection liveness, so a read + # timeout is not a lease failure — keep waiting for traffic. + continue + try: + result = state.dispatch(stream, message) + send(stream, {"ok": True, **result}) + except Exception as exc: + # Selection validates its candidate before changing ownership. + # A stale or invalid candidate can therefore be rejected + # without unloading the previously selected project. All + # other execution/contract failures terminate the lease. + preserved = message.get("op") == "projects.load" and isinstance(exc, HilError) + send(stream, {"ok": False, "error": str(exc), + **({"lease_preserved": True} if preserved else {})}) + if not preserved: + return + + +class AgentHandler(socketserver.BaseRequestHandler): + def handle(self): + self.request.settimeout(120) + self.request.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + try: + # serve_stream keys the lease on the stream itself. + serve_stream(self.server.state, self.request) + except (OSError, EOFError, ValueError): + pass + finally: + self.server.state.disconnect(self.request) + + +def _connect(options) -> int: + """Resident outbound mode: dial the gateway and serve frames over WebSocket.""" + from .worker import WorkerBackend + from .wsstream import connect_gateway + + root = options.root.expanduser() + state = AgentState(options.models_dir or root / "cache" / "models", + WorkerBackend(options.backend, inference_timeout=options.inference_timeout), root=root) + token = os.environ.get("BOTLAB_HIL_TOKEN") or getpass.getpass("Gateway token: ") + print(f"BotLab HIL agent: dialing out to {options.gateway} as device {options.device}", flush=True) + while True: + try: + stream = connect_gateway(options.gateway, "agent", options.device, token) + except HilError as exc: + print(f"Gateway connection failed: {exc}", file=sys.stderr, flush=True) + if options.retry_interval <= 0: + return 1 + else: + print(f"BotLab HIL agent: registered as {options.device} ({options.backend})", flush=True) + try: + serve_stream(state, stream) + except (OSError, EOFError, ValueError): + print("BotLab HIL agent: gateway link dropped", file=sys.stderr, flush=True) + finally: + state.disconnect(stream) + stream.close() + if options.retry_interval <= 0: + return 0 + time.sleep(options.retry_interval) + + +def main(argv=None): + argv = list(sys.argv[1:] if argv is None else argv) + if argv[:1] == ["connect"]: + parser = argparse.ArgumentParser( + description="Dial out to a BotLab HIL gateway and serve the device") + parser.add_argument("--gateway", required=True, help="wss:// gateway origin (ws:// loopback only)") + parser.add_argument("--device", required=True, help="device ID to register as") + parser.add_argument("--root", type=Path, default=Path("~/botlab"), help="Agent and project storage root") + parser.add_argument("--models-dir", type=Path, help="Legacy model cache (default: ROOT/cache/models)") + parser.add_argument("--backend", default="onnxruntime", help="onnxruntime or module:factory") + parser.add_argument("--inference-timeout", type=float, default=3.0, + help="Backend inference deadline in seconds") + parser.add_argument("--retry-interval", type=float, default=5.0, + help="seconds between gateway reconnection attempts; 0 exits on loss") + options = parser.parse_args(argv[1:]) + return _connect(options) + if argv[:1] == ["serve"]: + argv.pop(0) + parser = argparse.ArgumentParser(description="Serve a BotLab HIL target on a trusted network") + parser.add_argument("--host", required=True, help="Explicit development-interface address") + parser.add_argument("--port", type=int, default=50051) + parser.add_argument("--root", type=Path, default=Path("~/botlab"), help="Agent and project storage root") + parser.add_argument("--models-dir", type=Path, help="Legacy model cache (default: ROOT/cache/models)") + parser.add_argument("--backend", default="onnxruntime", help="onnxruntime or module:factory") + parser.add_argument("--inference-timeout", type=float, default=3.0, + help="Backend inference deadline in seconds") + options = parser.parse_args(argv) + from .worker import WorkerBackend + root = options.root.expanduser() + state = AgentState(options.models_dir or root / "cache" / "models", + WorkerBackend(options.backend, inference_timeout=options.inference_timeout), root=root) + with AgentServer((options.host, options.port), state) as server: + print(f"BotLab HIL agent: {options.host}:{server.server_address[1]} ({options.backend})", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + with state.lock: + state._release() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/botlab/hil/artifacts.py b/src/botlab/hil/artifacts.py new file mode 100644 index 0000000..bfa44ec --- /dev/null +++ b/src/botlab/hil/artifacts.py @@ -0,0 +1,402 @@ +"""Deterministic model-service builds and atomic, persistent target deployments. + +The HIL v1 artifact contains declared, embedded-weight ONNX models only. The +simulation and Python controller continue to execute on the host. +""" +from __future__ import annotations + +import base64 +import hashlib +import json +import math +import os +import re +import shutil +import sys +import tempfile +import time +import uuid +from pathlib import Path + +from ._onnx import require_embedded_tensors +from .protocol import MAX_MODEL_BYTES, SHA256, HilError + +PROJECT_ID = re.compile(r"^[A-Za-z0-9_-]{1,80}$") +MAX_ARTIFACT_BYTES = MAX_MODEL_BYTES +MAX_PROJECT_STORAGE_BYTES = 512 * 1024 * 1024 + + +def _canonical(value): + return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), + allow_nan=False).encode("utf-8") + + +def build_digest(manifest: dict) -> str: + return hashlib.sha256(_canonical({k: v for k, v in manifest.items() if k != "build_id"})).hexdigest() + + +def _identity(value): + if not isinstance(value, str) or not PROJECT_ID.fullmatch(value): + raise HilError("Invalid project ID") + return value + + +def _digest(value): + if not isinstance(value, str) or not SHA256.fullmatch(value): + raise HilError("Invalid build ID") + return value + + +def _regular(path: Path): + # Reject symlinks in every component, not just the final filename. + if any(part.is_symlink() for part in (path, *path.parents)): + raise HilError(f"BotLab paths must not contain symlinks: {path.name}") + return path + + +def _atomic_json(path: Path, value): + _regular(path) + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, name = tempfile.mkstemp(prefix=".write-", dir=path.parent) + try: + with os.fdopen(descriptor, "wb") as stream: + stream.write(_canonical(value)) + stream.flush() + os.fsync(stream.fileno()) + os.replace(name, path) + finally: + Path(name).unlink(missing_ok=True) + + +def _project_identity(directory: Path) -> str: + folder = _regular(directory / ".botlab") + folder.mkdir(exist_ok=True) + path = _regular(folder / "project.json") + try: + with path.open("x", encoding="utf-8") as stream: + json.dump({"schema": 1, "id": uuid.uuid4().hex}, stream) + except FileExistsError: + pass + try: + value = json.loads(path.read_text(encoding="utf-8")) + return _identity(value.get("id")) + except (ValueError, AttributeError) as exc: + raise HilError("Invalid .botlab/project.json; preserve or repair the project identity") from exc + + +def _check_expected_sources(expected_sources, actual_sources): + if expected_sources is None: + return + if not isinstance(expected_sources, dict) or not 1 <= len(expected_sources) <= 128: + raise HilError("Browser sources must contain 1–128 text files") + total = 0 + for name, source in expected_sources.items(): + if not isinstance(name, str) or not 0 < len(name) <= 512 or not isinstance(source, str): + raise HilError("Browser sources must map file names to text") + try: + total += len(source.encode("utf-8")) + except UnicodeEncodeError as exc: + raise HilError("Browser sources must be valid UTF-8 text") from exc + if total > 4 * 1024 * 1024: + raise HilError("Browser sources exceed the 4 MiB limit") + def normalize(files): + return {name: source.replace("\r\n", "\n").replace("\r", "\n") + for name, source in files.items()} + if normalize(expected_sources) != normalize(actual_sources): + raise HilError("Browser sources differ from the project on disk. " + "Save changes to local project files and reload before continuing.") + + +def _load_declarations(project_dir: Path, expected_sources=None): + """Read declarations without persisting bytecode or project identity.""" + from .._project import PROJECT_LOAD_LOCK, load_project + + project_dir = Path(project_dir).resolve() + # The existing declaration compiler temporarily imports project modules. + with PROJECT_LOAD_LOCK: + # Timestamp .pyc caches can reuse an old declaration after same-size + # edits within one second. Read through an empty cache namespace rather + # than deleting project files, and restore interpreter settings even on + # failed compilation. HIL declaration checks never persist bytecode. + with tempfile.TemporaryDirectory(prefix="botlab-build-bytecode-") as bytecode: + previous_prefix, previous_write = sys.pycache_prefix, sys.dont_write_bytecode + try: + sys.pycache_prefix = bytecode + sys.dont_write_bytecode = True + project = load_project(project_dir) + finally: + sys.pycache_prefix, sys.dont_write_bytecode = previous_prefix, previous_write + _check_expected_sources(expected_sources, project.files) + return project + + +def _model_content(project_dir, resource): + candidate = _regular(project_dir / resource["source"]) + if not candidate.resolve().is_relative_to(project_dir) or not candidate.is_file(): + raise HilError(f"Model {resource['id']} must be a project-local file") + size = candidate.stat().st_size + if not 0 < size <= MAX_MODEL_BYTES: + raise HilError("Each model must be nonempty and at most 32 MiB") + content = candidate.read_bytes() + require_embedded_tensors(content) + return content + + +def validate_loaded_project(project_dir: Path, loaded: dict, expected_sources: dict) -> None: + """Check local execution against a selection without building or selecting it.""" + project_dir = Path(project_dir).resolve() + try: + identity = json.loads(_regular(project_dir / ".botlab" / "project.json").read_text()) + if not isinstance(identity, dict) or _identity(identity.get("id")) != loaded["project_id"]: + raise HilError("The local project identity does not match the connected project") + project = _load_declarations(project_dir, expected_sources) + if project.project_digest != loaded["project_digest"]: + raise HilError("The local sources do not match the connected project version") + models = [] + for resource in project.manifest["resources"]: + if resource["kind"] != "model": + continue + content = _model_content(project_dir, resource) + digest = hashlib.sha256(content).hexdigest() + models.append({"id": resource["id"], "sha256": digest, + "path": f"models/{digest}.onnx", "size": len(content), + "config": resource.get("config", {})}) + if sorted(models, key=lambda item: item["id"]) != loaded["models"]: + raise HilError("The local model files do not match the connected project version") + except Exception as exc: + raise HilError("Open the matching local project and sources for the connected version " + "before running it. " + str(exc)) from exc + + +def build_project(project_dir: Path, expected_sources: dict | None = None) -> dict: + """Compile declarations and package models; never package arbitrary source files.""" + project_dir = Path(project_dir).resolve() + project = _load_declarations(project_dir, expected_sources) + identity = _project_identity(project_dir) + models, files = [], {} + total = 0 + for resource in project.manifest["resources"]: + if resource["kind"] != "model": + continue + content = _model_content(project_dir, resource) + digest = hashlib.sha256(content).hexdigest() + path = f"models/{digest}.onnx" + if path not in files: + total += len(content) + if total > MAX_ARTIFACT_BYTES: + raise HilError("HIL v1 deployment exceeds the 32 MiB artifact limit") + files[path] = base64.b64encode(content).decode("ascii") + models.append({"id": resource["id"], "sha256": digest, "path": path, "size": len(content), + "config": resource.get("config", {})}) + manifest = {"schema": 1, "project": {"id": identity, "name": project.manifest["project"]["name"]}, + "execution_kind": "model-service", "backend": "onnxruntime", + "project_digest": project.project_digest, + "control_hz": project.manifest["runtime"]["controlHz"], + "physics_hz": project.manifest["runtime"]["physicsHz"], + "models": sorted(models, key=lambda item: item["id"])} + manifest["build_id"] = build_digest(manifest) + artifact = {"manifest": manifest, "files": files} + validate_artifact(artifact) + return artifact + + +def validate_manifest(value): + if not isinstance(value, dict) or set(value) != { + "schema", "project", "execution_kind", "backend", "models", "build_id", + "project_digest", "control_hz", "physics_hz" + }: + raise HilError("Invalid deployment manifest") + if value["schema"] != 1 or type(value["schema"]) is not int: + raise HilError("Unsupported deployment schema") + if value["execution_kind"] != "model-service" or value["backend"] != "onnxruntime": + raise HilError("This deployment format supports model-service / onnxruntime only") + _digest(value["project_digest"]) + for field in ("control_hz", "physics_hz"): + number = value[field] + if type(number) not in (int, float) or not math.isfinite(number) or number <= 0: + raise HilError("Deployment frequencies must be finite positive numbers") + project = value["project"] + if not isinstance(project, dict) or set(project) != {"id", "name"}: + raise HilError("Invalid deployment project") + _identity(project["id"]) + if not isinstance(project["name"], str) or not 0 < len(project["name"]) <= 256: + raise HilError("Project name must contain 1–256 characters") + models = value["models"] + if not isinstance(models, list) or not 1 <= len(models) <= 32: + raise HilError("A model-service deployment requires 1–32 declared models") + identifiers = set() + paths = {} + for model in models: + if not isinstance(model, dict) or set(model) != {"id", "sha256", "path", "size", "config"}: + raise HilError("Invalid deployment model") + if not isinstance(model["config"], dict) or len(_canonical(model["config"])) > 16384: + raise HilError("Invalid or oversized model interface configuration") + identity = model["id"] + if not isinstance(identity, str) or not 0 < len(identity) <= 1024 or identity in identifiers: + raise HilError("Model resource IDs must be nonempty and unique") + identifiers.add(identity) + digest = _digest(model["sha256"]) + if model["path"] != f"models/{digest}.onnx": + raise HilError("Unsafe deployment model path") + size = model["size"] + if type(size) is not int or not 0 < size <= MAX_MODEL_BYTES: + raise HilError("Invalid deployment model size") + if model["path"] in paths and paths[model["path"]] != size: + raise HilError("Conflicting deployment model sizes") + paths[model["path"]] = size + if sum(paths.values()) > MAX_ARTIFACT_BYTES: + raise HilError("HIL v1 deployment exceeds the 32 MiB artifact limit") + if _digest(value["build_id"]) != build_digest(value): + raise HilError("Deployment build SHA-256 verification failed") + return value + + +def validate_artifact(artifact): + if not isinstance(artifact, dict) or set(artifact) != {"manifest", "files"}: + raise HilError("Deployment requires a manifest and files") + manifest = validate_manifest(artifact["manifest"]) + files = artifact["files"] + specs = {item["path"]: item for item in manifest["models"]} + if not isinstance(files, dict) or set(files) != set(specs): + raise HilError("Deployment files must exactly match the declared models") + decoded = {} + for path, item in specs.items(): + encoded = files[path] + if not isinstance(encoded, str) or len(encoded) > ((item["size"] + 2) // 3) * 4: + raise HilError("Deployment model size exceeds manifest") + try: + content = base64.b64decode(encoded, validate=True) + except ValueError as exc: + raise HilError("Invalid deployment base64") from exc + if len(content) != item["size"] or hashlib.sha256(content).hexdigest() != item["sha256"]: + raise HilError("Deployment model SHA-256 verification failed") + require_embedded_tensors(content) + decoded[path] = content + return manifest, decoded + + +class ProjectStore: + """Files owned by this agent, with immutable releases and explicit selection.""" + + def __init__(self, root: Path): + self.root = _regular(Path(root).expanduser().absolute()) + self.projects = _regular(self.root / "projects") + self.staging = _regular(self.root / "staging") + self.projects.mkdir(parents=True, exist_ok=True) + self.staging.mkdir(parents=True, exist_ok=True) + + def project_path(self, project_id): + return _regular(self.projects / _identity(project_id)) + + def release_path(self, project_id, build_id): + return _regular(self.project_path(project_id) / "releases" / _digest(build_id)) + + def current(self, project_id): + path = _regular(self.project_path(project_id) / "current") + if not path.exists(): + raise HilError("Project has no deployed release") + return _digest(json.loads(path.read_text())) + + def manifest(self, project_id, build_id=None, *, verify_files=False): + build_id = build_id or self.current(project_id) + release = self.release_path(project_id, build_id) + path = _regular(release / "manifest.json") + if not path.is_file(): + raise HilError("Project release is not deployed") + manifest = validate_manifest(json.loads(path.read_text())) + if manifest["project"]["id"] != project_id or manifest["build_id"] != build_id: + raise HilError("Deployed manifest does not match its release directory") + if verify_files: + for model in manifest["models"]: + item = _regular(release / model["path"]) + if not item.is_file() or item.stat().st_size != model["size"]: + raise HilError("Deployed model is missing or has changed; redeploy the project") + content = item.read_bytes() + if hashlib.sha256(content).hexdigest() != model["sha256"]: + raise HilError("Deployed model SHA-256 verification failed") + require_embedded_tensors(content) + return manifest + + def select(self, project_id, build_id): + self.manifest(project_id, build_id, verify_files=True) + _atomic_json(self.project_path(project_id) / "current", build_id) + + def deploy(self, artifact): + manifest, files = validate_artifact(artifact) + project_id, build_id = manifest["project"]["id"], manifest["build_id"] + release = self.release_path(project_id, build_id) + if release.exists(): + self.manifest(project_id, build_id, verify_files=True) + # A process can stop after the immutable directory rename and + # before selecting the initial release. Retrying repairs metadata. + project = self.project_path(project_id) + if not _regular(project / "project.json").exists(): + _atomic_json(project / "project.json", manifest["project"]) + if not _regular(project / "current").exists(): + _atomic_json(project / "current", build_id) + return {"project": self.summary(project_id), "already_deployed": True} + usage = sum(p.stat().st_size for p in self.projects.rglob("*") + if p.is_file() and not p.is_symlink()) + if usage + sum(map(len, files.values())) > MAX_PROJECT_STORAGE_BYTES: + raise HilError("Project storage exceeds 512 MiB; remove unused releases") + staging = Path(tempfile.mkdtemp(prefix="deploy-", dir=self.staging)) + try: + for path, content in files.items(): + destination = staging / path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(content) + _atomic_json(staging / "manifest.json", manifest) + _atomic_json(staging / ".deployment.json", {"created_at": time.time()}) + release.parent.mkdir(parents=True, exist_ok=True) + os.replace(staging, release) + _atomic_json(self.project_path(project_id) / "project.json", manifest["project"]) + _atomic_json(self.project_path(project_id) / "current", build_id) + finally: + if staging.exists(): + shutil.rmtree(staging) + return {"project": self.summary(project_id), "already_deployed": False} + + def summary(self, project_id): + folder = self.project_path(project_id) + releases = [] + for release in sorted(_regular(folder / "releases").iterdir()): + if not SHA256.fullmatch(release.name): + continue + manifest = self.manifest(project_id, release.name) + deployed = _regular(release / ".deployment.json") + releases.append({"build_id": release.name, "models": manifest["models"], + "created_at": json.loads(deployed.read_text())["created_at"]}) + releases.sort(key=lambda item: item["created_at"], reverse=True) + current = self.current(project_id) + manifest = self.manifest(project_id, current) + return {"id": project_id, "name": manifest["project"]["name"], + "current_build_id": current, "releases": releases, "state": "deployed"} + + def list(self): + return [self.summary(folder.name) for folder in sorted(self.projects.iterdir()) + if PROJECT_ID.fullmatch(folder.name) and folder.is_dir()] + + def remove(self, project_id, build_id=None): + folder = self.project_path(project_id) + if not folder.is_dir(): + raise HilError("Project is not deployed") + if build_id is None: + shutil.rmtree(folder) + else: + self.manifest(project_id, build_id) + current = self.current(project_id) + releases = [item for item in self.summary(project_id)["releases"] + if item["build_id"] != build_id] + if not releases: + shutil.rmtree(folder) + else: + if current == build_id: + _atomic_json(folder / "current", releases[0]["build_id"]) + shutil.rmtree(self.release_path(project_id, build_id)) + return {"removed": True} + + def log(self, project_id, session, event, **fields): + path = _regular(self.project_path(project_id) / "runs" / f"{_identity(session)}.jsonl") + path.parent.mkdir(exist_ok=True) + with path.open("ab") as stream: + stream.write(_canonical({"time": time.time(), "event": event, **fields}) + b"\n") diff --git a/src/botlab/hil/backends.py b/src/botlab/hil/backends.py new file mode 100644 index 0000000..40fc131 --- /dev/null +++ b/src/botlab/hil/backends.py @@ -0,0 +1,65 @@ +"""Execution plugins. A factory returns an object implementing describe/load/infer/close. + +load(path) returns (handle, inputs, outputs); infer(handle, named_tensors) +returns named tensors. Only explicit --backend module:factory imports plugin code. +""" +from __future__ import annotations + +import importlib +from pathlib import Path + +from ._onnx import require_embedded_tensors +from .protocol import HilError + + +class OnnxRuntimeBackend: + def __init__(self): + try: + import numpy as np + import onnxruntime as ort + except ImportError as exc: + raise HilError("Install numpy and onnxruntime on the target to use this backend") from exc + self.np, self.ort = np, ort + + def describe(self): + return {"backend": "onnxruntime", "provider": "CPUExecutionProvider", "dtypes": ["float32"]} + + def load(self, path: Path): + options = self.ort.SessionOptions() + options.intra_op_num_threads = 1 + options.inter_op_num_threads = 1 + content = path.read_bytes() + require_embedded_tensors(content) + session = self.ort.InferenceSession(content, sess_options=options, + providers=["CPUExecutionProvider"]) + def ports(items): + result = [] + for item in items: + if item.type != "tensor(float)": + raise HilError(f"Unsupported port {item.name}: {item.type}; HIL v1 requires float32") + result.append({"name": item.name, "dtype": "float32", "shape": list(item.shape)}) + return result + return session, ports(session.get_inputs()), ports(session.get_outputs()) + + def infer(self, handle, inputs): + feeds = {name: self.np.asarray(v["data"], dtype=self.np.float32).reshape(v["shape"]) + for name, v in inputs.items()} + values = handle.run(None, feeds) + return {meta.name: {"dtype": "float32", "shape": list(value.shape), "data": value.ravel().tolist()} + for meta, value in zip(handle.get_outputs(), values, strict=True)} + + def close(self, handle): + pass # Removing the final handle releases the ORT session. + + +def create_backend(name: str): + if name == "onnxruntime": + return OnnxRuntimeBackend() + if ":" not in name: + raise HilError("Backend must be onnxruntime or module:factory") + module, factory = name.split(":", 1) + backend = getattr(importlib.import_module(module), factory)() + for method in ("describe", "load", "infer", "close"): + if not callable(getattr(backend, method, None)): + raise HilError(f"Backend is missing {method}()") + return backend diff --git a/src/botlab/hil/devices.py b/src/botlab/hil/devices.py new file mode 100644 index 0000000..8612962 --- /dev/null +++ b/src/botlab/hil/devices.py @@ -0,0 +1,977 @@ +"""Linux target bootstrap over verified SSH; no board or project-specific runtime. + +The local machine downloads Linux wheels, so targets need Python and SSH but do +not need pip, virtualenv, Internet access or a preinstalled BotLab service. +Passwords are transient and never enter an argv, target profile or remote file. +""" +from __future__ import annotations + +import base64 +import concurrent.futures +import hashlib +import io +import ipaddress +import json +import os +import platform +import re +import shlex +import socket +import subprocess +import sys +import tarfile +import tempfile +import time +from collections.abc import Callable +from pathlib import Path + +from .protocol import HilError, error_details, receive, send + +Progress = Callable[[dict], None] +_AGENT_PORT = 50052 +_HOST = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.:%_-]{0,252}$") +_USER = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]{0,63}\$?$") +_REQUIREMENTS = ("numpy>=1.24,<3", "onnxruntime>=1.18,<2", "PyYAML>=6.0,<7") + + +def _run(argv, *, data=None, env=None, timeout=30, check=True): + try: + result = subprocess.run(argv, input=data, capture_output=True, + env=env, timeout=timeout) + except (OSError, subprocess.TimeoutExpired) as exc: + raise HilError(f"Device command failed: {type(exc).__name__}") from exc + if check and result.returncode: + # Never include argv: it may contain a command being sent to the target. + detail = result.stderr.decode("utf-8", "replace").strip()[-1800:] + raise HilError(detail or f"Device command exited with status {result.returncode}") + return result + + +def _ssh_config(target): + config = target.get("ssh", {}) + if not isinstance(config, dict): + raise HilError("SSH settings must be an object") + host, user = config.get("host", ""), config.get("user", "") + port = config.get("port", 22) + if not isinstance(host, str) or not _HOST.fullmatch(host): + raise HilError("A valid SSH hostname or IP address is required") + if not isinstance(user, str) or not _USER.fullmatch(user): + raise HilError("A valid SSH username is required") + if type(port) is not int or not 1 <= port <= 65535: + raise HilError("SSH port must be between 1 and 65535") + root = target.get("root", "~/botlab") + if (not isinstance(root, str) or len(root) > 512 or "\x00" in root + or "\n" in root or not (root.startswith("~/") or root.startswith("/")) + or root.rstrip("/") in ("", "~", "/home", "/usr", "/etc", "/opt", "/var") + or ".." in Path(root).parts): + raise HilError("Target root must be an absolute or ~/ path to a dedicated BotLab directory") + identity = config.get("identity_file") + if identity is not None and (not isinstance(identity, str) or not identity or "\x00" in identity): + raise HilError("SSH identity_file must be a file path") + return host, user, port, root + + +def _fingerprint(key): + try: + raw = base64.b64decode(key, validate=True) + except ValueError as exc: + raise HilError("Invalid SSH host key") from exc + return "SHA256:" + base64.b64encode(hashlib.sha256(raw).digest()).decode().rstrip("=") + + +def inspect_host_key(host: str, port: int = 22) -> dict: + """Return observed fingerprints, without trusting or writing any of them.""" + _ssh_config({"ssh": {"host": host, "user": "probe", "port": port}}) + result = _run(["ssh-keyscan", "-T", "4", "-p", str(port), "-t", "ed25519,ecdsa,rsa", host], + timeout=16, check=False) + keys = [] + for line in result.stdout.decode().splitlines(): + parts = line.split() + if len(parts) == 3 and not line.startswith("#"): + keys.append({"type": parts[1], "fingerprint": _fingerprint(parts[2]), "key": parts[2]}) + if not keys: + raise HilError("No SSH host key received; check the device address and SSH service") + lookup = host if port == 22 else f"[{host}]:{port}" + known = _run(["ssh-keygen", "-F", lookup], check=False) + known_keys = {parts[2] for line in known.stdout.decode().splitlines() + if len(parts := line.split()) >= 3 and not line.startswith("#")} + return {"host": host, "port": port, "known": any(k["key"] in known_keys for k in keys), + "keys": [{k: v for k, v in item.items() if k != "key"} for item in keys]} + + +class SSHTunnel: + def __init__(self, process, port: int, credentials_dir=None): + self.process = process + self.address = ("127.0.0.1", port) + self.endpoint = f"tcp://127.0.0.1:{port}" + self._credentials_dir = credentials_dir + + def close(self): + # EOF ends the remote exec helper and its PAM session. -N forwarding + # alone would not keep a non-lingering systemd user manager alive. + stream = getattr(self.process, "stdin", None) + if stream is not None and not stream.closed: + try: + stream.close() + except OSError: + pass + if self.process.poll() is None: + try: + self.process.wait(timeout=1) + except subprocess.TimeoutExpired: + self.process.terminate() + try: + self.process.wait(timeout=3) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=3) + if self._credentials_dir: + self._credentials_dir.cleanup() + self._credentials_dir = None + + def __enter__(self): + return self + + def __exit__(self, *_): + self.close() + + +class LinuxDevice: + def __init__(self, target: dict, credentials: dict | None = None, + progress: Progress | None = None): + self.host, self.user, self.port, self.root = _ssh_config(target) + self.target = dict(target) + self.credentials = dict(credentials or {}) + if set(self.credentials) - {"password", "host_key"}: + raise HilError("Unknown SSH credential field") + for value in self.credentials.values(): + if not isinstance(value, str) or len(value) > 4096 or "\x00" in value: + raise HilError("Invalid SSH credential") + self.progress = progress or (lambda _: None) + + def close(self): + self.credentials.clear() + + def _notify(self, stage, message): + self.progress({"stage": stage, "message": message}) + + def _ssh(self, directory): + argv = ["ssh", "-T", "-o", "StrictHostKeyChecking=yes", "-o", "ConnectTimeout=8", + "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3", + "-o", "LogLevel=ERROR", "-p", str(self.port)] + identity = self.target["ssh"].get("identity_file") + if identity: + argv += ["-i", str(Path(identity).expanduser()), "-o", "IdentitiesOnly=yes"] + env = os.environ.copy() + env["LC_ALL"] = "C" # Stable OpenSSH diagnostics for recoverable authentication failures. + fingerprint = self.credentials.get("host_key") or self.target["ssh"].get("host_key") + if fingerprint: + # Explicitly accepted fingerprint is compared against a fresh scan. + scan = _run(["ssh-keyscan", "-T", "4", "-p", str(self.port), + "-t", "ed25519,ecdsa,rsa", self.host], timeout=16, check=False) + accepted = [] + for line in scan.stdout.decode().splitlines(): + fields = line.split() + if len(fields) == 3 and not line.startswith("#") and _fingerprint(fields[2]) == fingerprint: + accepted.append(line) + if not accepted: + raise HilError("SSH host key changed or does not match the accepted fingerprint") + known = Path(directory) / "known_hosts" + known.write_text("\n".join(accepted) + "\n") + known.chmod(0o600) + argv += ["-o", f"UserKnownHostsFile={known}", "-o", "GlobalKnownHostsFile=/dev/null"] + password = self.credentials.get("password") + if password is not None: + secret = Path(directory) / "password" + secret.write_text(password) + secret.chmod(0o600) + askpass = Path(directory) / "askpass" + askpass.write_text("#!/bin/sh\ncase \"$1\" in *assword*) exec cat " + + shlex.quote(str(secret)) + ";; *) exit 1;; esac\n") + askpass.chmod(0o700) + env.update(SSH_ASKPASS=str(askpass), SSH_ASKPASS_REQUIRE="force", DISPLAY="botlab:0") + argv += ["-o", "NumberOfPasswordPrompts=1"] + else: + argv += ["-o", "BatchMode=yes"] + argv += [f"{self.user}@{self.host}"] + return argv, env + + def _ssh_failure(self, returncode, stderr): + detail = stderr.decode("utf-8", "replace") + # Match OpenSSH's final authentication diagnostic, not a remote Python, + # sudo or filesystem permission error. Ordinary transport failures also + # exit 255, so the status alone cannot justify asking for a password. + peer = re.escape(f"{self.user}@{self.host}") + auth_required = returncode == 255 and re.search( + rf"(?m)^{peer}: Permission denied \([A-Za-z0-9@._,-]+\)\.$", detail.strip()[-1800:] + ) is not None + if self.credentials.get("password"): + detail = detail.replace(self.credentials["password"], "[redacted]") + # Redact before trimming or truncating: either can split a credential + # and leave a fragment that later full-password replacement misses. + detail = detail.strip()[-1800:] + metadata = ({"error_code": "ssh_auth_required", "target_id": self.target.get("id"), + "password_attempted": bool(self.credentials.get("password"))} + if auth_required else {}) + return HilError(detail or f"SSH command exited with status {returncode}", **metadata) + + def _command(self, command: str, *, data=None, timeout=30): + with tempfile.TemporaryDirectory(prefix="botlab-ssh-") as directory: + argv, env = self._ssh(directory) + try: + result = _run([*argv, command], data=data, env=env, timeout=timeout, check=False) + if result.returncode: + raise self._ssh_failure(result.returncode, result.stderr) + return result.stdout + except HilError as exc: + detail = str(exc) + if self.credentials.get("password"): + detail = detail.replace(self.credentials["password"], "[redacted]") + raise HilError(detail, **error_details(exc)) from exc + + def _python(self, source: str, *, data=None, timeout=30): + return self._command("python3 -c " + shlex.quote(source), data=data, timeout=timeout) + + def inspect(self) -> dict: + self._notify("inspect", "Checking target Linux, Python, architecture and available storage") + source = _INSPECT.replace("__ROOT_JSON__", repr(self.root)) + result = json.loads(self._python(source)) + if result.get("system") != "Linux": + raise HilError("This device adapter requires Linux") + if tuple(result["python"][:2]) < (3, 10): + raise HilError("The target requires Python 3.10 or newer") + if result["machine"] not in ("aarch64", "x86_64"): + raise HilError("Built-in ONNX CPU provisioning currently supports Linux aarch64 and x86_64") + return result + + def provision(self) -> dict: + info = self.inspect() + backend = self.target.get("backend", "onnxruntime") + if backend != "onnxruntime": + raise HilError("Automatic provisioning currently supports onnxruntime; install custom backend dependencies separately") + agent_port = self.target.get("agent_port", _AGENT_PORT) + if type(agent_port) is not int or not 1 <= agent_port <= 65535: + raise HilError("Invalid target Agent port") + with tempfile.TemporaryDirectory(prefix="botlab-provision-") as directory: + folder = Path(directory) + self._notify("dependencies", "Downloading matching Linux wheels on this computer") + wheel_dir = download_wheels(info, progress=self.progress) + self._notify("package", "Packaging installed BotLab Agent and isolated dependencies") + bundle, digest = build_agent_bundle(folder, wheel_dir) + self._notify("upload", "Uploading Agent and dependencies over SSH") + params = {"root": self.root, "release": digest, "port": agent_port, "backend": backend} + source = _INSTALL.replace("__PARAMS_JSON__", repr(json.dumps(params))) + result = json.loads(self._python(source, data=bundle.read_bytes(), timeout=180)) + self._notify("start", "Starting target Agent on loopback") + source = _START.replace("__PARAMS_JSON__", repr(json.dumps(params))) + started = json.loads(self._python(source, timeout=35)) + self._notify("ready", "Target Agent is ready; project deployment can begin") + return {**result, **started, "device": info, "endpoint": f"tcp://127.0.0.1:{agent_port}"} + + def configure_network(self, interface: str, method: str, address: str | None = None, + gateway: str | None = None, netmask: str | None = None) -> dict: + """Stage a NetworkManager change; expires after 90 s unless confirmed. + + Rollback belongs to NetworkManager, so closing SSH cannot disable it. + Reconnect to the returned new_host and call confirm_network(transaction). + """ + if not isinstance(interface, str) or not re.fullmatch(r"[A-Za-z0-9_.:-]{1,32}", interface): + raise HilError("Invalid target network interface") + value, _, route = _network_values(method, address, netmask, gateway) + params = {"interface": interface, "method": method, "address": value or "", "gateway": route or ""} + source = (_NETWORK_BEGIN.replace("__PARAMS_JSON__", repr(json.dumps(params))) + .replace("__APPLY_SOURCE__", repr(_NETWORK_APPLY))) + prefix = "" if self.user == "root" else "sudo -n " + return json.loads(self._command(prefix + "python3 -c " + shlex.quote(source), timeout=30)) + + def confirm_network(self, transaction: str) -> dict: + if not isinstance(transaction, str) or not re.fullmatch(r"[a-f0-9]{32}", transaction): + raise HilError("Invalid network transaction") + source = _NETWORK_CONFIRM.replace("__TRANSACTION__", repr(transaction)) + prefix = "" if self.user == "root" else "sudo -n " + return json.loads(self._command(prefix + "python3 -c " + shlex.quote(source), timeout=40)) + + def open_tunnel(self) -> SSHTunnel: + agent_port = self.target.get("agent_port", _AGENT_PORT) + if type(agent_port) is not int or not 1 <= agent_port <= 65535: + raise HilError("Invalid target Agent port") + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + directory = tempfile.TemporaryDirectory(prefix="botlab-tunnel-") + tunnel = None + try: + argv, env = self._ssh(directory.name) + argv[-1:-1] = ["-o", "ExitOnForwardFailure=yes", "-o", "StdinNull=no", "-L", + f"127.0.0.1:{port}:127.0.0.1:{agent_port}"] + source = _CONNECT.replace("__PARAMS_JSON__", repr(json.dumps({ + "root": self.root, "port": agent_port, + }))) + # A persistent exec channel holds a genuine SSH/PAM login session. + # Its stdin stays open until SSHTunnel.close; no linger/sudo needed. + argv.append("python3 -c " + shlex.quote(source)) + ready_path = Path(directory.name) / "ready" + with ready_path.open("wb") as output: + process = subprocess.Popen(argv, stdin=subprocess.PIPE, stdout=output, + stderr=subprocess.PIPE, env=env) + tunnel = SSHTunnel(process, port, directory) + deadline = time.monotonic() + 45 + while time.monotonic() < deadline: + status = process.poll() + if status is not None: + raise self._ssh_failure(status, process.stderr.read()) + with ready_path.open("rb") as output: + ready = b"BOTLAB_TARGET_READY\n" in output.read(4096) + if not ready: + time.sleep(.1) + continue + try: + with socket.create_connection(tunnel.address, timeout=.5) as stream: + stream.settimeout(.5) + send(stream, {"op": "hello"}) + response = receive(stream) + if response.get("ok") is True and isinstance(response.get("device"), dict): + return tunnel + raise HilError("Target did not return a valid BotLab Agent hello") + except (OSError, EOFError): + time.sleep(.1) + raise HilError("Target Agent did not become ready; inspect target installation and Agent log") + except Exception: + if tunnel: + tunnel.close() + else: + directory.cleanup() + raise + + +def _platforms(info): + machine = info["machine"] + libc = info.get("libc", ["", ""]) + if libc[0] != "glibc": + raise HilError("Built-in provisioning requires glibc Linux; musl needs a target plugin") + try: + major, minor = map(int, libc[1].split(".")[:2]) + except (ValueError, IndexError) as exc: + raise HilError("Could not determine target glibc compatibility") from exc + if major != 2 or minor < 17: + raise HilError("Target glibc 2.17 or newer is required") + return [f"manylinux_2_{n}_{machine}" for n in range(min(minor, 50), 16, -1)] + [f"manylinux2014_{machine}"] + + +def download_wheels(info: dict, *, progress: Progress | None = None) -> Path: + platforms = _platforms(info) + version = ".".join(str(n) for n in info["python"][:2]) + signature = hashlib.sha256(json.dumps([version, platforms, _REQUIREMENTS]).encode()).hexdigest()[:20] + cache = Path(os.environ.get("BOTLAB_WHEEL_CACHE", "~/.cache/botlab/target-wheels")).expanduser() + destination = cache / signature + if (destination / ".complete").is_file() and list(destination.glob("*.whl")): + return destination + cache.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="download-", dir=cache) as temp: + folder = Path(temp) + downloader = [sys.executable, "-m", "pip"] + if _run([*downloader, "--version"], check=False).returncode: + environment = folder / "pip-env" + _run([sys.executable, "-m", "venv", str(environment)], timeout=60) + downloader = [str(environment / "bin" / "python"), "-m", "pip"] + wheels = folder / "wheels" + wheels.mkdir() + argv = [*downloader, "download", "--disable-pip-version-check", "--only-binary=:all:", + "--python-version", version, "--implementation", "cp", "--abi", "cp" + version.replace(".", ""), + "--dest", str(wheels)] + for item in platforms: + argv += ["--platform", item] + _run([*argv, *_REQUIREMENTS], timeout=600) + (wheels / ".complete").write_text(json.dumps({"requirements": _REQUIREMENTS, "python": version})) + try: + wheels.rename(destination) + except FileExistsError: + pass # Another device job downloaded the same environment. + return destination + + +def build_agent_bundle(folder: Path, wheels: Path) -> tuple[Path, str]: + """Package pure Python from this SDK, not the host's installed binary modules.""" + package = Path(__file__).resolve().parents[1] + payloads = {} + for path in sorted(package.rglob("*.py")): + if path.is_symlink() or "__pycache__" in path.parts: + continue + payloads["package/botlab/" + path.relative_to(package).as_posix()] = path.read_bytes() + for path in sorted(wheels.glob("*.whl")): + if path.is_symlink(): + raise HilError("Dependency wheels must not be symlinks") + payloads["wheels/" + path.name] = path.read_bytes() + if not any(name.startswith("wheels/") for name in payloads): + raise HilError("No Linux dependency wheels available") + manifest = {name: hashlib.sha256(content).hexdigest() for name, content in payloads.items()} + encoded = json.dumps(manifest, sort_keys=True).encode() + digest = hashlib.sha256(encoded).hexdigest() + bundle = folder / "agent.tar.gz" + with tarfile.open(bundle, "w:gz") as archive: + for name, content in {**payloads, "manifest.json": encoded}.items(): + meta = tarfile.TarInfo(name) + meta.size, meta.mode, meta.mtime = len(content), 0o600, 0 + archive.addfile(meta, io.BytesIO(content)) + return bundle, digest + + +_INSPECT = r''' +import json, os, platform, shutil, subprocess, sys +from pathlib import Path +root = Path(__ROOT_JSON__).expanduser() +parent = root +while not parent.exists(): + parent = parent.parent +network = [] +try: + network = json.loads(subprocess.check_output(['ip', '-j', 'address'], timeout=5)) +except Exception: + pass +identity = '' +try: + identity = Path('/etc/machine-id').read_text().strip() +except OSError: + pass +os_release = {} +try: + for line in Path('/etc/os-release').read_text().splitlines(): + if '=' in line: + key, value = line.split('=', 1) + os_release[key] = value.strip('"') +except OSError: + pass +network_manager = None +try: + if shutil.which('nmcli') and subprocess.check_output(['nmcli', '-t', '-f', 'RUNNING', 'general'], timeout=5).decode().strip() == 'running': + network_manager = 'NetworkManager' +except Exception: + pass +systemd_user_available = False +try: + if shutil.which('systemctl'): + systemd_user_available = subprocess.run(['systemctl', '--user', 'show-environment'], + capture_output=True, timeout=5).returncode == 0 +except (OSError, subprocess.TimeoutExpired): + pass +network_interfaces = [{'name': item.get('ifname'), 'active': item.get('operstate') == 'UP', + 'addresses': [{'address': a['local'], 'prefix': a['prefixlen']} for a in item.get('addr_info', []) if a.get('family') == 'inet']} + for item in network if item.get('ifname') != 'lo'] +memory = 0 +try: + memory = int(next(line.split()[1] for line in Path('/proc/meminfo').read_text().splitlines() if line.startswith('MemTotal:'))) * 1024 +except (OSError, StopIteration, ValueError): + pass +print(json.dumps({'system': platform.system(), 'machine': platform.machine(), 'hostname': platform.node(), + 'python': list(sys.version_info[:3]), 'python_executable': sys.executable, 'libc': platform.libc_ver(), + 'os': os_release, 'id': identity, 'memory_bytes': memory, 'free_bytes': shutil.disk_usage(parent).free, + 'root': str(root), 'network': network, 'network_interfaces': network_interfaces, + 'network_manager': network_manager, 'capabilities': {'provision': True, + 'network_config': 'networkmanager' if network_manager else None}, 'agent_installed': (root/'agent'/'current').exists(), + 'service_manager': 'systemd' if shutil.which('systemctl') else 'process', + 'systemd_user_available': systemd_user_available})) +''' + +_INSTALL = r''' +import hashlib, io, json, os, shutil, sys, tarfile, tempfile, zipfile +from pathlib import Path, PurePosixPath +params = json.loads(__PARAMS_JSON__) +root = Path(params['root']).expanduser() +if root.is_symlink() or any(p.is_symlink() for p in root.parents): + raise ValueError('BotLab root must not be a symlink') +root.mkdir(parents=True, exist_ok=True) +agent = root/'agent' +agent.mkdir(exist_ok=True) +releases = agent/'releases' +releases.mkdir(exist_ok=True) +release = releases/params['release'] +if any(p.is_symlink() for p in (agent, releases, release)): + raise ValueError('Agent paths must not be symlinks') +with tempfile.TemporaryDirectory(prefix='.install-', dir=agent) as temp: + stage = Path(temp) + raw = sys.stdin.buffer.read(512*1024*1024+1) + if len(raw) > 512*1024*1024: + raise ValueError('Agent bundle exceeds 512 MiB') + total = 0 + with tarfile.open(fileobj=io.BytesIO(raw), mode='r:gz') as archive: + names = set() + for item in archive: + path = PurePosixPath(item.name) + if not item.isfile() or path.is_absolute() or '..' in path.parts or item.name in names: + raise ValueError('Invalid Agent bundle member') + names.add(item.name) + total += item.size + if total > 768*1024*1024: + raise ValueError('Unpacked Agent bundle exceeds limit') + destination = stage/path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(archive.extractfile(item).read()) + encoded = (stage/'manifest.json').read_bytes() + if hashlib.sha256(encoded).hexdigest() != params['release']: + raise ValueError('Agent manifest checksum mismatch') + manifest = json.loads(encoded) + if set(manifest) != names - {'manifest.json'}: + raise ValueError('Agent bundle does not match manifest') + for name, checksum in manifest.items(): + if hashlib.sha256((stage/name).read_bytes()).hexdigest() != checksum: + raise ValueError('Agent file checksum mismatch') + deps = stage/'deps' + deps.mkdir() + unpacked = 0 + for wheel in (stage/'wheels').glob('*.whl'): + with zipfile.ZipFile(wheel) as archive: + for member in archive.infolist(): + path = PurePosixPath(member.filename) + if path.is_absolute() or '..' in path.parts or ((member.external_attr >> 16) & 0o170000) == 0o120000: + raise ValueError('Invalid dependency wheel member') + unpacked += member.file_size + if unpacked > 1024*1024*1024: + raise ValueError('Dependency wheels exceed 1 GiB unpacked limit') + if member.is_dir(): + continue + parts = list(path.parts) + if parts[0].endswith('.data'): + if len(parts) < 3 or parts[1] not in ('purelib', 'platlib'): + continue + parts = parts[2:] + target = deps.joinpath(*parts) + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(archive.read(member)) + shutil.rmtree(stage/'wheels') + if not release.exists(): + shutil.copytree(stage, release) +for name in ('projects', 'staging', 'environments'): + destination = root/name + if destination.is_symlink(): + raise ValueError('BotLab data paths must not be symlinks') + destination.mkdir(exist_ok=True) +print(json.dumps({'root': str(root), 'release': params['release'], 'installed': True})) +''' + +_START = r''' +import hashlib, json, os, shutil, signal, socket, subprocess, sys, time +from pathlib import Path +params = json.loads(__PARAMS_JSON__) +root = Path(params['root']).expanduser() +agent = root/'agent' +release = agent/'releases'/params['release'] +package, deps = release/'package', release/'deps' +env = dict(os.environ, PYTHONPATH=str(package)+os.pathsep+str(deps), PYTHONNOUSERSITE='1') +# Validate dependencies before touching a healthy previous Agent or current link. +subprocess.run([sys.executable, '-S', '-c', 'import numpy, onnxruntime; import botlab.hil.agent'], + env=env, check=True, stdout=subprocess.DEVNULL) +unit_name = 'botlab-agent-'+hashlib.sha256(str(root).encode()).hexdigest()[:12]+'.service' +systemd = False +try: + if shutil.which('systemctl'): + systemd = subprocess.run(['systemctl', '--user', 'show-environment'], + capture_output=True, timeout=5).returncode == 0 +except (OSError, subprocess.TimeoutExpired): + pass +if systemd: + subprocess.run(['systemctl', '--user', 'stop', unit_name], capture_output=True) +pid_file = agent/'agent.pid' +if pid_file.exists(): + try: + old = int(pid_file.read_text()) + command = Path('/proc')/str(old)/'cmdline' + data = command.read_bytes() + if b'botlab.hil.agent' in data and str(root).encode() in data: + os.kill(old, signal.SIGTERM) + for _ in range(50): + if not command.exists(): + break + time.sleep(.1) + if command.exists(): + raise RuntimeError('Previous Agent did not stop') + except (ValueError, FileNotFoundError, ProcessLookupError): + pass +try: + with socket.create_connection(('127.0.0.1', params['port']), timeout=.3): + raise RuntimeError('Agent port is occupied by an unmanaged process') +except (ConnectionRefusedError, TimeoutError, socket.timeout): + pass +current = agent/'current' +temporary = agent/'.next' +temporary.unlink(missing_ok=True) +temporary.symlink_to(release) +os.replace(temporary, current) +launcher = agent/'start-agent.py' +args = [sys.executable, '-S', '-m', 'botlab.hil.agent', 'serve', '--host', '127.0.0.1', + '--port', str(params['port']), '--root', str(root), '--backend', params['backend']] +# A small launcher also allows the process fallback to be restarted after a reboot. +launcher.write_text('import os\nfrom pathlib import Path\nroot = Path('+repr(str(root))+')\n' + +'env = dict(os.environ, PYTHONPATH=str(root/"agent/current/package")+os.pathsep+str(root/"agent/current/deps"), PYTHONNOUSERSITE="1")\n' + +'log = open(root/"agent/agent.log", "ab", buffering=0)\nos.dup2(log.fileno(), 1)\nos.dup2(log.fileno(), 2)\n' + +'os.chdir(root)\nos.execve('+repr(sys.executable)+', '+repr(args)+', env)\n') +launcher.chmod(0o700) +service = 'managed-process' +service_error = None +process = None +if systemd: + unit = Path.home()/'.config/systemd/user'/unit_name + unit.parent.mkdir(parents=True, exist_ok=True) + quote = lambda value: json.dumps(str(value).replace('%', '%%')) + unit.write_text('[Unit]\nDescription=BotLab target management Agent\nAfter=network.target\n' + +'[Service]\nType=simple\nExecStart='+quote(sys.executable)+' '+quote(launcher)+'\n' + +'Restart=on-failure\nRestartSec=3\nKillMode=control-group\n[Install]\nWantedBy=default.target\n') + try: + subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True, capture_output=True, timeout=10) + subprocess.run(['systemctl', '--user', 'enable', '--now', unit_name], check=True, capture_output=True, timeout=15) + service = 'systemd-user' + except Exception: + subprocess.run(['systemctl', '--user', 'stop', unit_name], capture_output=True) + service_error = 'User service unavailable; using managed process' +if service == 'managed-process': + log = open(agent/'agent.log', 'ab', buffering=0) + process = subprocess.Popen([sys.executable, str(launcher)], cwd=root, stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, start_new_session=True) + pid = process.pid +else: + pid = int(subprocess.check_output(['systemctl', '--user', 'show', '-p', 'MainPID', '--value', unit_name]).decode().strip()) +pid_file.write_text(str(pid)) +for _ in range(100): + if process and process.poll() is not None: + raise RuntimeError('Agent failed to start; inspect '+str(agent/'agent.log')) + try: + with socket.create_connection(('127.0.0.1', params['port']), timeout=.2): + break + except OSError: + time.sleep(.1) +else: + if process: + process.terminate() + elif service == 'systemd-user': + subprocess.run(['systemctl', '--user', 'stop', unit_name], capture_output=True) + raise RuntimeError('Agent start timed out; inspect '+str(agent/'agent.log')) +linger = False +if service == 'systemd-user': + try: + linger = subprocess.check_output(['loginctl', 'show-user', str(os.getuid()), '-p', 'Linger', '--value'], timeout=5).decode().strip() == 'yes' + except Exception: + pass +print(json.dumps({'running': True, 'pid': pid, 'port': params['port'], 'service': service, + 'service_error': service_error, 'autostart': service == 'systemd-user' and linger, + 'starts_on_login': service == 'systemd-user', 'log': str(agent/'agent.log')})) +''' + + +_CONNECT = r''' +import fcntl, hashlib, json, os, socket, struct, subprocess, sys, time +from pathlib import Path +params = json.loads(__PARAMS_JSON__) +root = Path(params['root']).expanduser() +agent = root/'agent' +launcher = agent/'start-agent.py' +if not launcher.is_file() or launcher.is_symlink(): + raise RuntimeError('BotLab Agent is not installed; choose Install and connect first') +if root.is_symlink() or agent.is_symlink(): + raise RuntimeError('BotLab Agent installation paths must not be symlinks') + +def hello(): + try: + connection = socket.create_connection(('127.0.0.1', params['port']), timeout=.5) + except OSError: + return False + with connection: + connection.settimeout(1) + payload = json.dumps({'version': 1, 'op': 'hello'}).encode() + connection.sendall(struct.pack('!I', len(payload))+payload) + def read(size): + data = bytearray() + while len(data) < size: + part = connection.recv(size-len(data)) + if not part: + raise RuntimeError('Agent closed its readiness connection') + data.extend(part) + return data + try: + size = struct.unpack('!I', read(4))[0] + if not 0 < size <= 1024*1024: + raise RuntimeError('Target port is occupied by an incompatible service') + result = json.loads(read(size)) + if result.get('version') != 1 or result.get('ok') is not True or not isinstance(result.get('device'), dict): + raise RuntimeError('Target port is occupied by an incompatible service') + return True + except (socket.timeout, OSError) as exc: + raise RuntimeError('Target Agent is accepting connections but did not answer hello') from exc + +# Serialize fallback startup across concurrent SDK hosts. A configured systemd +# unit is started idempotently; an already-ready Agent is never restarted. +lock_path = agent/'connect.lock' +if lock_path.is_symlink(): + raise RuntimeError('Unsafe Agent connection lock') +lock = open(lock_path, 'a') +os.chmod(lock_path, 0o600) +deadline = time.monotonic()+30 +while True: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except BlockingIOError: + if time.monotonic() >= deadline: + raise RuntimeError('Another connection is still starting the target Agent') + time.sleep(.1) +started = None +try: + if not hello(): + unit_name = 'botlab-agent-'+hashlib.sha256(str(root).encode()).hexdigest()[:12]+'.service' + unit = Path.home()/'.config/systemd/user'/unit_name + if unit.is_file() and Path('/usr/bin/systemctl').exists(): + # PAM has created the user manager for this exec session. Starting + # an enabled/already-starting unit is safe and never restarts it. + result = subprocess.run(['systemctl', '--user', 'start', unit_name], capture_output=True, timeout=20) + if result.returncode: + raise RuntimeError('Could not start installed Agent user service; inspect Agent log or reinstall') + else: + log = open(agent/'agent.log', 'ab', buffering=0) + started = subprocess.Popen([sys.executable, str(launcher)], cwd=root, + stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT, + start_new_session=True) + (agent/'agent.pid').write_text(str(started.pid)) + while time.monotonic() < deadline: + if started is not None and started.poll() is not None: + raise RuntimeError('Installed Agent failed to start; inspect Agent log or reinstall') + if hello(): + break + time.sleep(.1) + else: + raise RuntimeError('Installed Agent did not become ready; inspect Agent log or reinstall') +except BaseException: + # Only clean up a process started by this failed connection; never kill an + # existing Agent or alter the state of a configured systemd service. + if started is not None and started.poll() is None: + started.terminate() + try: + started.wait(timeout=3) + except subprocess.TimeoutExpired: + started.kill() + started.wait(timeout=3) + raise +finally: + fcntl.flock(lock, fcntl.LOCK_UN) + lock.close() +# Signal helper readiness before the Host validates the forwarded Agent hello. +print("BOTLAB_TARGET_READY", flush=True) +# EOF arrives when the SDK closes the tunnel, crashes, or loses SSH transport. +# Keep the login alive while the data forwarding channel is in use. +while sys.stdin.buffer.read(1): + pass +''' + + +def local_interfaces() -> list[dict]: + """Read adapters; this function never changes host network settings.""" + if platform.system() != "Darwin": + result = _run(["ip", "-j", "address"], check=False) + if result.returncode: + return [] + return [{"name": item["ifname"], "label": item["ifname"], + "addresses": [{"address": a["local"], "prefix": a["prefixlen"]} + for a in item.get("addr_info", []) if a.get("family") == "inet"], + "active": item.get("operstate") == "UP"} + for item in json.loads(result.stdout) if item["ifname"] != "lo"] + labels = {} + ports = _run(["networksetup", "-listallhardwareports"], check=False).stdout.decode() + for block in ports.split("\n\n"): + label = re.search(r"Hardware Port: (.+)", block) + device = re.search(r"Device: (.+)", block) + if label and device: + labels[device[1]] = label[1] + output = _run(["ifconfig", "-a"]).stdout.decode() + results = [] + for block in re.split(r"(?=^[A-Za-z][\w.-]*: flags=)", output, flags=re.M): + name = re.match(r"([\w.-]+): flags=", block) + if not name or name[1] == "lo0": + continue + addresses = [] + for address, mask in re.findall(r"\binet ([\d.]+) netmask (0x[0-9a-f]+|[\d.]+)", block): + mask = str(ipaddress.IPv4Address(int(mask, 16))) if mask.startswith("0x") else mask + addresses.append({"address": address, "prefix": ipaddress.IPv4Network(f"0.0.0.0/{mask}").prefixlen}) + results.append({"name": name[1], "label": labels.get(name[1], name[1]), + "addresses": addresses, "active": "status: active" in block}) + return results + + +def discover_devices(interface: str | None = None, profile: str | None = None, + host: str | None = None) -> dict: + """Probe only neighbors on the selected interface plus an explicit hostname. + + Board profiles contribute documented factory-address hints, never credentials. + This is discovery, not an unrestricted subnet/port scanner. + """ + interfaces = local_interfaces() + if interface and interface not in {item["name"] for item in interfaces}: + raise HilError("Selected network interface no longer exists") + selected = [item for item in interfaces if not interface or item["name"] == interface] + networks = [ipaddress.ip_network(f"{a['address']}/{a['prefix']}", strict=False) + for item in selected for a in item["addresses"]] + candidates = set() + if host: + _ssh_config({"ssh": {"host": host, "user": "probe"}}) + candidates.add(host) + if interface: + neighbors = _run(["arp", "-an"], check=False).stdout.decode() + for line in neighbors.splitlines(): + match = re.search(r"\(([\d.]+)\).*\bon\s+(\S+)", line) + if match and match[2] == interface and "incomplete" not in line: + candidates.add(match[1]) + if profile == "rdk-x5": + for hint in ("192.168.127.10", "192.168.128.10"): + if any(ipaddress.ip_address(hint) in network for network in networks): + candidates.add(hint) + local = {a["address"] for item in interfaces for a in item["addresses"]} + def probe(address): + try: + with socket.create_connection((address, 22), timeout=.6) as stream: + stream.settimeout(.6) + banner = stream.recv(256).decode("ascii", "replace").strip() + if banner.startswith("SSH-"): + return {"host": address, "port": 22, "transport": "ssh", "banner": banner[:120]} + except OSError: + pass + return None + with concurrent.futures.ThreadPoolExecutor(max_workers=12) as pool: + found = [item for item in pool.map(probe, sorted(candidates - local)[:64]) if item] + return {"interfaces": interfaces, "candidates": found, + "profile": profile, "requires_interface": not bool(interface or host)} + + +def _network_values(method, address=None, netmask=None, gateway=None): + if method not in ("dhcp", "static"): + raise HilError("Network method must be dhcp or static") + if method == "dhcp": + return None, None, None + try: + if not isinstance(address, str): + raise ValueError + value = ipaddress.IPv4Interface(address if "/" in address else f"{address}/{netmask or '24'}") + route = str(ipaddress.IPv4Address(gateway)) if gateway else "" + if value.ip.is_loopback or value.ip.is_multicast or value.ip.is_unspecified: + raise ValueError + if route and ipaddress.IPv4Address(route) not in value.network: + raise ValueError + if value.network.prefixlen < 31 and value.ip in (value.network.network_address, value.network.broadcast_address): + raise ValueError + except (ValueError, TypeError) as exc: + raise HilError("Provide a usable IPv4 address, subnet mask and optional gateway in the same subnet") from exc + return str(value), str(value.netmask), route + + +def configure_local_network(interface: str, method: str, address: str | None = None, + netmask: str | None = None, gateway: str | None = None) -> dict: + """Configure one Mac adapter, using the native administrator authorization UI. + + Only selected network service IPv4 settings are touched. The previous state + is returned so the caller can offer Restore. No administrator password is + collected by BotLab. + """ + if platform.system() != "Darwin": + raise HilError("Automatic host network configuration currently supports macOS") + interfaces = local_interfaces() + if interface not in {item["name"] for item in interfaces}: + raise HilError("Selected network interface no longer exists") + value, mask, route = _network_values(method, address, netmask, gateway) + listing = _run(["networksetup", "-listnetworkserviceorder"]).stdout.decode() + matches = re.findall(r"^\(\d+\) (.+)\n\(Hardware Port: .*?, Device: ([^)]+)\)", listing, re.M) + services = [name for name, device in matches if device == interface and not name.startswith("*")] + if len(services) != 1: + raise HilError("Select an adapter with exactly one enabled macOS network service") + service = services[0] + previous = _run(["networksetup", "-getinfo", service]).stdout.decode() + if method == "dhcp": + command = ["/usr/sbin/networksetup", "-setdhcp", service] + else: + command = ["/usr/sbin/networksetup", "-setmanual", service, value.split("/")[0], mask, route] + # JSON strings and AppleScript strings share the needed quote/backslash escapes. + script = "do shell script " + json.dumps(shlex.join(command)) + " with administrator privileges" + _run(["osascript", "-e", script], timeout=120) + current = _run(["networksetup", "-getinfo", service]).stdout.decode() + return {"interface": interface, "service": service, "method": method, + "previous": previous, "current": current, "interfaces": local_interfaces()} + + +_NETWORK_BEGIN = r''' +import json, os, re, shutil, subprocess, sys, time, uuid +from pathlib import Path +params = json.loads(__PARAMS_JSON__) +if os.geteuid() != 0: + raise RuntimeError('Network configuration requires passwordless sudo or a root SSH account') +if not shutil.which('nmcli') or not shutil.which('busctl'): + raise RuntimeError('This network adapter requires NetworkManager; keep existing configuration or install a platform plugin') +def call(*args): + return subprocess.check_output(args, stderr=subprocess.STDOUT, timeout=15).decode().strip() +base = ['busctl', '--system', 'call', 'org.freedesktop.NetworkManager', '/org/freedesktop/NetworkManager', 'org.freedesktop.NetworkManager'] +result = call(*base, 'GetDeviceByIpIface', 's', params['interface']) +match = re.fullmatch(r'o "(/org/freedesktop/NetworkManager/Devices/[0-9]+)"', result) +if not match: + raise RuntimeError('Interface is not managed by NetworkManager') +connection = call('nmcli', '-g', 'GENERAL.CON-UUID', 'device', 'show', params['interface']) +if not re.fullmatch('[a-fA-F0-9-]{36}', connection): + raise RuntimeError('Interface has no active NetworkManager connection') +result = call(*base, 'CheckpointCreate', 'aouu', '1', match[1], '90', '0') +checkpoint = re.fullmatch(r'o "(/org/freedesktop/NetworkManager/Checkpoint/[0-9]+)"', result) +if not checkpoint: + raise RuntimeError('Could not create network rollback checkpoint') +transaction = uuid.uuid4().hex +folder = Path('/run/botlab-network') +if folder.is_symlink(): + raise RuntimeError('Unsafe network transaction directory') +folder.mkdir(mode=0o700, exist_ok=True) +state = dict(params, transaction=transaction, connection=connection, checkpoint=checkpoint[1], expires=time.time()+90, applied=False) +path = folder/(transaction+'.json') +path.write_text(json.dumps(state)) +path.chmod(0o600) +source = __APPLY_SOURCE__ +log = open(folder/(transaction+'.log'), 'ab', buffering=0) +subprocess.Popen([sys.executable, '-c', source, str(path)], stdin=subprocess.DEVNULL, + stdout=log, stderr=log, start_new_session=True) +print(json.dumps({'transaction': transaction, 'rollback_seconds': 90, + 'new_host': params.get('address', '').split('/')[0] or None, + 'requires_confirmation': True, 'interface': params['interface']})) +''' + +_NETWORK_APPLY = r''' +import json, subprocess, sys, time +from pathlib import Path +path = Path(sys.argv[1]) +state = json.loads(path.read_text()) +time.sleep(2) +args = ['nmcli', '--wait', '15', 'device', 'modify', state['interface']] +if state['method'] == 'dhcp': + args += ['ipv4.method', 'auto', 'ipv4.addresses', '', 'ipv4.gateway', ''] +else: + args += ['ipv4.method', 'manual', 'ipv4.addresses', state['address'], 'ipv4.gateway', state['gateway']] +try: + subprocess.run(args, timeout=20, check=True) + state['applied'] = True +except Exception as exc: + state['error'] = str(exc) +path.write_text(json.dumps(state)) +''' + +_NETWORK_CONFIRM = r''' +import json, os, subprocess, time +from pathlib import Path +transaction = __TRANSACTION__ +if os.geteuid() != 0: + raise RuntimeError('Network configuration requires administrator privileges') +path = Path('/run/botlab-network')/(transaction+'.json') +state = json.loads(path.read_text()) +if time.time() >= state['expires'] or not state['applied']: + raise RuntimeError(state.get('error') or 'Network transaction expired or is not ready; previous settings will be restored') +base = ['busctl', '--system', 'call', 'org.freedesktop.NetworkManager', '/org/freedesktop/NetworkManager', 'org.freedesktop.NetworkManager'] +# Atomically verify the checkpoint still exists and reserve time to commit. +# The daemon retains rollback responsibility even if this SSH connection fails. +subprocess.run(base+['CheckpointAdjustRollbackTimeout', 'ou', state['checkpoint'], '60'], + check=True, timeout=10, stdout=subprocess.DEVNULL) +args = ['nmcli', '--wait', '10', 'connection', 'modify', 'uuid', state['connection']] +if state['method'] == 'dhcp': + args += ['ipv4.method', 'auto', 'ipv4.addresses', '', 'ipv4.gateway', ''] +else: + args += ['ipv4.method', 'manual', 'ipv4.addresses', state['address'], 'ipv4.gateway', state['gateway']] +subprocess.run(args, check=True, timeout=15, stdout=subprocess.DEVNULL) +subprocess.run(base+['CheckpointDestroy', 'o', state['checkpoint']], check=True, timeout=10, stdout=subprocess.DEVNULL) +path.unlink() +print(json.dumps({'confirmed': True, 'interface': state['interface'], 'method': state['method']})) +''' diff --git a/src/botlab/hil/gateway.py b/src/botlab/hil/gateway.py new file mode 100644 index 0000000..48501ff --- /dev/null +++ b/src/botlab/hil/gateway.py @@ -0,0 +1,239 @@ +"""Device gateway for hosted HIL: relays opaque HIL v1 frames between the +local coordinator and resident board agents that dial out. + +The gateway never parses HIL frames — pairing and byte relay only — so the +inference path inherits no Control-command semantics: no receipts, no retry, +no task table. There is exactly one shared bearer token (operator channel, +same restricted-credential stance as Control M1) and no tokenless mode; it +arrives in the ``Authorization`` header or, for browsers that cannot set +handshake headers, as one leading ``{"type": "gateway-auth", "token": ...}`` +text control frame. ``ws://`` is for loopback development, remote gateways +must use ``wss://``. + +Pairing rules: +- one agent per device ID: a re-registration takes over and closes the old + agent connection together with its paired coordinator (device restart); +- one coordinator per device: a second coordinator is refused ("device busy"); +- either side disappearing closes the other. +""" +from __future__ import annotations + +import argparse +import json +import os +import secrets +import ssl +import sys +import threading +from pathlib import Path + +from .protocol import MAX_FRAME_BYTES, HilError + +RECV_TIMEOUT = 600 +AUTH_TIMEOUT = 5 + + +def _parse_path(path: str) -> tuple[str, str] | None: + if not path.startswith("/"): + return None + parts = path.split("/") + if len(parts) != 3 or parts[1] not in ("agent", "device") or not parts[2]: + return None + return parts[1], parts[2] + + +class _Agent: + def __init__(self, websocket): + self.websocket = websocket + self.host = None + self.closed = threading.Event() + + +class Gateway: + def __init__(self, token: str): + self.token = token + self.lock = threading.Lock() + self.agents: dict[str, _Agent] = {} + + # -- websockets.sync.server plumbing ----------------------------------- + + def handler(self, websocket): + route = _parse_path(websocket.request.path) + if route is None: + websocket.close(1008, "unknown gateway path") + return + channel, device = route + header = websocket.request.headers.get("Authorization", "") + if not secrets.compare_digest(header, f"Bearer {self.token}"): + # Browsers cannot set Authorization headers on a WebSocket + # handshake; the same credential may arrive as one leading text + # control frame instead. + if not self._frame_auth(websocket): + return + if channel == "agent": + self._agent(websocket, device) + else: + self._device(websocket, device) + + def _frame_auth(self, websocket) -> bool: + """Authenticate a header-less client (the browser) with one text frame. + + The frame is gateway control traffic, not an HIL frame — the relay + itself still never parses HIL traffic, and any text frame after + authentication remains a protocol error exactly as before. + """ + try: + message = websocket.recv(timeout=AUTH_TIMEOUT) + except TimeoutError: + websocket.close(1008, "gateway authentication timeout") + return False + except Exception: + return False + token = None + if isinstance(message, str): + try: + frame = json.loads(message) + except ValueError: + frame = None + if isinstance(frame, dict) and frame.get("type") == "gateway-auth" \ + and isinstance(frame.get("token"), str): + token = frame["token"] + if token is None or not secrets.compare_digest( + token.encode("utf-8"), self.token.encode("utf-8")): + websocket.close(1008, "invalid gateway token") + return False + return True + + # -- registration ------------------------------------------------------- + + def _agent(self, websocket, device): + agent = _Agent(websocket) + with self.lock: + previous = self.agents.get(device) + if previous is not None and not previous.closed.is_set(): + # Device restart or redeploy: the new resident agent wins and + # the displaced pairing dies with it. + previous.closed.set() + previous.websocket.close(1012, "device re-registered") + self.agents[device] = agent + try: + while not agent.closed.is_set(): + try: + message = websocket.recv(timeout=RECV_TIMEOUT) + except TimeoutError: + continue + except Exception: + break + if isinstance(message, str): + websocket.close(1008, "binary frames required") + break + with self.lock: + host = agent.host + if host is None: + # Coordinators speak first (hello); unsolicited agent + # traffic before pairing violates the contract. + websocket.close(1008, "agent spoke before pairing") + break + try: + host.send(message) + except Exception: + break + finally: + with self.lock: + if self.agents.get(device) is agent: + del self.agents[device] + agent.closed.set() + host = agent.host + if host is not None: + host.close(1011, "device agent disconnected") + + def _device(self, websocket, device): + with self.lock: + agent = self.agents.get(device) + if agent is None or agent.closed.is_set(): + websocket.close(1008, "unknown device") + return + if agent.host is not None: + websocket.close(1008, "device busy") + return + agent.host = websocket + try: + while not agent.closed.is_set(): + try: + message = websocket.recv(timeout=RECV_TIMEOUT) + except TimeoutError: + continue + except Exception: + break + if isinstance(message, str): + websocket.close(1008, "binary frames required") + break + try: + agent.websocket.send(message) + except Exception: + break + finally: + # A leaving coordinator must kill the device link too: the HIL + # lease is per agent connection, exactly like the TCP transport. + agent.closed.set() + websocket.close() + agent.websocket.close(1011, "coordinator disconnected") + + +def _ssl_context(certificate: Path | None, key: Path | None) -> ssl.SSLContext | None: + if not certificate and not key: + return None + if not certificate or not key: + raise HilError("Gateway TLS needs both --tls-cert and --tls-key") + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(certificate, key) + return context + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser( + description="Relay HIL device traffic for board agents dialing out") + parser.add_argument("--host", default="127.0.0.1", + help="bind address (use 0.0.0.0 only behind a TLS reverse proxy)") + parser.add_argument("--port", type=int, default=50060) + parser.add_argument("--tls-cert", type=Path, help="serve wss:// directly with this certificate") + parser.add_argument("--tls-key", type=Path, help="private key matching --tls-cert") + parser.add_argument("--print-token", action="store_true", + help="print the shared token at startup (operator channel only)") + options = parser.parse_args(argv) + try: + from websockets.sync.server import serve + except ImportError as exc: + raise HilError("The gateway needs the websockets package; install botlab-python[gateway]") from exc + token = os.environ.get("BOTLAB_HIL_GATEWAY_TOKEN") or secrets.token_urlsafe(32) + gateway = Gateway(token) + try: + context = _ssl_context(options.tls_cert, options.tls_key) + except (HilError, OSError, ssl.SSLError) as error: + print(f"Gateway TLS setup failed: {error}", file=sys.stderr) + return 2 + try: + server = serve(gateway.handler, options.host, options.port, ssl=context, + max_size=MAX_FRAME_BYTES, compression=None, + ping_interval=20, ping_timeout=20) + except OSError as error: + print(f"Gateway cannot listen on {options.host}:{options.port}: {error}", file=sys.stderr) + return 2 + port = server.socket.getsockname()[1] + scheme = "wss" if context else "ws" + print(f"BotLab HIL gateway: {scheme}://{options.host}:{port}", file=sys.stderr, flush=True) + print(f"Agent enrollment: botlab-agent connect --gateway {scheme}://{options.host}:{port} " + f"--device ", file=sys.stderr, flush=True) + if options.print_token: + print(f"Token: {token}", file=sys.stderr, flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.shutdown() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/botlab/hil/host.py b/src/botlab/hil/host.py new file mode 100644 index 0000000..b78f026 --- /dev/null +++ b/src/botlab/hil/host.py @@ -0,0 +1,829 @@ +"""Stdlib-only HIL coordinator used by the local BotLab HTTP server.""" +from __future__ import annotations + +import copy +import os +import re +import socket +import threading +import time +import uuid +from pathlib import Path + +from .protocol import ( + SHA256, + HilError, + endpoint_address, + error_details, + ports, + receive, + send, + tensors, +) +from .targets import read_targets, remove_target, save_target, validate_target + + +class _StartCancelled(HilError): + pass + + +class HilHost: + def __init__(self, project_dir: Path, selected_target: str | None = None): + self.project_dir = Path(project_dir).resolve() + self.lock = threading.RLock() + self.targets = read_targets() + self.selected_target = selected_target + if selected_target and not any(t["id"] == selected_target for t in self.targets): + endpoint_address(selected_target) + target = validate_target({"id": "command-line", "name": "Command-line target", + "endpoint": selected_target}) + self.targets.append(target) + self.selected_target = target["id"] + self.sock = None + self.device = None + self.session = None + self.error = None + self.metrics = {"calls": 0, "inference_ms": 0, "rtt_ms": 0} + self.last_activity = 0.0 + self.sequence = -1 + self.models = {} + self.tunnel = None + self.tunnel_device = None + self.credentials = {} + self.credential_targets = {} + self.device_info = {} + self.network_pending = {} + self.artifact = None + self.application = {"built": None, "loaded": None, "projects": []} + self.operation_lock = threading.RLock() + self.operations = {} + self.operation = None + self.cancelled_starts = {} + self.project_generation = 0 + self.closed = False + + def state(self): + return {"available": True, "targets": [dict(item) for item in self.targets], + "selectedTarget": self.selected_target, "connected": self.sock is not None, + "device": self.device, "session": self.session, + "metrics": dict(self.metrics), "error": self.error, + "application": copy.deepcopy(self.application), + "device_info": copy.deepcopy(self.device_info), + "credential_status": {target["id"]: {"password": bool( + self._credentials_for(target).get("password"))} for target in self.targets}, + "capabilities": {"targetManagement": 1, "projects": 1, "ssh": True, + "targetProjects": 1, "transientCredentials": 1, "credentialPrompt": 1, + "stepwiseExecution": 1, "deploymentVersions": 1}, + "operation": self._operation_snapshot()} + + def _drop(self): + sock, self.sock = self.sock, None + self.session = None + self.models = {} + self.sequence = -1 + if sock: + try: + sock.shutdown(socket.SHUT_RDWR) + except OSError: + pass + sock.close() + if self.tunnel: + self.tunnel.close() + self.tunnel = None + if self.tunnel_device: + self.tunnel_device.close() + self.tunnel_device = None + self.application["loaded"] = None + self.project_generation += 1 + + def _rpc(self, message, timeout=5): + if not self.sock: + raise HilError("Connect a target first") + started = time.perf_counter() + try: + self.sock.settimeout(timeout) + send(self.sock, message) + result = receive(self.sock) + if result.get("ok") is not True and not ( + message.get("op") == "projects.load" and result.get("lease_preserved") is True + ): + raise HilError(result.get("error") or "Target request failed") + self.last_activity = time.monotonic() + except (OSError, ValueError, EOFError) as exc: + self.error = self._redact(str(exc) or type(exc).__name__) + self._drop() + raise HilError(self.error) from exc + # A failed project selection is validated before the Agent changes its + # lease. Only this explicit response may preserve the current selection; + # transport failures and every other RPC error still drop the connection. + if result.get("ok") is not True: + raise HilError(result.get("error") or "Target project selection failed") + return result, (time.perf_counter() - started) * 1000 + + def _check_session(self, body): + if not self.session or body.get("session") != self.session: + raise HilError("Stale or inactive HIL session") + + def _operation_snapshot(self, operation_id=None): + with self.operation_lock: + item = self.operations.get(operation_id) if operation_id else self.operation + if item is None: + return None + return copy.deepcopy({k: v for k, v in item.items() + if k != "result" and not k.startswith("_")}) + + def _redact(self, message): + passwords = {credential["password"] for credential in self.credentials.values() + if isinstance(credential.get("password"), str) and credential["password"]} + for password in sorted(passwords, key=len, reverse=True): + message = message.replace(password, "[redacted]") + return message + + def _progress(self, value): + with self.operation_lock: + if self.operation: + self.operation["phase"] = value.get("stage", "working") + self.operation["messages"].append(self._redact(str(value.get("message", "")))) + # A total count lets polling clients distinguish repeated lines + # and locate new messages after this bounded tail rolls over. + self.operation["message_count"] += 1 + self.operation["messages"] = self.operation["messages"][-100:] + + def _job(self, kind, callback, *, request_id=None): + with self.operation_lock: + if self.operation and self.operation["status"] == "running": + raise HilError("Wait for the current target operation to finish") + operation = {"id": uuid.uuid4().hex, "kind": kind, "status": "running", + "phase": "starting", "messages": [], "message_count": 0, "error": None} + if request_id is not None: + self._validate_start_request(request_id) + if any(item.get("request_id") == request_id for item in self.operations.values()): + raise HilError("This HIL start request already exists") + operation["request_id"] = request_id + self.operations[operation["id"]] = self.operation = operation + while len(self.operations) > 20: + del self.operations[next(iter(self.operations))] + def run(): + try: + with self.lock: + if self.closed: + raise HilError("HIL host is closed") + result = callback() + if self.closed: + self._drop() + raise HilError("HIL host was closed during the operation") + # Publish success under the same cancellation lock. A cancel + # arriving after this point uses the exact completed lease. + with self.operation_lock: + self._check_start_cancelled(request_id) + operation.update(status="succeeded", phase="complete", result=result) + except Exception as exc: + if isinstance(exc, _StartCancelled): + with self.lock: + self._release_cancelled_start(operation) + message = self._redact(str(exc) or type(exc).__name__) + details = error_details(exc) + # Authentication is recoverable through the browser credential + # prompt, not a terminal connection failure (including Cancel). + self.error = None if details else message + with self.operation_lock: + operation.update(status="failed", phase="cancelled" if isinstance(exc, _StartCancelled) + else "failed", error=message, **details) + threading.Thread(target=run, name="botlab-" + kind, daemon=True).start() + return {"operation": self._operation_snapshot(operation["id"])} + + @staticmethod + def _validate_start_request(request_id): + if not isinstance(request_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{16,128}", request_id): + raise HilError("HIL start request_id must be a unique 16–128 character identifier") + + def _check_start_cancelled(self, request_id): + if request_id is None: + return # Compatibility with older clients and direct CLI callers. + with self.operation_lock: + if request_id in self.cancelled_starts or ( + self.operation and self.operation.get("request_id") == request_id + and self.operation.get("_cancel_requested") + ): + raise _StartCancelled("HIL start was cancelled") + + def _release_cancelled_start(self, operation): + """Caller owns the Host lock; never release a subsequent run or load.""" + session = operation.get("_started_session") + try: + if session and self.session == session: + self._handle("POST", "/hil/stop", {"session": session}) + if (self.session is None and self.sock is not None + and operation.get("_loaded_generation") == self.project_generation + and self.application["loaded"] is not None): + self._rpc({"op": "projects.unload"}) + self.application["loaded"] = None + self.project_generation += 1 + self._projects() + except (OSError, ValueError, EOFError): + self._drop() # TCP lease release also clears the Agent's loaded project. + + def _cancel_start(self, body): + request_id = body.get("request_id") + self._validate_start_request(request_id) + with self.operation_lock: + now = time.monotonic() + # Retain cancellation-before-start races without unbounded history. + self.cancelled_starts = {key: stamp for key, stamp in self.cancelled_starts.items() + if now - stamp < 1200} + self.cancelled_starts[request_id] = now + while len(self.cancelled_starts) > 128: + oldest = next(iter(self.cancelled_starts)) + if self.operation and oldest == self.operation.get("request_id"): + self.cancelled_starts[oldest] = self.cancelled_starts.pop(oldest) + continue + del self.cancelled_starts[oldest] + operation = next((item for item in self.operations.values() + if item.get("request_id") == request_id), None) + pending = operation is not None and operation["status"] == "running" + if operation is not None: + operation["_cancel_requested"] = True + if pending: + operation["phase"] = "cancelling" + if operation is not None and not pending: + with self.lock: + self._release_cancelled_start(operation) + with self.operation_lock: + operation.pop("result", None) + operation.update(status="failed", phase="cancelled", error="HIL start was cancelled") + return {"cancelled": True, "request_id": request_id, + "operation": self._operation_snapshot(operation["id"]) if operation else None} + + def _target(self, target_id=None): + target_id = target_id or self.selected_target + target = next((t for t in self.targets if t["id"] == target_id), None) + if target is None: + raise HilError("Choose a configured target") + return target + + @staticmethod + def _credential_identity(target): + # Fingerprint acceptance verifies the same host; it must not discard the + # password that was just entered in target configuration. + identity = {key: copy.deepcopy(target.get(key)) for key in + ("transport", "endpoint", "root", "backend", "agent_port")} + identity["ssh"] = {key: value for key, value in target.get("ssh", {}).items() + if key != "host_key"} + return identity + + def _credentials_for(self, target): + target_id = target["id"] + bound = self.credential_targets.get(target_id) + if bound is not None and bound != self._credential_identity(target): + self.credentials.pop(target_id, None) + self.credential_targets.pop(target_id, None) + return dict(self.credentials.get(target_id, {})) + + def _store_credentials(self, target, credentials): + self.credentials[target["id"]] = dict(credentials) + self.credential_targets[target["id"]] = self._credential_identity(target) + + @staticmethod + def _validate_credentials(credentials): + if not isinstance(credentials, dict) or set(credentials) - {"password", "host_key"}: + raise HilError("Invalid transient SSH credentials") + if any(not isinstance(value, str) or len(value) > 4096 or "\x00" in value + for value in credentials.values()): + raise HilError("Invalid transient SSH credential") + fingerprint = credentials.get("host_key") + if fingerprint is not None and not re.fullmatch(r"SHA256:[A-Za-z0-9+/]{43}", fingerprint): + raise HilError("Invalid SSH host key fingerprint") + return dict(credentials) + + def _device(self, body): + from .devices import LinuxDevice + target = self._target(body.get("target")) + if target.get("transport") != "ssh": + raise HilError("This operation requires an SSH target") + credentials = self._credentials_for(target) + for field in ("password", "host_key"): + if body.get(field) is not None: + credentials[field] = body[field] + device = LinuxDevice(target, credentials, self._progress) + self._store_credentials(target, credentials) + return device + + def _open_stream(self, target: dict, body: dict | None = None): + """Open one agent stream: direct TCP, SSH tunnel or gateway relay. + + Returns (stream, tunnel, device); callers close stream, then tunnel, + then device. Gateway streams are outbound WebSocket links to the + device gateway; the HIL protocol itself is unchanged and the token + comes exclusively from BOTLAB_HIL_TOKEN. + """ + if target.get("transport") == "gateway": + from .wsstream import connect_gateway + token = os.environ.get("BOTLAB_HIL_TOKEN") + if not token: + raise HilError("Gateway targets need BOTLAB_HIL_TOKEN set for the run server") + return connect_gateway(target["endpoint"], "device", target["device"], token), None, None + address = endpoint_address(target["endpoint"]) + tunnel = device = None + if target.get("transport") == "ssh": + device = self._device(body or {}) + try: + tunnel = device.open_tunnel() + address = tunnel.address + except BaseException: + device.close() + raise + try: + stream = socket.create_connection(address, timeout=3) + except BaseException: + if tunnel: + tunnel.close() + if device: + device.close() + raise + if hasattr(stream, "setsockopt"): + stream.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + return stream, tunnel, device + + def _inspect_device(self, body, provision=False): + # Management jobs hold self.lock. Refuse an active session before any + # device or connection changes; inspecting an idle target is read-only. + if self.session: + raise HilError("Stop the project before checking or installing the target") + target = self._target(body.get("target")) + if not provision and self.sock and target["id"] != self.selected_target: + raise HilError("Disconnect before checking another target") + self.device_info.pop(target["id"], None) + device = self._device(body) + try: + if provision: + self._drop() + result = device.provision() if provision else device.inspect() + self.device_info[device.target["id"]] = result + self.selected_target = device.target["id"] + self.error = None + return result + finally: + device.close() + + def _build(self, expected_sources=None): + if self.session: + raise HilError("Stop the project before building or deploying") + from .artifacts import build_project + self._progress({"stage": "build", "message": "Validating the project and building its target application"}) + self.artifact = build_project(self.project_dir, expected_sources=expected_sources) + self.application["built"] = self.artifact["manifest"] + self.error = None + return {"built": self.artifact["manifest"]} + + def _supports_projects(self): + return bool(self.device and self.device.get("capabilities", {}).get("projects") == 1) + + def _projects(self): + if not self._supports_projects(): + raise HilError("This target Agent does not support application deployment; initialize or update it first") + result, _ = self._rpc({"op": "projects.list"}) + self.application.update(projects=result["projects"], loaded=result.get("loaded")) + return result + + def _target_projects(self, body, *, remove=False): + if self.session: + raise HilError("Stop the project before managing target projects") + target_id = body.get("target") + if not isinstance(target_id, str) or not target_id: + raise HilError("Choose a configured target to scan") + target = self._target(target_id) + if remove: + expected = body.get("expected_target") + if not isinstance(expected, dict) or validate_target(expected) != target: + raise HilError("Target settings changed. Scan the target and confirm project deletion again.") + if "build_id" in body and ( + not isinstance(body["build_id"], str) or not SHA256.fullmatch(body["build_id"]) + ): + raise HilError("Choose a valid deployed version to delete") + if not isinstance(body.get("project_id"), str) or not body["project_id"]: + raise HilError("Choose a deployed project to delete") + + # Agent errors terminate their TCP lease. All browsing/removal gets a + # separate connection so even a malformed inventory or refusal cannot + # unload the main project or change the simulation's target selection. + stream = tunnel = device = None + try: + self._progress({"stage": "connect", "message": "Opening target project management connection"}) + stream, tunnel, device = self._open_stream(target, {"target": target_id}) + + def request(message): + stream.settimeout(10) + send(stream, message) + result = receive(stream) + if result.get("ok") is not True: + raise HilError(result.get("error") or "Target project request failed") + return result + + hello = request({"op": "hello"})["device"] + if hello.get("capabilities", {}).get("projects") != 1: + raise HilError("Target Agent does not support project management. Install or update it from the main Connect action.") + if hello.get("backend") != target["backend"]: + raise HilError(f"Target backend is {hello.get('backend')}, expected {target['backend']}") + if remove: + if "build_id" in body and hello.get("capabilities", {}).get("deploymentVersions") != 1: + raise HilError("Target Agent does not support deleting individual versions. Update it from the main Connect action.") + self._progress({"stage": "remove", "message": ( + "Deleting the selected deployed version" if "build_id" in body + else "Deleting the selected target project and all releases")}) + request({"op": "projects.remove", "project_id": body["project_id"], + **({"build_id": body["build_id"]} if "build_id" in body else {})}) + self._progress({"stage": "scan", "message": "Reading deployed target projects"}) + result = request({"op": "projects.list"}) + if remove and self.sock and target_id == self.selected_target: + self.application.update(projects=result["projects"], loaded=result.get("loaded")) + self.error = None + return {"target": target_id, "projects": result["projects"], "loaded": result.get("loaded")} + finally: + if stream: + stream.close() + if tunnel: + tunnel.close() + if device: + device.close() + + def _deploy(self): + if not self._supports_projects(): + raise HilError("Connect a target with application deployment support first") + if self.artifact is None: + raise HilError("Build the project before deploying") + if self.session: + raise HilError("Stop the project before deploying") + self._progress({"stage": "deploy", "message": "Installing the application on the target"}) + result, _ = self._rpc({"op": "projects.deploy", "artifact": self.artifact}, timeout=120) + self._projects() + return result + + def _load_project(self, body): + if self.session: + raise HilError("Stop before loading another project") + if not self._supports_projects(): + raise HilError("This target does not support deployed projects") + scoped = "target" in body or "expected_target" in body + if scoped: + target_id = body.get("target") + if not self.sock or target_id != self.selected_target: + raise HilError("Target changed or disconnected. Connect the selected target before loading a version.") + expected = body.get("expected_target") + if not isinstance(expected, dict) or expected != self._target(target_id): + raise HilError("Target settings changed. Scan the target and select the deployed version again.") + if self.device.get("capabilities", {}).get("deploymentVersions") != 1: + raise HilError("Target Agent does not support safe version switching. Update it from the main Connect action.") + if not isinstance(body.get("project_id"), str) or not body["project_id"]: + raise HilError("Choose a deployed project to load") + if "build_id" in body and ( + not isinstance(body["build_id"], str) or not SHA256.fullmatch(body["build_id"]) + ): + raise HilError("Choose a valid deployed version to load") + built = self.application["built"] + project_id = body.get("project_id") or (built and built["project"]["id"]) + build_id = body.get("build_id") or (built and built["build_id"] if not body.get("project_id") else None) + if not project_id: + raise HilError("Build and deploy the project before connecting it") + if scoped: + # Browse on an independent connection before changing the main + # ownership lease. A stale UI selection must not unload its project. + inventory = self._target_projects({"target": body["target"]}) + project = next((item for item in inventory["projects"] if item["id"] == project_id), None) + if project is None: + raise HilError("Project is no longer deployed. Scan the target and select another project.") + build_id = build_id or project["current_build_id"] + if not any(item["build_id"] == build_id for item in project["releases"]): + raise HilError("Version is no longer deployed. Scan the target and select another version.") + self._progress({"stage": "load", "message": "Loading the selected target project"}) + result, _ = self._rpc({"op": "projects.load", "project_id": project_id, "build_id": build_id}, timeout=60) + self.application["loaded"] = result["loaded"] + self.project_generation += 1 + self._projects() + return result + + def _start_project(self, body): + request_id = body.get("request_id") + self._check_start_cancelled(request_id) + self._build(body.get("sources")) + self._check_start_cancelled(request_id) + manifest = self.artifact["manifest"] + self._deploy() + self._check_start_cancelled(request_id) + self._load_project({"project_id": manifest["project"]["id"], "build_id": manifest["build_id"]}) + with self.operation_lock: + self.operation["_loaded_generation"] = self.project_generation + self._check_start_cancelled(request_id) + self._progress({"stage": "run", "message": "Starting target inference"}) + return self._start_session(body) + + def _start_loaded_project(self, body): + from .artifacts import validate_loaded_project + + request_id = body.get("request_id") + self._check_start_cancelled(request_id) + if self.session: + raise HilError("Stop the current session before starting another") + loaded = self.application["loaded"] + if not self.sock or not self._supports_projects() or loaded is None: + raise HilError("Connect a deployed project before running it") + if (body.get("project_id") != loaded["project_id"] + or body.get("build_id") != loaded["build_id"]): + raise HilError("The connected project version changed. Refresh the selection before running it") + if not isinstance(body.get("sources"), dict): + raise HilError("Open the matching local project and provide its browser sources before running it") + self._progress({"stage": "validate", "message": "Checking local sources against the connected project version"}) + validate_loaded_project(self.project_dir, loaded, body["sources"]) + self._check_start_cancelled(request_id) + self._progress({"stage": "run", "message": "Starting the connected project version"}) + # The selection predates this start. Cancellation releases only the new + # session; it must never unload the user's explicitly selected project. + return self._start_session(body) + + def _start_session(self, body): + request_id = body.get("request_id") + self._check_start_cancelled(request_id) + result = self._handle("POST", "/hil/start", {"prepared": True}) + with self.operation_lock: + self.operation["_started_session"] = result["session"] + self._check_start_cancelled(request_id) + return result + + def handle(self, method: str, path: str, body: dict | None = None) -> dict: + body = {} if body is None else body + if not isinstance(body, dict): + raise HilError("HIL request body must be an object") + if method == "POST" and path == "/hil/cancel-start": + return self._cancel_start(body) + if method == "GET" and path.startswith("/hil/operations/"): + operation_id = path.rsplit("/", 1)[-1] + with self.operation_lock: + op = self.operations.get(operation_id) + if not op: + raise HilError("Unknown target operation") + return {"operation": self._operation_snapshot(operation_id), + **({"result": copy.deepcopy(op["result"])} if "result" in op else {})} + if self.operation and self.operation["status"] == "running": + if method == "GET" and path == "/hil/state": + return self.state() + raise HilError("Wait for the current target operation to finish") + if method == "POST": + jobs = { + "/hil/build": ("build", self._build), + "/hil/deploy": ("deploy", self._deploy), + "/hil/projects/load": ("load", lambda: self._load_project(body)), + "/hil/targets/projects": ("projects-scan", lambda: self._target_projects(body)), + "/hil/targets/projects/remove": ("projects-remove", lambda: self._target_projects(body, remove=True)), + "/hil/devices/inspect": ("inspect", lambda: self._inspect_device(body)), + "/hil/devices/provision": ("provision", lambda: self._inspect_device(body, True)), + } + if path in jobs: + return self._job(*jobs[path]) + if path == "/hil/devices/host-key": + from .devices import inspect_host_key + config = self._target(body.get("target")).get("ssh", {}) + return self._job("identity", lambda: inspect_host_key(config.get("host"), config.get("port", 22))) + if path == "/hil/devices/discover": + from .devices import discover_devices + return self._job("discover", lambda: discover_devices(body.get("interface"), body.get("profile"), body.get("host"))) + if path == "/hil/devices/local-network": + from .devices import configure_local_network + return self._job("network", lambda: configure_local_network( + body.get("interface"), body.get("method"), body.get("address"), body.get("netmask"), body.get("gateway"))) + if path in ("/hil/devices/network", "/hil/devices/network/confirm"): + def network(): + if path.endswith("/confirm"): + from .devices import LinuxDevice + transaction = body.get("transaction") + pending = self.network_pending.get(transaction) + if not pending or pending["target"]["id"] != body.get("target"): + raise HilError("Unknown network change for this target") + new_host = body.get("host") or pending["new_host"] + if not new_host: + raise HilError("Discover and supply the target's new address before confirming") + target = copy.deepcopy(pending["target"]) + target["ssh"]["host"] = new_host + credentials = dict(self.credentials.get(target["id"], {})) + credentials.update({k: body[k] for k in ("password", "host_key") if body.get(k) is not None}) + device = LinuxDevice(target, credentials, self._progress) + try: + result = device.confirm_network(transaction) + if credentials.get("host_key"): + target["ssh"]["host_key"] = credentials["host_key"] + save_target(target) + self.targets = read_targets() + self._store_credentials(target, credentials) + self.device_info.pop(target["id"], None) + self.network_pending.pop(transaction, None) + return result + finally: + device.close() + device = self._device(body) + try: + if self.session: + raise HilError("Stop the project before configuring the target network") + self._drop() + result = device.configure_network(body.get("interface"), body.get("method"), body.get("address"), body.get("gateway"), body.get("netmask")) + self.network_pending[result["transaction"]] = {"target": copy.deepcopy(device.target), "new_host": result.get("new_host")} + return result + finally: + device.close() + return self._job("network", network) + if path == "/hil/connect" and self._target(body.get("target")).get("transport") == "ssh": + return self._job("connect", lambda: self._handle(method, path, body)) + if path == "/hil/start-loaded": + self._validate_start_request(body.get("request_id")) + return self._job("run", lambda: self._start_loaded_project(body), request_id=body["request_id"]) + if path == "/hil/start" and self._supports_projects() and self.device.get("backend") == "onnxruntime": + return self._job("run", lambda: self._start_project(body), request_id=body.get("request_id")) + if path == "/hil/start" and body.get("request_id") is not None: + return self._job("run", lambda: self._start_session(body), request_id=body["request_id"]) + with self.lock: + try: + return self._handle(method, path, body) + except (OSError, ValueError, EOFError) as exc: + message = self._redact(str(exc) or type(exc).__name__) + details = error_details(exc) + self.error = None if details else message + raise HilError(message, **details) from exc + + def _handle(self, method, path, body): + if not isinstance(body, dict): + raise HilError("HIL request body must be an object") + if method == "GET" and path == "/hil/state": + if self.sock and time.monotonic() - self.last_activity > 30: + try: + self._rpc({"op": "hello"}) + except HilError: + pass + return self.state() + if method == "GET" and path == "/hil/projects": + return self._projects() + if method != "POST": + raise HilError("Unknown HIL route") + if path == "/hil/credentials": + if set(body) != {"target", "expected_target", "password"}: + raise HilError("Target, expected target settings and password are required") + target = self._target(body.get("target")) + if target.get("transport") != "ssh": + raise HilError("Transient SSH credentials require an SSH target") + if not isinstance(body.get("expected_target"), dict) or body["expected_target"] != target: + raise HilError("Target settings changed. Retry the operation before entering its password.") + credentials = self._validate_credentials({"password": body.get("password")}) + if not credentials["password"]: + raise HilError("Enter the SSH password") + credentials = {**self._credentials_for(target), **credentials} + self._store_credentials(target, credentials) + if (self.operation and self.operation.get("error_code") == "ssh_auth_required" + and self.operation.get("target_id") == target["id"] + and self.error == self.operation.get("error")): + self.error = None + return self.state() + if path == "/hil/targets/remove": + target_id = body.get("target") + if target_id == self.selected_target and self.sock: + raise HilError("Disconnect before removing this target") + remove_target(target_id) + self.targets = read_targets() + self.credentials.pop(target_id, None) + self.credential_targets.pop(target_id, None) + self.device_info.pop(target_id, None) + if self.selected_target == target_id: + self.selected_target = None + return self.state() + if path in ("/hil/projects/unload", "/hil/projects/remove"): + if path == "/hil/projects/remove" and "target" in body and ( + self.sock is None or body["target"] != self.selected_target + ): + raise HilError("Target changed or disconnected. " + "Refresh the target and confirm project deletion again.") + if self.session: + raise HilError("Stop the project first") + if not self._supports_projects(): + raise HilError("Target does not support project management") + result, _ = self._rpc({**body, "op": "projects.unload" if path.endswith("unload") else "projects.remove"}) + if path.endswith("unload"): + self.project_generation += 1 + self._projects() + return result + if path == "/hil/targets": + settings = dict(body) + supplied = self._validate_credentials(settings.pop("credentials", {})) + target = validate_target(settings) + previous = next((item for item in self.targets if item["id"] == target["id"]), None) + if self.sock and target["id"] == self.selected_target and target != previous: + raise HilError("Disconnect before changing connected target settings") + if supplied and target.get("transport") != "ssh": + raise HilError("Transient SSH credentials require an SSH target") + credentials = self._credentials_for(previous) if previous else {} + if previous is None or self._credential_identity(previous) != self._credential_identity(target): + credentials = {} + if previous != target: + self.device_info.pop(target["id"], None) + if previous and previous.get("ssh", {}).get("host_key") != target.get("ssh", {}).get("host_key"): + credentials.pop("host_key", None) + credentials.update(supplied) + target = save_target(target) + if credentials: + self._store_credentials(target, credentials) + else: + self.credentials.pop(target["id"], None) + self.credential_targets.pop(target["id"], None) + self.targets = read_targets() + if not self.sock: + self.selected_target = target["id"] + self.error = None + return self.state() + if path == "/hil/connect": + target_id = body.get("target", self.selected_target) + target = next((t for t in self.targets if t["id"] == target_id), None) + if target is None: + raise HilError("Choose a configured target") + self._drop() + self.selected_target = target_id + try: + self.sock, self.tunnel, self.tunnel_device = self._open_stream(target, body) + result, _ = self._rpc({"op": "hello"}) + self.device = result["device"] + if self.device.get("backend") != target["backend"]: + raise HilError(f"Target backend is {self.device.get('backend')}, expected {target['backend']}") + self.error = None + if self._supports_projects(): + self._projects() + except Exception: + self._drop() + raise + return self.state() + if path == "/hil/disconnect": + if self.session: + self._handle("POST", "/hil/stop", {"session": self.session}) + self._drop() + self.device = None + self.error = None + return self.state() + if path == "/hil/start": + if self.session: + raise HilError("Stop the current session before starting another") + result, _ = self._rpc({"op": "start"}) + self.session = result["session"] + self.sequence = -1 + self.models = {} + self.error = None + return {"session": self.session, "device": result["device"]} + if path == "/hil/stop": + if body.get("session") and body["session"] != self.session: + raise HilError("Stale HIL session; current session was not stopped") + if self.session: + self._rpc({"op": "stop", "session": self.session}) + self.session = None + self.models = {} + self.sequence = -1 + return self.state() + if path == "/hil/models": + self._check_session(body) + result, _ = self._rpc({**body, "op": "load"}, timeout=30) + try: + if result.get("sha256") != body.get("sha256"): + raise HilError("Target returned the wrong model identity") + ports(result.get("inputs")) + ports(result.get("outputs")) + except HilError: + self._drop() + raise + self.models[result["sha256"]] = result + return {key: result[key] for key in ("sha256", "inputs", "outputs")} + if path == "/hil/infer": + self._check_session(body) + seq = body.get("seq") + if type(seq) is not int or seq < 0 or seq <= self.sequence: + raise HilError("Inference sequence must increase within a session") + model = self.models.get(body.get("model")) + if not model: + raise HilError("Model is not loaded in this session") + tensors(body.get("inputs"), model["inputs"]) + self.sequence = seq + result, rtt = self._rpc({**body, "op": "infer"}) + try: + if result.get("session") != self.session or result.get("seq") != seq: + raise HilError("Target returned a stale inference response") + outputs = result.get("outputs") + if not isinstance(outputs, dict): + raise HilError("Target returned invalid outputs") + tensors(outputs, model["outputs"]) + except HilError: + self._drop() + raise + self.metrics = {"calls": result["calls"], "inference_ms": result["inference_ms"], "rtt_ms": rtt} + return {"session": result["session"], "seq": seq, "outputs": outputs, **self.metrics} + raise HilError("Unknown HIL route") + + def close(self): + self.closed = True + with self.lock: + if self.sock and self.session: + try: + self._rpc({"op": "stop", "session": self.session}, timeout=1) + except HilError: + pass + self._drop() + self.credentials.clear() + self.credential_targets.clear() diff --git a/src/botlab/hil/protocol.py b/src/botlab/hil/protocol.py new file mode 100644 index 0000000..7146a31 --- /dev/null +++ b/src/botlab/hil/protocol.py @@ -0,0 +1,151 @@ +"""BotLab HIL v1: bounded length-framed JSON and float32 tensor contracts.""" +from __future__ import annotations + +import json +import math +import re +import socket +import struct +from urllib.parse import urlsplit + +VERSION = 1 +MAX_MODEL_BYTES = 32 * 1024 * 1024 +MAX_FRAME_BYTES = 48 * 1024 * 1024 +MAX_TENSOR_ELEMENTS = 1_000_000 +SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +class HilError(ValueError): + """An actionable HIL error, optionally carrying a recoverable SSH challenge.""" + + def __init__(self, message, *, error_code=None, target_id=None, password_attempted=None): + super().__init__(message) + self.error_code = error_code + self.target_id = target_id + self.password_attempted = password_attempted + + +def error_details(error) -> dict: + """Only public recovery metadata; never expose credentials or exception internals.""" + if isinstance(error, HilError) and error.error_code == "ssh_auth_required": + return {"error_code": error.error_code, "target_id": error.target_id, + "password_attempted": error.password_attempted is True} + return {} + + +def endpoint_address(endpoint: str) -> tuple[str, int]: + if not isinstance(endpoint, str): + raise HilError("Target endpoint must be tcp://host:port") + try: + url = urlsplit(endpoint if "://" in endpoint else "tcp://" + endpoint) + if (url.scheme != "tcp" or not url.hostname or url.port is None + or url.username or url.password or url.path or url.query or url.fragment): + raise ValueError + if not 1 <= url.port <= 65535: + raise ValueError + return url.hostname, url.port + except ValueError as exc: + raise HilError("Target endpoint must be tcp://host:port") from exc + + +def gateway_endpoint(endpoint: str) -> str: + """Validate a gateway origin: ws:// stays loopback, wss:// goes anywhere. + + Mirrors the Control M1 endpoint rule — plaintext never leaves the loopback, + and paths are derived from the device ID, never stored. + """ + if not isinstance(endpoint, str): + raise HilError("Gateway endpoint must be ws:// or wss://") + try: + url = urlsplit(endpoint) + scheme = url.scheme.lower() + if url.username or url.password or url.path not in ("", "/") or url.query or url.fragment: + raise ValueError + if not url.hostname: + raise ValueError + if url.port is not None and not 1 <= url.port <= 65535: + raise ValueError + if scheme == "ws": + if url.hostname not in ("127.0.0.1", "localhost", "::1") or url.port is None: + raise ValueError + elif scheme != "wss": + raise ValueError + return f"{scheme}://{url.netloc.lower()}" + except ValueError as exc: + raise HilError("Gateway endpoint must be a bare ws://loopback:port or wss://host[:port] origin") from exc + + +def _read(sock: socket.socket, size: int) -> bytes: + result = bytearray() + while len(result) < size: + part = sock.recv(size - len(result)) + if not part: + raise EOFError("Target disconnected") + result.extend(part) + return bytes(result) + + +def receive(sock: socket.socket) -> dict: + size = struct.unpack("!I", _read(sock, 4))[0] + if not 0 < size <= MAX_FRAME_BYTES: + raise HilError("HIL message exceeds the frame limit") + try: + result = json.loads(_read(sock, size), parse_constant=lambda _: (_ for _ in ()).throw(ValueError())) + except (ValueError, UnicodeError) as exc: + raise HilError("Malformed HIL JSON") from exc + if not isinstance(result, dict) or result.get("version") != VERSION: + raise HilError("Unsupported HIL protocol version (expected 1)") + return result + + +def send(sock: socket.socket, value: dict) -> None: + payload = json.dumps({**value, "version": VERSION}, allow_nan=False, separators=(",", ":")).encode() + if len(payload) > MAX_FRAME_BYTES: + raise HilError("HIL message exceeds the frame limit") + sock.sendall(struct.pack("!I", len(payload)) + payload) + + +def tensor(value: object, spec: dict | None = None) -> dict: + if not isinstance(value, dict) or value.get("dtype") != "float32": + raise HilError("HIL v1 accepts float32 tensors only") + shape, data = value.get("shape"), value.get("data") + if not isinstance(shape, list) or len(shape) > 16 or any(type(n) is not int or n < 0 for n in shape): + raise HilError("Tensor shape must contain non-negative integer dimensions") + size = math.prod(shape) + if size > MAX_TENSOR_ELEMENTS or not isinstance(data, list) or len(data) != size: + raise HilError("Tensor data length does not match shape or exceeds limit") + if any(type(n) not in (int, float) or abs(n) > 3.4028234663852886e38 or not math.isfinite(n) for n in data): + raise HilError("Tensor values must be finite float32 numbers") + if spec: + expected = spec["shape"] + if spec["dtype"] != "float32" or len(shape) != len(expected) or any( + isinstance(n, int) and n >= 0 and n != actual + for n, actual in zip(expected, shape, strict=True) + ): + raise HilError(f"Tensor shape does not match port {spec['name']}: expected {expected}, got {shape}") + return {"dtype": "float32", "shape": shape, "data": data} + + +def tensors(values: object, specs: list[dict]) -> dict: + if not isinstance(values, dict) or set(values) != {s["name"] for s in specs}: + raise HilError("Tensor names do not match the model ports") + return {spec["name"]: tensor(values[spec["name"]], spec) for spec in specs} + + +def ports(value: object) -> list[dict]: + """Validate backend-provided metadata before exposing it to consumers.""" + if not isinstance(value, list) or not value or len(value) > 128: + raise HilError("Models require 1–128 named ports") + names = set() + for spec in value: + if not isinstance(spec, dict): + raise HilError("Invalid model port metadata") + name, shape = spec.get("name"), spec.get("shape") + if not isinstance(name, str) or not name or len(name) > 1024 or name in names: + raise HilError("Model port names must be nonempty and unique") + names.add(name) + if spec.get("dtype") != "float32" or not isinstance(shape, list) or len(shape) > 16: + raise HilError("HIL v1 requires float32 ports with valid shapes") + if any(not (n is None or type(n) is int and n >= 0 or isinstance(n, str)) for n in shape): + raise HilError("Invalid model port dimension") + return value diff --git a/src/botlab/hil/targets.py b/src/botlab/hil/targets.py new file mode 100644 index 0000000..cef0a84 --- /dev/null +++ b/src/botlab/hil/targets.py @@ -0,0 +1,113 @@ +"""User-owned target inventory; it contains connection settings, never credentials.""" +from __future__ import annotations + +import json +import os +import re +import tempfile +import uuid +from pathlib import Path + +from .protocol import HilError, endpoint_address, gateway_endpoint + + +def targets_path() -> Path: + override = os.environ.get("BOTLAB_TARGETS_FILE") + return Path(override).expanduser() if override else Path.home() / ".config/botlab/targets.json" + + +def read_targets(path: Path | None = None) -> list[dict]: + path = path or targets_path() + if not path.exists(): + return [] + try: + document = json.loads(path.read_text()) + if document.get("version") != 1 or not isinstance(document.get("targets"), list): + raise ValueError + result = [validate_target(item) for item in document["targets"]] + if len(result) > 100 or len({item["id"] for item in result}) != len(result): + raise ValueError + return result + except (ValueError, TypeError, AttributeError) as exc: + raise HilError(f"Invalid target configuration: {path}") from exc + + +def validate_target(value: dict) -> dict: + if not isinstance(value, dict): + raise HilError("Target must be an object") + target_id = value.get("id") or uuid.uuid4().hex[:12] + if not isinstance(target_id, str) or not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", target_id): + raise HilError("Target ID must use letters, digits, underscores or hyphens") + name = value.get("name") + if not isinstance(name, str) or not name.strip() or len(name) > 100: + raise HilError("Target name must be 1–100 characters") + if "password" in value or "credentials" in value: + raise HilError("Credentials must not be saved in target settings") + transport = value.get("transport", "tcp") + if transport not in ("tcp", "ssh", "gateway"): + raise HilError("Target transport must be tcp, ssh or gateway") + endpoint = value.get("endpoint") + extras = {} + if transport == "gateway": + # The gateway token never lives here; it arrives via BOTLAB_HIL_TOKEN. + endpoint = gateway_endpoint(endpoint) + device = value.get("device") + if not isinstance(device, str) or not re.fullmatch(r"[a-zA-Z0-9_-]{1,64}", device): + raise HilError("Gateway target requires a device ID (letters, digits, _ or -)") + extras = {"transport": "gateway", "device": device} + elif transport == "ssh": + from .devices import _ssh_config + config = value.get("ssh") + if not isinstance(config, dict) or set(config) - {"host", "user", "port", "identity_file", "host_key"}: + raise HilError("Invalid SSH settings; passwords are transient") + host, user, port, root = _ssh_config(value) + config = {**config, "host": host, "user": user, "port": port} + fingerprint = config.get("host_key") + if fingerprint is not None and (not isinstance(fingerprint, str) or not re.fullmatch(r"SHA256:[A-Za-z0-9+/]{43}", fingerprint)): + raise HilError("Invalid SSH host key fingerprint") + agent_port = value.get("agent_port", 50052) + if type(agent_port) is not int or not 1 <= agent_port <= 65535: + raise HilError("Invalid target agent port") + endpoint = f"tcp://127.0.0.1:{agent_port}" + profile = value.get("profile", "generic-linux") + if profile not in ("generic-linux", "rdk-x5"): + raise HilError("Unsupported device profile") + extras = {"transport": "ssh", "ssh": config, "root": root, + "agent_port": agent_port, "profile": profile} + else: + endpoint_address(endpoint) + backend = value.get("backend") or "onnxruntime" + if not isinstance(backend, str) or len(backend) > 200: + raise HilError("Invalid backend name") + return {"id": target_id, "name": name.strip(), "endpoint": endpoint, "backend": backend, **extras} + + +def save_target(value: dict, path: Path | None = None) -> dict: + target = validate_target(value) + path = path or targets_path() + items = read_targets(path) + items = [item for item in items if item["id"] != target["id"]] + [target] + if len(items) > 100: + raise HilError("At most 100 targets may be configured") + _write_targets(items, path) + return target + + +def remove_target(target_id: str, path: Path | None = None) -> None: + path = path or targets_path() + items = read_targets(path) + if not any(item["id"] == target_id for item in items): + raise HilError("Unknown target") + _write_targets([item for item in items if item["id"] != target_id], path) + + +def _write_targets(items, path): + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(dir=path.parent, prefix=".targets-") + try: + with os.fdopen(fd, "w") as stream: + json.dump({"version": 1, "targets": items}, stream, indent=2) + stream.write("\n") + os.replace(temporary, path) + finally: + Path(temporary).unlink(missing_ok=True) diff --git a/src/botlab/hil/worker.py b/src/botlab/hil/worker.py new file mode 100644 index 0000000..c8ca013 --- /dev/null +++ b/src/botlab/hil/worker.py @@ -0,0 +1,140 @@ +"""Killable inference process; target management stays alive after backend faults.""" +from __future__ import annotations + +import math +import multiprocessing +import threading +from pathlib import Path + +from .backends import create_backend +from .protocol import HilError + + +def _serve(connection, backend_name): + backend = None + handles = {} + try: + backend = create_backend(backend_name) + connection.send({"ok": True, "value": backend.describe()}) + while True: + op, args = connection.recv() + try: + if op == "load": + handle, inputs, outputs = backend.load(Path(args[0])) + identity = max(handles, default=0) + 1 + handles[identity] = handle + value = identity, inputs, outputs + elif op == "infer": + value = backend.infer(handles[args[0]], args[1]) + elif op == "close": + handle = handles.pop(args[0], None) + if handle is not None: + backend.close(handle) + value = None + else: + raise HilError("Unknown inference worker operation") + connection.send({"ok": True, "value": value}) + except Exception as exc: + connection.send({"ok": False, "error": str(exc) or type(exc).__name__}) + except (EOFError, OSError): + pass + except Exception as exc: + try: + connection.send({"ok": False, "error": str(exc) or type(exc).__name__}) + except (OSError, EOFError): + pass + finally: + connection.close() + + +class WorkerBackend: + """Backend-compatible proxy used by the CLI, with opaque generation handles.""" + + def __init__(self, name, *, inference_timeout=3.0, load_timeout=30.0): + for value in (inference_timeout, load_timeout): + if not math.isfinite(value) or value <= 0: + raise HilError("Worker timeouts must be finite and positive") + self.name = name + self.inference_timeout = inference_timeout + self.load_timeout = load_timeout + self.lock = threading.Lock() + self.process = self.connection = None + self.generation = 0 + self.metadata = None + with self.lock: + self._start() + + def _read(self, connection, timeout): + if not connection.poll(timeout): + raise HilError("Inference worker timed out; its process was terminated") + value = connection.recv() + if not value.get("ok"): + raise HilError(value.get("error") or "Inference worker failed") + return value["value"] + + def _start(self): + context = multiprocessing.get_context("spawn") + parent, child = context.Pipe() + process = context.Process(target=_serve, args=(child, self.name), daemon=True) + self.process, self.connection = process, parent + self.generation += 1 + try: + process.start() + child.close() + self.metadata = self._read(parent, self.load_timeout) + except Exception: + child.close() + self.reset() + raise + + def _call(self, op, args, timeout, generation=None): + with self.lock: + if generation is not None and generation != self.generation: + raise HilError("Inference worker session has been released") + if self.process is None: + if generation is not None: + raise HilError("Inference worker session has been released") + self._start() + connection = self.connection + current = self.generation + try: + connection.send((op, args)) + result = self._read(connection, timeout) + if current != self.generation: + raise HilError("Inference worker session has been released") + return result + except (OSError, EOFError, TypeError, HilError) as exc: + self.reset() + raise HilError(str(exc) or "Inference worker disconnected") from exc + + def describe(self): + return dict(self.metadata) + + def load(self, path): + identity, inputs, outputs = self._call("load", [str(path)], self.load_timeout) + return (self.generation, identity), inputs, outputs + + def infer(self, handle, inputs): + generation, identity = handle + return self._call("infer", [identity, inputs], self.inference_timeout, generation) + + def close(self, handle): + generation, identity = handle + if generation == self.generation and self.process is not None: + self._call("close", [identity], 1.0, generation) + + def reset(self): + # Deliberately does not acquire the request lock: Stop can interrupt a + # hung native call and unblock an in-flight request in another thread. + process, connection = self.process, self.connection + self.process = self.connection = None + self.generation += 1 + if process is not None and process.pid is not None: + if process.is_alive(): + process.terminate() + process.join(timeout=0.3) + if process.is_alive(): + process.kill() + process.join(timeout=0.3) + if connection is not None: + connection.close() diff --git a/src/botlab/hil/wsstream.py b/src/botlab/hil/wsstream.py new file mode 100644 index 0000000..36516f2 --- /dev/null +++ b/src/botlab/hil/wsstream.py @@ -0,0 +1,113 @@ +"""WebSocket transport for HIL v1: a socket-shaped view over one connection. + +The device gateway relays opaque protocol bytes, so both endpoints keep using +``protocol.receive``/``send`` unchanged. ``websockets`` is imported lazily — +plain TCP targets must not pay for gateway mode, and the CLI import chain +stays standard-library only. +""" +from __future__ import annotations + +import os +import ssl + +from .protocol import MAX_FRAME_BYTES, HilError + +GATEWAY_TIMEOUT_HEADER = 5 + + +class WebSocketStream: + """Adapter exposing ``recv``/``sendall``/``settimeout``/``close``. + + ``sendall`` sends exactly one binary message; ``recv`` serves bytes from an + internal buffer refilled one message at a time, so partial reads behave + like a stream socket for ``protocol.receive``. + """ + + def __init__(self, websocket): + self.websocket = websocket + self._buffer = b"" + self._timeout: float | None = None + + def settimeout(self, timeout): + self._timeout = timeout + + def sendall(self, data): + try: + self.websocket.send(data) + except Exception as exc: # websockets raises its own hierarchy + raise OSError(_describe(exc)) from exc + + def recv(self, bufsize): + while not self._buffer: + try: + message = self.websocket.recv(timeout=self._timeout) + except TimeoutError: + raise + except Exception as exc: + raise OSError(_describe(exc)) from exc + if not isinstance(message, (bytes, bytearray)): + raise OSError("Gateway channel received a non-binary WebSocket frame") + if not message: + raise OSError("Gateway channel received an empty WebSocket message") + self._buffer = bytes(message) + result = self._buffer[:bufsize] + self._buffer = self._buffer[bufsize:] + return result + + def close(self): + try: + self.websocket.close() + except Exception: + pass + + def shutdown(self, how): + """Socket-compatible teardown for HilHost._drop: no half-close exists.""" + self.close() + + +def _describe(exc: Exception) -> str: + reason = getattr(exc, "recv_reason", None) or "" + text = str(exc) or type(exc).__name__ + return f"{text} ({reason})" if reason else text + + +def gateway_url(endpoint: str, channel: str, device: str) -> str: + """Build ``//`` from a validated bare origin.""" + from urllib.parse import quote + return f"{endpoint.rstrip('/')}/{channel}/{quote(device, safe='')}" + + +def connect_gateway(endpoint: str, channel: str, device: str, token: str) -> WebSocketStream: + """Dial a gateway channel with bearer authentication over verified TLS. + + ``ws://`` is accepted for loopback development gateways only (the target + validator enforces this); ``wss://`` verification is never disabled. A + private CA is honored via ``BOTLAB_HIL_CA_BUNDLE``. + + Exactly one dial is attempted. websockets' sync TLS opening handshake + stalls with small probability (upstream race, reproducible without any + BotLab code); a stalled dial surfaces here as a clean ``HilError`` — + never a silent retry, because a retry can race the server side of the + stalled attempt (device channel: spurious ``device busy``). + """ + try: + from websockets.sync.client import connect + except ImportError as exc: + raise HilError("Gateway mode needs the websockets package; install botlab-python[target]") from exc + url = gateway_url(endpoint, channel, device) + ca_bundle = os.environ.get("BOTLAB_HIL_CA_BUNDLE") + context = ssl.create_default_context(cafile=ca_bundle) if endpoint.startswith("wss://") else None + if ca_bundle and context is None: + raise HilError("BOTLAB_HIL_CA_BUNDLE applies to wss:// gateways only") + # legacy=True keeps the long-lived connection object (no context + # manager); compression=None skips permessage-deflate — tensor frames + # pay CPU latency for negligible size wins; proxy=None dials the + # gateway directly, never through ambient system proxy settings. + try: + websocket = connect(url, additional_headers=[("Authorization", f"Bearer {token}")], + ssl=context, open_timeout=GATEWAY_TIMEOUT_HEADER, + max_size=MAX_FRAME_BYTES, compression=None, legacy=True, + proxy=None) + except Exception as exc: + raise HilError(f"Gateway connection failed: {_describe(exc)}") from exc + return WebSocketStream(websocket) diff --git a/src/botlab/sdk/__init__.py b/src/botlab/sdk/__init__.py index f1e55d4..9a91843 100644 --- a/src/botlab/sdk/__init__.py +++ b/src/botlab/sdk/__init__.py @@ -4,8 +4,16 @@ """ from ._compat import create_proxy +from ._config import read_config from ._controllers import Controller, PolicyController, PositionController -from ._declaration import default_joint_positions, project, resource_config +from ._declaration import ( + action, + control_profile, + declare_control, + default_joint_positions, + project, + resource_config, +) from ._history import history, resize from ._math import ( absolute, @@ -89,6 +97,7 @@ "Signal", "Simulation", "absolute", + "action", "affine", "all_of", "any_of", @@ -96,9 +105,11 @@ "clock", "concat", "constant", + "control_profile", "cos", "cross", "default_joint_positions", + "declare_control", "delay", "derivative", "dot", @@ -127,6 +138,7 @@ "quaternion_multiply", "quaternion_slerp", "quaternion_yaw", + "read_config", "reduce_max", "reduce_min", "reduce_sum", diff --git a/src/botlab/sdk/_config.py b/src/botlab/sdk/_config.py new file mode 100644 index 0000000..12c461c --- /dev/null +++ b/src/botlab/sdk/_config.py @@ -0,0 +1,775 @@ +"""Declaration data files: :func:`read_config` and the restricted YAML parser. + +A project keeps its declaration values in ``config/*.yaml`` and references them +from ``controller.py``; the source file stays structure and wiring only:: + + runtime = read_config("config/runtime.yaml") + project(runtime["name"], control_hz=runtime["control_hz"], ...) + +The parser here is deliberately small and dependency-free: Pyodide ships no +PyYAML and the declaration channel serves file text through the host rather +than the virtual filesystem. The accepted subset is the one BotLab's +TypeScript ``parseRestrictedYaml`` accepts (YAML 1.2 core schema) and is +pinned against it by a dual-end fixture corpus: block mappings and sequences, +flow collections, quoted scalars, block scalars, comments and blank lines. +Anchors, aliases, tags, merge keys, directives, multiple documents, non-string +mapping keys and non-finite numbers are rejected instead of interpreted. +""" + +import math +import re + +import botlab_host + +__all__ = ["read_config"] + +_CONFIG_DIRECTORY = "config" +_MAX_SAFE_INTEGER = 2 ** 53 - 1 + +_NULL_TOKENS = frozenset({"", "~", "null", "Null", "NULL"}) +_TRUE_TOKENS = frozenset({"true", "True", "TRUE"}) +_FALSE_TOKENS = frozenset({"false", "False", "FALSE"}) +_DECIMAL_INT = re.compile(r"[-+]?[0-9]+\Z") +_HEXADECIMAL_INT = re.compile(r"[-+]?0x[0-9a-fA-F]+\Z") +_OCTAL_INT = re.compile(r"[-+]?0o[0-7]+\Z") +_FLOAT_NUMBER = re.compile(r"[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?\Z") +_NON_FINITE = re.compile(r"[-+]?\.(?:inf|Inf|INF|nan|NaN|NAN)\Z") +_QUOTE_PAIRS = {'"', "'"} +_FLOW_DELIMITERS = ",[]{}" + + +def _config_path(path): + """The canonical config file reference: ``config/.yaml``.""" + if not isinstance(path, str) or not path: + raise ValueError("read_config path must be a non-empty string") + if "\\" in path: + raise ValueError(f'read_config path "{path}" must use "/" separators') + segments = path.split("/") + if len(segments) != 2 or segments[0] != _CONFIG_DIRECTORY or not segments[1]: + raise ValueError( + f'read_config path "{path}" must name one file inside ' + f'"{_CONFIG_DIRECTORY}/", like "config/runtime.yaml"' + ) + name = segments[1] + if not name.endswith(".yaml") or name.endswith(".preset.yaml") or name.startswith("."): + raise ValueError( + f'read_config path "{path}" must be a project config .yaml file; ' + "preset calibration keeps its root-level *.preset.yaml form" + ) + return path + + +def read_config(path): + """Read one ``config/*.yaml`` declaration file as plain Python data. + + The returned structure is JSON-compatible (mappings, sequences, strings, + numbers, booleans, null) so it flows straight into ``project()``, + resource constructors and ``control_profile``. Anything the restricted + parser does not accept raises ``ValueError`` naming the file and line. + """ + source = _config_path(path) + text = botlab_host.read_project_file(source) + if not isinstance(text, str): + raise ValueError(f'read_config("{source}") needs the file text from the host') + return _parse_restricted_yaml(source, text) + + +# --------------------------------------------------------------------------- +# Restricted YAML subset parser +# +# Line-oriented recursive descent. ``_index`` is the 0-based index of the next +# raw line to examine; ``_Line.number`` is that line's 1-based number. +# --------------------------------------------------------------------------- + + +class _YamlError(ValueError): + def __init__(self, path, line, message): + super().__init__(f"{path}:{line}: {message}") + + +def _resolve_plain_scalar(token, path, line): + """YAML 1.2 core-schema resolution for one plain (unquoted) scalar.""" + if token in _NULL_TOKENS: + return None + if token in _TRUE_TOKENS: + return True + if token in _FALSE_TOKENS: + return False + if _NON_FINITE.match(token): + raise _YamlError(path, line, f'"{token}" is not a finite number') + if _DECIMAL_INT.match(token): + return _yaml_int(int(token, 10)) + if _HEXADECIMAL_INT.match(token): + return _yaml_int(int(token, 16)) + if _OCTAL_INT.match(token): + return _yaml_int(int(token, 8)) + if _FLOAT_NUMBER.match(token): + value = float(token) + if not math.isfinite(value): + raise _YamlError(path, line, f'"{token}" is not a finite number') + # JSON has one number type: the TypeScript side reads the same digits + # as a float64, so integrals normalize to integers on both sides. + if value.is_integer() and abs(value) <= _MAX_SAFE_INTEGER: + return int(value) + return value + return token + + +def _yaml_int(value): + if -_MAX_SAFE_INTEGER <= value <= _MAX_SAFE_INTEGER: + return value + return float(value) + + +def _find_key_separator(content): + """Column of the ':' that ends a plain mapping key, or None. + + A ':' only separates a key when it ends the token (end of line) or is + followed by a space; a '#' after whitespace starts a comment and ends the + search. Plain keys may contain ':' as long as no space follows it. + """ + for index in range(len(content)): + character = content[index] + if character == "#" and index > 0 and content[index - 1] in " \t": + return None + if character == ":" and ( + index + 1 == len(content) or content[index + 1] in " \t" + ): + return index + return None + + +def _quoted_span(text, quote): + """Index of the closing quote of the scalar starting at text[0], or None.""" + index = 1 + while index < len(text): + character = text[index] + if quote == "'": + if character == "'": + if index + 1 < len(text) and text[index + 1] == "'": + index += 2 + continue + return index + else: + if character == "\\": + index += 2 + continue + if character == quote: + return index + index += 1 + return None + + +def _quoted_key_entry(content): + """True when a quoted scalar followed by ':' opens a compact mapping.""" + if content[0] not in _QUOTE_PAIRS: + return False + end = _quoted_span(content, content[0]) + if end is None: + return False + rest = content[end + 1:] + return rest.startswith(":") and (len(rest) == 1 or rest[1] in " \t") + + +_DQUOTED_ESCAPES = { + "n": "\n", "t": "\t", "r": "\r", '"': '"', "\\": "\\", "0": "\0", + "a": "\a", "b": "\b", "f": "\f", "v": "\v", "e": "\x1b", + " ": " ", "/": "/", +} + + +def _decode_quoted(text, path, line): + """Decode a complete quoted token (quotes included); line breaks fold to spaces.""" + quote = text[0] + if quote == "'": + return _fold_line_breaks(text[1:-1].replace("''", "'")) + raw = _fold_line_breaks(text[1:-1]) + output = [] + index = 0 + while index < len(raw): + character = raw[index] + if character != "\\": + output.append(character) + index += 1 + continue + marker = raw[index + 1] if index + 1 < len(raw) else "" + if marker in _DQUOTED_ESCAPES: + output.append(_DQUOTED_ESCAPES[marker]) + index += 2 + continue + if marker in ("x", "u", "U"): + width = {"x": 2, "u": 4, "U": 8}[marker] + digits = raw[index + 2: index + 2 + width] + try: + output.append(chr(int(digits, 16))) + except ValueError: + raise _YamlError( + path, line, f'invalid \\{marker} escape in a "..." scalar' + ) from None + index += 2 + width + continue + raise _YamlError(path, line, f'unsupported escape "\\{marker}" in a "..." scalar') + return "".join(output) + + +def _fold_line_breaks(text): + """Flow-scalar folding: one break becomes a space, blank lines become breaks.""" + if "\n" not in text: + return text + output = "" + pending_breaks = 0 + for part in text.split("\n"): + part = part.strip() + if part == "": + pending_breaks += 1 + continue + if not output: + output = part + elif pending_breaks: + output += "\n" * pending_breaks + part + else: + output += " " + part + pending_breaks = 0 + return output + + +def _strip_inline_comment(text): + """Cut a plain scalar at the '#' that starts a comment (whitespace before it).""" + for index in range(len(text)): + if text[index] == "#" and (index == 0 or text[index - 1] in " \t"): + return text[:index] + return text + + +class _Line: + __slots__ = ("number", "indent", "content") + + def __init__(self, number, indent, content): + self.number = number + self.indent = indent + self.content = content + + +class _Parser: + def __init__(self, path, text): + if text.startswith(""): + text = text[1:] + self._path = path + normalized = text.replace("\r\n", "\n").replace("\r", "\n") + # A trailing newline terminates the last line; it does not open a new + # empty one (block scalar `|+` chomping counts real blank lines only). + if normalized.endswith("\n"): + normalized = normalized[:-1] + self._lines = normalized.split("\n") if normalized else [""] + self._index = 0 + + def _error(self, line_number, message): + raise _YamlError(self._path, line_number, message) + + # -- line plumbing ------------------------------------------------------ + + def _scan(self, index): + """(indent, content) with comments kept; blank lines yield empty content.""" + raw = self._lines[index] + stripped = raw.lstrip(" ") + if stripped.startswith("\t"): + self._error(index + 1, "tabs are not allowed as indentation") + return len(raw) - len(stripped), stripped.rstrip() + + def _next_content(self): + """0-based index of the next non-blank non-comment line, or None.""" + index = self._index + while index < len(self._lines): + _, content = self._scan(index) + if content and not content.startswith("#"): + return index + index += 1 + return None + + def _peek(self): + index = self._next_content() + if index is None: + return None + indent, content = self._scan(index) + return _Line(index + 1, indent, content) + + def _take(self): + line = self._peek() + if line is not None: + self._index = line.number + return line + + @staticmethod + def _is_sequence_entry(content): + return content.startswith("- ") or content == "-" + + # -- document ------------------------------------------------------------- + + def parse_document(self): + first = self._take() + if first is None: + self._error(1, "the YAML document must not be empty") + if first.content.startswith("%"): + self._error(first.number, "YAML directives are not allowed") + if first.content == "---": + first = self._take() + if first is None: + self._error(1, "the YAML document must not be empty") + if first.content in ("---", "..."): + self._error(first.number, "a document marker here would start a second document") + if self._is_sequence_entry(first.content): + self._error(first.number, "the YAML document root must be a mapping") + root = self._parse_mapping(first) + trailing = self._peek() + if trailing is not None: + if trailing.content == "...": + self._index = trailing.number + after = self._peek() + if after is None: + return root + trailing = after + self._error(trailing.number, f'unexpected content "{trailing.content}" after the document') + return root + + # -- block collections ------------------------------------------------------ + + def _parse_mapping(self, first): + entries = {} + line = first + while line is not None: + if line.indent != first.indent: + if line.indent > first.indent: + self._error(line.number, "unexpected indentation") + return entries + if line.content in ("---", "..."): + return entries + if self._is_sequence_entry(line.content): + self._error(line.number, "a sequence entry cannot appear inside a mapping") + self._index = line.number + key, rest = self._split_entry(line) + if key in entries: + self._error(line.number, f'duplicate mapping key "{key}"') + entries[key] = ( + self._nested_value(first.indent) + if rest is None + else self._inline_value(line, rest, first.indent) + ) + line = self._peek() + return entries + + def _parse_sequence(self, first): + items = [] + line = first + while line is not None: + if line.indent != first.indent: + if line.indent > first.indent: + self._error(line.number, "unexpected indentation") + return items + if not self._is_sequence_entry(line.content): + return items + self._index = line.number + if line.content == "-": + items.append(self._nested_value(first.indent, dashes_below=False)) + line = self._peek() + continue + rest = line.content[2:] + offset = line.indent + 2 + while rest.startswith(" "): + rest = rest[1:] + offset += 1 + items.append( + self._sequence_item(first.indent, _Line(line.number, offset, rest)) + ) + line = self._peek() + return items + + def _sequence_item(self, seq_indent, item_line): + """One `- item`: a compact mapping entry or an inline value.""" + content = item_line.content + if not content or content.startswith("#"): + return self._nested_value(seq_indent, dashes_below=False) + if content[0] in "&*!": + self._error(item_line.number, "anchors, aliases and tags are not allowed") + if content[0] in "{[": + return self._inline_value(item_line, content, seq_indent) + if _quoted_key_entry(content) or _find_key_separator(content) is not None: + return self._parse_mapping(item_line) + return self._inline_value(item_line, content, seq_indent) + + def _nested_value(self, parent_indent, dashes_below=True): + """The value under an empty key or dash. + + Under a mapping key the value may be a deeper block, a sequence at the + key's own indent, or null. Under a dash, dashes at the same indent are + siblings, so only a deeper block or null can follow. + """ + child = self._peek() + if child is None or child.indent < parent_indent: + return None + if child.indent == parent_indent: + if dashes_below and self._is_sequence_entry(child.content): + return self._parse_sequence(child) + return None + return self._parse_block(self._take()) + + def _parse_block(self, line): + if self._is_sequence_entry(line.content): + return self._parse_sequence(line) + return self._parse_mapping(line) + + # -- entries ---------------------------------------------------------------- + + def _split_entry(self, line): + """Split `key: value` into the string key and value text. + + The value text is None when the entry's value lives on the following + lines (empty or comment-only rest). + """ + content = line.content + if content[0] in _QUOTE_PAIRS: + end = _quoted_span(content, content[0]) + if end is None: + self._error(line.number, f"unterminated {content[0]}-quoted mapping key") + key = _decode_quoted(content[: end + 1], self._path, line.number) + rest = content[end + 1:].lstrip(" \t") + if not rest.startswith(":"): + self._error(line.number, "a quoted mapping key must be followed by ':'") + rest = rest[1:] + else: + if content[0] in "&*": + self._error(line.number, "anchors and aliases are not allowed") + if content[0] == "!": + self._error(line.number, "explicit YAML tags are not allowed") + split = _find_key_separator(content) + if split is None: + self._error(line.number, f'expected "key: value", got "{content}"') + key = _resolve_plain_scalar(content[:split].rstrip(), self._path, line.number) + if not isinstance(key, str): + self._error(line.number, "mapping keys must be strings") + if key == "<<": + self._error(line.number, "YAML merge keys are not allowed") + rest = content[split + 1:] + rest = rest.lstrip(" \t") + return key, (rest or None) + + def _inline_value(self, line, rest, continuation_indent): + if rest[0] in "&*!": + kind = {"&": "anchor", "*": "alias", "!": "tag"}[rest[0]] + self._error(line.number, f"YAML {kind}s are not allowed") + if rest[0] in "{[": + return self._parse_flow(line, rest) + if rest[0] in "|>": + return self._parse_block_scalar(line, rest) + if rest[0] in _QUOTE_PAIRS: + return self._parse_quoted_value(line, rest) + return self._parse_plain_value(line, rest, continuation_indent) + + # -- scalars ------------------------------------------------------------------ + + def _parse_quoted_value(self, line, rest): + quote = rest[0] + end = _quoted_span(rest, quote) + if end is not None: + trailing = rest[end + 1:].lstrip(" ") + if trailing and not trailing.startswith("#"): + self._error(line.number, f'unexpected "{trailing}" after a quoted scalar') + return _decode_quoted(rest[: end + 1], self._path, line.number) + parts = [rest.rstrip()] + index = line.number + while index < len(self._lines): + _, content = self._scan(index) + parts.append(content) + if content and (span := _quoted_span(content, quote)) is not None: + self._index = index + 1 + token = "\n".join(parts[:-1]) + "\n" + content[: span + 1] + return _decode_quoted(token, self._path, line.number) + index += 1 + self._error(line.number, f"unterminated {quote}-quoted scalar") + + def _parse_plain_value(self, line, rest, continuation_indent): + """A plain scalar, possibly folded across more-indented following lines.""" + first = _strip_inline_comment(rest).rstrip() + if _find_key_separator(first) is not None: + self._error(line.number, "nested mappings are not allowed in compact mappings") + parts = [first] + index = line.number + blanks = 0 + while index < len(self._lines): + _, content = self._scan(index) + if not content: + blanks += 1 + index += 1 + continue + if content.startswith("#"): + break + indent = len(self._lines[index]) - len(self._lines[index].lstrip(" ")) + if indent <= continuation_indent: + break + if _find_key_separator(content) is not None or self._is_sequence_entry(content): + self._error(index + 1, "a plain scalar cannot continue into a mapping entry") + if blanks: + parts.append("") + blanks = 0 + parts.append(content) + index += 1 + self._index = index + return _resolve_plain_scalar( + _fold_line_breaks("\n".join(parts)).strip(), self._path, line.number + ) + + def _parse_block_scalar(self, line, rest): + header = _strip_inline_comment(rest).rstrip() + folded = header[0] == ">" + chomp = "clip" + explicit_indent = None + for character in header[1:]: + if character == "-": + chomp = "strip" + elif character == "+": + chomp = "keep" + elif character.isdigit(): + explicit_indent = int(character) + else: + self._error(line.number, f'unsupported block scalar header "{header}"') + content_indent = None if explicit_indent is None else line.indent + explicit_indent + collected = [] + index = line.number + while index < len(self._lines): + raw = self._lines[index] + stripped = raw.lstrip(" ") + if not stripped: + collected.append("") + index += 1 + continue + indent = len(raw) - len(stripped) + if content_indent is None: + if indent <= line.indent: + break + content_indent = indent + if indent < content_indent: + break + collected.append(raw[content_indent:]) + index += 1 + self._index = index + if not any(collected): + return "\n" * len(collected) if chomp == "keep" else "" + trailing = 0 + for part in reversed(collected): + if part == "": + trailing += 1 + else: + break + core = collected[: len(collected) - trailing] + body = _fold_block_lines(core) if folded else core + text = "\n".join(body) + if chomp == "strip": + return text + if chomp == "clip": + return text + "\n" + return text + "\n" * (1 + trailing) + + # -- flow collections ------------------------------------------------------------ + + def _parse_flow(self, line, rest): + scanner = _FlowScanner(self, line.number, rest) + value = scanner.parse_value() + scanner.expect_end() + self._index = scanner.line_number + return value + + +def _fold_block_lines(lines): + """Folded block scalar content: adjacent lines join with spaces, breaks stay.""" + output = None + breaks = 0 + for line in lines: + if line == "": + breaks += 1 + continue + if output is None: + output = line + elif breaks: + output += "\n" * breaks + line + else: + output += " " + line + breaks = 0 + return [] if output is None else [output] + + +class _FlowScanner: + """Character scanner for one flow collection, which may span lines. + + Multi-line *plain* scalars inside a flow collection are not part of the + subset: they stop at end of line and the collection then fails on the + unexpected text rather than guessing a join. + """ + + def __init__(self, parser, line_number, text): + self._parser = parser + self._path = parser._path + self._total_lines = len(parser._lines) + self.line_number = line_number + self._text = text + self._position = 0 + + def _error(self, message): + raise _YamlError(self._path, self.line_number, message) + + def _skip_spaces(self): + while self._position < len(self._text) and self._text[self._position] in " \t": + self._position += 1 + + def _carry(self): + """Move to the next raw line, stripped of its indentation.""" + if self.line_number >= self._total_lines: + return False + self.line_number += 1 + raw = self._parser._lines[self.line_number - 1] + stripped = raw.lstrip(" ") + self._text = "" if stripped.startswith("#") else stripped.rstrip() + self._position = 0 + return True + + def _skip_whitespace(self): + """Skip spaces and line breaks; stops on content or end of input.""" + while True: + self._skip_spaces() + if self._position < len(self._text): + return + if not self._carry(): + return + + def expect_end(self): + """Only spaces or a comment may follow the collection on its last line.""" + self._skip_spaces() + if self._position < len(self._text) and self._text[self._position] != "#": + self._error(f'unexpected "{self._text[self._position:]}" after a flow collection') + + def parse_value(self): + self._skip_whitespace() + if self._position >= len(self._text): + self._error("a flow collection is missing its closing bracket") + character = self._text[self._position] + if character == "{": + return self._parse_map() + if character == "[": + return self._parse_seq() + if character in _QUOTE_PAIRS: + return self._parse_quoted() + return self._parse_plain() + + def _parse_map(self): + self._position += 1 + entries = {} + while True: + self._skip_whitespace() + if self._position >= len(self._text): + self._error("a flow mapping is missing its closing '}'") + if self._text[self._position] == "}": + self._position += 1 + return entries + key = self.parse_value() + if not isinstance(key, str): + self._error("mapping keys must be strings") + if key in entries: + self._error(f'duplicate mapping key "{key}"') + self._skip_whitespace() + if self._position < len(self._text) and self._text[self._position] == ":": + self._position += 1 + entries[key] = self.parse_value() + else: + entries[key] = None + self._skip_whitespace() + if self._position >= len(self._text): + self._error("a flow mapping is missing its closing '}'") + character = self._text[self._position] + if character == "}": + self._position += 1 + return entries + if character != ",": + self._error(f'expected "," or "}}", got "{character}"') + self._position += 1 + + def _parse_seq(self): + self._position += 1 + items = [] + while True: + self._skip_whitespace() + if self._position >= len(self._text): + self._error("a flow sequence is missing its closing ']'") + if self._text[self._position] == "]": + self._position += 1 + return items + items.append(self.parse_value()) + self._skip_whitespace() + if self._position >= len(self._text): + self._error("a flow sequence is missing its closing ']'") + character = self._text[self._position] + if character == "]": + self._position += 1 + return items + if character != ",": + self._error(f'expected "," or "]", got "{character}"') + self._position += 1 + + def _parse_quoted(self): + quote = self._text[self._position] + token = "" + fragment_start = self._position + self._position += 1 + while True: + if self._position >= len(self._text): + token += self._text[fragment_start:] + "\n" + if not self._carry(): + self._error(f"unterminated {quote}-quoted scalar") + fragment_start = 0 + self._position = 0 + continue + character = self._text[self._position] + if ( + quote == "'" + and character == "'" + and self._position + 1 < len(self._text) + and self._text[self._position + 1] == "'" + ): + self._position += 2 + continue + if quote == '"' and character == "\\": + self._position += 2 + continue + if character == quote: + token += self._text[fragment_start: self._position + 1] + self._position += 1 + return _decode_quoted(token, self._path, self.line_number) + self._position += 1 + + def _parse_plain(self): + start = self._position + position = self._position + end = None + while position < len(self._text): + character = self._text[position] + if character in _FLOW_DELIMITERS: + end = position + break + if character == ":" and ( + position + 1 >= len(self._text) + or self._text[position + 1] in " \t" + _FLOW_DELIMITERS + ): + end = position + break + if character == "#" and position > start and self._text[position - 1] in " \t": + end = position + break + position += 1 + if end is None: + end = len(self._text) + self._position = end + token = self._text[start:end].strip() + if not token: + self._error("expected a value in a flow collection") + return _resolve_plain_scalar(token, self._path, self.line_number) + + +def _parse_restricted_yaml(path, text): + return _Parser(path, text).parse_document() diff --git a/src/botlab/sdk/_declaration.py b/src/botlab/sdk/_declaration.py index c33b426..74b4d9d 100644 --- a/src/botlab/sdk/_declaration.py +++ b/src/botlab/sdk/_declaration.py @@ -2,6 +2,7 @@ import inspect import math import os +import re import botlab_host @@ -46,6 +47,33 @@ def _non_empty_string(value, label): return value +# 展示文案支持的界面语言;与前端 src/domain/low-code/localizedText.ts 保持一致。 +_TEXT_LOCALES = ("en", "zh") + + +def _localized_text(value, label): + """Display text: a non-empty string, or {"en": ..., "zh": ...} with "en" as fallback.""" + if isinstance(value, str): + # 纯字符串沿用原有规则,已有工程的计划输出不变。 + return _non_empty_string(value, label) + if not isinstance(value, dict): + raise TypeError(f'{label} must be a non-empty string or a {{"en": ..., "zh": ...}} mapping') + unknown = sorted(set(value) - set(_TEXT_LOCALES)) + if unknown: + raise ValueError(f"{label} has unsupported locales {unknown} (expected {list(_TEXT_LOCALES)})") + if "en" not in value: + raise ValueError(f'{label} must include an "en" entry') + table = {} + for locale in _TEXT_LOCALES: + if locale not in value: + continue + text = value[locale] + if not isinstance(text, str) or not text.strip(): + raise ValueError(f"{label}.{locale} must be a non-empty string") + table[locale] = text.strip() + return table + + def _plan_value(value, label): if isinstance(value, _ResourceValues): value = value._plan @@ -151,6 +179,236 @@ def project(name, *, control_hz=None, physics_hz=None, parameters=None): ) +# --------------------------------------------------------------------------- +# Control profile +# +# The external CLI-control face is declared, not derived: which parameter is +# the vx axis, what an action is called and which policy states it may start +# from are project decisions the compiled plan cannot guess. The declaration +# travels in the manifest; the browser page cross-checks it against the +# compiled plan before uploading a control descriptor to a middleware. +# --------------------------------------------------------------------------- + +_CONTROL_SCHEMA = "botlab.control.profile.v1" +_CONTROL_NAME = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +_CONTROL_CAPABILITY = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$") +_CONTROL_RESERVED_CAPABILITIES = ("motion.twist", "control.stop") +_CONTROL_EXPERIENCE_LIMIT = 32768 +_CANONICAL_DECIMAL_STATE_KEY = re.compile(r"^(0|[1-9][0-9]*)$") + + +def _state_code_key(key): + """State codes arrive as ints from Python and as canonical decimal strings + from restricted YAML, whose mapping keys can only be strings.""" + if isinstance(key, bool): + raise ValueError("control_profile states keys must be integer state codes") + if isinstance(key, int): + return key + if isinstance(key, str) and _CANONICAL_DECIMAL_STATE_KEY.match(key): + return int(key) + raise ValueError("control_profile states keys must be integer state codes") + + +def _control_state_code(value, label): + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError(f"{label} must be non-negative integers") + return value + + +def _control_state_codes(value, label): + codes = list(value) if isinstance(value, (list, tuple)) else None + if not codes: + raise ValueError(f"{label} must be a non-empty sequence of state codes") + codes = [_control_state_code(code, label) for code in codes] + if len(set(codes)) != len(codes): + raise ValueError(f"{label} must not repeat states") + return codes + + +def action(name, event_value, *, allowed_states, target_states, capability=None, idempotent=False): + """Declare one named action for a :func:`control_profile`. + + ``name`` is the CLI alias an AI assistant calls. ``event_value`` must be one + of the mapped command values the project feeds its ``state_machine(...)``. + ``allowed_states`` are the policy states the action may start from and + ``target_states`` the steady states it reaches -- declared explicitly, never + derived from the state machine's transitions. ``capability`` defaults to + ``action.``. + """ + return _normalize_action( + { + "name": name, + "event_value": event_value, + "allowed_states": allowed_states, + "target_states": target_states, + "capability": capability, + "idempotent": idempotent, + }, + "control action", + ) + + +_ACTION_FIELDS = ("name", "event_value", "allowed_states", "target_states", "capability", "idempotent") + + +def _normalize_action(item, label): + """Validate and normalize one action mapping. + + ``action(...)`` declarations and plain mappings (the ones a project reads + from ``config/control.yaml`` via :func:`read_config`) share this path: + ``capability`` and ``idempotent`` default the same way, unknown fields are + rejected rather than silently dropped. + """ + if not isinstance(item, dict): + raise TypeError(f"{label} must come from action(...)") + unknown = set(item) - set(_ACTION_FIELDS) + if unknown: + raise ValueError(f"{label} has unknown fields {sorted(unknown)}") + name = item.get("name") + if not isinstance(name, str) or not _CONTROL_NAME.match(name): + raise ValueError(f'{label} name "{name}" must match ^[a-z][a-z0-9_]{{0,63}}$') + event_value = item.get("event_value") + if isinstance(event_value, bool) or not isinstance(event_value, int) or event_value == 0: + raise ValueError(f'{label} "{name}" event_value must be a non-zero integer') + resolved_capability = f"action.{name}" if item.get("capability") is None else item["capability"] + if ( + not isinstance(resolved_capability, str) + or not _CONTROL_CAPABILITY.match(resolved_capability) + or resolved_capability in _CONTROL_RESERVED_CAPABILITIES + ): + raise ValueError( + f'{label} "{name}" capability "{resolved_capability}" must be ' + "dotted lowercase and must not use motion.twist or control.stop" + ) + idempotent = item.get("idempotent", False) + if not isinstance(idempotent, bool): + raise TypeError(f'{label} "{name}" idempotent must be a boolean') + return { + "name": name, + "event_value": event_value, + "allowed_states": _control_state_codes( + item.get("allowed_states"), f'{label} "{name}" allowed_states' + ), + "target_states": _control_state_codes( + item.get("target_states"), f'{label} "{name}" target_states' + ), + "capability": resolved_capability, + "idempotent": idempotent, + } + + +def _control_state_node(value): + """The plan node id string naming the state machine the descriptor drives. + + A state_machine(...) signal compiles to exactly one ``proc-state-machine`` + node whose id is its label ("state-machine"); the paired page cross-checks + the declared name against the compiled plan before uploading anything. + """ + return _non_empty_string(value, "control_profile state_node") + + +def control_profile( + robot, + *, + state_node, + vx, + yaw_rate, + event, + actions=(), + motion_states=None, + states=None, + experience=None, +): + """Declare the external CLI-control face of the project. + + ``robot`` is a ``Robot(...)`` handle, ``state_node`` the plan node id of the + ``state_machine(...)`` driving the policy modes, and ``vx`` / ``yaw_rate`` / + ``event`` the project parameter names of the motion axes and the command + event. Pass the result to :func:`declare_control`; the browser page + cross-checks it against the compiled plan before pairing with a + ``botlab control serve`` middleware. + """ + items = list(actions) if isinstance(actions, (list, tuple)) else None + if not items: + raise ValueError("control_profile actions must be a non-empty sequence of action(...) declarations") + declared_actions = [] + names = set() + capabilities = set() + event_values = [] + for index, item in enumerate(items): + declared = _normalize_action(item, f"control_profile actions[{index}]") + if declared["name"] in names: + raise ValueError(f'duplicate control action name "{declared["name"]}"') + names.add(declared["name"]) + if declared["capability"] in capabilities: + raise ValueError(f'duplicate control capability id "{declared["capability"]}"') + capabilities.add(declared["capability"]) + if declared["event_value"] not in event_values: + event_values.append(declared["event_value"]) + declared_actions.append(declared) + + motion_states_codes = ( + None if motion_states is None + else _control_state_codes(motion_states, "control_profile motion_states") + ) + + state_names = None + if states is not None: + if not isinstance(states, dict) or not states: + raise ValueError("control_profile states must be a non-empty mapping of state code to name") + state_names = {} + for key, value in states.items(): + code = _state_code_key(key) + if not isinstance(value, str) or not value: + raise ValueError(f'control_profile states[{code}] must be a non-empty string') + state_names[str(code)] = value + + if experience is not None: + if not isinstance(experience, str) or not experience: + raise ValueError("control_profile experience must be a non-empty string") + if len(experience.encode("utf-8")) > _CONTROL_EXPERIENCE_LIMIT: + raise ValueError( + f"control_profile experience exceeds {_CONTROL_EXPERIENCE_LIMIT} bytes" + ) + + axis_vx = _non_empty_string(vx, "control_profile vx") + axis_yaw = _non_empty_string(yaw_rate, "control_profile yaw_rate") + event_parameter = _non_empty_string(event, "control_profile event") + if len({axis_vx, axis_yaw, event_parameter}) != 3: + raise ValueError("control_profile vx, yaw_rate and event must be distinct parameters") + + return { + "schema": _CONTROL_SCHEMA, + "robot": _resource_source(robot, "control_profile robot"), + "state_node": _control_state_node(state_node), + "motion_parameters": {"vx": axis_vx, "yaw_rate": axis_yaw}, + "event_parameter": event_parameter, + "motion_states": motion_states_codes, + "states": state_names, + "experience": experience, + "actions": declared_actions, + } + + +def _control_profile_value(control): + if not isinstance(control, dict) or control.get("schema") != _CONTROL_SCHEMA: + raise TypeError("declare_control expects a control_profile(...) declaration") + return control + + +def declare_control(control): + """Attach a :func:`control_profile` to the project declaration. + + Call it after ``project()``: the profile's motion and event axes must be + declared parameters, and the browser page only pairs a project whose + declaration matches its compiled plan. + """ + botlab_host.declareProjectControl( + _plan_value(_control_profile_value(control), "project control"), + _declaration_location(), + ) + + def resource_config(resource, field): return { "$botlab": "resource-config", diff --git a/src/botlab/sdk/_robot.py b/src/botlab/sdk/_robot.py index 4d02559..5cf4a18 100644 --- a/src/botlab/sdk/_robot.py +++ b/src/botlab/sdk/_robot.py @@ -7,6 +7,7 @@ _host_list, _host_snapshot, _host_value, + _localized_text, _non_empty_string, _policy_pd_vector, default_joint_positions, @@ -167,28 +168,31 @@ def default_angles(self): # -- observation signals ----------------------------------------------- - def base_angular_velocity(self, *, scale=None, source="root"): + def base_angular_velocity(self, *, scale=None, source="root", imu_sensors=None): return Signal("obs-base-ang-vel", "base-angular-velocity", _observation_data( - { + _with_imu_sensors({ "dim": 3, "velocitySource": _non_empty_string(source, "velocity source"), "rootAngularVelocityFrame": "body", - }, + }, imu_sensors), scale=scale, )) - def base_linear_velocity(self, *, scale=None, source="root"): + def base_linear_velocity(self, *, scale=None, source="root", imu_sensors=None): return Signal("obs-base-lin-vel", "base-linear-velocity", _observation_data( - {"dim": 3, "velocitySource": _non_empty_string(source, "velocity source")}, + _with_imu_sensors( + {"dim": 3, "velocitySource": _non_empty_string(source, "velocity source")}, + imu_sensors, + ), scale=scale, )) - def projected_gravity(self, *, target="root"): + def projected_gravity(self, *, target="root", imu_sensors=None): target = _non_empty_string(target, "projected gravity target") data = {"dim": 3, "targetKind": "root" if target == "root" else "body"} if target != "root": data["target"] = target - return Signal("obs-projected-gravity", "projected-gravity", data) + return Signal("obs-projected-gravity", "projected-gravity", _with_imu_sensors(data, imu_sensors)) def base_height(self): return Signal("obs-base-height", "base-height", {"dim": 1}) @@ -261,11 +265,11 @@ def _joint_order(self, order): return None return order - def imu(self, *, order=None, scale=None): + def imu(self, *, order=None, scale=None, imu_sensors=None): """Root orientation quaternion followed by base angular velocity.""" indices = None if order is None else list(order) return Signal("obs-imu", "imu", _observation_data( - {"dim": 7 if indices is None else len(indices)}, + _with_imu_sensors({"dim": 7 if indices is None else len(indices)}, imu_sensors), order=indices, scale=scale, )) @@ -444,6 +448,7 @@ def effort_control(self, effort, *, effort_limit=None): def commands( self, *parameter_names, + parameters=None, waypoint=None, input_mode="static", ramp_rate=None, @@ -455,8 +460,20 @@ def commands( Axis limits come from each parameter's `range` in project(), so they are declared once rather than restated here. A ramp_rate of zero - disables smoothing and applies interactive input immediately. + disables smoothing and applies interactive input immediately. The + names may also arrive as one ``parameters=[...]`` sequence -- the form + a ``commands(**read_config("config/io.yaml")["..."])`` call site needs. """ + if parameters is not None: + if parameter_names: + raise ValueError( + "commands takes either positional parameter names or parameters=[...], not both" + ) + if not isinstance(parameters, (list, tuple)) or not parameters: + raise ValueError( + "commands parameters must be a non-empty sequence of parameter names" + ) + parameter_names = parameters names = [ _non_empty_string(name, f"command parameter {index}") for index, name in enumerate(parameter_names) @@ -630,10 +647,7 @@ def commands( ) normalized = {**key_data, "values": values} if "label" in mapping: - normalized["label"] = _non_empty_string( - mapping["label"], - f"{label} label", - ) + normalized["label"] = _localized_text(mapping["label"], f"{label} label") normalized_mappings.append(normalized) data["commandEncoding"] = "mapped" data["commandMappings"] = normalized_mappings @@ -644,6 +658,15 @@ def commands( return Signal("obs-commands", "commands", data) +def _with_imu_sensors(data, imu_sensors): + """Write `imuSensors` only when given; an omitted field keeps the runtime's legacy rule.""" + if imu_sensors is not None: + if not isinstance(imu_sensors, bool): + raise TypeError("imu_sensors must be a bool") + data["imuSensors"] = imu_sensors + return data + + def _observation_data(data, *, order=None, default=None, scale=None): if order is not None: data["indices"] = order diff --git a/tests/test_browser_adapter.py b/tests/test_browser_adapter.py new file mode 100644 index 0000000..4389e88 --- /dev/null +++ b/tests/test_browser_adapter.py @@ -0,0 +1,58 @@ +"""CLI adapter contract: project imports, runtime ownership, and isolation.""" + +import sys +from pathlib import Path + +import pytest + +from botlab._cli import main + + +def test_adapter_receives_installed_runtime_without_compiling_project(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + # No controller.py: source-only browser controllers bypass host compilation. + (project / "adapter_example.py").write_text( + "from pathlib import Path\n" + "def run(context):\n" + " assert context.project_dir == Path.cwd()\n" + " assert context.ui_dir.name == 'installed-runtime'\n" + " assert context.host == '127.0.0.1'\n" + " assert context.port == 12345\n" + " assert context.inference_endpoint == 'board:50051'\n" + " assert not context.open_browser\n" + " return 7\n" + ) + monkeypatch.setattr("botlab._cli.ensure_browser_runtime", lambda: tmp_path / "installed-runtime") + previous_cwd, previous_path = Path.cwd(), sys.path[:] + try: + assert main([ + "run", str(project), "--browser-adapter", "adapter_example:run", + "--inference-endpoint", "board:50051", "--port", "12345", "--no-browser", + ]) == 7 + assert Path.cwd() == previous_cwd + assert sys.path == previous_path + finally: + sys.modules.pop("adapter_example", None) + + +@pytest.mark.parametrize("options", [ + ["--native"], ["--headless"], ["--out", "release.zip"], + ["--no-server"], ["--report", "report.json"], +]) +def test_incompatible_modes_rejected_before_loading_adapter(options, tmp_path, capsys): + assert main(["run", str(tmp_path), "--browser-adapter", "absent:run", *options]) == 2 + assert "requires a live browser run" in capsys.readouterr().err + + +@pytest.mark.parametrize("entry", ["bad", "bad:path:run", "../bad:run", "absent:run"]) +def test_invalid_adapter_is_actionable_error(entry, tmp_path): + previous_cwd, previous_path = Path.cwd(), sys.path[:] + assert main(["run", str(tmp_path), "--browser-adapter", entry]) == 2 + assert Path.cwd() == previous_cwd + assert sys.path == previous_path + + +def test_endpoint_does_not_silently_run_local_inference(tmp_path, capsys): + assert main(["run", str(tmp_path), "--inference-endpoint", "board:50051"]) == 2 + assert "requires --browser-adapter" in capsys.readouterr().err diff --git a/tests/test_control_cli.py b/tests/test_control_cli.py new file mode 100644 index 0000000..2733b67 --- /dev/null +++ b/tests/test_control_cli.py @@ -0,0 +1,513 @@ +"""Control API contract against an unrelated, joint-only stdlib middleware.""" + +from __future__ import annotations + +import contextlib +import io +import json +import os +import subprocess +import sys +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import parse_qs, unquote, urlsplit + +import pytest + +from botlab.control import client as transport +from botlab.control.cli import main +from botlab.control.discovery import endpoint, project_key +from botlab.control.protocol import ControlError +from botlab.control.receipts import Receipts + + +class JointMiddleware: + """No base pose, velocity, sit action, policy state or MicroDuck imports.""" + + def __init__(self, project): + self.project = str(project.resolve()) + self.key = project_key(project) + self.instance = "joint-instance-1" + self.session = "joint-session" + self.episode = "joint-episode" + self.revision = "joints-v1" + self.api_version = 1 + self.commands = {} + self.received = [] + self.gets = [] + self.evicted = set() + self.redirect = False + self.descriptor = None + self.experience_text = "# Joint middleware experience" + self.capability = {"id": "joint.position", "version": 1, "aliases": ["home"], + "input_schema": {"type": "object", "required": ["positions"], + "additionalProperties": False, + "properties": {"positions": {"type": "array", "minItems": 2, + "maxItems": 2, "items": {"type": "number", "minimum": -2, "maximum": 2}}}}, + "units": {"positions": "rad"}, "frame": "joint", + "completion_semantics": "all joints within declared tolerance", + "max_duration_s": 5, "stop_semantics": "hold current joint positions"} + owner = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def reply(self, data, status=200): + body = json.dumps(data).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def fail(self, code, status=409): + self.reply({"ok": False, "error": {"code": code, "message": code}}, status) + + def do_GET(self): + if self.headers.get("Authorization") != "Bearer test-token": + return self.fail("unauthorized", 401) + parsed = urlsplit(self.path) + owner.gets.append(self.path) + if owner.redirect: + self.send_response(302) + self.send_header("Location", "/should-not-be-requested") + self.end_headers() + return + response = {"ok": True, "instance_id": owner.instance} + if parsed.path.endswith("/info"): + response.update(api_version=owner.api_version, protocol="botlab-control", project=owner.project, + project_key=owner.key, backend={"id": "joint-test", "kind": "simulation"}) + elif parsed.path.endswith("/capabilities"): + response.update(api_version=owner.api_version, capabilities_revision=owner.revision, + capabilities=[owner.capability]) + elif parsed.path.endswith("/sessions"): + response["sessions"] = [{"session_id": owner.session, "episode_id": owner.episode}] + elif parsed.path.endswith("/state"): + response.update(project_key=owner.key, state=owner.state()) + elif parsed.path.endswith("/experience"): + response.update(experience=owner.experience_text) + elif "/commands/" in parsed.path: + command_id = unquote(parsed.path.rsplit("/", 1)[1]) + if parse_qs(parsed.query).get("instance_id") != [owner.instance]: + return self.fail("wrong_instance") + if command_id in owner.evicted: + return self.fail("result_evicted") + if command_id not in owner.commands: + return self.fail("not_found", 404) + response["command"] = owner.commands[command_id] + response["pending"] = not response["command"]["terminal"] + else: + return self.fail("not_found", 404) + self.reply(response) + + def do_POST(self): + if self.headers.get("Authorization") != "Bearer test-token": + return self.fail("unauthorized", 401) + envelope = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) + if envelope["instance_id"] != owner.instance: + return self.fail("wrong_instance") + if envelope["session_id"] != owner.session or envelope["episode_id"] != owner.episode: + return self.fail("wrong_session") + if envelope["capabilities_revision"] != owner.revision: + return self.fail("capabilities_changed") + command_id = envelope["request_id"] + if command_id not in owner.commands: + owner.received.append(envelope) + owner.commands[command_id] = {"command_id": command_id, "status": "accepted", "terminal": False, + "instance_id": owner.instance, "session_id": owner.session, + "episode_id": owner.episode, "initial_observation": {"capture_stage": "accepted", "positions": [0, 0]}, + "completion_observation": None, "current_state": owner.state()} + self.reply({"ok": True, "command": owner.commands[command_id]}) + + self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.endpoint = f"http://127.0.0.1:{self.server.server_port}" + + def state(self): + state = {"ready": True, "connected": True, "external_enabled": True, "run_state": "running", + "session_id": self.session, "episode_id": self.episode, "state_age_ms": 1, + "progress_age_ms": 1, "observations": {"positions": [0.2, -0.2]}} + if getattr(self, "generation", None) is not None: + state["control_generation"] = self.generation + return state + + def publish(self, root): + directory = root / self.key + directory.mkdir(parents=True, mode=0o700, exist_ok=True) + root.chmod(0o700) + directory.chmod(0o700) + self.descriptor = directory / f"{self.instance}.json" + self.descriptor.write_text(json.dumps({"schema": 1, "api_version": 1, "protocol": "botlab-control", + "project": self.project, "project_key": self.key, "instance_id": self.instance, + "endpoint": self.endpoint, "token": "test-token", "pid": os.getpid()})) + self.descriptor.chmod(0o600) + + def close(self): + self.server.shutdown() + self.server.server_close() + self.thread.join() + + +@pytest.fixture +def middleware(tmp_path, monkeypatch): + project = tmp_path / "project" + project.mkdir() + runtime = tmp_path / "runtime" + monkeypatch.setenv("BOTLAB_CONTROL_RUNTIME_DIR", str(runtime)) + monkeypatch.setenv("BOTLAB_CONTROL_RECEIPTS_DIR", str(tmp_path / "receipts")) + monkeypatch.chdir(project) + server = JointMiddleware(project) + server.publish(runtime) + try: + yield server + finally: + server.close() + + +def invoke(*args): + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = main([*args, "--json"]) + return code, json.loads(output.getvalue()) + + +def call(request_id="joint-request"): + return invoke("call", "joint.position", "--args", '{"positions":[0.5,-0.5]}', "--request-id", request_id) + + +def test_joint_only_middleware_end_to_end(middleware): + assert invoke("state")[1]["state"]["observations"] == {"positions": [0.2, -0.2]} + assert "policy_state" not in invoke("state")[1]["state"] + assert invoke("sessions")[0] == 0 + code, submitted = call() + assert code == 0 + assert submitted["command"]["status"] == "accepted" + code, pending = invoke("wait", "--id", "joint-request", "--timeout", "0") + assert code == 0 and pending["pending"] is True + middleware.commands["joint-request"].update(status="completed", terminal=True, + completion_observation={"positions": [0.5, -0.5], "step": 42}) + code, completed = invoke("wait", "--id", "joint-request") + assert code == 0 and completed["command"]["completion_observation"]["step"] == 42 + assert completed["command"]["current_state"]["observations"]["positions"] == [0.2, -0.2] + assert invoke("stop", "--request-id", "joint-stop")[0] == 0 + assert middleware.received[-1]["capability"] == "control.stop" + + +def test_unknown_robot_shortcuts_are_not_guessed(middleware): + assert invoke("move", "--vx", ".25", "--duration", "1")[1]["error"]["code"] == "unsupported_capability" + assert invoke("action", "sit")[1]["error"]["code"] == "unsupported_capability" + assert not middleware.received + + +def test_alias_resolves_declared_capability(middleware): + middleware.capability["input_schema"] = {"type": "object"} + assert invoke("action", "home")[0] == 0 + assert middleware.received[-1]["capability"] == "joint.position" + + +def test_arguments_file_and_schema_validation(middleware, tmp_path): + arguments = tmp_path / "args.json" + arguments.write_text('{"positions":[0.1,0.2]}') + assert invoke("call", "joint.position", "--args-file", str(arguments))[0] == 0 + assert invoke("call", "joint.position", "--args", '{"positions":[3,0]}')[1]["error"]["code"] == "invalid_args" + assert len(middleware.received) == 1 + + +@pytest.mark.parametrize("args", [("call", "joint.position", "--args", '{"positions":[NaN,0]}'), + ("move", "--vx", "nan", "--duration", "1"), + ("wait", "--id", "x", "--timeout", "nan"), ("not-a-command",), + ("call",), ("move", "--vx", "bad")]) +def test_json_parse_errors_are_single_json_object(middleware, args): + code, result = invoke(*args) + assert code == 2 and result["ok"] is False + assert not middleware.received + + +def test_retry_is_durable_private_and_no_credentials(middleware): + assert call()[0] == 0 + receipt = Receipts(middleware.project).load("joint-request") + assert receipt["envelope"]["session_id"] == middleware.session + raw = Receipts(middleware.project).path("joint-request").read_text() + assert "test-token" not in raw and '"token"' not in raw + if os.name == "posix": + assert Receipts(middleware.project).path("joint-request").stat().st_mode & 0o777 == 0o600 + assert call()[0] == 0 + assert invoke("retry", "--id", "joint-request")[0] == 0 + assert len(middleware.received) == 1 + + +def test_retry_from_fresh_process(middleware): + assert call()[0] == 0 + process = subprocess.run([sys.executable, "-m", "botlab.control", "retry", "--id", "joint-request", "--json"], + env={**os.environ, "PYTHONPATH": str(Path(transport.__file__).parents[2])}, + text=True, capture_output=True) + assert process.returncode == 0, process.stderr + assert json.loads(process.stdout)["command"]["command_id"] == "joint-request" + assert len(middleware.received) == 1 + + +def test_duplicate_request_intent_cannot_change(middleware): + call() + code, error = invoke("call", "joint.position", "--args", '{"positions":[0,0]}', "--request-id", "joint-request") + assert code == 2 and error["error"]["code"] == "request_conflict" + assert len(middleware.received) == 1 + + +def test_explicit_empty_request_id_is_not_replaced_with_generated_identity(middleware): + for _ in range(2): + code, result = call("") + assert code == 2 and result["error"]["code"] == "invalid_identity" + assert not middleware.received + + +def test_missing_request_id_generates_an_identity(middleware): + code, result = invoke("call", "joint.position", "--args", '{"positions":[0.5,-0.5]}') + assert code == 0 + assert len(result["command"]["command_id"]) == 36 + assert middleware.received[0]["request_id"] == result["command"]["command_id"] + + +def test_lost_submit_response_is_queried_not_replayed(middleware, monkeypatch): + original = transport.request + + def lose_response(descriptor, path, body=None, timeout=5): + result = original(descriptor, path, body, timeout) + if body is not None: + raise ControlError("network_timeout", "response lost") + return result + + monkeypatch.setattr(transport, "request", lose_response) + code, unknown = call() + assert code == 2 and unknown["request_id"] == "joint-request" + assert unknown["instance_id"] == middleware.instance + monkeypatch.setattr(transport, "request", original) + assert invoke("retry", "--id", "joint-request")[0] == 0 + assert len(middleware.received) == 1 + + +def test_original_identity_is_reused_only_when_unknown(middleware): + call() + original = middleware.received[0].copy() + middleware.commands.clear() + assert invoke("retry", "--id", "joint-request")[0] == 0 + assert middleware.received == [original, original] + + +@pytest.mark.parametrize("new_generation", [2, None]) +def test_unreceived_request_cannot_resume_changed_control_generation(middleware, monkeypatch, new_generation): + middleware.generation = 1 + original = transport.request + + def fail_before_delivery(descriptor, path, body=None, timeout=5): + if body is not None: + raise ControlError("connection_error", "request never reached middleware") + return original(descriptor, path, body, timeout) + + monkeypatch.setattr(transport, "request", fail_before_delivery) + assert call()[0] == 2 + receipt = Receipts(middleware.project).load("joint-request") + assert receipt["control_generation"] == 1 + assert "control_generation" not in receipt["envelope"] + middleware.generation = new_generation + monkeypatch.setattr(transport, "request", original) + code, result = invoke("retry", "--id", "joint-request") + assert code == 2 and result["error"]["code"] == "state_changed" + assert not middleware.received + + +def test_unchanged_control_generation_allows_unknown_retry(middleware): + middleware.generation = 1 + call() + original = middleware.received[0].copy() + middleware.commands.clear() + assert invoke("retry", "--id", "joint-request")[0] == 0 + assert middleware.received == [original, original] + + +def test_changed_control_generation_still_allows_known_result_query(middleware): + middleware.generation = 1 + call() + middleware.generation = 2 + assert invoke("retry", "--id", "joint-request")[0] == 0 + assert len(middleware.received) == 1 + + +@pytest.mark.parametrize("field", ["session", "episode", "revision"]) +def test_unknown_request_is_not_rebound(middleware, field): + call() + middleware.commands.clear() + setattr(middleware, field, "changed") + code, result = invoke("retry", "--id", "joint-request") + assert code == 2 and result["error"]["code"] == "original_identity_changed" + assert len(middleware.received) == 1 + + +def test_restart_never_replays_original_request(middleware): + call() + middleware.instance = "joint-instance-2" + code, result = invoke("retry", "--id", "joint-request") + assert code == 2 and result["error"]["code"] == "wrong_instance" + assert len(middleware.received) == 1 + + +def test_missing_receipt_and_evicted_result_never_replay(middleware): + assert invoke("retry", "--id", "missing")[1]["error"]["code"] == "missing_receipt" + call() + middleware.evicted.add("joint-request") + assert invoke("retry", "--id", "joint-request")[1]["error"]["code"] == "result_evicted" + assert len(middleware.received) == 1 + + +@pytest.mark.parametrize("status", ["cancelled", "failed", "expired"]) +def test_terminal_failure_exit_code(middleware, status): + call() + middleware.commands["joint-request"].update(status=status, terminal=True) + assert invoke("wait", "--id", "joint-request")[0] == 3 + + +def test_wrong_project_and_explicit_wrong_session(middleware, tmp_path): + assert invoke("--project", str(tmp_path / "other"), "--endpoint", middleware.endpoint, "state")[0] == 2 + assert invoke("--session", "wrong", "state")[1]["error"]["code"] == "wrong_session" + middleware.project = str(tmp_path / "wrong") + assert invoke("state")[1]["error"]["code"] == "wrong_project" + + +def test_multi_instance_requires_explicit_selection(middleware): + second = JointMiddleware(Path(middleware.project)) + second.instance = "second-instance" + second.publish(Path(os.environ["BOTLAB_CONTROL_RUNTIME_DIR"])) + try: + assert invoke("state")[1]["error"]["code"] == "ambiguous_instance" + assert invoke("--instance", second.instance, "state")[1]["instance_id"] == second.instance + code, result = invoke("instances") + assert code == 0 and len(result["instances"]) == 2 + assert "test-token" not in json.dumps(result) + finally: + second.close() + + +def test_versions_metadata_and_redirect_rejected(middleware): + middleware.api_version = 2 + assert invoke("state")[1]["error"]["code"] == "unsupported_version" + middleware.api_version = 1 + middleware.capability["version"] = 2 + assert invoke("capabilities")[1]["error"]["code"] == "unsupported_capability_version" + middleware.capability["version"] = 1 + del middleware.capability["units"] + assert invoke("capabilities")[1]["error"]["code"] == "invalid_protocol" + middleware.redirect = True + assert invoke("state")[1]["error"]["code"] == "redirect_refused" + assert not any("should-not" in path for path in middleware.gets) + + +def test_connection_token_requires_explicit_flag(middleware): + assert "token" not in invoke("connection")[1] + assert invoke("connection", "--show-token")[1]["token"] == "test-token" + + +def test_experience_reads_the_project_document(middleware): + assert invoke("experience")[1]["experience"] == "# Joint middleware experience" + middleware.experience_text = None + assert invoke("experience")[1]["error"]["code"] == "invalid_protocol" + + +def test_serve_argument_validation_is_single_json(): + code, result = invoke("serve", "--port", "80", "--origin", "http://127.0.0.1:5199") + assert code == 2 and result["error"]["code"] == "invalid_port" + code, result = invoke("serve") + assert code == 2 and result["error"]["code"] == "invalid_arguments" + + +@pytest.mark.parametrize("origin", ["http://example.com:80", "http://10.0.0.5:8776", "http://127.0.0.1:80/path", + "http://user:secret@127.0.0.1:80", "http://127.0.0.1:80?token=x", + "http://127.0.0.1:80#x", "https://user:secret@example.com", + "ftp://127.0.0.1:80", "http://127.0.0.1", "https://:443"]) +def test_endpoint_never_exposes_auth_to_remote_or_redirect(origin): + with pytest.raises(ControlError): + endpoint(origin) + + +def test_unsafe_discovery_file_is_not_trusted(middleware): + middleware.descriptor.chmod(0o644) + if os.name == "posix": + assert invoke("state")[1]["error"]["code"] == "no_service" + + +def test_receipt_write_failure_prevents_submit(middleware, monkeypatch): + def fail(*args, **kwargs): + raise ControlError("receipt_unavailable", "disk full") + monkeypatch.setattr(Receipts, "save", fail) + assert call()[1]["error"]["code"] == "receipt_unavailable" + assert not middleware.received + + +def test_explicit_selection_must_not_rebind_receipt(middleware): + call() + code, result = invoke("--instance", "other-instance", "retry", "--id", "joint-request") + assert code == 2 and result["error"]["code"] == "identity_conflict" + assert len(middleware.received) == 1 + + +def test_bad_schema_returns_json_protocol_error(middleware): + middleware.capability["input_schema"]["properties"] = None + assert call()[1]["error"]["code"] == "invalid_protocol" + assert not middleware.received + + +def test_invalid_command_identity_or_terminal_flag_is_rejected(middleware): + call() + middleware.commands["joint-request"]["command_id"] = "other-command" + assert invoke("wait", "--id", "joint-request")[1]["error"]["code"] == "wrong_command" + middleware.commands["joint-request"]["command_id"] = "joint-request" + middleware.commands["joint-request"]["terminal"] = True + assert invoke("wait", "--id", "joint-request")[1]["error"]["code"] == "invalid_protocol" + + +def test_changed_episode_does_not_prevent_querying_known_task(middleware): + call() + middleware.episode = "later-episode" + assert invoke("retry", "--id", "joint-request")[0] == 0 + assert len(middleware.received) == 1 + + +def test_descriptor_replacement_refuses_retry(middleware): + call() + middleware.descriptor.unlink() + middleware.instance = "replacement-instance" + middleware.publish(Path(os.environ["BOTLAB_CONTROL_RUNTIME_DIR"])) + assert invoke("retry", "--id", "joint-request")[1]["error"]["code"] == "original_instance_unavailable" + assert len(middleware.received) == 1 + + +def test_cli_dispatch_and_base_install_without_optional_packages(tmp_path): + source = Path(transport.__file__).parents[2] + + def blocked_cli(arguments): + script = ( + "import importlib.abc\n" + "import sys\n" + "class BlockOptional(importlib.abc.MetaPathFinder):\n" + " def find_spec(self, fullname, path=None, target=None):\n" + " if fullname.split('.')[0] in {'aiohttp', 'numpy', 'mujoco', 'onnxruntime'," + " 'microduck_cli', 'microduck_control'}:\n" + " raise ImportError('optional package forbidden: ' + fullname)\n" + "sys.meta_path.insert(0, BlockOptional())\n" + "from botlab._cli import main\n" + f"raise SystemExit(main({arguments!r} + ['--json']))\n" + ) + return subprocess.run([sys.executable, "-c", script], cwd=tmp_path, + env={**os.environ, "PYTHONPATH": str(source)}, text=True, capture_output=True) + + process = blocked_cli(["control", "instances"]) + assert process.returncode == 0, process.stderr + assert json.loads(process.stdout)["instances"] == [] + # The serve parser face itself stays importable without aiohttp... + help_process = blocked_cli(["control", "serve", "--help"]) + assert help_process.returncode == 0 and "--origin" in help_process.stdout, help_process.stderr + # ...while actually serving reports the missing extra instead of crashing. + serve_process = blocked_cli(["control", "serve", "--origin", "http://127.0.0.1:5199"]) + assert serve_process.returncode == 2, serve_process.stderr + assert json.loads(serve_process.stdout)["error"]["code"] == "missing_dependency" diff --git a/tests/test_control_descriptor.py b/tests/test_control_descriptor.py new file mode 100644 index 0000000..b1b5769 --- /dev/null +++ b/tests/test_control_descriptor.py @@ -0,0 +1,269 @@ +"""Descriptor validation and the compiled engine, microduck-equivalent fixture.""" +from __future__ import annotations + +import copy +import json + +import pytest + +from botlab.control.descriptor import ( + MAX_EXPERIENCE_BYTES, + DescriptorEngine, + revision_of, + validate_descriptor, +) +from botlab.control.protocol import ControlError, validate_capabilities + + +def sample() -> dict: + """The microduck control surface as a page would declare it.""" + return { + "v": 1, "type": "descriptor", + "profile": {"robot_id": "MicroDuck", "state_node_id": "state-machine", + "motion_parameters": {"vx": "forward_velocity", "yaw_rate": "yaw_rate"}, + "event_parameter": "policy_command", "event_values": [1, 2, 3, 4, 5, 6]}, + "motion": {"vx": {"min": -0.2, "max": 0.25, "unit": "m/s"}, + "yaw_rate": {"min": -1, "max": 1, "unit": "rad/s"}, + "allowed_states": [0]}, + "actions": [ + {"name": "sit", "event_value": 1, "allowed_states": [0], "target_states": [2], + "capability": "posture.sit", "idempotent": True}, + {"name": "stand_up", "event_value": 1, "allowed_states": [2], "target_states": [0], + "capability": "posture.stand_up", "idempotent": True}, + {"name": "kick_left", "event_value": 2, "allowed_states": [0], "target_states": [0], + "capability": "behavior.kick_left"}, + {"name": "kick_right", "event_value": 3, "allowed_states": [0], "target_states": [0], + "capability": "behavior.kick_right"}, + {"name": "roll", "event_value": 4, "allowed_states": [0], "target_states": [0], + "capability": "behavior.roll"}, + {"name": "ground_pick", "event_value": 5, "allowed_states": [0], "target_states": [0], + "capability": "behavior.ground_pick"}, + {"name": "recover", "event_value": 6, "allowed_states": [0], "target_states": [0], + "capability": "behavior.recover"}, + ], + "states": {"0": "WALK", "1": "SIT_ENTER", "2": "SIT", "3": "SIT_EXIT", "4": "KICK_LEFT", + "5": "KICK_RIGHT", "6": "POST_KICK", "7": "ROLL", "8": "ROLL_TIPPED", + "9": "GROUND_PICK", "10": "WALK_RESET", "11": "RECOVER_ENTER", + "12": "RECOVER", "13": "RECOVER_UPRIGHT"}, + "experience": "# MicroDuck control experience\n\nWalk threshold vx=0.25.", + } + + +def mutate(**overrides): + payload = sample() + payload.update(overrides) + return payload + + +class TestValidateDescriptor: + def test_sample_is_valid(self): + assert validate_descriptor(sample()) is not None + + def test_unknown_top_level_field_is_rejected(self): + with pytest.raises(ControlError, match="known fields"): + validate_descriptor(mutate(project_ref="hosted")) + + def test_wrong_version_or_type_is_rejected(self): + with pytest.raises(ControlError): + validate_descriptor(mutate(v=2)) + with pytest.raises(ControlError): + validate_descriptor(mutate(type="state")) + + def test_non_finite_values_are_rejected(self): + payload = sample() + payload["motion"]["vx"]["min"] = float("nan") + with pytest.raises(ControlError, match="finite"): + validate_descriptor(payload) + + def test_profile_must_be_complete(self): + for field in ("robot_id", "state_node_id", "motion_parameters", "event_parameter", "event_values"): + payload = sample() + del payload["profile"][field] + with pytest.raises(ControlError): + validate_descriptor(payload) + + def test_motion_parameters_must_be_exactly_two_distinct_axes(self): + payload = sample() + payload["profile"]["motion_parameters"]["vy"] = "lateral_velocity" + with pytest.raises(ControlError): + validate_descriptor(payload) + payload = sample() + payload["profile"]["motion_parameters"]["vx"] = payload["profile"]["motion_parameters"]["yaw_rate"] + with pytest.raises(ControlError, match="distinct"): + validate_descriptor(payload) + + def test_event_values_must_be_nonzero_unique_integers(self): + for bad in ([], [0], [1, 1], ["1"], [True]): + payload = sample() + payload["profile"]["event_values"] = bad + with pytest.raises(ControlError): + validate_descriptor(payload) + + def test_axis_bounds_must_bound_a_range(self): + payload = sample() + payload["motion"]["vx"]["min"] = 0.25 + with pytest.raises(ControlError, match="min < max"): + validate_descriptor(payload) + payload = sample() + payload["motion"]["yaw_rate"]["unit"] = "" + with pytest.raises(ControlError): + validate_descriptor(payload) + + def test_state_lists_must_be_nonempty_unique_codes(self): + for bad in ([], [0, 0], [-1], ["0"], [True]): + payload = sample() + payload["motion"]["allowed_states"] = bad + with pytest.raises(ControlError): + validate_descriptor(payload) + + def test_toggle_actions_may_share_an_event_value(self): + assert validate_descriptor(sample()) # sit and stand_up both use event 1 + + def test_action_names_and_capabilities_must_be_unique(self): + payload = sample() + payload["actions"][2]["name"] = "sit" + with pytest.raises(ControlError, match="Duplicate action name"): + validate_descriptor(payload) + payload = sample() + payload["actions"][2]["capability"] = "posture.sit" + with pytest.raises(ControlError, match="Duplicate capability id"): + validate_descriptor(payload) + + def test_reserved_capability_ids_are_rejected(self): + payload = sample() + payload["actions"][2]["capability"] = "motion.twist" + with pytest.raises(ControlError, match="invalid capability id"): + validate_descriptor(payload) + + def test_action_event_value_must_be_declared(self): + payload = sample() + payload["actions"][2]["event_value"] = 9 + with pytest.raises(ControlError, match="profile.event_values"): + validate_descriptor(payload) + + def test_default_capability_and_idempotent_are_filled_in(self): + payload = mutate(actions=[{"name": "wave", "event_value": 1, + "allowed_states": [0], "target_states": [1]}], + states={"0": "WALK", "1": "WAVE"}) + validated = validate_descriptor(payload) + assert validated["actions"][0]["capability"] == "action.wave" + assert validated["actions"][0]["idempotent"] is False + + def test_states_keys_must_be_canonical_codes(self): + for bad in ({"00": "WALK"}, {"-1": "NEG"}, {"x": "LABEL"}, {"0": ""}): + payload = sample() + payload["states"] = bad + with pytest.raises(ControlError): + validate_descriptor(payload) + + def test_oversized_experience_is_rejected(self): + payload = sample() + payload["experience"] = "x" * (MAX_EXPERIENCE_BYTES + 1) + with pytest.raises(ControlError, match="exceeds"): + validate_descriptor(payload) + + +class TestRevision: + def test_revision_is_stable_and_descriptor_sensitive(self): + first = revision_of(validate_descriptor(sample())) + assert first.startswith("gen-") and len(first) == 20 + assert revision_of(validate_descriptor(sample())) == first + changed = sample() + changed["experience"] = "different" + assert revision_of(validate_descriptor(changed)) != first + + +class TestDescriptorEngine: + def engine(self) -> DescriptorEngine: + return DescriptorEngine(validate_descriptor(sample())) + + def test_capabilities_satisfy_the_frozen_contract(self): + engine = self.engine() + validate_capabilities({"api_version": 1, "capabilities_revision": engine.revision, + "capabilities": engine.capabilities()}) + + def test_capability_surface(self): + capabilities = {entry["id"]: entry for entry in self.engine().capabilities()} + assert set(capabilities) == {"motion.twist", "control.stop", "posture.sit", + "posture.stand_up", "behavior.kick_left", + "behavior.kick_right", "behavior.roll", + "behavior.ground_pick", "behavior.recover"} + twist = capabilities["motion.twist"] + assert twist["input_schema"]["properties"]["vx"]["minimum"] == -0.2 + assert twist["input_schema"]["properties"]["vx"]["maximum"] == 0.25 + assert twist["input_schema"]["properties"]["vy"] == {"type": "number", "const": 0, "default": 0} + assert twist["units"]["vx"] == "m/s" and twist["frame"] == "body" + assert capabilities["control.stop"]["aliases"] == ["stop"] + assert capabilities["behavior.kick_left"]["aliases"] == ["kick_left"] + assert capabilities["behavior.kick_left"]["preconditions"] == { + "allowed_state_codes": [0], "target_state_codes": [0]} + + def test_normalize_bounds_and_roundtrip(self): + engine = self.engine() + command = engine.normalize({"kind": "move", "vx": 0.2, "yaw_rate": -0.5, "duration_s": 3}) + assert command == {"kind": "move", "vx": 0.2, "vy": 0.0, "yaw_rate": -0.5, "duration_s": 3.0} + assert engine.public_command(command) == ( + "motion.twist", {"vx": 0.2, "vy": 0.0, "yaw_rate": -0.5, "duration_s": 3.0}) + with pytest.raises(ControlError, match="vx must be in"): + engine.normalize({"kind": "move", "vx": 0.5, "duration_s": 1}) + with pytest.raises(ControlError): + engine.normalize({"kind": "move", "duration_s": 20}) + with pytest.raises(ControlError): + engine.normalize({"kind": "action", "name": "fly"}) + assert engine.normalize({"kind": "stop"}) == {"kind": "stop"} + + def test_from_capability_resolves_declared_ids(self): + engine = self.engine() + assert engine.from_capability("posture.sit", {}) == {"kind": "action", "name": "sit"} + assert engine.from_capability("motion.twist", {"vx": 0.1, "duration_s": 1})["kind"] == "move" + assert engine.from_capability("control.stop", {}) == {"kind": "stop"} + with pytest.raises(ControlError): + engine.from_capability("nav.goto", {}) + + def test_prepare_compiles_the_wire_payload(self): + engine = self.engine() + payload, timeout, satisfied = engine.prepare( + {"kind": "move", "vx": 0.15, "vy": 0.0, "yaw_rate": 0.0, "duration_s": 2.0}, + {"policy_state_code": 0}) + assert payload == {"kind": "move", "values": {"forward_velocity": 0.15, "yaw_rate": 0.0}, + "duration_ms": 2000.0, "ttl_ms": 500, "allowed_states": [0]} + assert timeout == pytest.approx(12.0) + assert satisfied is False + with pytest.raises(ControlError, match="Movement requires"): + engine.prepare({"kind": "move", "vx": 0.1, "vy": 0, "yaw_rate": 0, "duration_s": 1}, + {"policy_state_code": 2}) + + def test_prepare_action_semantics(self): + engine = self.engine() + payload, timeout, satisfied = engine.prepare({"kind": "action", "name": "sit"}, + {"policy_state_code": 0}) + assert payload["event_value"] == 1 and payload["timeout_ms"] == 10000 + assert satisfied is False + _, _, already = engine.prepare({"kind": "action", "name": "sit"}, {"policy_state_code": 2}) + assert already is True # idempotent sit from SIT + # stand_up from WALK is already satisfied (idempotent, target=WALK); a + # non-idempotent action outside its allowed set must be refused instead. + assert engine.prepare({"kind": "action", "name": "stand_up"}, {"policy_state_code": 0})[2] is True + with pytest.raises(ControlError, match="not allowed from"): + engine.prepare({"kind": "action", "name": "kick_left"}, {"policy_state_code": 2}) + + def test_decode_and_observations(self): + engine = self.engine() + snapshot = {"policy_state_code": 2, "policy_state_steps": 7, "position_world_m": [1, 2, 3], + "orientation_wxyz": [1, 0, 0, 0], "base_velocity": [0] * 6, + "effective_parameters": {"forward_velocity": 0.1}} + state = engine.decode_state(snapshot) + assert state["policy_state"] == "SIT" + observations = engine.observations(state) + assert observations["base_pose"]["frame"] == "world" + assert observations["policy"] == {"code": 2, "name": "SIT", "steps": 7} + assert engine.decode_state({"policy_state_code": 99})["policy_state"] == "UNKNOWN" + assert engine.state_available({"policy_state_code": 0}) is True + assert engine.state_available({}) is False + + def test_deep_copies_keep_the_descriptor_frozen(self): + engine = self.engine() + frozen = copy.deepcopy(engine.capabilities()) + engine.capabilities()[0]["input_schema"]["properties"]["vx"]["minimum"] = 99 + assert engine.capabilities() == frozen + json.dumps(engine.capabilities()) # serializable diff --git a/tests/test_control_protocol.py b/tests/test_control_protocol.py new file mode 100644 index 0000000..0ad6d0b --- /dev/null +++ b/tests/test_control_protocol.py @@ -0,0 +1,125 @@ +"""Frozen wire contract of the shared Control protocol core (stdlib only).""" + +from __future__ import annotations + +import math +from pathlib import Path + +import pytest + +from botlab.control import protocol +from botlab.control.discovery import endpoint + + +def envelope(**overrides) -> dict: + body = {"request_id": "req-1", "instance_id": "inst-1", "session_id": "sess-1", + "episode_id": "ep-1", "capabilities_revision": "rev-1", + "capability": "motion.twist", "args": {"vx": 0.25}} + body.update(overrides) + return body + + +class TestFrozenConstants: + def test_command_statuses_are_the_v1_wire_set(self): + assert protocol.COMMAND_STATUSES == {"accepted", "applied", "completed", + "cancelled", "failed", "expired"} + + def test_terminal_statuses_stay_a_subset_of_command_statuses(self): + assert protocol.TERMINAL < protocol.COMMAND_STATUSES + + def test_envelope_fields_are_frozen_at_exactly_eight(self): + assert protocol.ENVELOPE_FIELDS == {"request_id", "instance_id", "session_id", + "episode_id", "capabilities_revision", + "capability", "args"} + assert protocol.IDENTITY_FIELDS == protocol.ENVELOPE_FIELDS - {"args"} + + def test_local_v1_declares_no_protocol_extensions(self): + assert isinstance(protocol.PROTOCOL_EXTENSIONS, tuple) + assert protocol.PROTOCOL_EXTENSIONS == () + + +class TestValidateEnvelope: + def test_complete_envelope_passes_through(self): + body = envelope() + assert protocol.validate_envelope(body) is body + + def test_missing_and_unknown_fields_are_rejected(self): + with pytest.raises(protocol.ControlError) as missing: + incomplete = envelope() + incomplete.pop("request_id") + protocol.validate_envelope(incomplete) + assert missing.value.code == "invalid_parameter" + with pytest.raises(protocol.ControlError) as unknown: + protocol.validate_envelope(envelope(project_ref="hosted-1")) + assert unknown.value.code == "invalid_parameter" + + @pytest.mark.parametrize("key", sorted(protocol.IDENTITY_FIELDS)) + def test_identity_fields_must_be_nonempty_strings(self, key): + for bad in ("", None, 7, []): + with pytest.raises(protocol.ControlError): + protocol.validate_envelope(envelope(**{key: bad})) + + def test_request_id_is_bounded_by_the_wire_limit(self): + with pytest.raises(protocol.ControlError) as error: + protocol.validate_envelope(envelope(request_id="x" * (protocol.MAX_REQUEST_ID + 1))) + assert error.value.code == "invalid_parameter" + + @pytest.mark.parametrize("value", [math.nan, math.inf, -math.inf]) + def test_non_finite_numbers_anywhere_in_args_are_rejected(self, value): + with pytest.raises(protocol.ControlError): + protocol.validate_envelope(envelope(args={"vx": value})) + with pytest.raises(protocol.ControlError): + protocol.validate_envelope(envelope(args={"nested": {"list": [[value]]}})) + + def test_server_integrations_raise_their_own_exception_type(self): + class ServiceError(Exception): + pass + + def factory(code: str, message: str) -> ServiceError: + return ServiceError(f"{code}: {message}") + + with pytest.raises(ServiceError, match="invalid_parameter"): + protocol.validate_envelope({}, error=factory) + assert protocol.validate_envelope(envelope(), error=factory) + + +class TestEndpoint: + @pytest.mark.parametrize("origin", [ + "http://127.0.0.1:8776", "http://localhost:8776", # local middleware + "https://control.example.com", "https://control.example.com:8443", + "https://127.0.0.1:8443", # loopback TLS (local testing) + ]) + def test_accepts_loopback_http_and_any_https(self, origin): + assert endpoint(origin) == origin + + def test_trailing_slashes_normalize_away(self): + assert endpoint("https://control.example.com/") == "https://control.example.com" + + @pytest.mark.parametrize("origin", [ + "http://control.example.com:8443", # plaintext never leaves the loopback + "http://127.0.0.1", # local http keeps the explicit-port rule + "https://user:secret@control.example.com", + "https://control.example.com/runtime", + "https://control.example.com?token=x", + "https://control.example.com#frag", + "https://control.example.com:99999", + "ws://control.example.com", + "https://", + ]) + def test_rejects_plaintext_remote_and_structured_suffixes(self, origin): + with pytest.raises(protocol.ControlError): + endpoint(origin) + + +class TestProjectKey: + def test_key_is_a_stable_short_digest_of_the_resolved_path(self, tmp_path: Path): + first = protocol.project_key(tmp_path) + assert first == protocol.project_key(tmp_path) + assert len(first) == 16 and int(first, 16) >= 0 + + def test_relative_and_absolute_references_agree(self, tmp_path: Path, monkeypatch): + monkeypatch.chdir(tmp_path.parent) + assert protocol.project_key(tmp_path.name) == protocol.project_key(tmp_path) + + def test_distinct_locations_get_distinct_keys(self, tmp_path: Path): + assert protocol.project_key(tmp_path) != protocol.project_key(tmp_path.parent) diff --git a/tests/test_control_remote.py b/tests/test_control_remote.py new file mode 100644 index 0000000..9d00fde --- /dev/null +++ b/tests/test_control_remote.py @@ -0,0 +1,159 @@ +"""Remote middleware flows over real TLS: enrollment, pinned identity, restart +refusal, re-enrollment and multi-instance selection against a dev CA.""" + +from __future__ import annotations + +import json +import os +import shutil +import ssl +import stat +import subprocess +import threading +from pathlib import Path + +import pytest + +from botlab.control.discovery import project_key +from tests.test_control_cli import JointMiddleware, invoke + + +@pytest.fixture(scope="module") +def authority(tmp_path_factory) -> Path: + if shutil.which("openssl") is None: + pytest.skip("openssl is required for TLS middleware tests") + root = tmp_path_factory.mktemp("pki") + + def run(*args: str) -> None: + subprocess.run(["openssl", *args], check=True, capture_output=True, cwd=root) + + # Python 3.13 verifies with VERIFY_X509_STRICT by default: a CA without + # basicConstraints/keyUsage is rejected ("CA cert does not include key usage + # extension"), so the fixture CA carries the extensions a real CA has. + run("req", "-x509", "-newkey", "rsa:2048", "-keyout", "ca.key", "-out", "ca.crt", + "-days", "2", "-nodes", "-subj", "/CN=BotLab Test CA", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign") + run("req", "-newkey", "rsa:2048", "-keyout", "server.key", "-out", "server.csr", + "-nodes", "-subj", "/CN=localhost") + (root / "san.ext").write_text("subjectAltName=DNS:localhost,IP:127.0.0.1\n") + run("x509", "-req", "-in", "server.csr", "-CA", "ca.crt", "-CAkey", "ca.key", + "-CAcreateserial", "-out", "server.crt", "-days", "2", "-extfile", "san.ext") + return root + + +class RemoteMiddleware(JointMiddleware): + """The joint stub served over TLS, as a remote middleware would be.""" + + def __init__(self, project: Path, authority: Path): + super().__init__(project) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(authority / "server.crt", authority / "server.key") + self.server.shutdown() + self.thread.join() + self.server.socket = context.wrap_socket(self.server.socket, server_side=True) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.endpoint = f"https://127.0.0.1:{self.server.server_port}" + + +def prepare(tmp_path: Path, monkeypatch, authority: Path) -> Path: + project = tmp_path / "project" + project.mkdir() + monkeypatch.setenv("BOTLAB_CONTROL_RUNTIME_DIR", str(tmp_path / "runtime")) + monkeypatch.setenv("BOTLAB_CONTROL_RECEIPTS_DIR", str(tmp_path / "receipts")) + monkeypatch.setenv("BOTLAB_CONTROL_CA_BUNDLE", str(authority / "ca.crt")) + monkeypatch.setenv("BOTLAB_CONTROL_TOKEN", "test-token") + monkeypatch.chdir(project) + return project + + +@pytest.fixture +def remote(tmp_path, monkeypatch, authority): + project = prepare(tmp_path, monkeypatch, authority) + server = RemoteMiddleware(project, authority) + yield server + server.close() + + +def enrolled_descriptor(instance_id: str) -> tuple[Path, dict]: + runtime = Path(os.environ["BOTLAB_CONTROL_RUNTIME_DIR"]) + directory = runtime / project_key(Path.cwd()) + descriptor = directory / f"{instance_id}.json" + return descriptor, json.loads(descriptor.read_text()) + + +def test_enroll_pins_identity_and_controls_over_tls(remote): + code, result = invoke("enroll", "--endpoint", remote.endpoint) + assert code == 0 + assert result["ok"] and result["instance_id"] == remote.instance + assert result["websocket"] == f"wss://127.0.0.1:{remote.server.server_port}/runtime" + + descriptor, payload = enrolled_descriptor(remote.instance) + assert payload["remote"] is True and payload["token"] == "test-token" + assert payload["project_key"] == remote.key and payload["endpoint"] == remote.endpoint + assert stat.S_IMODE(descriptor.stat().st_mode) & 0o077 == 0 + + code, state = invoke("state") + assert code == 0 and state["state"]["session_id"] == remote.session + code, command = invoke("call", "joint.position", "--args", '{"positions":[0.5,-0.5]}', + "--request-id", "remote-1") + assert code == 0 and command["command"]["status"] == "accepted" + assert remote.received[0]["instance_id"] == remote.instance + + +def test_remote_restart_refuses_replay_until_reenrolled(remote): + assert invoke("enroll", "--endpoint", remote.endpoint)[0] == 0 + assert invoke("call", "joint.position", "--args", '{"positions":[0.5,-0.5]}', + "--request-id", "remote-2")[0] == 0 + + remote.instance = "joint-instance-restarted" + code, result = invoke("wait", "--id", "remote-2") + assert code == 2 and result["error"]["code"] == "wrong_instance" + code, result = invoke("retry", "--id", "remote-2") + assert code == 2 and result["error"]["code"] == "wrong_instance" + + code, result = invoke("enroll", "--endpoint", remote.endpoint) + assert code == 0 and result["instance_id"] == "joint-instance-restarted" + # Re-enrollment replaced this endpoint's previous descriptor, not stacked it. + descriptor, payload = enrolled_descriptor("joint-instance-restarted") + assert payload["remote"] is True + code, state = invoke("state") + assert code == 0 and state["state"]["session_id"] == remote.session + + +def test_identity_pin_mismatch_is_refused(remote): + assert invoke("enroll", "--endpoint", remote.endpoint)[0] == 0 + remote.key = "a-different-project" + code, result = invoke("state") + assert code == 2 and result["error"]["code"] == "wrong_project" + + +def test_self_signed_tls_is_rejected_without_the_dev_ca(remote, monkeypatch): + monkeypatch.delenv("BOTLAB_CONTROL_CA_BUNDLE") + code, result = invoke("enroll", "--endpoint", remote.endpoint) + assert code == 2 and result["error"]["code"] == "connection_error" + runtime = Path(os.environ["BOTLAB_CONTROL_RUNTIME_DIR"]) + assert not runtime.exists() or not any(runtime.rglob("*.json")) + + +def test_multiple_enrolled_instances_require_explicit_selection(tmp_path, monkeypatch, authority): + project = prepare(tmp_path, monkeypatch, authority) + first = RemoteMiddleware(project, authority) + second = RemoteMiddleware(project, authority) + try: + assert invoke("enroll", "--endpoint", first.endpoint)[0] == 0 + # Both stubs report the same instance identity until told apart; a + # second endpoint reusing it must fail cleanly instead of clobbering. + code, result = invoke("enroll", "--endpoint", second.endpoint) + assert code == 2 and result["error"]["code"] == "instance_conflict" + second.instance = "joint-instance-second" + assert invoke("enroll", "--endpoint", second.endpoint)[0] == 0 + code, result = invoke("state") + assert code == 2 and result["error"]["code"] == "ambiguous_instance" + assert {item["instance_id"] for item in result["instances"]} == {first.instance, second.instance} + code, state = invoke("state", "--instance", second.instance) + assert code == 0 and state["state"]["session_id"] == second.session + finally: + first.close() + second.close() diff --git a/tests/test_control_serve.py b/tests/test_control_serve.py new file mode 100644 index 0000000..21ac86c --- /dev/null +++ b/tests/test_control_serve.py @@ -0,0 +1,487 @@ +"""Generic middleware tests: descriptor pairing, ported service semantics, HTTP face. + +The task-table semantics (freshness, deduplication, leases, cancellation) are +ported from microduck's proven suite; here the adapter starts empty and a +descriptor uploaded over the paired connection compiles it. +""" +from __future__ import annotations + +import asyncio +import json +import os +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import patch + +try: + from aiohttp import WSMsgType, WSServerHandshakeError + from aiohttp.test_utils import TestClient, TestServer +except ImportError as error: # pragma: no cover - without the serve extra + raise unittest.SkipTest("aiohttp (botlab-python[serve]) is required for serve tests") from error + +from botlab.control import serve_service +from botlab.control.descriptor import revision_of, validate_descriptor +from botlab.control.discovery import descriptors +from botlab.control.protocol import ControlError as ProtocolError +from botlab.control.serve_service import ControlError, GenericControlService, create_app, serve + +TOKEN = "test-token-only" +ORIGIN = "http://127.0.0.1:8775" + + +class Clock: + def __init__(self): + self.now = 100.0 + + def __call__(self): + return self.now + + +class Browser: + """Records wire commands without pretending to execute a simulation.""" + + def __init__(self): + self.closed = False + self.messages = [] + + async def send_json(self, message): + self.messages.append(message) + + async def close(self, **kwargs): + self.closed = True + + +def descriptor(): + """The microduck control surface as a page would declare it.""" + return { + "v": 1, "type": "descriptor", + "profile": {"robot_id": "MicroDuck", "state_node_id": "state-machine", + "motion_parameters": {"vx": "forward_velocity", "yaw_rate": "yaw_rate"}, + "event_parameter": "policy_command", "event_values": [1, 2, 3, 4, 5, 6]}, + "motion": {"vx": {"min": -0.2, "max": 0.25, "unit": "m/s"}, + "yaw_rate": {"min": -1, "max": 1, "unit": "rad/s"}, + "allowed_states": [0]}, + "actions": [ + {"name": "sit", "event_value": 1, "allowed_states": [0], "target_states": [2], + "capability": "posture.sit", "idempotent": True}, + {"name": "stand_up", "event_value": 1, "allowed_states": [2], "target_states": [0], + "capability": "posture.stand_up", "idempotent": True}, + {"name": "kick_left", "event_value": 2, "allowed_states": [0], "target_states": [0], + "capability": "behavior.kick_left"}, + ], + "states": {"0": "WALK", "2": "SIT"}, + "experience": "# MicroDuck control experience", + } + + +def snapshot(**changes): + return { + "v": 1, "type": "state", "session_id": "browser-a", + "episode_id": "episode-a", "control_generation": 2, + "basis_id": "basis-a", "run_state": "running", "external_enabled": True, "step": 10, + "sim_time_s": 0.2, "captured_mono_ms": 200, "state_age_ms": 0, + "policy_state_code": 0, "policy_state_steps": 10, + "position_world_m": [0, 0, 0.1], + "orientation_wxyz": [1, 0, 0, 0], + "effective_parameters": {"forward_velocity": 0, "yaw_rate": 0}, + **changes, + } + + +def result(command_id="cmd-a", status="completed", **changes): + return {"v": 1, "type": "result", "session_id": "browser-a", + "episode_id": "episode-a", "command_id": command_id, + "status": status, "step": 110, "sim_time_s": 2.2, **changes} + + +def move_args(**changes): + return {"vx": 0.1, "vy": 0, "yaw_rate": 0, "duration_s": 2, **changes} + + +class ServiceTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.clock = Clock() + self.service = GenericControlService(TOKEN, {ORIGIN}, clock=self.clock) + self.browser = Browser() + self.service.socket = self.browser + + def envelope(self, capability, args, request_id="cmd-a", **changes): + return {"request_id": request_id, "instance_id": self.service.instance_id, + "session_id": "browser-a", "episode_id": "episode-a", + "capabilities_revision": self.service.adapter.revision, + "capability": capability, "args": args, **changes} + + async def pair(self): + await self.service.accept_message(descriptor()) + + async def submit(self, **changes): + envelope = self.envelope("motion.twist", move_args()) + envelope.update(changes) + return await self.service.submit_public(envelope) + + async def assert_rejected(self, code, coroutine): + with self.assertRaises((ControlError, ProtocolError)) as caught: + await coroutine + self.assertEqual(caught.exception.code, code) + + def commands(self): + return [message for message in self.browser.messages if message.get("type") == "command"] + + async def test_unpaired_service_refuses_project_semantics(self): + with self.assertRaises(ControlError) as caught: + self.service.capabilities() + self.assertEqual(caught.exception.code, "no_project_paired") + with self.assertRaises(ControlError) as caught: + self.service.experience() + self.assertEqual(caught.exception.code, "no_project_paired") + envelope = {"request_id": "cmd-a", "instance_id": self.service.instance_id, + "session_id": "browser-a", "episode_id": "episode-a", + "capabilities_revision": "gen-x", "capability": "motion.twist", + "args": move_args()} + await self.assert_rejected("no_project_paired", self.service.submit_public(envelope)) + info = self.service.info() + self.assertEqual(info["extensions"], ["project-descriptor"]) + self.assertEqual(info["backend"], {"id": "browser", "kind": "simulation"}) + self.assertFalse(self.service.state()["ready"]) + + async def test_state_frames_before_descriptor_are_rejected(self): + with self.assertRaises(ProtocolError) as caught: + await self.service.accept_message(snapshot()) + self.assertEqual(caught.exception.code, "invalid_message") + await self.pair() + await self.service.accept_message(snapshot()) + self.assertTrue(self.service.state()["ready"]) + + async def test_descriptor_compiles_engine_and_acks(self): + await self.pair() + ack = self.browser.messages[0] + self.assertEqual(ack["type"], "descriptor_ack") + self.assertEqual(ack["capabilities_revision"], + revision_of(validate_descriptor(descriptor()))) + self.assertEqual(ack["profile"]["robot_id"], "MicroDuck") + capabilities = self.service.capabilities() + self.assertEqual({entry["id"] for entry in capabilities["capabilities"]}, + {"motion.twist", "control.stop", "posture.sit", + "posture.stand_up", "behavior.kick_left"}) + self.assertEqual(self.service.experience()["experience"], + "# MicroDuck control experience") + + async def test_invalid_descriptor_rejected_without_pairing(self): + broken = descriptor() + broken["surprise"] = True + with self.assertRaises(ProtocolError) as caught: + await self.service.accept_message(broken) + self.assertEqual(caught.exception.code, "invalid_descriptor") + self.assertIsNone(self.service.adapter) + + async def test_move_wire_and_bounds(self): + await self.pair() + await self.service.accept_message(snapshot()) + self.assertTrue(self.service.state()["ready"]) + for args in (move_args(vx=0.251), move_args(yaw_rate=-1.01), + move_args(duration_s=0.049), move_args(duration_s=10.001)): + with self.subTest(args=args): + await self.assert_rejected( + "out_of_range", self.service.submit_public( + self.envelope("motion.twist", args))) + await self.assert_rejected("invalid_parameter", self.service.submit_public( + self.envelope("motion.twist", move_args(vx=True)))) + await self.assert_rejected("invalid_parameter", self.service.submit_public( + self.envelope("motion.twist", move_args(vx=float("nan"))))) + await self.assert_rejected("unsupported_capability", self.service.submit_public( + self.envelope("nav.goto", {}))) + self.assertEqual(self.commands(), []) + submitted = await self.submit() + self.assertEqual(submitted["status"], "accepted") + self.assertFalse(submitted["terminal"]) + wire = self.commands()[0] + self.assertEqual(wire["values"], {"forward_velocity": 0.1, "yaw_rate": 0}) + self.assertEqual(wire["duration_ms"], 2000) + self.assertEqual(wire["episode_id"], "episode-a") + self.assertEqual(wire["allowed_states"], [0]) + + async def test_envelope_guards(self): + await self.pair() + await self.service.accept_message(snapshot()) + await self.assert_rejected("wrong_instance", self.submit(instance_id="other-instance")) + await self.assert_rejected("capabilities_changed", self.submit(capabilities_revision="gen-stale")) + await self.assert_rejected("wrong_session", self.submit(session_id="another-browser")) + + async def test_retry_deduplicates_and_conflicting_retry_is_rejected(self): + await self.pair() + await self.service.accept_message(snapshot()) + original = await self.submit() + retried = await self.submit() + self.assertEqual(original["command_id"], retried["command_id"]) + self.assertEqual(len(self.commands()), 1) + await self.assert_rejected("request_conflict", self.submit(args=move_args(vx=0.2))) + await self.assert_rejected("busy", self.submit(request_id="cmd-b")) + await self.service.accept_message(result()) + await self.service.disconnected() + # The public envelope needs a live session, so a resubmission after + # disconnect is refused; the terminal outcome stays reachable by ID. + await self.assert_rejected("wrong_session", self.submit()) + self.assertEqual(self.service.api_job(self.service.jobs["cmd-a"])["status"], "completed") + self.assertEqual(len(self.commands()), 1) + + async def test_stop_cancels_motion_but_waits_for_browser_commit(self): + await self.pair() + await self.service.accept_message(snapshot()) + await self.submit() + stop = await self.service.submit_public( + self.envelope("control.stop", {}, request_id="stop-a")) + self.assertEqual(self.service.jobs["cmd-a"]["reason"], "stop_requested") + self.assertEqual(stop["status"], "accepted") + self.assertEqual(self.commands()[-1]["kind"], "stop") + await self.service.accept_message(result(command_id="stop-a")) + self.assertEqual(self.service.jobs["stop-a"]["status"], "completed") + + async def test_episode_change_cancels_and_retires_results(self): + await self.pair() + await self.service.accept_message(snapshot()) + await self.submit() + await self.service.accept_message(result(episode_id="old-episode")) + await self.service.accept_message(result(status="applied")) + self.assertEqual(self.service.jobs["cmd-a"]["status"], "applied") + await self.service.accept_message(snapshot(episode_id="episode-b", step=1)) + self.assertEqual(self.service.jobs["cmd-a"]["reason"], "episode_changed") + await self.service.accept_message(result()) + self.assertEqual(self.service.jobs["cmd-a"]["status"], "cancelled") + + async def test_action_semantics_and_disallowed_states(self): + await self.pair() + await self.service.accept_message(snapshot(policy_state_code=2)) + sit = await self.service.submit_public( + self.envelope("posture.sit", {}, request_id="sit-a")) + self.assertEqual((sit["status"], sit["reason"]), ("completed", "already_satisfied")) + self.assertEqual(self.commands(), []) + # Refusals are checked while idle: a busy table answers busy first. + await self.assert_rejected("invalid_state", self.service.submit_public( + self.envelope("behavior.kick_left", {}, request_id="kick-a"))) + await self.assert_rejected("invalid_state", self.submit(request_id="move-b")) + stand = await self.service.submit_public( + self.envelope("posture.stand_up", {}, request_id="stand-a")) + self.assertEqual(stand["status"], "accepted") + wire = self.commands()[-1] + self.assertEqual((wire["kind"], wire["event_value"], + wire["allowed_states"], wire["target_states"]), + ("event", 1, [2], [0])) + + async def test_monitor_renews_with_latest_basis_and_expires(self): + await self.pair() + await self.service.accept_message(snapshot()) + await self.submit() + await self.service.accept_message(snapshot(step=11, basis_id="basis-b", control_generation=3)) + monitor = asyncio.create_task(self.service.monitor_jobs()) + try: + await asyncio.sleep(0.12) + renewals = [m for m in self.browser.messages if m["type"] == "renew"] + self.assertEqual(len(renewals), 1) + self.assertEqual(renewals[0]["basis_id"], "basis-b") + self.assertEqual(renewals[0]["control_generation"], 3) + self.clock.now += 1.01 + await asyncio.sleep(0.12) + self.assertEqual(self.service.jobs["cmd-a"]["status"], "expired") + finally: + monitor.cancel() + with self.assertRaises(asyncio.CancelledError): + await monitor + + +class HttpTests(unittest.IsolatedAsyncioTestCase): + async def asyncSetUp(self): + self.service = GenericControlService(TOKEN, {ORIGIN}) + self.client = TestClient(TestServer(create_app(self.service))) + self.addAsyncCleanup(self.client.close) + await self.client.start_server() + self.headers = {"Authorization": "Bearer " + TOKEN} + + async def pair_over_ws(self): + ws = await self.client.ws_connect("/runtime", headers={"Origin": ORIGIN}) + await ws.send_json({"v": 1, "type": "hello", "token": TOKEN}) + welcome = await ws.receive_json() + assert welcome["type"] == "welcome" + await ws.send_json(descriptor()) + ack = await ws.receive_json() + assert ack["type"] == "descriptor_ack" + await ws.send_json(snapshot()) + return ws, ack + + async def test_http_host_origin_token_and_preflight_boundary(self): + cases = ( + ({}, 401, "unauthorized"), + ({"Authorization": "Bearer wrong"}, 401, "unauthorized"), + ({**self.headers, "Host": "malicious.example"}, 403, "forbidden_host"), + ({**self.headers, "Origin": "https://malicious.example"}, 403, "forbidden_origin"), + ) + for headers, status, code in cases: + with self.subTest(headers=headers): + response = await self.client.get("/control/v1/state", headers=headers) + self.assertEqual(response.status, status) + self.assertEqual((await response.json())["error"]["code"], code) + preflight = await self.client.options("/control/v1/state", + headers={"Origin": ORIGIN, "Access-Control-Request-Method": "GET"}) + self.assertEqual(preflight.status, 204) + self.assertEqual(preflight.headers["Access-Control-Allow-Origin"], ORIGIN) + response = await self.client.get("/control/v1/state", headers=self.headers) + self.assertEqual(response.status, 200) + self.assertFalse((await response.json())["state"]["ready"]) + + async def test_project_endpoints_refuse_before_pairing(self): + for path in ("/control/v1/capabilities", "/control/v1/experience"): + with self.subTest(path=path): + response = await self.client.get(path, headers=self.headers) + self.assertEqual(response.status, 409) + self.assertEqual((await response.json())["error"]["code"], "no_project_paired") + response = await self.client.post("/control/v1/commands", headers=self.headers, + json={"request_id": "x", "instance_id": self.service.instance_id, + "session_id": "s", "episode_id": "e", + "capabilities_revision": "gen-x", + "capability": "motion.twist", "args": {}}) + self.assertEqual(response.status, 409) + self.assertEqual((await response.json())["error"]["code"], "no_project_paired") + + async def test_websocket_boundary_and_welcome_negotiation(self): + for headers in ({}, {"Origin": "https://malicious.example"}): + with self.assertRaises(WSServerHandshakeError) as caught: + await self.client.ws_connect("/runtime", headers=headers) + self.assertEqual(caught.exception.status, 403) + bad = await self.client.ws_connect("/runtime", headers={"Origin": ORIGIN}) + await bad.send_json({"v": 1, "type": "hello", "token": "wrong"}) + self.assertEqual((await bad.receive()).type, WSMsgType.CLOSE) + self.assertEqual(bad.close_code, 1008) + owner = await self.client.ws_connect("/runtime", headers={"Origin": ORIGIN}) + await owner.send_json({"v": 1, "type": "hello", "token": TOKEN}) + welcome = await owner.receive_json() + self.assertEqual(welcome["profile"], None) + self.assertEqual(welcome["descriptor"], {"version": 1}) + second = await self.client.ws_connect("/runtime", headers={"Origin": ORIGIN}) + await second.send_json({"v": 1, "type": "hello", "token": TOKEN}) + self.assertEqual((await second.receive()).type, WSMsgType.CLOSE) + await owner.close() + await second.close() + await bad.close() + + async def test_second_descriptor_on_a_connection_closes_it(self): + ws = await self.client.ws_connect("/runtime", headers={"Origin": ORIGIN}) + await ws.send_json({"v": 1, "type": "hello", "token": TOKEN}) + await ws.receive_json() + await ws.send_json(descriptor()) + self.assertEqual((await ws.receive_json())["type"], "descriptor_ack") + await ws.send_json(descriptor()) + frame = await ws.receive() + self.assertEqual(frame.type, WSMsgType.CLOSE) + self.assertEqual(ws.close_code, 1008) + await ws.close() + + async def test_invalid_descriptor_closes_without_pairing(self): + broken = descriptor() + broken["surprise"] = True + ws = await self.client.ws_connect("/runtime", headers={"Origin": ORIGIN}) + await ws.send_json({"v": 1, "type": "hello", "token": TOKEN}) + await ws.receive_json() + await ws.send_json(broken) + self.assertEqual((await ws.receive()).type, WSMsgType.CLOSE) + self.assertEqual(ws.close_code, 1008) + self.assertIsNone(self.service.adapter) + await ws.close() + + async def test_full_pairing_flow_over_real_websocket(self): + ws, ack = await self.pair_over_ws() + self.addAsyncCleanup(ws.close) + self.assertEqual(ack["profile"]["robot_id"], "MicroDuck") + response = await self.client.get("/control/v1/capabilities", headers=self.headers) + payload = await response.json() + self.assertEqual(payload["capabilities_revision"], ack["capabilities_revision"]) + response = await self.client.get("/control/v1/experience", headers=self.headers) + self.assertEqual((await response.json())["experience"], "# MicroDuck control experience") + + envelope = {"request_id": "cmd-a", "instance_id": self.service.instance_id, + "session_id": "browser-a", "episode_id": "episode-a", + "capabilities_revision": ack["capabilities_revision"], + "capability": "motion.twist", "args": move_args()} + response = await self.client.post("/control/v1/commands", headers=self.headers, json=envelope) + command = (await response.json())["command"] + self.assertEqual(command["status"], "accepted") + self.assertEqual(command["current_state"]["policy_state"], "WALK") + + wire = await ws.receive_json() + self.assertEqual(wire["type"], "command") + self.assertEqual(wire["values"], {"forward_velocity": 0.1, "yaw_rate": 0}) + pending = await self.client.get("/control/v1/commands/cmd-a?timeout=0.01", headers=self.headers) + self.assertTrue((await pending.json())["pending"]) + + await ws.send_json(snapshot(step=110)) + await ws.send_json(result()) + final = await self.client.get("/control/v1/commands/cmd-a?timeout=1", headers=self.headers) + payload = await final.json() + self.assertFalse(payload["pending"]) + self.assertEqual(payload["command"]["status"], "completed") + self.assertEqual(payload["command"]["completion_observation"]["capture_stage"], "completed") + + async def test_stop_endpoint_requires_stop_capability(self): + await self.pair_over_ws() + envelope = {"request_id": "stop-a", "instance_id": self.service.instance_id, + "session_id": "browser-a", "episode_id": "episode-a", + "capabilities_revision": self.service.adapter.revision, + "capability": "motion.twist", "args": {}} + response = await self.client.post("/control/v1/stop", headers=self.headers, json=envelope) + self.assertEqual(response.status, 400) + self.assertEqual((await response.json())["error"]["code"], "invalid_parameter") + + +class ServeTests(unittest.TestCase): + def args(self, **changes): + values = {"port": 8776, "host": "127.0.0.1", "origin": [ORIGIN], + "public_host": [], "public_endpoint": None, "print_token": False, + "project": Path(self.project)} + values.update(changes) + return SimpleNamespace(**values) + + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.project = Path(self.temporary.name) / "project" + self.project.mkdir() + + def test_argument_validation(self): + for changes, code in (({"port": 80}, "invalid_port"), + ({"port": 70000}, "invalid_port"), + ({"origin": []}, "invalid_parameter"), + ({"public_endpoint": "http://127.0.0.1:9443"}, "invalid_parameter")): + with self.subTest(changes=changes): + with self.assertRaises(ControlError) as caught: + serve(self.args(**changes)) + self.assertEqual(caught.exception.code, code) + + def test_registers_private_discoverable_descriptor_and_cleans_up(self): + runtime = Path(self.temporary.name) / "runtime" + seen = {} + + def fake_run_app(app, **kwargs): + found = descriptors(self.project) + seen["found"] = found + directory = runtime / found[0]["project_key"] + seen["files"] = list(directory.glob("*.json")) + seen["mode"] = seen["files"][0].stat().st_mode & 0o777 + seen["directory_mode"] = directory.stat().st_mode & 0o777 + seen["payload"] = json.loads(seen["files"][0].read_text()) + + with patch.dict(os.environ, {"BOTLAB_CONTROL_RUNTIME_DIR": str(runtime)}), \ + patch.object(serve_service.web, "run_app", fake_run_app): + summary = serve(self.args()) + item = seen["found"][0] + self.assertEqual(summary["instance_id"], item["instance_id"]) + self.assertEqual(item["endpoint"], "http://127.0.0.1:8776") + self.assertEqual(item["websocket"], "ws://127.0.0.1:8776/runtime") + self.assertTrue(item["token"]) + self.assertEqual(seen["mode"], 0o600) + self.assertEqual(seen["directory_mode"], 0o700) + self.assertEqual(seen["payload"]["pid"], os.getpid()) + self.assertEqual(list((runtime / item["project_key"]).glob("*.json")), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_hil.py b/tests/test_hil.py new file mode 100644 index 0000000..519cfdb --- /dev/null +++ b/tests/test_hil.py @@ -0,0 +1,291 @@ +"""Contract tests use an arbitrary multi-input target, independent of any robot.""" +import base64 +import hashlib +import socket +import struct +import threading +from pathlib import Path + +import pytest + +from botlab.hil import HilError, HilHost +from botlab.hil.agent import AgentServer, AgentState +from botlab.hil.protocol import MAX_FRAME_BYTES, endpoint_address, receive, tensor +from botlab.hil.targets import read_targets, save_target + + +class ArithmeticBackend: + closed = 0 + + def describe(self): + return {"backend": "arithmetic", "provider": "test"} + + def load(self, path): + assert path.read_bytes() == b"test-model" + def spec(name): + return {"name": name, "dtype": "float32", "shape": [2, 3]} + return object(), [spec("left"), spec("right")], [spec("sum"), spec("difference")] + + def infer(self, handle, inputs): + left, right = inputs["left"]["data"], inputs["right"]["data"] + return {"sum": values([a + b for a, b in zip(left, right, strict=True)]), + "difference": values([a - b for a, b in zip(left, right, strict=True)])} + + def close(self, handle): + self.closed += 1 + + +def values(data=None): + return {"dtype": "float32", "shape": [2, 3], "data": data or [1, 2, 3, 4, 5, 6]} + + +@pytest.fixture +def live(tmp_path, monkeypatch): + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "targets.json")) + backend = ArithmeticBackend() + state = AgentState(tmp_path / "models", backend) + server = AgentServer(("127.0.0.1", 0), state) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + target = save_target({"id": "test", "name": "Test", "backend": "arithmetic", + "endpoint": f"tcp://127.0.0.1:{server.server_address[1]}"}) + host = HilHost(tmp_path, target["id"]) + try: + yield host, state, target + finally: + host.close() + server.shutdown() + server.server_close() + thread.join() + + +def prepare(host): + assert host.handle("POST", "/hil/connect", {"target": "test"})["connected"] + session = host.handle("POST", "/hil/start")["session"] + content = b"test-model" + digest = hashlib.sha256(content).hexdigest() + model = host.handle("POST", "/hil/models", {"session": session, "sha256": digest, + "data": base64.b64encode(content).decode()}) + return session, model + + +def infer(host, session, model, seq=0): + return host.handle("POST", "/hil/infer", {"session": session, "seq": seq, + "model": model["sha256"], "inputs": {"left": values(), "right": values([2] * 6)}}) + + +def test_multi_input_multi_output_live_transport(live): + host, state, _ = live + session, model = prepare(host) + assert [p["name"] for p in model["inputs"]] == ["left", "right"] + result = infer(host, session, model) + assert result["outputs"]["sum"]["data"] == [3, 4, 5, 6, 7, 8] + assert result["outputs"]["difference"]["data"] == [-1, 0, 1, 2, 3, 4] + assert result["calls"] == state.calls == 1 + assert result["rtt_ms"] >= result["inference_ms"] >= 0 + host.handle("POST", "/hil/stop", {"session": session}) + assert state.backend.closed == 1 + assert state.owner is None + + +def test_rejects_replayed_and_stale_steps(live): + host, state, _ = live + session, model = prepare(host) + infer(host, session, model, 7) + with pytest.raises(HilError, match="sequence"): + infer(host, session, model, 7) + assert state.calls == 1 + host.handle("POST", "/hil/stop", {"session": session}) + new_session = host.handle("POST", "/hil/start")["session"] + assert new_session != session + with pytest.raises(HilError, match="Stale"): + infer(host, session, model, 8) + with pytest.raises(HilError, match="Stale"): + host.handle("POST", "/hil/stop", {"session": session}) + assert state.session == new_session + + +def test_agent_enforces_exclusive_lease(live): + host, state, target = live + prepare(host) + other = HilHost(host.project_dir, target["id"]) + try: + other.handle("POST", "/hil/connect", {"target": target["id"]}) + with pytest.raises(HilError, match="active session"): + other.handle("POST", "/hil/start") + assert state.session == host.session + finally: + other.close() + + +def test_hash_mismatch_closes_and_releases_session(live): + host, state, _ = live + session, _ = prepare(host) + with pytest.raises(HilError, match="SHA-256"): + host.handle("POST", "/hil/models", {"session": session, "sha256": "0" * 64, + "data": base64.b64encode(b"wrong").decode()}) + assert not host.state()["connected"] + # Joining the state lock waits for a potentially finishing server request. + with state.lock: + pass + # Reconnect and acquire a fresh session verifies release on the server. + host.handle("POST", "/hil/connect", {"target": "test"}) + assert host.handle("POST", "/hil/start")["session"] != session + + +def test_inference_failure_is_not_retried(live): + host, state, _ = live + session, model = prepare(host) + calls = [] + def fail(handle, feeds): + calls.append(1) + raise RuntimeError("backend failure") + state.backend.infer = fail + with pytest.raises(HilError, match="backend failure"): + infer(host, session, model) + assert calls == [1] + assert state.calls == 0 + assert not host.state()["connected"] + + +@pytest.mark.parametrize("shape,data", [([3], [1, 2]), ([2, 3], [float("nan")] * 6), + ([2, 3], [1e40] * 6), ([True], [1]), ([-1], [])]) +def test_invalid_tensors(shape, data): + with pytest.raises(HilError): + tensor({"dtype": "float32", "shape": shape, "data": data}) + + +def test_shape_and_names_checked_before_execution(live): + host, state, _ = live + session, model = prepare(host) + with pytest.raises(HilError, match="names"): + host.handle("POST", "/hil/infer", {"session": session, "seq": 1, + "model": model["sha256"], "inputs": {"wrong": values()}}) + assert state.calls == 0 + with pytest.raises(HilError, match="shape"): + tensor({"dtype": "float32", "shape": [6], "data": list(range(6))}, model["inputs"][0]) + + +def test_frame_limit_rejected_before_payload_read(): + left, right = socket.socketpair() + try: + left.sendall(struct.pack("!I", MAX_FRAME_BYTES + 1)) + with pytest.raises(HilError, match="frame limit"): + receive(right) + finally: + left.close() + right.close() + + +def test_target_settings_atomic_and_endpoint_validation(tmp_path, monkeypatch): + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "targets.json")) + target = save_target({"id": "board", "name": "Device", "endpoint": "tcp://192.0.2.1:50051"}) + save_target({**target, "endpoint": "tcp://192.0.2.2:50051"}) + assert read_targets() == [{**target, "endpoint": "tcp://192.0.2.2:50051"}] + assert not list(tmp_path.glob(".targets-*")) + for endpoint in ("https://host:4", "tcp://host", "tcp://user:pass@host:4", "tcp://host:4/path"): + with pytest.raises(HilError): + endpoint_address(endpoint) + + +def test_agent_validates_sequence_independently(tmp_path): + state = AgentState(tmp_path, ArithmeticBackend()) + owner = object() + session = state.dispatch(owner, {"op": "start"})["session"] + data = b"test-model" + digest = hashlib.sha256(data).hexdigest() + state.dispatch(owner, {"op": "load", "session": session, "sha256": digest, + "data": base64.b64encode(data).decode()}) + request = {"op": "infer", "session": session, "seq": 0, "model": digest, + "inputs": {"left": values(), "right": values()}} + state.dispatch(owner, request) + with pytest.raises(HilError, match="sequence"): + state.dispatch(owner, request) + with pytest.raises(HilError, match="Stale"): + state.dispatch(object(), {**request, "seq": 1}) + state.disconnect(owner) + assert state.backend.closed == 1 + assert state.owner is None + + +def test_builtin_ort_uses_real_inference_and_rejects_external_weights(tmp_path): + pytest.importorskip("onnxruntime") + from botlab.hil.backends import create_backend + backend = create_backend("onnxruntime") + assets = Path(__file__).parent / "assets" + handle, inputs, outputs = backend.load(assets / "identity.onnx") + shape = inputs[0]["shape"] + import math + data = [float(i) for i in range(math.prod(shape))] + result = backend.infer(handle, {inputs[0]["name"]: {"dtype": "float32", "shape": shape, "data": data}}) + assert result[outputs[0]["name"]]["data"] == data + backend.close(handle) + # External tensors are intentionally unsupported; only the uploaded artifact is readable. + with pytest.raises(HilError, match="External ONNX tensor"): + backend.load(assets / "external_weights.onnx") + + +@pytest.mark.parametrize("message", [b"\x3a\x03\x2a\x01\x68", b"\x3a\xff", b"\x00"]) +def test_embedded_onnx_validation_rejects_malformed_or_external(message): + from botlab.hil._onnx import require_embedded_tensors + with pytest.raises(HilError): + require_embedded_tensors(message) + + +def test_embedded_onnx_validation_checks_nested_attribute_tensors(): + from botlab.hil._onnx import require_embedded_tensors + def message(tag, child): + return bytes([tag * 8 + 2, len(child)]) + child + # Model.graph -> Graph.node -> Node.attribute -> Attribute.t -> external_data. + external = message(7, message(1, message(5, message(5, b"\x6a\x00")))) + with pytest.raises(HilError, match="External"): + require_embedded_tensors(external) + + +def test_serve_stream_survives_idle_read_timeouts(tmp_path): + """Coordinators compile for minutes before their first frame; idle reads + must keep the lease alive instead of terminating the dispatch loop.""" + import json + + from botlab.hil.agent import serve_stream + + state = AgentState(tmp_path / "models", ArithmeticBackend()) + sent = [] + + class IdleThenHelloStream: + stage = 0 + frame = b"" + offset = 0 + + def settimeout(self, value): + pass + + def recv(self, size): + if self.stage == 0: + self.stage = 1 + raise TimeoutError("idle read") + if self.stage == 1: + payload = json.dumps({"op": "hello", "version": 1}).encode() + self.frame = struct.pack(">I", len(payload)) + payload + self.stage = 2 + self.offset = 0 + chunk = self.frame[self.offset:self.offset + size] + if not chunk: + raise OSError("stream closed after hello") + self.offset += size + return chunk + + def sendall(self, data): + sent.append(data) + + def close(self): + pass + + # The stream closes after hello; the transport error ends the loop (caller + # owns reconnection), but the idle timeouts before it must not have. + with pytest.raises(OSError, match="closed after hello"): + serve_stream(state, IdleThenHelloStream()) + assert len(sent) == 1 + reply = json.loads(sent[0][4:].decode()) + assert reply["ok"] is True + assert reply["device"]["backend"] == "arithmetic" diff --git a/tests/test_hil_auth.py b/tests/test_hil_auth.py new file mode 100644 index 0000000..f4c3b77 --- /dev/null +++ b/tests/test_hil_auth.py @@ -0,0 +1,246 @@ +"""Recoverable SSH authentication without guessing keys or persisting passwords.""" +import copy +import io +import json +import subprocess +import threading +import time +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +import pytest + +from botlab._server import start_project_server +from botlab.hil import devices +from botlab.hil.host import HilHost +from botlab.hil.protocol import HilError, error_details +from botlab.hil.targets import save_target + + +def profile(): + return {"id": "linux", "name": "Linux board", "transport": "ssh", + "ssh": {"host": "board.local", "user": "robot"}} + + +def rejection(): + return b"robot@board.local: Permission denied (publickey,password).\n" + + +@pytest.mark.parametrize("password", [None, "incorrect-secret"]) +def test_openssh_auth_rejection_carries_recovery_metadata_and_redacts(password, monkeypatch): + credentials = {"password": password} if password else {} + device = devices.LinuxDevice(profile(), credentials) + seen = {} + + def run(argv, **kwargs): + seen.update(argv=argv, env=kwargs["env"]) + detail = rejection() + (password.encode() if password else b"") + return subprocess.CompletedProcess(argv, 255, b"", detail) + + monkeypatch.setattr(devices.subprocess, "run", run) + with pytest.raises(HilError) as caught: + device._command("python3 -c 'pass'") + assert error_details(caught.value) == {"error_code": "ssh_auth_required", "target_id": "linux", + "password_attempted": password is not None} + assert seen["env"]["LC_ALL"] == "C" + if password: + assert password not in str(caught.value) + assert password not in repr(seen["argv"]) + else: + assert "BatchMode=yes" in seen["argv"] + # Default key files and ssh-agent remain OpenSSH's responsibility. + assert "IdentitiesOnly=yes" not in seen["argv"] + + +@pytest.mark.parametrize(("code", "stderr"), [ + (1, rejection()), + (255, b"python3: Permission denied\n"), + (255, b"Permission denied (publickey,password).\n"), + (255, b"another@board.local: Permission denied (publickey).\n"), + (255, b"ssh: connect to host board.local port 22: Operation timed out\n"), + (255, b"Host key verification failed.\n"), + (1, b"sudo: a password is required\n"), +]) +def test_transport_host_key_and_remote_permissions_never_request_ssh_password(code, stderr, monkeypatch): + monkeypatch.setattr(devices.subprocess, "run", lambda argv, **_kwargs: + subprocess.CompletedProcess(argv, code, b"", stderr)) + with pytest.raises(HilError) as caught: + devices.LinuxDevice(profile())._command("python3 -c 'pass'") + assert error_details(caught.value) == {} + + +@pytest.mark.parametrize(("password", "padding"), [ + ("synthetic-review-password-0123456789", "x" * 1790), + (" synthetic-review-password-0123456789 ", ""), +]) +def test_ssh_error_redacts_before_truncation_or_whitespace_trimming(password, padding, monkeypatch): + stderr = (password + padding).encode() + monkeypatch.setattr(devices.subprocess, "run", lambda argv, **_kwargs: + subprocess.CompletedProcess(argv, 1, b"", stderr)) + with pytest.raises(HilError) as caught: + devices.LinuxDevice(profile(), {"password": password})._command("python3 -c 'pass'") + error = str(caught.value) + assert "0123456789" not in error + assert "[redacted]" in error + assert len(error) <= 1800 + + +def test_successful_key_auth_needs_no_password(monkeypatch): + monkeypatch.setattr(devices.subprocess, "run", lambda argv, **_kwargs: + subprocess.CompletedProcess(argv, 0, b"key-auth-ok", b"")) + assert devices.LinuxDevice(profile())._command("python3 -c 'pass'") == b"key-auth-ok" + + +def test_tunnel_auth_rejection_preserves_metadata_and_cleans_temp_files(monkeypatch): + class Process: + stdin = io.BytesIO() + stderr = io.BytesIO(rejection()) + + def poll(self): + return 255 + + process = Process() + captured = {} + + def popen(argv, **kwargs): + captured.update(environment=kwargs["env"]) + return process + + monkeypatch.setattr(devices.subprocess, "Popen", popen) + with pytest.raises(HilError) as caught: + devices.LinuxDevice(profile(), {"password": "wrong-secret"}).open_tunnel() + assert error_details(caught.value) == {"error_code": "ssh_auth_required", "target_id": "linux", + "password_attempted": True} + assert process.stdin.closed + from pathlib import Path + assert not Path(captured["environment"]["SSH_ASKPASS"]).exists() + + +@pytest.fixture +def auth_http(project_dir, tmp_path, monkeypatch): + target_file = tmp_path / "targets.json" + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(target_file)) + target = save_target(profile()) + host = HilHost(project_dir) + ui = tmp_path / "ui" + ui.mkdir() + (ui / "index.html").write_text("test") + httpd, base = start_project_server(project_dir, None, "127.0.0.1", 0, ui_dir=ui, + release_provider=lambda: b"zip", hil=host) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + + def api(method, path, body=None): + headers = {"X-Botlab-Hil-Token": httpd.botlab_hil_token, "Origin": base} + data = None + if method == "POST": + headers["Content-Type"] = "application/json" + data = json.dumps(body or {}).encode() + with urlopen(Request(base + path, data=data, method=method, headers=headers), timeout=5) as response: + return json.load(response) + + try: + yield host, target, target_file, api + finally: + host.close() + httpd.shutdown() + httpd.server_close() + thread.join() + + +def finish(api, response): + for _ in range(200): + result = api("GET", "/hil/operations/" + response["operation"]["id"]) + if result["operation"]["status"] != "running": + return result + time.sleep(.01) + pytest.fail("Authentication operation did not finish") + + +@pytest.mark.parametrize("route", ["/hil/devices/inspect", "/hil/devices/provision", + "/hil/connect", "/hil/targets/projects"]) +def test_async_auth_challenge_is_recoverable_without_terminal_state_error(auth_http, monkeypatch, route): + host, target, _, api = auth_http + + def rejected(self, *_args, **_kwargs): + raise self._ssh_failure(255, rejection()) + + monkeypatch.setattr(devices.LinuxDevice, "_python", rejected) + monkeypatch.setattr(devices.LinuxDevice, "open_tunnel", rejected) + first = finish(api, api("POST", route, {"target": target["id"]})) + assert first["operation"]["error_code"] == "ssh_auth_required" + assert first["operation"]["target_id"] == target["id"] + assert first["operation"]["password_attempted"] is False + assert host.state()["error"] is None + response = api("POST", "/hil/credentials", { + "target": target["id"], "expected_target": target, "password": "incorrect-secret"}) + assert response["credential_status"][target["id"]] == {"password": True} + second = finish(api, api("POST", route, {"target": target["id"]})) + assert second["operation"]["password_attempted"] is True + assert "incorrect-secret" not in json.dumps(second) + assert host.state()["error"] is None + + +def test_credentials_are_snapshot_bound_memory_only_and_do_not_change_main_state(auth_http): + host, target, target_file, api = auth_http + original = target_file.read_bytes() + host.selected_target = "another" + host.application = {"built": {"id": "built"}, "loaded": {"id": "loaded"}, "projects": []} + previous_application = copy.deepcopy(host.application) + host.error = "A separate error" + response = api("POST", "/hil/credentials", { + "target": target["id"], "expected_target": target, "password": "new-secret"}) + assert response["capabilities"]["credentialPrompt"] == 1 + assert response["selectedTarget"] == "another" + assert response["application"] == previous_application + assert not response["connected"] + assert response["error"] == "A separate error" + assert "new-secret" not in json.dumps(response) + assert target_file.read_bytes() == original + assert host._credentials_for(target)["password"] == "new-secret" + fresh = HilHost(host.project_dir) + try: + assert fresh.state()["credential_status"][target["id"]] == {"password": False} + finally: + fresh.close() + + +@pytest.mark.parametrize("invalid", ["changed", "missing", "wrong-id", "empty", "not-string", "extra"]) +def test_credentials_reject_changed_or_missing_snapshot_and_invalid_password(auth_http, invalid): + host, target, target_file, api = auth_http + original = target_file.read_bytes() + body = {"target": target["id"], "expected_target": copy.deepcopy(target), "password": "secret"} + if invalid == "changed": + body["expected_target"]["ssh"]["host"] = "old-host.local" + elif invalid == "missing": + del body["expected_target"] + elif invalid == "wrong-id": + body["target"] = "missing" + elif invalid == "empty": + body["password"] = "" + elif invalid == "not-string": + body["password"] = 123 + else: + body["save_password"] = True + with pytest.raises(HTTPError) as caught: + api("POST", "/hil/credentials", body) + assert caught.value.code == 400 + assert host.credentials == {} + assert target_file.read_bytes() == original + + +def test_synchronous_http_error_retains_auth_metadata_and_redacts(auth_http, monkeypatch): + host, target, _, api = auth_http + host._store_credentials(target, {"password": "hidden-secret"}) + + def reject(*_args): + raise HilError("Rejected hidden-secret", error_code="ssh_auth_required", + target_id=target["id"], password_attempted=True) + + monkeypatch.setattr(host, "_handle", reject) + with pytest.raises(HTTPError) as caught: + api("POST", "/hil/credentials", {}) + response = json.load(caught.value) + assert response == {"error": "Rejected [redacted]", "error_code": "ssh_auth_required", + "target_id": "linux", "password_attempted": True} + assert host.error is None diff --git a/tests/test_hil_devices.py b/tests/test_hil_devices.py new file mode 100644 index 0000000..d889234 --- /dev/null +++ b/tests/test_hil_devices.py @@ -0,0 +1,525 @@ +"""Device adapters must not trust unknown hosts, leak credentials, or reuse host binaries.""" +import base64 +import hashlib +import io +import json +import shutil +import subprocess +import sys +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from botlab.hil import devices +from botlab.hil.protocol import HilError + + +def target(**updates): + return {"transport": "ssh", "ssh": {"host": "board.local", "user": "robot"}, **updates} + + +def result(stdout=b"", code=0, stderr=b""): + return subprocess.CompletedProcess([], code, stdout, stderr) + + +@pytest.mark.parametrize("config", [ + {"ssh": {"host": "-oProxyCommand=bad", "user": "robot"}}, + {"ssh": {"host": "board", "user": "root;whoami"}}, + {"ssh": {"host": "board", "user": "robot", "port": True}}, + {"root": "/"}, {"root": "~/"}, {"root": "~/../other"}, +]) +def test_reject_invalid_device_before_any_subprocess(config, monkeypatch): + monkeypatch.setattr(devices, "_run", lambda *_args, **_kwargs: pytest.fail("executed command")) + with pytest.raises(HilError): + devices.LinuxDevice(target(**config)) + + +def test_password_transient_and_never_in_argv(tmp_path): + device = devices.LinuxDevice(target(), {"password": "a secret $() password"}) + argv, env = device._ssh(tmp_path) + assert "StrictHostKeyChecking=yes" in argv + assert "a secret" not in repr(argv) + assert env["SSH_ASKPASS_REQUIRE"] == "force" + secret = tmp_path / "password" + assert secret.stat().st_mode & 0o777 == 0o600 + assert secret.read_text() == "a secret $() password" + assert "secret $()" not in (tmp_path / "askpass").read_text() + assert "password" not in repr(device.target) + device.close() + assert not device.credentials + + +def test_ssh_fingerprint_mismatch_fails_before_auth(tmp_path, monkeypatch): + key = base64.b64encode(b"observed key").decode() + monkeypatch.setattr(devices, "_run", lambda *_args, **_kwargs: + result(f"board.local ssh-ed25519 {key}\n".encode())) + device = devices.LinuxDevice(target(), {"password": "secret", "host_key": "SHA256:wrong"}) + with pytest.raises(HilError, match="host key changed"): + device._ssh(tmp_path) + assert not (tmp_path / "password").exists() + + +def test_accepted_fingerprint_is_pinned_in_private_known_hosts(tmp_path, monkeypatch): + key = base64.b64encode(b"observed key").decode() + monkeypatch.setattr(devices, "_run", lambda *_args, **_kwargs: + result(f"board.local ssh-ed25519 {key}\n".encode())) + profile = target() + profile["ssh"]["host_key"] = devices._fingerprint(key) + device = devices.LinuxDevice(profile) + argv, _ = device._ssh(tmp_path) + assert f"UserKnownHostsFile={tmp_path / 'known_hosts'}" in argv + assert "GlobalKnownHostsFile=/dev/null" in argv + assert key in (tmp_path / "known_hosts").read_text() + + +def test_download_matches_target_not_host(): + tags = devices._platforms({"machine": "aarch64", "libc": ["glibc", "2.31"]}) + assert tags[0] == "manylinux_2_31_aarch64" + assert tags[-1] == "manylinux2014_aarch64" + assert all("x86" not in tag and "macos" not in tag for tag in tags) + with pytest.raises(HilError, match="musl"): + devices._platforms({"machine": "aarch64", "libc": ["musl", "1.2"]}) + + +def test_download_uses_cross_platform_binary_only_and_cache(tmp_path, monkeypatch): + calls = [] + monkeypatch.setenv("BOTLAB_WHEEL_CACHE", str(tmp_path)) + def run(argv, **kwargs): + calls.append(argv) + if "download" in argv: + folder = Path(argv[argv.index("--dest") + 1]) + (folder / "numpy-test.whl").write_bytes(b"wheel") + return result() + monkeypatch.setattr(devices, "_run", run) + info = {"machine": "aarch64", "libc": ["glibc", "2.35"], "python": [3, 10, 12]} + first = devices.download_wheels(info) + again = devices.download_wheels(info) + assert first == again + command, = [call for call in calls if "download" in call] + assert "--only-binary=:all:" in command + assert command[command.index("--python-version") + 1] == "3.10" + assert "manylinux_2_35_aarch64" in command + assert not any("rdk" in arg or "microduck" in arg for arg in command) + + +def test_bundle_contains_sdk_source_and_linux_wheels_not_host_binaries(tmp_path): + wheels = tmp_path / "wheels" + wheels.mkdir() + wheel = wheels / "numpy-1-cp310-manylinux2014_aarch64.whl" + wheel.write_bytes(b"linux binary") + bundle, digest = devices.build_agent_bundle(tmp_path, wheels) + with tarfile.open(bundle) as archive: + names = archive.getnames() + manifest = archive.extractfile("manifest.json").read() + assert hashlib.sha256(manifest).hexdigest() == digest + assert "package/botlab/hil/agent.py" in names + assert not any(name.endswith((".dylib", ".so", ".pyc")) for name in names) + assert f"wheels/{wheel.name}" in names + for name, checksum in json.loads(manifest).items(): + assert hashlib.sha256(archive.extractfile(name).read()).hexdigest() == checksum + + +def execute_install(tmp_path, entries, release=None): + buffer = io.BytesIO() + manifest = json.dumps({name: hashlib.sha256(content).hexdigest() + for name, content in entries.items()}, sort_keys=True).encode() + digest = release or hashlib.sha256(manifest).hexdigest() + with tarfile.open(fileobj=buffer, mode="w:gz") as archive: + for name, content in {**entries, "manifest.json": manifest}.items(): + member = tarfile.TarInfo(name) + member.size = len(content) + archive.addfile(member, io.BytesIO(content)) + root = tmp_path.resolve() / "botlab" + source = devices._INSTALL.replace("__PARAMS_JSON__", repr(json.dumps({"root": str(root), "release": digest}))) + run = subprocess.run([sys.executable, "-c", source], input=buffer.getvalue(), capture_output=True) + return run, root, digest + + +def test_install_rejects_tar_traversal_before_write(tmp_path): + run, root, _ = execute_install(tmp_path, {"../escaped": b"bad"}) + assert run.returncode != 0 + assert b"Invalid Agent bundle member" in run.stderr + assert not (root / "escaped").exists() + + +def test_install_checks_digest_before_switching_release(tmp_path): + run, root, _ = execute_install(tmp_path, {"package/a.py": b"x=1"}, release="0" * 64) + assert run.returncode != 0 + assert b"checksum mismatch" in run.stderr + assert not (root / "agent" / "current").exists() + + +def test_install_unpacks_wheel_into_separate_environment(tmp_path): + wheel = io.BytesIO() + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr("example/module.py", "answer=42") + archive.writestr("sample-1.data/purelib/extra.py", "answer=1") + archive.writestr("sample-1.data/scripts/unneeded", "not installed") + run, root, digest = execute_install(tmp_path, { + "package/botlab/hil/agent.py": b"", "wheels/example.whl": wheel.getvalue()}) + assert run.returncode == 0, run.stderr.decode() + release = root / "agent" / "releases" / digest + assert (release / "deps" / "example" / "module.py").read_text() == "answer=42" + assert (release / "deps" / "extra.py").exists() + assert not (release / "deps" / "unneeded").exists() + assert not (root / "agent" / "current").exists() # Activated only after import preflight. + assert (root / "projects").is_dir() + assert not (root / "projects").is_symlink() + + +@pytest.mark.parametrize("entry", ["../escape.py", "/absolute.py"]) +def test_install_rejects_malformed_dependency_wheel(tmp_path, entry): + wheel = io.BytesIO() + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr(entry, "bad") + run, root, _ = execute_install(tmp_path, {"wheels/example.whl": wheel.getvalue()}) + assert run.returncode != 0 + assert b"Invalid dependency wheel member" in run.stderr + assert not (root / "agent" / "current").exists() + + +def test_network_values_reject_unusable_address(): + for address in ["127.0.0.1", "0.0.0.0", "224.1.1.1", "192.168.1.0/24", "192.168.1.255/24"]: + with pytest.raises(HilError): + devices._network_values("static", address) + assert devices._network_values("static", "192.168.9.100", "255.255.255.0") == ( + "192.168.9.100/24", "255.255.255.0", "") + with pytest.raises(HilError): + devices._network_values("static", "192.168.9.100/24", gateway="192.168.8.1") + + +def test_network_change_uses_nm_checkpoint_and_explicit_confirmation(monkeypatch): + device = devices.LinuxDevice(target()) + commands = [] + def command(source, **kwargs): + commands.append(source) + return b'{"transaction":"0123456789abcdef0123456789abcdef", "requires_confirmation":true}' + monkeypatch.setattr(device, "_command", command) + changed = device.configure_network("usb0", "static", "192.168.9.10/24") + assert changed["requires_confirmation"] + assert "CheckpointCreate" in commands[0] + assert "start_new_session=True" in commands[0] + assert "CheckpointDestroy" not in commands[0] + device.confirm_network(changed["transaction"]) + assert "CheckpointDestroy" in commands[1] + with pytest.raises(HilError): + device.confirm_network("../../etc/passwd") + + +def test_local_network_touches_only_selected_service_with_os_authorization(monkeypatch): + monkeypatch.setattr(devices.platform, "system", lambda: "Darwin") + monkeypatch.setattr(devices, "local_interfaces", lambda: [{"name": "en9", "addresses": []}]) + calls = [] + def run(argv, **kwargs): + calls.append(argv) + if "-listnetworkserviceorder" in argv: + return result(b"(1) Wi-Fi\n(Hardware Port: Wi-Fi, Device: en0)\n(2) USB Board\n(Hardware Port: USB, Device: en9)\n") + return result(b"Manual Configuration\nIP address: 192.168.9.100\n") + monkeypatch.setattr(devices, "_run", run) + state = devices.configure_local_network("en9", "static", "192.168.9.100/24") + assert state["service"] == "USB Board" + auth, = [call for call in calls if call[0] == "osascript"] + assert "with administrator privileges" in auth[2] + assert "USB Board" in auth[2] + assert "Wi-Fi" not in auth[2] + + +def test_discovery_does_not_probe_neighbors_without_interface(monkeypatch): + monkeypatch.setattr(devices, "local_interfaces", lambda: [{"name": "en9", "addresses": [], "active": True}]) + monkeypatch.setattr(devices, "_run", lambda *_args, **_kwargs: pytest.fail("read unscoped neighbors")) + found = devices.discover_devices(profile="rdk-x5") + assert found["candidates"] == [] + assert found["requires_interface"] + + +def test_device_inspection_rejects_unsupported_python(monkeypatch): + device = devices.LinuxDevice(target()) + monkeypatch.setattr(device, "_python", lambda *_args, **_kwargs: + json.dumps({"system": "Linux", "python": [3, 9], "machine": "aarch64"})) + with pytest.raises(HilError, match="Python 3.10"): + device.inspect() + + +@pytest.mark.parametrize("probe_result, expected", [ + (0, True), + (1, False), + (subprocess.TimeoutExpired("systemctl", 5), False), + (OSError("user manager unavailable"), False), +]) +@pytest.mark.parametrize("agent_installed", [False, True]) +def test_inspection_checks_systemd_user_manager_independently_of_agent( + tmp_path, monkeypatch, capsys, probe_result, expected, agent_installed, +): + root = tmp_path / "botlab" + if agent_installed: + (root / "agent" / "current").mkdir(parents=True) + calls = [] + monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/systemctl" if name == "systemctl" else None) + monkeypatch.setattr(devices.subprocess, "check_output", lambda *_args, **_kwargs: b"[]") + def run(argv, **kwargs): + calls.append((argv, kwargs)) + if isinstance(probe_result, Exception): + raise probe_result + return result(code=probe_result) + monkeypatch.setattr(devices.subprocess, "run", run) + exec(devices._INSPECT.replace("__ROOT_JSON__", repr(str(root))), {}) + info = json.loads(capsys.readouterr().out) + assert info["systemd_user_available"] is expected + assert info["agent_installed"] is agent_installed + assert info["service_manager"] == "systemd" # Existing compatibility field. + assert calls == [(["systemctl", "--user", "show-environment"], {"capture_output": True, "timeout": 5})] + + +def test_inspection_without_systemctl_reports_unavailable(tmp_path, monkeypatch, capsys): + monkeypatch.setattr(shutil, "which", lambda _name: None) + monkeypatch.setattr(devices.subprocess, "check_output", lambda *_args, **_kwargs: b"[]") + monkeypatch.setattr(devices.subprocess, "run", lambda *_args, **_kwargs: pytest.fail("no systemctl installed")) + exec(devices._INSPECT.replace("__ROOT_JSON__", repr(str(tmp_path / "botlab"))), {}) + info = json.loads(capsys.readouterr().out) + assert info["systemd_user_available"] is False + assert info["service_manager"] == "process" + + +def test_failed_dependency_preflight_keeps_previous_release(tmp_path): + root = tmp_path.resolve() / 'botlab' + agent = root / 'agent' + old = agent / 'releases' / ('1' * 64) + new = agent / 'releases' / ('2' * 64) + old.mkdir(parents=True) + (new / 'package').mkdir(parents=True) + (new / 'deps').mkdir() + (new / 'deps' / 'numpy.py').write_text('raise ImportError("incompatible new runtime")') + (agent / 'current').symlink_to(old) + params = {'root': str(root), 'release': '2' * 64, 'port': 50052, 'backend': 'onnxruntime'} + source = devices._START.replace('__PARAMS_JSON__', repr(json.dumps(params))) + run = subprocess.run([sys.executable, '-c', source], capture_output=True) + assert run.returncode != 0 + assert b'incompatible new runtime' in run.stderr + assert (agent / 'current').resolve() == old + assert not (agent / 'agent.pid').exists() + + +def test_agent_launch_isolated_from_global_site_packages(): + assert "[sys.executable, '-S', '-c'" in devices._START + assert "args = [sys.executable, '-S', '-m', 'botlab.hil.agent'" in devices._START + + +@pytest.mark.parametrize('name', ['_INSPECT', '_INSTALL', '_START', '_CONNECT', '_NETWORK_BEGIN', '_NETWORK_APPLY', '_NETWORK_CONFIRM']) +def test_embedded_remote_scripts_are_valid_python(name): + source = (getattr(devices, name).replace('__ROOT_JSON__', repr('~/botlab')) + .replace('__PARAMS_JSON__', repr(json.dumps({'root': '~/botlab', 'release': '0' * 64}))) + .replace('__APPLY_SOURCE__', repr(devices._NETWORK_APPLY)) + .replace('__TRANSACTION__', repr('0' * 32))) + compile(source, name, 'exec') + + +def test_open_tunnel_holds_exec_session_and_waits_for_real_hello(monkeypatch): + import socket + import threading + + from botlab.hil.protocol import receive, send + + calls = [] + seen = {} + finished = threading.Event() + class Process: + stdin = io.BytesIO() + stderr = io.BytesIO() + def poll(self): + return 0 if self.stdin.closed else None + def wait(self, timeout=None): + assert self.stdin.closed + return 0 + process = Process() + def popen(argv, **kwargs): + kwargs['stdout'].write(b'BOTLAB_TARGET_READY\n') + kwargs['stdout'].flush() + seen.update(argv=argv, kwargs=kwargs) + local_port = int(argv[argv.index('-L')+1].split(':')[1]) + listener = socket.socket() + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(('127.0.0.1', local_port)) + listener.listen() + def server(): + try: + # Forwarding is ready before the remote Agent: an accepted local + # socket alone must not make open_tunnel return success. + first, _ = listener.accept() + first.close() + with listener.accept()[0] as stream: + calls.append(receive(stream)) + send(stream, {'ok': True, 'device': {'backend': 'onnxruntime'}}) + finally: + listener.close() + finished.set() + threading.Thread(target=server, daemon=True).start() + return process + device = devices.LinuxDevice(target()) + monkeypatch.setattr(device, '_ssh', lambda _directory: (['ssh', 'robot@board.local'], {})) + monkeypatch.setattr(devices.subprocess, 'Popen', popen) + tunnel = device.open_tunnel() + assert len(calls) == 1 and calls[0]['op'] == 'hello' + assert '-N' not in seen['argv'] + assert 'StdinNull=no' in seen['argv'] + assert seen['argv'][-1].startswith('python3 -c ') + assert seen['kwargs']['stdin'] == subprocess.PIPE + assert not process.stdin.closed + tunnel.close() + assert process.stdin.closed + assert finished.wait(1) + + +def test_invalid_agent_hello_closes_exec_and_credentials(monkeypatch): + import socket + import threading + + from botlab.hil.protocol import receive, send + + seen = {} + class Process: + stdin = io.BytesIO() + stderr = io.BytesIO() + def poll(self): + return 0 if self.stdin.closed else None + def wait(self, timeout=None): + return 0 + process = Process() + def popen(argv, **kwargs): + kwargs['stdout'].write(b'BOTLAB_TARGET_READY\n') + kwargs['stdout'].flush() + listener = socket.socket() + listener.bind(('127.0.0.1', int(argv[argv.index('-L')+1].split(':')[1]))) + listener.listen() + def server(): + try: + with listener.accept()[0] as stream: + receive(stream) + send(stream, {'ok': False, 'error': 'not ready'}) + finally: + listener.close() + threading.Thread(target=server, daemon=True).start() + return process + device = devices.LinuxDevice(target()) + def ssh(directory): + seen['directory'] = directory + return ['ssh', 'robot@board.local'], {} + monkeypatch.setattr(device, '_ssh', ssh) + monkeypatch.setattr(devices.subprocess, 'Popen', popen) + with pytest.raises(HilError, match='valid BotLab Agent hello'): + device.open_tunnel() + assert process.stdin.closed + assert not Path(seen['directory']).exists() + + +def _start_local_connection_helper(root, port): + source = devices._CONNECT.replace('__PARAMS_JSON__', repr(json.dumps({'root': str(root), 'port': port}))) + return subprocess.Popen([sys.executable, '-c', source], stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def _make_fake_agent(root, port): + agent = root / 'agent' + agent.mkdir(parents=True) + source = '''import json, os, socketserver, struct +from pathlib import Path +folder = Path(__file__).parent +with (folder/'starts').open('a') as stream: + stream.write(str(os.getpid())+'\\n') +class Handler(socketserver.BaseRequestHandler): + def handle(self): + def read(size): + data = b'' + while len(data) < size: + part = self.request.recv(size-len(data)) + if not part: + raise EOFError + data += part + return data + try: + message = json.loads(read(struct.unpack('!I', read(4))[0])) + payload = json.dumps({'version':1,'ok':True,'device':{'backend':'onnxruntime'}}).encode() + self.request.sendall(struct.pack('!I',len(payload))+payload) + except (EOFError, OSError): + pass +class Server(socketserver.ThreadingTCPServer): + allow_reuse_address = True +with Server(('127.0.0.1', PORT), Handler) as server: + server.serve_forever() +'''.replace('PORT', str(port)) + (agent / 'start-agent.py').write_text(source) + return agent + + +def _wait_agent_port(port, children, timeout=5): + import socket + import time + + from botlab.hil.protocol import receive, send + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + for process in children: + assert process.poll() is None, process.stderr.read().decode() + try: + with socket.create_connection(('127.0.0.1', port), timeout=.2) as stream: + stream.settimeout(.2) + send(stream, {'op': 'hello'}) + if receive(stream).get('ok'): + return + except (OSError, EOFError): + pass + time.sleep(.05) + pytest.fail('Local fake Agent did not become ready') + + +def test_connection_helper_requires_installation(tmp_path): + child = _start_local_connection_helper(tmp_path / 'absent', 50052) + _, error = child.communicate(timeout=5) + assert child.returncode != 0 + assert b'Install and connect first' in error + + +def test_connection_helper_starts_once_and_keeps_login_until_eof(tmp_path): + import os + import signal + import socket + import time + with socket.socket() as listener: + listener.bind(('127.0.0.1', 0)) + port = listener.getsockname()[1] + root = tmp_path / 'board with spaces' + agent = _make_fake_agent(root, port) + children = [_start_local_connection_helper(root, port), _start_local_connection_helper(root, port)] + try: + _wait_agent_port(port, children) + # Both logins remain active after readiness; their locks must have been + # released so the second connection reuses the same running Agent. + time.sleep(.2) + assert all(child.poll() is None for child in children) + assert len((agent / 'starts').read_text().splitlines()) == 1 + children[0].stdin.close() + children[0].wait(timeout=3) + assert children[0].returncode == 0 + assert children[1].poll() is None + _wait_agent_port(port, [children[1]]) + # A fresh login must not restart an Agent used by another connection. + third = _start_local_connection_helper(root, port) + children.append(third) + time.sleep(.2) + assert third.poll() is None + assert len((agent / 'starts').read_text().splitlines()) == 1 + finally: + for child in children: + if child.stdin and not child.stdin.closed: + child.stdin.close() + try: + child.wait(timeout=3) + except subprocess.TimeoutExpired: + child.kill() + child.wait(timeout=3) + if (agent / 'agent.pid').exists(): + try: + os.kill(int((agent / 'agent.pid').read_text()), signal.SIGTERM) + except ProcessLookupError: + pass diff --git a/tests/test_hil_gateway.py b/tests/test_hil_gateway.py new file mode 100644 index 0000000..561d5bc --- /dev/null +++ b/tests/test_hil_gateway.py @@ -0,0 +1,495 @@ +"""HIL device-gateway flows: outbound agent registration, transparent relay, +exclusive pairing, device-restart takeover and stale-session rejection. + +These cover the *functional* acceptance tier only: the model executes +correctly through the gateway. The wall-clock tier (loop rate at the +project's control_hz) is measured on real hardware via `botlab target bench`. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import shutil +import ssl +import subprocess +import threading +import time +from pathlib import Path + +import pytest + +from botlab.hil.agent import AgentState, serve_stream +from botlab.hil.gateway import Gateway +from botlab.hil.host import HilHost +from botlab.hil.protocol import HilError, gateway_endpoint, receive, send +from botlab.hil.targets import save_target +from botlab.hil.wsstream import WebSocketStream, connect_gateway + +pytest.importorskip("websockets") +from websockets.exceptions import ConnectionClosed # noqa: E402 +from websockets.sync.client import connect as ws_connect # noqa: E402 +from websockets.sync.server import serve as ws_serve # noqa: E402 + +TOKEN = "gateway-test-token" +DEVICE = "bench-board" + + +class EchoBackend: + """Deterministic stand-in backend: doubles every input port.""" + + def describe(self): + return {"backend": "echo", "provider": "test", "dtypes": ["float32"]} + + def load(self, path): + spec = [{"name": "x", "dtype": "float32", "shape": [2]}] + return object(), spec, spec + + def infer(self, handle, inputs): + return {name: {"dtype": "float32", "shape": v["shape"], + "data": [value * 2 for value in v["data"]]} + for name, v in inputs.items()} + + def close(self, handle): + pass + + +class AgentThread: + """Resident board agent mirroring `botlab-agent connect`: one AgentState, + redialing the gateway whenever a lease terminates or the link drops.""" + + def __init__(self, endpoint: str, device: str, models_dir: Path, token: str = TOKEN): + self.endpoint, self.device, self.token = endpoint, device, token + self.state = AgentState(models_dir, EchoBackend()) + self.stop = threading.Event() + self.stream = None + self.thread = threading.Thread(target=self._run, daemon=True) + self.thread.start() + + def _run(self): + while not self.stop.is_set(): + try: + self.stream = connect_gateway(self.endpoint, "agent", self.device, self.token) + except HilError: + self.stop.wait(0.05) + continue + try: + serve_stream(self.state, self.stream) + except (OSError, EOFError, ValueError): + pass + finally: + self.state.disconnect(self.stream) + self.stream.close() + self.stream = None + + def close(self): + self.stop.set() + stream = self.stream + if stream: + stream.close() + self.thread.join(timeout=5) + + +def wait_registered(server_gateway: Gateway, device: str = DEVICE, timeout: float = 5) -> None: + deadline = time.monotonic() + timeout + while device not in server_gateway.agents: + assert time.monotonic() < deadline, f"device {device} never registered" + time.sleep(0.02) + + +def wait_unregistered(server_gateway: Gateway, device: str = DEVICE) -> None: + deadline = time.monotonic() + 5 + while device in server_gateway.agents: + assert time.monotonic() < deadline, f"device {device} never deregistered" + time.sleep(0.02) + + +def wait_replaced(server_gateway: Gateway, previous, device: str = DEVICE) -> None: + """Wait until `previous`'s registration has been displaced by a new one. + + The resident agent redials within milliseconds of a terminated lease, so + polling for an empty registry races the re-registration; comparing the + registered `_Agent` object is deterministic. + """ + deadline = time.monotonic() + 5 + while server_gateway.agents.get(device) is previous or device not in server_gateway.agents: + assert time.monotonic() < deadline, f"device {device} registration never replaced" + time.sleep(0.02) + + +@pytest.fixture +def gateway(): + server_gateway = Gateway(TOKEN) + server = ws_serve(server_gateway.handler, "127.0.0.1", 0, + max_size=64 * 1024 * 1024, ping_interval=5, ping_timeout=5) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + yield f"ws://127.0.0.1:{server.socket.getsockname()[1]}", server_gateway + server.shutdown() + thread.join(timeout=5) + + +@pytest.fixture +def board(tmp_path, monkeypatch, gateway): + """Registered device plus a gateway target the HilHost can connect to.""" + endpoint, server_gateway = gateway + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "targets.json")) + monkeypatch.setenv("BOTLAB_HIL_TOKEN", TOKEN) + agent = AgentThread(endpoint, DEVICE, tmp_path / "models") + wait_registered(server_gateway) + save_target({"id": "gw", "name": "Gateway board", "transport": "gateway", + "endpoint": endpoint, "device": DEVICE, "backend": "echo"}) + yield agent + agent.close() + + +def load_echo_model(host: HilHost) -> str: + content = b"echo-model-bytes" + digest = hashlib.sha256(content).hexdigest() + host.handle("POST", "/hil/models", {"session": host.session, + "sha256": digest, + "data": base64.b64encode(content).decode()}) + return digest + + +def run_inference(host: HilHost, digest: str, seq: int): + return host.handle("POST", "/hil/infer", { + "session": host.session, "seq": seq, "model": digest, + "inputs": {"x": {"dtype": "float32", "shape": [2], "data": [1.0, 2.5]}}}) + + +def dial(endpoint: str, device: str = DEVICE, token: str = TOKEN) -> WebSocketStream: + return WebSocketStream(ws_connect( + f"{endpoint}/device/{device}", + additional_headers=[("Authorization", f"Bearer {token}")], + open_timeout=5, max_size=64 * 1024 * 1024, legacy=True)) + + +def test_gateway_end_to_end(board, tmp_path): + host = HilHost(tmp_path / "project", selected_target="gw") + try: + state = host.handle("POST", "/hil/connect", {"target": "gw"}) + assert state["connected"] is True + assert state["device"]["backend"] == "echo" + session = host.handle("POST", "/hil/start", {})["session"] + digest = load_echo_model(host) + result = run_inference(host, digest, 0) + assert result["outputs"]["x"]["data"] == [2.0, 5.0] + assert result["rtt_ms"] >= 0 and result["inference_ms"] >= 0 + + # Serial multi-model loop stands in for the functional in-loop tier. + for seq in range(1, 25): + run_inference(host, digest, seq) + host.handle("POST", "/hil/stop", {"session": session}) + assert host.handle("GET", "/hil/state")["session"] is None + finally: + host.close() + + +def test_gateway_unknown_device(gateway, tmp_path, monkeypatch): + endpoint, _ = gateway + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "targets.json")) + monkeypatch.setenv("BOTLAB_HIL_TOKEN", TOKEN) + save_target({"id": "ghost", "name": "Ghost", "transport": "gateway", + "endpoint": endpoint, "device": "nobody-registered", "backend": "echo"}) + host = HilHost(tmp_path / "project", selected_target="ghost") + try: + with pytest.raises(HilError, match="unknown device"): + host.handle("POST", "/hil/connect", {"target": "ghost"}) + finally: + host.close() + + +def test_gateway_exclusive_pairing(board, tmp_path): + first = HilHost(tmp_path / "project", selected_target="gw") + second = HilHost(tmp_path / "project2", selected_target="gw") + try: + assert first.handle("POST", "/hil/connect", {"target": "gw"})["connected"] is True + with pytest.raises(HilError, match="device busy"): + second.handle("POST", "/hil/connect", {"target": "gw"}) + # The first coordinator keeps its exclusive link. + first.handle("POST", "/hil/start", {}) + digest = load_echo_model(first) + assert run_inference(first, digest, 0)["outputs"]["x"]["data"] == [2.0, 5.0] + finally: + first.close() + second.close() + + +def test_gateway_bad_token(gateway, tmp_path, monkeypatch): + endpoint, _ = gateway + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "targets.json")) + monkeypatch.setenv("BOTLAB_HIL_TOKEN", "wrong-token") + save_target({"id": "gw", "name": "Gateway board", "transport": "gateway", + "endpoint": endpoint, "device": DEVICE, "backend": "echo"}) + host = HilHost(tmp_path / "project", selected_target="gw") + try: + with pytest.raises(HilError, match="invalid gateway token"): + host.handle("POST", "/hil/connect", {"target": "gw"}) + finally: + host.close() + + +def dial_unauthenticated(endpoint: str, device: str = DEVICE): + """Browser-shaped dial: no Authorization header can be set on a WebSocket + handshake, so the credential must travel in a leading text frame.""" + return ws_connect(f"{endpoint}/device/{device}", open_timeout=5, + max_size=64 * 1024 * 1024, legacy=True) + + +def expect_close(raw) -> tuple[int, str]: + with pytest.raises(ConnectionClosed) as exc: + raw.recv(timeout=5) + close = exc.value.rcvd + assert close is not None, "peer closed without a close frame" + return close.code, close.reason + + +def test_gateway_text_frame_auth(board, gateway): + """The browser coordinator authenticates with one gateway-auth text frame, + then runs the full device-chain flow unchanged.""" + endpoint, _ = gateway + raw = dial_unauthenticated(endpoint) + raw.send(json.dumps({"type": "gateway-auth", "token": TOKEN})) + stream = WebSocketStream(raw) + try: + send(stream, {"op": "hello"}) + assert receive(stream)["ok"] is True + send(stream, {"op": "start"}) + session = receive(stream)["session"] + content = b"echo-model-bytes" + digest = hashlib.sha256(content).hexdigest() + send(stream, {"op": "load", "session": session, "sha256": digest, + "data": base64.b64encode(content).decode()}) + assert receive(stream)["ok"] is True + send(stream, {"op": "infer", "session": session, "seq": 0, "model": digest, + "inputs": {"x": {"dtype": "float32", "shape": [2], "data": [1.0, 2.5]}}}) + assert receive(stream)["outputs"]["x"]["data"] == [2.0, 5.0] + finally: + stream.close() + + +def test_gateway_text_frame_auth_bad_token(gateway): + endpoint, _ = gateway + raw = dial_unauthenticated(endpoint) + raw.send(json.dumps({"type": "gateway-auth", "token": "not-the-token"})) + assert expect_close(raw) == (1008, "invalid gateway token") + + +def test_gateway_text_frame_auth_malformed(gateway): + endpoint, _ = gateway + raw = dial_unauthenticated(endpoint) + raw.send("not json at all") + assert expect_close(raw) == (1008, "invalid gateway token") + + +def test_gateway_text_frame_auth_timeout(gateway, monkeypatch): + monkeypatch.setattr("botlab.hil.gateway.AUTH_TIMEOUT", 0.2) + endpoint, _ = gateway + raw = dial_unauthenticated(endpoint) + assert expect_close(raw) == (1008, "gateway authentication timeout") + + +def test_gateway_text_after_auth_still_rejected(board, gateway): + """The gateway-auth frame is control traffic, not an HIL frame: once + authenticated, text frames remain a protocol error exactly as before.""" + endpoint, _ = gateway + stream = dial(endpoint) + try: + send(stream, {"op": "hello"}) + assert receive(stream)["ok"] is True + stream.websocket.send("a second text frame") + with pytest.raises(OSError): + receive(stream) + finally: + stream.close() + + +def test_gateway_missing_token_refused_before_dial(board, tmp_path, monkeypatch): + monkeypatch.delenv("BOTLAB_HIL_TOKEN") + host = HilHost(tmp_path / "project", selected_target="gw") + try: + with pytest.raises(HilError, match="BOTLAB_HIL_TOKEN"): + host.handle("POST", "/hil/connect", {"target": "gw"}) + finally: + host.close() + + +def test_gateway_device_restart_discards_stale_results(board, gateway, tmp_path): + endpoint, server_gateway = gateway + host = HilHost(tmp_path / "project", selected_target="gw") + try: + host.handle("POST", "/hil/connect", {"target": "gw"}) + stale_session = host.handle("POST", "/hil/start", {})["session"] + + # Device restart: the old agent is replaced by a fresh process whose + # AgentState knows nothing about the old session. Re-registration + # displaces the paired coordinator connection as well. + board.close() + wait_unregistered(server_gateway) + fresh = AgentThread(endpoint, DEVICE, tmp_path / "models2") + wait_registered(server_gateway) + deadline = time.monotonic() + 5 + while True: + try: + host.handle("POST", "/hil/start", {}) + except HilError: + break # old link is gone, as intended + assert time.monotonic() < deadline, "old link stayed usable after device restart" + + # A new coordinator pairing starts clean, rejects stale inference and + # terminates the lease — exactly like the TCP transport would. + replaced = server_gateway.agents[DEVICE] + stream = dial(endpoint) + try: + send(stream, {"op": "hello"}) + assert receive(stream)["ok"] is True + send(stream, {"op": "start"}) + session = receive(stream)["session"] + assert session != stale_session + content = b"echo-model-bytes" + digest = hashlib.sha256(content).hexdigest() + send(stream, {"op": "load", "session": session, "sha256": digest, + "data": base64.b64encode(content).decode()}) + assert receive(stream)["ok"] is True + send(stream, {"op": "infer", "session": stale_session, "seq": 99, + "model": digest, + "inputs": {"x": {"dtype": "float32", "shape": [2], "data": [0, 0]}}}) + result = receive(stream) + assert result["ok"] is False and "Stale" in result["error"] + finally: + stream.close() + + # The rejection killed the device link; the resident agent redials and + # serves a clean pairing again. + wait_replaced(server_gateway, replaced) + stream = dial(endpoint) + try: + send(stream, {"op": "hello"}) + assert receive(stream)["ok"] is True + send(stream, {"op": "start"}) + session = receive(stream)["session"] + assert session != stale_session + content = b"echo-model-bytes" + digest = hashlib.sha256(content).hexdigest() + send(stream, {"op": "load", "session": session, "sha256": digest, + "data": base64.b64encode(content).decode()}) + assert receive(stream)["ok"] is True + send(stream, {"op": "infer", "session": session, "seq": 0, + "model": digest, + "inputs": {"x": {"dtype": "float32", "shape": [2], "data": [1, 1]}}}) + assert receive(stream)["outputs"]["x"]["data"] == [2.0, 2.0] + finally: + stream.close() + fresh.close() + finally: + host.close() + + +def test_gateway_endpoint_rules(): + assert gateway_endpoint("ws://127.0.0.1:50060") == "ws://127.0.0.1:50060" + assert gateway_endpoint("wss://boards.example.com") == "wss://boards.example.com" + assert gateway_endpoint("wss://Boards.Example.COM:8443/") == "wss://boards.example.com:8443" + for rejected in ("ws://10.0.0.5:50060", "wss://gw.example.com/device/x", + "http://127.0.0.1:50060", "ws://127.0.0.1", + "wss://user:pw@gw.example.com", "ftp://gw.example.com", + "wss://gw.example.com:99999", "ws://[::1]:50060/x", ""): + with pytest.raises(HilError): + gateway_endpoint(rejected) + + +def test_gateway_target_roundtrip(tmp_path, monkeypatch): + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "targets.json")) + target = save_target({"id": "x5", "name": "Desk X5", "transport": "gateway", + "endpoint": "wss://Boards.Example.com", "device": "rdk-x5-desk"}) + assert target["endpoint"] == "wss://boards.example.com" + assert target["device"] == "rdk-x5-desk" + # Credentials never persist in target settings. + assert "password" not in target and "token" not in target + with pytest.raises(HilError): + save_target({"id": "bad", "name": "Bad", "transport": "gateway", + "endpoint": "ws://192.168.1.10:50060", "device": "x"}) + with pytest.raises(HilError): + save_target({"id": "bad2", "name": "Bad", "transport": "gateway", + "endpoint": "wss://boards.example.com", "device": "no spaces!"}) + + +@pytest.fixture(scope="module") +def tls_authority(tmp_path_factory) -> Path: + if shutil.which("openssl") is None: + pytest.skip("openssl is required for gateway TLS tests") + root = tmp_path_factory.mktemp("pki") + + def run(*args: str) -> None: + subprocess.run(["openssl", *args], check=True, capture_output=True, cwd=root) + + # Python 3.13 verifies with VERIFY_X509_STRICT by default: a CA without + # basicConstraints/keyUsage is rejected ("CA cert does not include key usage + # extension"), so the fixture CA carries the extensions a real CA has. + run("req", "-x509", "-newkey", "rsa:2048", "-keyout", "ca.key", "-out", "ca.crt", + "-days", "2", "-nodes", "-subj", "/CN=BotLab HIL Test CA", + "-addext", "basicConstraints=critical,CA:TRUE", + "-addext", "keyUsage=critical,keyCertSign,cRLSign") + run("req", "-newkey", "rsa:2048", "-keyout", "server.key", "-out", "server.csr", + "-nodes", "-subj", "/CN=localhost") + (root / "san.ext").write_text("subjectAltName=DNS:localhost,IP:127.0.0.1\n") + run("x509", "-req", "-in", "server.csr", "-CA", "ca.crt", "-CAkey", "ca.key", + "-CAcreateserial", "-out", "server.crt", "-days", "2", "-extfile", "san.ext") + return root + + +def test_gateway_tls_verified(tls_authority, tmp_path, monkeypatch): + # websockets' sync TLS opening handshake stalls with small probability + # (upstream race, reproduced without any BotLab code). A stalled dial + # surfaces as a clean single-attempt HilError; production absorbs it at + # the layers that own retrying (the agent redials, the host user + # reconnects). This test retries the whole scenario once because the + # server side of a stalled attempt recovers as soon as the client goes + # away — anything else failing still fails the test. + for attempt in (1, 2): + try: + _tls_scenario(tls_authority, tmp_path, monkeypatch) + except HilError as exc: + if attempt == 2 or "timed out" not in str(exc): + raise + else: + return + + +def _tls_scenario(tls_authority, tmp_path, monkeypatch): + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(tls_authority / "server.crt", tls_authority / "server.key") + server_gateway = Gateway(TOKEN) + server = ws_serve(server_gateway.handler, "127.0.0.1", 0, ssl=context, + max_size=64 * 1024 * 1024, ping_interval=5, ping_timeout=5) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + endpoint = f"wss://localhost:{server.socket.getsockname()[1]}" + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "targets.json")) + monkeypatch.setenv("BOTLAB_HIL_TOKEN", TOKEN) + monkeypatch.delenv("BOTLAB_HIL_CA_BUNDLE", raising=False) + try: + # Without the dev CA bundle the TLS handshake must fail outright. + with pytest.raises(HilError, match="Gateway connection failed"): + connect_gateway(endpoint, "agent", DEVICE, TOKEN) + + monkeypatch.setenv("BOTLAB_HIL_CA_BUNDLE", str(tls_authority / "ca.crt")) + agent = AgentThread(endpoint, DEVICE, tmp_path / "models") + # A stalled TLS opening handshake burns its whole 5 s timeout before + # the agent's next dial; leave room for a few of those under load. + wait_registered(server_gateway, timeout=25) + save_target({"id": "tls", "name": "TLS gateway", "transport": "gateway", + "endpoint": endpoint, "device": DEVICE, "backend": "echo"}) + host = HilHost(tmp_path / "project", selected_target="tls") + try: + assert host.handle("POST", "/hil/connect", {"target": "tls"})["connected"] is True + host.handle("POST", "/hil/start", {}) + digest = load_echo_model(host) + assert run_inference(host, digest, 0)["outputs"]["x"]["data"] == [2.0, 5.0] + finally: + host.close() + agent.close() + finally: + server.shutdown() + thread.join(timeout=5) diff --git a/tests/test_hil_host_management.py b/tests/test_hil_host_management.py new file mode 100644 index 0000000..a85f3bf --- /dev/null +++ b/tests/test_hil_host_management.py @@ -0,0 +1,1445 @@ +"""Browser-facing management routes with a real CPU Agent and loopback HTTP.""" +import base64 +import copy +import json +import math +import shutil +import tempfile +import threading +import time +import uuid +from pathlib import Path +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +import pytest + +from botlab._server import start_project_server +from botlab.hil.agent import AgentServer, AgentState +from botlab.hil.host import HilHost +from botlab.hil.protocol import HilError +from botlab.hil.targets import read_targets, save_target +from botlab.hil.worker import WorkerBackend +from tests.test_hil_projects import artifact + + +def wait_job(api, response, *, timeout=10): + operation = response["operation"] + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + result = api("GET", "/hil/operations/" + operation["id"]) + if result["operation"]["status"] != "running": + return result + time.sleep(0.01) + pytest.fail("Management operation did not finish: " + json.dumps(result)) + + +def completed(api, response): + result = wait_job(api, response) + assert result["operation"]["status"] == "succeeded", result + return result["result"] + + +@pytest.fixture +def management(project_dir, tmp_path, monkeypatch): + pytest.importorskip("onnxruntime") + # Server state (targets file, agent cache, UI dir) lives OUTSIDE the + # project directory, matching the `botlab run` production layout — the + # recursive project walk must never pick it up as project files. + # .resolve(): mkdtemp lands under /var → /private/var on macOS, and the + # HIL path checks reject symlinked parents. + state_root = Path(tempfile.mkdtemp(prefix="botlab-hil-test-")).resolve() + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(state_root / "targets.json")) + worker = WorkerBackend("onnxruntime") + state = AgentState(state_root / "cache", worker, root=state_root / "target") + agent = AgentServer(("127.0.0.1", 0), state) + agent_thread = threading.Thread(target=agent.serve_forever, daemon=True) + agent_thread.start() + target = save_target({"id": "cpu", "name": "Generic CPU", "backend": "onnxruntime", + "endpoint": f"tcp://127.0.0.1:{agent.server_address[1]}"}) + host = HilHost(project_dir, target["id"]) + ui = state_root / "ui" + ui.mkdir() + (ui / "index.html").write_text("test") + httpd, base = start_project_server(project_dir, None, "127.0.0.1", 0, ui_dir=ui, + release_provider=lambda: b"zip", hil=host) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + + def api(method, path, body=None, *, timeout=5): + headers = {"X-Botlab-Hil-Token": httpd.botlab_hil_token, "Origin": base} + data = None + if method == "POST": + headers["Content-Type"] = "application/json" + data = json.dumps(body or {}).encode() + with urlopen(Request(base + path, data=data, method=method, headers=headers), timeout=timeout) as result: + return json.load(result) + + try: + yield host, state, api + finally: + host.close() + httpd.shutdown() + httpd.server_close() + thread.join() + agent.shutdown() + agent.server_close() + agent_thread.join() + worker.reset() + shutil.rmtree(state_root, ignore_errors=True) + + +def test_full_build_deploy_load_run_stop_unload_management(management): + host, agent, api = management + assert api("POST", "/hil/connect", {"target": "cpu"})["connected"] + built = completed(api, api("POST", "/hil/build"))["built"] + assert built["execution_kind"] == "model-service" + assert api("GET", "/hil/state")["application"]["built"] == built + deployed = completed(api, api("POST", "/hil/deploy")) + assert deployed["project"]["id"] == built["project"]["id"] + loaded = completed(api, api("POST", "/hil/projects/load"))["loaded"] + assert loaded["build_id"] == built["build_id"] + assert host.session is None + running = completed(api, api("POST", "/hil/start")) + session = running["session"] + metadata = api("POST", "/hil/models", {"session": session, + "sha256": built["models"][0]["sha256"]}) + port = metadata["inputs"][0] + data = [float(index) for index in range(math.prod(port["shape"]))] + inferred = api("POST", "/hil/infer", {"session": session, "seq": 0, + "model": metadata["sha256"], "inputs": {port["name"]: { + "dtype": "float32", "shape": port["shape"], "data": data}}}) + assert inferred["outputs"][metadata["outputs"][0]["name"]]["data"] == data + assert inferred["calls"] == agent.calls == 1 + stopped = api("POST", "/hil/stop", {"session": session}) + assert stopped["session"] is None + assert stopped["application"]["loaded"] is not None + assert agent.models == {} + assert api("POST", "/hil/projects/unload")["loaded"] is None + assert len(api("GET", "/hil/projects")["projects"]) == 1 + api("POST", "/hil/projects/remove", {"project_id": built["project"]["id"]}) + assert api("GET", "/hil/projects")["projects"] == [] + assert not api("POST", "/hil/disconnect")["connected"] + + +def browser_sources(host): + return {name: (host.project_dir / name).read_text() + for name in ("main.py", "controller.py", "robot.preset.yaml")} + + +def prepare_loaded_project(api): + api("POST", "/hil/connect", {"target": "cpu"}) + completed(api, api("POST", "/hil/build")) + completed(api, api("POST", "/hil/deploy")) + return completed(api, api("POST", "/hil/projects/load"))["loaded"] + + +def loaded_start_body(host, loaded): + return {"request_id": uuid.uuid4().hex, "sources": browser_sources(host), + "project_id": loaded["project_id"], "build_id": loaded["build_id"]} + + +def test_deploy_requires_existing_build_without_remote_changes(management, monkeypatch): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + def unexpected(*args, **kwargs): + pytest.fail("Deploy must not build or issue remote requests without an artifact") + monkeypatch.setattr(host, "_build", unexpected) + monkeypatch.setattr(host, "_rpc", unexpected) + result = wait_job(api, api("POST", "/hil/deploy")) + assert result["operation"]["status"] == "failed" + assert "Build the project before deploying" in result["operation"]["error"] + assert host.artifact is None + assert host.application["built"] is None + assert agent.store.list() == [] + assert not (host.project_dir / ".botlab").exists() + + +def test_deploy_keeps_built_artifact_after_local_sources_and_model_change(management, monkeypatch): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + built = completed(api, api("POST", "/hil/build"))["built"] + artifact_before = copy.deepcopy(host.artifact) + (host.project_dir / "controller.py").write_text("invalid python!\n") + (host.project_dir / "assets/models/policy.onnx").write_bytes(b"changed after build") + def unexpected(*args, **kwargs): + pytest.fail("Deploy must consume the existing build") + monkeypatch.setattr(host, "_build", unexpected) + deployed = completed(api, api("POST", "/hil/deploy")) + assert deployed["project"]["current_build_id"] == built["build_id"] + assert host.artifact == artifact_before + assert agent.store.manifest(built["project"]["id"], verify_files=True) == built + + +def test_cli_deploy_builds_and_installs_without_running(management, capsys): + from botlab._cli import main + + host, agent, _ = management + assert main(["deploy", str(host.project_dir), "--target", "cpu"]) == 0 + result = json.loads(capsys.readouterr().out) + assert result["project"]["id"] == agent.store.list()[0]["id"] + assert agent.session is agent.loaded is None + + +def test_stepwise_run_uses_exact_selected_release_without_build_deploy_or_load(management, monkeypatch): + host, agent, api = management + first = prepare_loaded_project(api) + source = host.project_dir / "controller.py" + original = source.read_text() + source.write_text(original + "\n# Newer local version\n") + newer = completed(api, api("POST", "/hil/build"))["built"] + completed(api, api("POST", "/hil/deploy")) + completed(api, api("POST", "/hil/projects/load", { + "project_id": first["project_id"], "build_id": first["build_id"]})) + source.write_text(original) + assert newer["build_id"] != first["build_id"] + before = copy.deepcopy(host.application) + def unexpected(*args, **kwargs): + pytest.fail("Stepwise run must not build, deploy, or select a project") + for name in ("_build", "_deploy", "_load_project"): + monkeypatch.setattr(host, name, unexpected) + from botlab.hil import artifacts + monkeypatch.setattr(artifacts, "build_project", unexpected) + monkeypatch.setattr(artifacts, "_project_identity", unexpected) + calls = [] + original_rpc = host._rpc + def record_rpc(request, *args, **kwargs): + calls.append(request["op"]) + return original_rpc(request, *args, **kwargs) + monkeypatch.setattr(host, "_rpc", record_rpc) + result = completed(api, api("POST", "/hil/start-loaded", loaded_start_body(host, first))) + assert result["session"] == agent.session + assert agent.loaded == first + assert host.application == before + assert calls == ["start"] + assert api("GET", "/hil/state")["capabilities"]["stepwiseExecution"] == 1 + metadata = api("POST", "/hil/models", {"session": result["session"], + "sha256": first["models"][0]["sha256"]}) + assert metadata["sha256"] == first["models"][0]["sha256"] + api("POST", "/hil/stop", {"session": result["session"]}) + assert agent.loaded == first + + +def test_stepwise_run_does_not_require_an_in_memory_build(management): + host, agent, api = management + selected = prepare_loaded_project(api) + # A fresh host can connect a persistent release without building locally. + host.artifact = None + host.application["built"] = None + result = completed(api, api("POST", "/hil/start-loaded", loaded_start_body(host, selected))) + assert agent.session == result["session"] + assert host.artifact is None + assert host.application["built"] is None + assert host.application["loaded"] == agent.loaded == selected + api("POST", "/hil/stop", {"session": result["session"]}) + + +@pytest.mark.parametrize("change", ["browser", "disk", "model", "identity", "missing_identity", "missing_sources"]) +def test_stepwise_run_rejects_unmatched_local_project_without_changing_selection(management, change): + host, agent, api = management + selected = prepare_loaded_project(api) + body = loaded_start_body(host, selected) + before = copy.deepcopy(host.application) + identity_file = host.project_dir / ".botlab/project.json" + if change == "browser": + body["sources"]["controller.py"] += "\n# Unsaved edit\n" + elif change == "disk": + source = host.project_dir / "controller.py" + source.write_text(source.read_text() + "\n# Saved edit\n") + body["sources"] = browser_sources(host) + elif change == "model": + model = host.project_dir / "assets/models/policy.onnx" + model.write_bytes(model.read_bytes() + b"\x12\x07changed") + elif change == "identity": + identity_file.write_text(json.dumps({"schema": 1, "id": "another-project"})) + elif change == "missing_identity": + identity_file.unlink() + else: + del body["sources"] + result = wait_job(api, api("POST", "/hil/start-loaded", body)) + assert result["operation"]["status"] == "failed" + assert "Open the matching local project" in result["operation"]["error"] + assert host.application == before + assert agent.loaded == selected + assert agent.session is host.session is None + if change == "missing_identity": + assert not identity_file.exists() + + +@pytest.mark.parametrize("change", ["project_id", "build_id", "unloaded"]) +def test_stepwise_run_rejects_stale_or_missing_selection(management, change): + host, agent, api = management + selected = prepare_loaded_project(api) + body = loaded_start_body(host, selected) + if change == "unloaded": + api("POST", "/hil/projects/unload") + else: + body[change] = "another-project" if change == "project_id" else "f" * 64 + before = copy.deepcopy(host.application) + result = wait_job(api, api("POST", "/hil/start-loaded", body)) + assert result["operation"]["status"] == "failed" + assert host.application == before + assert agent.session is None + assert agent.loaded == (None if change == "unloaded" else selected) + + +@pytest.mark.parametrize("stage", ["validation", "started", "completed"]) +def test_cancel_stepwise_run_preserves_preselected_project(management, monkeypatch, stage): + host, agent, api = management + selected = prepare_loaded_project(api) + body = loaded_start_body(host, selected) + entered, proceed = threading.Event(), threading.Event() + if stage == "validation": + from botlab.hil import artifacts + original = artifacts.validate_loaded_project + def pause_validation(*args, **kwargs): + original(*args, **kwargs) + entered.set() + assert proceed.wait(5) + monkeypatch.setattr(artifacts, "validate_loaded_project", pause_validation) + elif stage == "started": + original = host._handle + def pause_start(method, path, request): + result = original(method, path, request) + if path == "/hil/start": + entered.set() + assert proceed.wait(5) + return result + monkeypatch.setattr(host, "_handle", pause_start) + job = api("POST", "/hil/start-loaded", body) + try: + if stage == "completed": + completed(api, job) + else: + assert entered.wait(3) + api("POST", "/hil/cancel-start", {"request_id": body["request_id"]}, timeout=1) + finally: + proceed.set() + result = wait_job(api, job) + assert result["operation"]["phase"] == "cancelled" + assert host.session is agent.session is None + assert agent.loaded == host.application["loaded"] == selected + assert host.sock is not None + assert len(agent.store.list()[0]["releases"]) == 1 + + +def test_async_build_progress_endpoint_is_responsive(management, monkeypatch): + host, _, api = management + entered, proceed = threading.Event(), threading.Event() + original = host._build + def slow_build(): + host._progress({"stage": "validation", "message": "Checking local project"}) + entered.set() + assert proceed.wait(5) + return original() + monkeypatch.setattr(host, "_build", slow_build) + response = api("POST", "/hil/build") + try: + assert entered.wait(2) + started = time.monotonic() + state = api("GET", "/hil/state", timeout=1) + op = api("GET", "/hil/operations/" + response["operation"]["id"], timeout=1) + assert time.monotonic() - started < 1 + assert state["operation"]["phase"] == op["operation"]["phase"] == "validation" + assert "Checking local project" in op["operation"]["messages"] + with pytest.raises(HTTPError) as rejected: + api("POST", "/hil/build") + assert rejected.value.code == 400 + finally: + proceed.set() + assert completed(api, response)["built"] + + +def test_progress_count_tracks_repeated_messages_beyond_retained_tail(management, monkeypatch): + host, _, api = management + emit_tail, tail_ready = threading.Event(), threading.Event() + emit_more, more_ready = threading.Event(), threading.Event() + finish = threading.Event() + + def chatty_build(): + assert emit_tail.wait(5) + for index in range(5): + host._progress({"stage": "build", "message": f"Preparing {index}"}) + for _ in range(105): + host._progress({"stage": "build", "message": "Repeated download progress"}) + tail_ready.set() + assert emit_more.wait(5) + host._progress({"stage": "build", "message": "Repeated download progress"}) + more_ready.set() + assert finish.wait(5) + return {"built": True} + + monkeypatch.setattr(host, "_build", chatty_build) + response = api("POST", "/hil/build") + try: + assert response["operation"]["messages"] == [] + assert response["operation"]["message_count"] == 0 + emit_tail.set() + assert tail_ready.wait(2) + first = api("GET", "/hil/operations/" + response["operation"]["id"])["operation"] + assert first["message_count"] == 110 + assert first["messages"] == ["Repeated download progress"] * 100 + emit_more.set() + assert more_ready.wait(2) + second = api("GET", "/hil/state")["operation"] + assert second["message_count"] == 111 + assert second["messages"] == first["messages"] + assert first["message_count"] == 110 # Earlier snapshots remain immutable. + finally: + emit_tail.set() + emit_more.set() + finish.set() + assert completed(api, response)["built"] + assert api("GET", "/hil/state")["operation"]["message_count"] == 111 + + monkeypatch.setattr(host, "_build", lambda: {"built": True}) + next_response = api("POST", "/hil/build") + assert next_response["operation"]["id"] != response["operation"]["id"] + assert completed(api, next_response)["built"] + next_operation = api("GET", "/hil/state")["operation"] + assert next_operation["message_count"] == 0 + assert next_operation["messages"] == [] + + +def test_failed_build_preserves_deployed_project_and_next_run_recovers(management): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + started = completed(api, api("POST", "/hil/start")) + api("POST", "/hil/stop", {"session": started["session"]}) + previous = agent.loaded.copy() + source = host.project_dir / "controller.py" + original = source.read_text() + source.write_text("not valid python!\n") + try: + failed = wait_job(api, api("POST", "/hil/start")) + assert failed["operation"]["status"] == "failed" + assert agent.session is None + assert agent.loaded == previous + assert len(agent.store.list()[0]["releases"]) == 1 + finally: + source.write_text(original) + assert completed(api, api("POST", "/hil/start"))["session"] + api("POST", "/hil/stop") + + +def test_browser_source_mismatch_does_not_deploy_or_start(management): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + sources = {name: (host.project_dir / name).read_text() + for name in ("main.py", "controller.py", "robot.preset.yaml")} + unsaved = {**sources, "controller.py": sources["controller.py"] + "\n# Browser draft\n"} + failed = wait_job(api, api("POST", "/hil/start", {"sources": unsaved})) + assert failed["operation"]["status"] == "failed" + assert "Browser sources differ" in failed["operation"]["error"] + assert agent.store.list() == [] + assert agent.session is None + assert agent.loaded is None + assert not (host.project_dir / ".botlab/project.json").exists() + started = completed(api, api("POST", "/hil/start", {"sources": sources})) + assert started["session"] + assert len(agent.store.list()) == 1 + api("POST", "/hil/stop") + + +def test_one_click_run_selects_current_project_not_other_loaded_project(management): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + # Deploy a different application's model via actual TCP on the same owner. + other = artifact("another-project", "Another project") + with host.lock: + host._rpc({"op": "projects.deploy", "artifact": other}) + completed(api, api("POST", "/hil/projects/load", {"project_id": "another-project"})) + assert agent.loaded["project_id"] == "another-project" + result = completed(api, api("POST", "/hil/start")) + current = api("GET", "/hil/state")["application"] + assert current["loaded"]["project_id"] == current["built"]["project"]["id"] + assert agent.loaded["project_id"] != "another-project" + assert len(agent.store.list()) == 2 + api("POST", "/hil/stop", {"session": result["session"]}) + + +def test_target_failure_cleans_host_session_and_can_reconnect(management): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + started = completed(api, api("POST", "/hil/start")) + with pytest.raises(HTTPError): + api("POST", "/hil/models", {"session": started["session"], "sha256": "a" * 64, + "data": base64.b64encode(b"undeployed").decode()}) + assert host.session is None + assert not api("GET", "/hil/state")["connected"] + api("POST", "/hil/connect", {"target": "cpu"}) + restarted = completed(api, api("POST", "/hil/start")) + assert restarted["session"] != started["session"] + api("POST", "/hil/stop") + + +def ssh_profile(): + return {"id": "linux", "name": "Linux board", "transport": "ssh", "profile": "generic-linux", + "ssh": {"host": "192.0.2.10", "user": "robot"}, "root": "~/botlab"} + + +def test_target_inventory_never_persists_credentials_and_removal_clears_state(project_dir, tmp_path, monkeypatch): + path = tmp_path / "profiles.json" + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(path)) + host = HilHost(project_dir) + profile = ssh_profile() + try: + host.handle("POST", "/hil/targets", profile) + assert read_targets()[0]["transport"] == "ssh" + for secret in ({"password": "private-password"}, + {"ssh": {**profile["ssh"], "password": "private-password"}}): + with pytest.raises(HilError): + host.handle("POST", "/hil/targets", {**profile, **secret}) + with pytest.raises(HilError): + save_target({**profile, "credentials": {"password": "private-password"}}) + assert "private-password" not in path.read_text() + host.credentials["linux"] = {"password": "private-password"} + host.device_info["linux"] = {"machine": "aarch64"} + assert "private-password" not in json.dumps(host.state()) + host.handle("POST", "/hil/targets/remove", {"target": "linux"}) + assert read_targets() == [] + assert host.credentials == host.device_info == {} + assert host.selected_target is None + finally: + host.close() + + +def test_editing_target_does_not_reuse_another_host_credentials(project_dir, tmp_path, monkeypatch): + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "profiles.json")) + host = HilHost(project_dir) + profile = ssh_profile() + try: + host.handle("POST", "/hil/targets", profile) + host.credentials["linux"] = {"password": "old-host-password"} + host.device_info["linux"] = {"hostname": "old-host"} + host.handle("POST", "/hil/targets", {**profile, "ssh": {**profile["ssh"], "host": "192.0.2.11"}}) + assert "linux" not in host.credentials + assert "linux" not in host.device_info + finally: + host.close() + + +def test_invalid_transient_password_completes_failed_job(project_dir, tmp_path, monkeypatch): + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "profiles.json")) + save_target(ssh_profile()) + host = HilHost(project_dir, "linux") + try: + job = host.handle("POST", "/hil/devices/inspect", {"target": "linux", "password": 123}) + result = wait_job(host.handle, job, timeout=2) + assert result["operation"]["status"] == "failed" + assert host.credentials.get("linux", {}).get("password") is None + finally: + host.close() + + +def test_async_device_errors_are_redacted_and_device_closed(project_dir, tmp_path, monkeypatch): + from botlab.hil import devices + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "profiles.json")) + save_target(ssh_profile()) + closed = [] + class FailedDevice: + def __init__(self, target, credentials, progress): + self.target = target + self.credentials = credentials + self.progress = progress + def inspect(self): + self.progress({"stage": "inspect", "message": "Remote progress mentions private-password"}) + raise HilError("Remote failure mentions private-password") + def close(self): + closed.append(True) + monkeypatch.setattr(devices, "LinuxDevice", FailedDevice) + host = HilHost(project_dir, "linux") + try: + response = host.handle("POST", "/hil/devices/inspect", {"password": "private-password"}) + result = wait_job(host.handle, response) + assert result["operation"]["status"] == "failed" + assert "private-password" not in json.dumps(result) + assert "private-password" not in json.dumps(host.state()) + assert "[redacted]" in result["operation"]["error"] + assert result["operation"]["message_count"] == 1 + assert result["operation"]["messages"] == ["Remote progress mentions [redacted]"] + assert closed == [True] + finally: + host.close() + + +def test_synchronous_device_errors_are_redacted(management, monkeypatch): + host, _, api = management + host.credentials["cpu"] = {"password": "private-password"} + + def failed_command(*args): + raise HilError("Remote failure mentions private-password") + + monkeypatch.setattr(host, "_handle", failed_command) + with pytest.raises(HTTPError) as failed: + api("POST", "/hil/disconnect") + response = failed.value.read().decode() + assert "private-password" not in response + assert "[redacted]" in response + assert host.state()["error"] == "Remote failure mentions [redacted]" + + +def test_cancel_before_start_request_arrives_never_builds_or_leases(management): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + request_id = uuid.uuid4().hex + assert api("POST", "/hil/cancel-start", {"request_id": request_id})["cancelled"] + result = wait_job(api, api("POST", "/hil/start", {"request_id": request_id})) + assert result["operation"]["status"] == "failed" + assert result["operation"]["phase"] == "cancelled" + assert agent.store.list() == [] + assert agent.session is agent.loaded is None + assert not (host.project_dir / ".botlab/project.json").exists() + + +def test_cancel_pending_build_is_responsive_and_does_not_deploy(management, monkeypatch): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + entered, proceed = threading.Event(), threading.Event() + original = host._build + def pause_build(*args, **kwargs): + result = original(*args, **kwargs) + entered.set() + assert proceed.wait(5) + return result + monkeypatch.setattr(host, "_build", pause_build) + request_id = uuid.uuid4().hex + job = api("POST", "/hil/start", {"request_id": request_id}) + try: + assert entered.wait(2) + started = time.monotonic() + cancelled = api("POST", "/hil/cancel-start", {"request_id": request_id}, timeout=1) + assert time.monotonic() - started < 1 + assert cancelled["operation"]["phase"] == "cancelling" + assert api("GET", "/hil/state", timeout=1)["session"] is None + finally: + proceed.set() + result = wait_job(api, job) + assert result["operation"]["phase"] == "cancelled" + assert agent.store.list() == [] + assert agent.session is agent.loaded is None + + +@pytest.mark.parametrize("stage", ["loaded", "started"]) +def test_cancel_during_load_or_start_response_releases_all_owned_resources(management, monkeypatch, stage): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + entered, proceed = threading.Event(), threading.Event() + if stage == "loaded": + original = host._load_project + def pause_load(*args, **kwargs): + result = original(*args, **kwargs) + entered.set() + assert proceed.wait(5) + return result + monkeypatch.setattr(host, "_load_project", pause_load) + else: + original = host._handle + def pause_start(method, path, body): + result = original(method, path, body) + if path == "/hil/start": + entered.set() + assert proceed.wait(5) + return result + monkeypatch.setattr(host, "_handle", pause_start) + request_id = uuid.uuid4().hex + job = api("POST", "/hil/start", {"request_id": request_id}) + try: + assert entered.wait(3) + assert agent.loaded is not None + if stage == "started": + assert agent.session is not None + api("POST", "/hil/cancel-start", {"request_id": request_id}, timeout=1) + finally: + proceed.set() + result = wait_job(api, job) + assert result["operation"]["phase"] == "cancelled" + assert "result" not in result + assert host.session is agent.session is agent.loaded is None + assert agent.models == {} + assert host.application["loaded"] is None + assert len(agent.store.list()) == 1 # Cancellation preserves installed files. + assert all(not key.startswith("_") for key in result["operation"]) + + +def test_cancel_completed_start_and_late_cancel_cannot_stop_new_session(management): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + first_id = uuid.uuid4().hex + first = completed(api, api("POST", "/hil/start", {"request_id": first_id})) + assert agent.session == first["session"] + response = api("POST", "/hil/cancel-start", {"request_id": first_id}) + assert response["operation"]["phase"] == "cancelled" + assert agent.session is agent.loaded is None + second_id = uuid.uuid4().hex + second = completed(api, api("POST", "/hil/start", {"request_id": second_id})) + assert second["session"] != first["session"] + api("POST", "/hil/cancel-start", {"request_id": first_id}) + assert agent.session == host.session == second["session"] + assert api("GET", "/hil/state")["operation"]["request_id"] == second_id + api("POST", "/hil/stop") + + +def test_cancel_old_start_does_not_unload_a_new_manual_selection(management): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + request_id = uuid.uuid4().hex + started = completed(api, api("POST", "/hil/start", {"request_id": request_id})) + api("POST", "/hil/stop", {"session": started["session"]}) + selected = completed(api, api("POST", "/hil/projects/load"))["loaded"] + api("POST", "/hil/cancel-start", {"request_id": request_id}) + assert host.application["loaded"] == agent.loaded == selected + + +def test_cancelling_other_id_does_not_cancel_pending_start(management, monkeypatch): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + entered, proceed = threading.Event(), threading.Event() + original = host._build + def pause_build(*args, **kwargs): + entered.set() + assert proceed.wait(5) + return original(*args, **kwargs) + monkeypatch.setattr(host, "_build", pause_build) + request_id = uuid.uuid4().hex + job = api("POST", "/hil/start", {"request_id": request_id}) + try: + assert entered.wait(2) + assert api("POST", "/hil/cancel-start", {"request_id": uuid.uuid4().hex})["operation"] is None + finally: + proceed.set() + result = completed(api, job) + assert host.session == agent.session == result["session"] + api("POST", "/hil/stop") + + +@pytest.mark.parametrize("replacement_password", [None, "corrected-password"]) +def test_network_confirmation_persists_verified_host_key_not_password(project_dir, tmp_path, monkeypatch, replacement_password): + from botlab.hil import devices + profile_path = tmp_path / "profiles.json" + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(profile_path)) + target = save_target(ssh_profile()) + host = HilHost(project_dir, "linux") + transaction = uuid.uuid4().hex + fingerprint = "SHA256:" + "a" * 43 + host.credentials["linux"] = {"password": "transient-password", "host_key": fingerprint} + expected_password = replacement_password or "transient-password" + host.network_pending[transaction] = {"target": target, "new_host": "192.0.2.11"} + checked = [] + class VerifiedDevice: + def __init__(self, config, credentials, progress): + assert config["ssh"]["host"] == "192.0.2.11" + assert credentials == {"password": expected_password, "host_key": fingerprint} + def confirm_network(self, request): + checked.append(request) + return {"confirmed": True} + def close(self): + pass + monkeypatch.setattr(devices, "LinuxDevice", VerifiedDevice) + try: + result = completed(host.handle, host.handle("POST", "/hil/devices/network/confirm", { + "target": "linux", "transaction": transaction, "host": "192.0.2.11", + **({"password": replacement_password} if replacement_password else {})})) + assert result["confirmed"] + assert checked == [transaction] + saved = read_targets()[0] + assert saved["ssh"]["host"] == "192.0.2.11" + assert saved["ssh"]["host_key"] == fingerprint + assert "transient-password" not in profile_path.read_text() + assert expected_password not in profile_path.read_text() + assert host.credentials["linux"]["password"] == expected_password + assert transaction not in host.network_pending + finally: + host.close() + + +def test_delete_target_guard_rejects_other_connected_target_without_rpc(management, monkeypatch): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + completed(api, api("POST", "/hil/build")) + deployed = completed(api, api("POST", "/hil/deploy")) + project_id = deployed["project"]["id"] + calls = [] + original = host._rpc + def record_rpc(request, *args, **kwargs): + calls.append(request["op"]) + return original(request, *args, **kwargs) + monkeypatch.setattr(host, "_rpc", record_rpc) + with pytest.raises(HTTPError) as rejected: + api("POST", "/hil/projects/remove", {"target": "other-target", "project_id": project_id}) + assert rejected.value.code == 400 + assert "Target changed or disconnected" in json.load(rejected.value)["error"] + assert calls == [] + assert len(agent.store.list()) == 1 + assert api("POST", "/hil/projects/remove", {"target": "cpu", "project_id": project_id})["removed"] + assert calls == ["projects.remove", "projects.list"] + assert agent.store.list() == [] + + +def test_delete_target_guard_rejects_disconnected_target_without_rpc(management, monkeypatch): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + completed(api, api("POST", "/hil/build")) + deployed = completed(api, api("POST", "/hil/deploy")) + api("POST", "/hil/disconnect") + calls = [] + monkeypatch.setattr(host, "_rpc", lambda *args, **kwargs: calls.append(args)) + with pytest.raises(HTTPError) as rejected: + api("POST", "/hil/projects/remove", {"target": "cpu", "project_id": deployed["project"]["id"]}) + assert rejected.value.code == 400 + assert "Target changed or disconnected" in json.load(rejected.value)["error"] + assert calls == [] + assert len(agent.store.list()) == 1 + + +@pytest.mark.parametrize("operation", ["inspect", "provision"]) +def test_running_project_rejects_device_management_without_releasing_session(management, monkeypatch, operation): + from botlab.hil import devices + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + started = completed(api, api("POST", "/hil/start")) + session = started["session"] + connection = host.sock + loaded = host.application["loaded"].copy() + host.device_info["cpu"] = {"agent_installed": True} + def no_device(*args, **kwargs): + pytest.fail("Device management must reject a running project before SSH") + monkeypatch.setattr(devices, "LinuxDevice", no_device) + rejected = wait_job(api, api("POST", "/hil/devices/" + operation, {"target": "cpu"})) + assert rejected["operation"]["status"] == "failed" + assert "Stop the project" in rejected["operation"]["error"] + assert host.sock is connection + assert host.session == agent.session == session + assert host.application["loaded"] == agent.loaded == loaded + assert host.device_info["cpu"]["agent_installed"] + # The same real Agent lease still accepts a model request after rejection. + model = host.application["built"]["models"][0]["sha256"] + metadata = api("POST", "/hil/models", {"session": session, "sha256": model}) + port = metadata["inputs"][0] + values = [float(index) for index in range(math.prod(port["shape"]))] + result = api("POST", "/hil/infer", {"session": session, "seq": 0, "model": model, + "inputs": {port["name"]: {"dtype": "float32", "shape": port["shape"], "data": values}}}) + assert result["calls"] == 1 + api("POST", "/hil/stop") + + +@pytest.mark.parametrize("inspection_fails", [False, True]) +def test_idle_inspection_preserves_connection_and_loaded_project(management, monkeypatch, inspection_fails): + from botlab.hil import devices + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + completed(api, api("POST", "/hil/start")) + api("POST", "/hil/stop") + connection = host.sock + loaded = host.application["loaded"].copy() + # Represent the already-established loopback connection as an SSH target; + # only the independent OS inspection is stubbed, never the Agent protocol. + host.targets[0] = {**ssh_profile(), "id": "cpu"} + host.device_info["cpu"] = {"hostname": "stale-device", "agent_installed": True} + closed = [] + class InspectedDevice: + def __init__(self, target, credentials, progress): + self.target = target + def inspect(self): + assert "cpu" not in host.device_info + if inspection_fails: + raise HilError("SSH inspection failed") + return {"hostname": "current-device", "agent_installed": False} + def close(self): + closed.append(True) + monkeypatch.setattr(devices, "LinuxDevice", InspectedDevice) + checked = wait_job(api, api("POST", "/hil/devices/inspect", {"target": "cpu"})) + assert checked["operation"]["status"] == ("failed" if inspection_fails else "succeeded") + assert host.sock is connection + assert host.selected_target == "cpu" + assert host.application["loaded"] == agent.loaded == loaded + assert host.session is agent.session is None + if inspection_fails: + assert "cpu" not in api("GET", "/hil/state")["device_info"] + else: + assert host.device_info["cpu"]["hostname"] == "current-device" + assert not host.device_info["cpu"]["agent_installed"] + assert closed == [True] + assert api("GET", "/hil/projects")["loaded"] == loaded + + +def test_inspecting_another_target_preserves_current_connection(management, monkeypatch): + from botlab.hil import devices + host, _, api = management + api("POST", "/hil/targets", ssh_profile()) + api("POST", "/hil/connect", {"target": "cpu"}) + connection = host.sock + def no_device(*args, **kwargs): + pytest.fail("Checking another target must be refused before SSH") + monkeypatch.setattr(devices, "LinuxDevice", no_device) + rejected = wait_job(api, api("POST", "/hil/devices/inspect", {"target": "linux"})) + assert rejected["operation"]["status"] == "failed" + assert "Disconnect before checking another target" in rejected["operation"]["error"] + assert host.selected_target == "cpu" + assert host.sock is connection + assert api("GET", "/hil/projects")["projects"] == [] + + +def test_idle_provision_disconnects_before_installation(management, monkeypatch): + from botlab.hil import devices + host, _, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + completed(api, api("POST", "/hil/start")) + api("POST", "/hil/stop") + host.targets[0] = {**ssh_profile(), "id": "cpu"} + installed = [] + class ProvisionedDevice: + def __init__(self, target, credentials, progress): + self.target = target + def provision(self): + assert host.sock is None + assert host.session is None + assert host.application["loaded"] is None + installed.append(True) + return {"installed": True, "device": {"agent_installed": False}} + def close(self): + pass + monkeypatch.setattr(devices, "LinuxDevice", ProvisionedDevice) + result = completed(api, api("POST", "/hil/devices/provision", {"target": "cpu"})) + assert result["installed"] + assert installed == [True] + assert not api("GET", "/hil/state")["connected"] + + +def test_saved_password_is_transient_and_survives_fingerprint_acceptance(project_dir, tmp_path, monkeypatch): + profile_path = tmp_path / "profiles.json" + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(profile_path)) + host = HilHost(project_dir) + try: + profile = ssh_profile() + saved = host.handle("POST", "/hil/targets", { + **profile, "credentials": {"password": "private-password"}}) + assert saved["credential_status"]["linux"] == {"password": True} + assert saved["capabilities"]["transientCredentials"] == 1 + assert saved["capabilities"]["targetProjects"] == 1 + assert "private-password" not in json.dumps(saved) + assert "credentials" not in profile_path.read_text() + assert "private-password" not in profile_path.read_text() + profile = {**read_targets()[0], "name": "Renamed board"} + profile["ssh"] = {**profile["ssh"], "host_key": "SHA256:" + "a" * 43} + updated = host.handle("POST", "/hil/targets", profile) + assert updated["credential_status"]["linux"] == {"password": True} + assert host.credentials["linux"]["password"] == "private-password" + fresh = HilHost(project_dir) + assert fresh.state()["credential_status"]["linux"] == {"password": False} + fresh.close() + finally: + host.close() + assert host.credentials == host.credential_targets == {} + + +@pytest.mark.parametrize("credentials", [None, [], {"password": 123}, {"password": "bad\x00value"}, + {"token": "secret"}, {"host_key": "bad"}]) +def test_invalid_saved_transient_credentials_do_not_write_target(project_dir, tmp_path, monkeypatch, credentials): + profile_path = tmp_path / "profiles.json" + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(profile_path)) + host = HilHost(project_dir) + try: + with pytest.raises(HilError, match="credential|fingerprint"): + host.handle("POST", "/hil/targets", {**ssh_profile(), "credentials": credentials}) + assert not profile_path.exists() + assert host.credentials == {} + finally: + host.close() + + +@pytest.mark.parametrize("change", [ + {"ssh": {"host": "192.0.2.11", "user": "robot"}}, + {"ssh": {"host": "192.0.2.10", "user": "other"}}, + {"ssh": {"host": "192.0.2.10", "user": "robot", "port": 2222}}, + {"ssh": {"host": "192.0.2.10", "user": "robot", "identity_file": "~/.ssh/another"}}, + {"root": "~/another-botlab"}, {"backend": "another"}, {"agent_port": 50053}, +]) +def test_connection_identity_edits_invalidate_saved_password(project_dir, tmp_path, monkeypatch, change): + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "profiles.json")) + host = HilHost(project_dir) + try: + host.handle("POST", "/hil/targets", {**ssh_profile(), "credentials": {"password": "old-secret"}}) + response = host.handle("POST", "/hil/targets", {**ssh_profile(), **change}) + assert response["credential_status"]["linux"] == {"password": False} + assert "linux" not in host.credentials + response = host.handle("POST", "/hil/targets", { + **ssh_profile(), **change, "credentials": {"password": "new-secret"}}) + assert response["credential_status"]["linux"] == {"password": True} + assert host.credentials["linux"] == {"password": "new-secret"} + finally: + host.close() + + +def test_saving_unconnected_target_or_same_connected_password_preserves_main(management): + host, _, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + connection = host.sock + api("POST", "/hil/targets", {**ssh_profile(), "credentials": {"password": "other-password"}}) + assert host.sock is connection + assert host.selected_target == "cpu" + # Model the existing main connection's SSH configuration without new I/O. + target = save_target({**ssh_profile(), "id": "cpu"}) + host.targets = read_targets() + response = api("POST", "/hil/targets", {**target, "credentials": {"password": "corrected-password"}}) + assert response["connected"] + assert response["credential_status"]["cpu"]["password"] + assert host.sock is connection + assert host.selected_target == "cpu" + with pytest.raises(HTTPError): + api("POST", "/hil/targets", {**target, "ssh": {**target["ssh"], "host": "192.0.2.11"}}) + assert host.sock is connection + assert host.credentials["cpu"]["password"] == "corrected-password" + assert "corrected-password" not in json.dumps(api("GET", "/hil/state")) + + +def test_scoped_scan_disconnected_does_not_select_or_connect_main(management): + host, agent, api = management + agent.store.deploy(artifact()) + host.selected_target = None + inventory = completed(api, api("POST", "/hil/targets/projects", {"target": "cpu"})) + assert inventory["target"] == "cpu" + assert [project["id"] for project in inventory["projects"]] == ["project-one"] + assert inventory["loaded"] is None + assert host.selected_target is None + assert host.sock is None + assert host.application == {"built": None, "loaded": None, "projects": []} + missing = wait_job(api, api("POST", "/hil/targets/projects")) + assert missing["operation"]["status"] == "failed" + assert "Choose a configured target" in missing["operation"]["error"] + + +def test_scoped_other_target_inventory_and_delete_preserve_main(management, tmp_path): + from tests.test_hil_projects import Backend + host, main_agent, api = management + other_state = AgentState(tmp_path / "other-cache", Backend(), root=tmp_path / "other-target") + other_agent = AgentServer(("127.0.0.1", 0), other_state) + thread = threading.Thread(target=other_agent.serve_forever, daemon=True) + thread.start() + try: + other = save_target({"id": "other", "name": "Other board", "backend": "onnxruntime", + "endpoint": f"tcp://127.0.0.1:{other_agent.server_address[1]}"}) + host.targets = read_targets() + other_state.store.deploy(artifact("other-project")) + other_state.store.deploy(artifact("other-project", revision="second")) + api("POST", "/hil/connect", {"target": "cpu"}) + completed(api, api("POST", "/hil/start")) + api("POST", "/hil/stop") + original_connection = host.sock + original_application = copy.deepcopy(host.application) + original_loaded = copy.deepcopy(main_agent.loaded) + scanned = completed(api, api("POST", "/hil/targets/projects", {"target": "other"})) + assert [project["id"] for project in scanned["projects"]] == ["other-project"] + assert len(scanned["projects"][0]["releases"]) == 2 + deleted = completed(api, api("POST", "/hil/targets/projects/remove", { + "target": "other", "project_id": "other-project", "expected_target": other})) + assert deleted == {"target": "other", "projects": [], "loaded": None} + assert other_state.store.list() == [] + assert host.selected_target == "cpu" + assert host.sock is original_connection + assert host.application == original_application + assert main_agent.loaded == original_loaded + assert host.session is None + finally: + other_agent.shutdown() + other_agent.server_close() + thread.join() + + +@pytest.mark.parametrize("stale", ["missing", "wrong-id", "old-config"]) +def test_scoped_delete_requires_confirmed_target_config(management, monkeypatch, stale): + host, agent, api = management + agent.store.deploy(artifact()) + expected = copy.deepcopy(host.targets[0]) + if stale == "wrong-id": + expected["id"] = "other" + elif stale == "old-config": + expected["endpoint"] = "tcp://127.0.0.1:1" + def unexpected_connection(*args, **kwargs): + pytest.fail("Stale deletion must fail before connecting") + monkeypatch.setattr("botlab.hil.host.socket.create_connection", unexpected_connection) + response = wait_job(host.handle, host.handle("POST", "/hil/targets/projects/remove", { + "target": "cpu", "project_id": "project-one", + **({"expected_target": expected} if stale != "missing" else {})})) + assert response["operation"]["status"] == "failed" + assert "Scan the target and confirm" in response["operation"]["error"] + assert len(agent.store.list()) == 1 + + +def test_scoped_loaded_project_is_protected_without_unloading(management): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + completed(api, api("POST", "/hil/start")) + api("POST", "/hil/stop") + loaded = copy.deepcopy(agent.loaded) + connection = host.sock + inventory = completed(api, api("POST", "/hil/targets/projects", {"target": "cpu"})) + assert inventory["loaded"] == loaded + result = wait_job(api, api("POST", "/hil/targets/projects/remove", { + "target": "cpu", "project_id": loaded["project_id"], "expected_target": host.targets[0]})) + assert result["operation"]["status"] == "failed" + assert "Unload the project before deleting its loaded release" in result["operation"]["error"] + assert host.sock is connection + assert host.application["loaded"] == agent.loaded == loaded + assert len(agent.store.list()) == 1 + + +def test_scoped_scan_refuses_active_hil_before_network_io(management): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + session = completed(api, api("POST", "/hil/start"))["session"] + connection = host.sock + result = wait_job(api, api("POST", "/hil/targets/projects", {"target": "cpu"})) + assert result["operation"]["status"] == "failed" + assert "Stop the project" in result["operation"]["error"] + assert host.sock is connection + assert host.session == agent.session == session + + +@pytest.mark.parametrize("missing_agent", [False, True]) +def test_scoped_ssh_scan_uses_transient_auth_and_closes_temporary_tunnel(management, monkeypatch, missing_agent): + from botlab.hil import devices + host, agent, api = management + endpoint = host.targets[0]["endpoint"] + from botlab.hil.protocol import endpoint_address + address = endpoint_address(endpoint) + api("POST", "/hil/targets", {**ssh_profile(), "credentials": {"password": "scan-password"}}) + closed = [] + class Tunnel: + def __init__(self): + self.address = address + def close(self): + closed.append("tunnel") + class Device: + def __init__(self, target, credentials, progress): + assert target["id"] == "linux" + assert credentials["password"] == "scan-password" + self.target = target + self.progress = progress + def open_tunnel(self): + if missing_agent: + self.progress({"stage": "connect", "message": "scan-password"}) + raise HilError("Agent is not installed; scan-password") + return Tunnel() + def close(self): + closed.append("device") + monkeypatch.setattr(devices, "LinuxDevice", Device) + result = wait_job(api, api("POST", "/hil/targets/projects", {"target": "linux"})) + assert result["operation"]["status"] == ("failed" if missing_agent else "succeeded") + assert closed == (["device"] if missing_agent else ["tunnel", "device"]) + assert "scan-password" not in json.dumps(result) + assert "scan-password" not in json.dumps(api("GET", "/hil/state")) + if missing_agent: + assert "Agent is not installed" in result["operation"]["error"] + else: + assert result["result"] == {"target": "linux", "projects": [], "loaded": None} + assert host.sock is None + assert host.selected_target == "linux" + + +def test_scoped_failed_inventory_does_not_release_main_loaded_project(management, monkeypatch): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + completed(api, api("POST", "/hil/start")) + api("POST", "/hil/stop") + loaded = copy.deepcopy(agent.loaded) + connection = host.sock + def failed_inventory(): + raise HilError("Invalid project inventory") + monkeypatch.setattr(agent.store, "list", failed_inventory) + response = wait_job(api, api("POST", "/hil/targets/projects", {"target": "cpu"})) + assert response["operation"]["kind"] == "projects-scan" + assert response["operation"]["status"] == "failed" + assert "Invalid project inventory" in response["operation"]["error"] + assert host.sock is connection + assert host.application["loaded"] == agent.loaded == loaded + assert host.selected_target == "cpu" + assert host.session is None + + +def scoped_selection(host, value): + manifest = value["manifest"] + return {"target": "cpu", "expected_target": copy.deepcopy(host.targets[0]), + "project_id": manifest["project"]["id"], "build_id": manifest["build_id"]} + + +def test_scoped_load_switches_exact_versions_and_projects(management, monkeypatch): + host, agent, api = management + old, new, other = artifact(), artifact(revision="second"), artifact("other-project") + for value in (old, new, other): + agent.store.deploy(value) + api("POST", "/hil/connect", {"target": "cpu"}) + connection = host.sock + def unexpected_build_or_deploy(*args, **kwargs): + pytest.fail("Selecting a deployed version must not build or deploy") + monkeypatch.setattr(host, "_build", unexpected_build_or_deploy) + monkeypatch.setattr(host, "_deploy", unexpected_build_or_deploy) + for value in (new, old, other): + selection = scoped_selection(host, value) + loaded = completed(api, api("POST", "/hil/projects/load", selection))["loaded"] + assert loaded["project_id"] == selection["project_id"] + assert loaded["build_id"] == selection["build_id"] + assert host.application["loaded"] == agent.loaded == loaded + project = next(item for item in host.application["projects"] if item["id"] == loaded["project_id"]) + assert project["current_build_id"] == loaded["build_id"] + assert project["state"] == "loaded" + assert agent.store.current(loaded["project_id"]) == loaded["build_id"] + assert host.sock is connection + assert host.session is None + assert host.artifact is None + assert len(agent.store.list()) == 2 + assert host.state()["capabilities"]["deploymentVersions"] == 1 + assert agent.device()["capabilities"]["deploymentVersions"] == 1 + + +@pytest.mark.parametrize("stale", ["missing", "different-target", "endpoint", "name", "extra-setting"]) +def test_scoped_load_requires_exact_connected_target_snapshot(management, stale): + host, agent, api = management + loaded = prepare_loaded_project(api) + value = artifact("other-project") + agent.store.deploy(value) + body = scoped_selection(host, value) + if stale == "missing": + body.pop("expected_target") + elif stale == "different-target": + body["target"] = "other" + elif stale == "endpoint": + body["expected_target"]["endpoint"] = "tcp://127.0.0.1:1" + elif stale == "name": + body["expected_target"]["name"] = "Old display name" + else: + body["expected_target"]["unexpected"] = True + connection, owner = host.sock, agent.project_owner + result = wait_job(api, api("POST", "/hil/projects/load", body)) + assert result["operation"]["status"] == "failed" + assert "Target " in result["operation"]["error"] + assert host.sock is connection + assert agent.project_owner is owner + assert host.application["loaded"] == agent.loaded == loaded + + +def test_scoped_load_requires_target_connection(management): + host, agent, api = management + value = artifact() + agent.store.deploy(value) + result = wait_job(api, api("POST", "/hil/projects/load", scoped_selection(host, value))) + assert result["operation"]["status"] == "failed" + assert host.sock is None + assert agent.loaded is None + + +@pytest.mark.parametrize("stale", ["project", "version", "empty-version", "null-version"]) +def test_scoped_load_stale_selection_preserves_existing_lease(management, stale): + host, agent, api = management + loaded = prepare_loaded_project(api) + value = artifact("other-project") + agent.store.deploy(value) + body = scoped_selection(host, value) + if stale == "project": + body["project_id"] = "removed-project" + elif stale == "version": + body["build_id"] = "a" * 64 + else: + body["build_id"] = "" if stale == "empty-version" else None + connection, owner = host.sock, agent.project_owner + result = wait_job(api, api("POST", "/hil/projects/load", body)) + assert result["operation"]["status"] == "failed" + assert host.sock is connection + assert agent.project_owner is owner + assert host.application["loaded"] == agent.loaded == loaded + assert agent.store.current(loaded["project_id"]) == loaded["build_id"] + assert api("GET", "/hil/projects")["loaded"] == loaded + + +@pytest.mark.parametrize("path", ["/hil/projects/load", "/hil/targets/projects/remove"]) +def test_scoped_version_mutation_rejects_running_session(management, path): + host, agent, api = management + value = artifact("inactive-project") + agent.store.deploy(value) + api("POST", "/hil/connect", {"target": "cpu"}) + session = completed(api, api("POST", "/hil/start"))["session"] + connection, loaded = host.sock, copy.deepcopy(agent.loaded) + result = wait_job(api, api("POST", path, scoped_selection(host, value))) + assert result["operation"]["status"] == "failed" + assert "Stop" in result["operation"]["error"] + assert host.sock is connection + assert host.session == agent.session == session + assert host.application["loaded"] == agent.loaded == loaded + assert len(agent.store.list()) == 2 + + +def test_scoped_remove_inactive_version_preserves_loaded_version_and_refreshes_inventory(management): + host, agent, api = management + old, new = artifact(), artifact(revision="second") + for value in (old, new): + agent.store.deploy(value) + api("POST", "/hil/connect", {"target": "cpu"}) + loaded = completed(api, api("POST", "/hil/projects/load", scoped_selection(host, new)))["loaded"] + connection, owner = host.sock, agent.project_owner + result = completed(api, api("POST", "/hil/targets/projects/remove", scoped_selection(host, old))) + assert [item["build_id"] for item in result["projects"][0]["releases"]] == [new["manifest"]["build_id"]] + assert host.application["projects"] == result["projects"] + assert result["loaded"] == host.application["loaded"] == agent.loaded == loaded + assert host.sock is connection + assert agent.project_owner is owner + assert agent.store.current("project-one") == loaded["build_id"] + + +@pytest.mark.parametrize("whole_project", [False, True]) +def test_scoped_remove_last_version_or_whole_inactive_project(management, whole_project): + host, agent, api = management + loaded = prepare_loaded_project(api) + other = artifact("inactive-project") + agent.store.deploy(other) + if whole_project: + agent.store.deploy(artifact("inactive-project", revision="second")) + body = scoped_selection(host, other) + if whole_project: + body.pop("build_id") + connection = host.sock + result = completed(api, api("POST", "/hil/targets/projects/remove", body)) + assert [item["id"] for item in result["projects"]] == [loaded["project_id"]] + assert host.application["projects"] == result["projects"] + assert host.application["loaded"] == agent.loaded == loaded + assert host.sock is connection + assert not agent.store.project_path("inactive-project").exists() + + +def test_scoped_remove_loaded_version_is_rejected_without_unloading(management): + host, agent, api = management + loaded = prepare_loaded_project(api) + connection, owner = host.sock, agent.project_owner + result = wait_job(api, api("POST", "/hil/targets/projects/remove", { + "target": "cpu", "expected_target": host.targets[0], + "project_id": loaded["project_id"], "build_id": loaded["build_id"]})) + assert result["operation"]["status"] == "failed" + assert "Unload" in result["operation"]["error"] + assert host.sock is connection + assert agent.project_owner is owner + assert host.application["loaded"] == agent.loaded == loaded + assert len(agent.store.list()) == 1 + + +@pytest.mark.parametrize("build_id", [None, "", " ", 1, "invalid", "../release"]) +def test_scoped_remove_invalid_version_never_deletes_whole_project(management, build_id): + host, agent, api = management + value = artifact() + agent.store.deploy(value) + result = wait_job(api, api("POST", "/hil/targets/projects/remove", { + **scoped_selection(host, value), "build_id": build_id})) + assert result["operation"]["status"] == "failed" + assert "valid deployed version" in result["operation"]["error"] + assert len(agent.store.list()) == 1 + + +def test_scoped_remove_inactive_current_version_keeps_loaded_older_version(management): + host, agent, api = management + old, new = artifact(), artifact(revision="second") + agent.store.deploy(old) + api("POST", "/hil/connect", {"target": "cpu"}) + loaded = completed(api, api("POST", "/hil/projects/load", scoped_selection(host, old)))["loaded"] + # Deployment can update the project's default without changing its loaded version. + agent.store.deploy(new) + assert agent.store.current("project-one") == new["manifest"]["build_id"] + result = completed(api, api("POST", "/hil/targets/projects/remove", scoped_selection(host, new))) + assert result["projects"][0]["current_build_id"] == loaded["build_id"] + assert host.application["loaded"] == agent.loaded == loaded + assert len(result["projects"][0]["releases"]) == 1 + + +def test_scoped_load_deleted_after_preflight_preserves_previous_selection(management, monkeypatch): + host, agent, api = management + loaded = prepare_loaded_project(api) + candidate = artifact("inactive-project") + agent.store.deploy(candidate) + original_scan = host._target_projects + def delete_after_scan(*args, **kwargs): + inventory = original_scan(*args, **kwargs) + agent.store.remove("inactive-project", candidate["manifest"]["build_id"]) + return inventory + monkeypatch.setattr(host, "_target_projects", delete_after_scan) + connection, owner = host.sock, agent.project_owner + result = wait_job(api, api("POST", "/hil/projects/load", scoped_selection(host, candidate))) + assert result["operation"]["status"] == "failed" + assert "not deployed" in result["operation"]["error"] + assert host.sock is connection + assert agent.project_owner is owner + assert host.application["loaded"] == agent.loaded == loaded + assert host.session is agent.session is None + assert api("GET", "/hil/projects")["loaded"] == loaded + # The preserved connection remains usable, including its execution lease. + session = completed(api, api("POST", "/hil/start-loaded", loaded_start_body(host, loaded)))["session"] + assert host.session == agent.session == session + + +@pytest.mark.parametrize("single_version", [False, True]) +def test_scoped_single_version_delete_requires_agent_capability(management, monkeypatch, single_version): + host, agent, api = management + value = artifact() + agent.store.deploy(value) + original_device = agent.device + def legacy_device(): + result = original_device() + result["capabilities"].pop("deploymentVersions") + return result + monkeypatch.setattr(agent, "device", legacy_device) + body = scoped_selection(host, value) + if not single_version: + body.pop("build_id") + result = wait_job(api, api("POST", "/hil/targets/projects/remove", body)) + if single_version: + assert result["operation"]["status"] == "failed" + assert "Update" in result["operation"]["error"] + assert len(agent.store.list()) == 1 + else: + assert result["operation"]["status"] == "succeeded" + assert agent.store.list() == [] + + +def test_agent_load_refusal_preserves_active_session_and_connection(management): + host, agent, api = management + api("POST", "/hil/connect", {"target": "cpu"}) + session = completed(api, api("POST", "/hil/start"))["session"] + connection, loaded = host.sock, copy.deepcopy(agent.loaded) + with pytest.raises(HilError, match="Stop"): + host._rpc({"op": "projects.load", "project_id": loaded["project_id"], + "build_id": loaded["build_id"]}) + assert host.sock is connection + assert host.session == agent.session == session + assert host.application["loaded"] == agent.loaded == loaded + assert api("POST", "/hil/stop", {"session": session})["session"] is None + + +def test_lease_preserved_response_cannot_suppress_other_rpc_failures(management, monkeypatch): + host, agent, api = management + prepare_loaded_project(api) + original_dispatch = agent.dispatch + def invalid_unload_response(owner, request): + if request["op"] == "projects.unload": + return {"ok": False, "error": "Unexpected unload failure", "lease_preserved": True} + return original_dispatch(owner, request) + monkeypatch.setattr(agent, "dispatch", invalid_unload_response) + with pytest.raises(HilError, match="Unexpected unload failure"): + host._rpc({"op": "projects.unload"}) + assert host.sock is None + assert host.application["loaded"] is None + + +def test_scoped_load_requires_agent_capability_without_changing_selection(management, monkeypatch): + host, agent, api = management + original_device = agent.device + def legacy_device(): + result = original_device() + result["capabilities"].pop("deploymentVersions") + return result + monkeypatch.setattr(agent, "device", legacy_device) + loaded = prepare_loaded_project(api) + candidate = artifact("inactive-project") + agent.store.deploy(candidate) + connection, owner = host.sock, agent.project_owner + result = wait_job(api, api("POST", "/hil/projects/load", scoped_selection(host, candidate))) + assert result["operation"]["status"] == "failed" + assert "Update" in result["operation"]["error"] + assert host.sock is connection + assert agent.project_owner is owner + assert host.application["loaded"] == agent.loaded == loaded diff --git a/tests/test_hil_http.py b/tests/test_hil_http.py new file mode 100644 index 0000000..d42c78e --- /dev/null +++ b/tests/test_hil_http.py @@ -0,0 +1,105 @@ +"""Local HIL HTTP boundary: bootstrap, origin isolation, and request limits.""" +import json +import threading +from urllib.error import HTTPError +from urllib.request import Request, urlopen + +import pytest + +from botlab._server import start_project_server + + +class Host: + def handle(self, method, path, body=None): + return {"selectedTarget": "x5", "method": method, "path": path, "body": body} + + +@pytest.fixture +def server(tmp_path): + (tmp_path / "index.html").write_text("editor") + httpd, base = start_project_server(tmp_path, None, "127.0.0.1", 0, + ui_dir=tmp_path, release_provider=lambda: b"zip", hil=Host()) + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + try: + yield httpd, base + finally: + httpd.shutdown() + httpd.server_close() + thread.join() + + +def test_bootstrap_and_authorized_state(server): + httpd, base = server + with urlopen(base) as response: + source = response.read().decode() + assert "window.BOTLAB_HIL=" in source + assert httpd.botlab_hil_token in source + assert response.headers["Cache-Control"] == "no-store" + request = Request(base + "/hil/state", headers={"X-Botlab-Hil-Token": httpd.botlab_hil_token}) + with urlopen(request) as response: + assert json.load(response)["selectedTarget"] == "x5" + + +@pytest.mark.parametrize("override", [{}, {"Origin": "https://other.example"}, {"Host": "other.example"}]) +def test_rejects_untrusted_calls(server, override): + httpd, base = server + headers = {"Content-Type": "application/json"} + if override: + headers["X-Botlab-Hil-Token"] = httpd.botlab_hil_token + headers.update(override) + with pytest.raises(HTTPError) as error: + urlopen(Request(base + "/hil/start", data=b"{}", headers=headers)) + assert error.value.code == 403 + + +def test_rejects_oversized_body_before_read(server): + httpd, base = server + headers = {"Content-Type": "application/json", "X-Botlab-Hil-Token": httpd.botlab_hil_token, + "Content-Length": str(49 * 1024 * 1024)} + with pytest.raises(HTTPError) as error: + urlopen(Request(base + "/hil/models", data=b"{}", headers=headers), timeout=2) + assert error.value.code == 400 + + +def test_valid_post_and_stale_token(server): + httpd, base = server + headers = {"Content-Type": "application/json", "X-Botlab-Hil-Token": httpd.botlab_hil_token, + "Origin": base} + with urlopen(Request(base + "/hil/start", data=b"{}", headers=headers)) as response: + assert json.load(response)["body"] == {} + headers["X-Botlab-Hil-Token"] = "old-token" + with pytest.raises(HTTPError) as error: + urlopen(Request(base + "/hil/start", data=b"{}", headers=headers)) + assert error.value.code == 403 + +@pytest.mark.parametrize("option", ["--native", "--headless", "--no-server"]) +def test_hil_requires_live_browser_mode(project_dir, option, capsys): + from botlab._cli import main + assert main(["run", str(project_dir), "--hil", "x5", option]) == 2 + assert "standard live browser" in capsys.readouterr().err + + +def test_hil_rejects_old_browser_runtime(project_dir, tmp_path, monkeypatch, capsys): + from botlab import _cli + monkeypatch.setattr(_cli, "ensure_browser_runtime", lambda: tmp_path) + assert _cli.main(["run", str(project_dir), "--hil", "x5"]) == 2 + assert "does not support HIL v1" in capsys.readouterr().err + + +def test_hil_cli_preselects_without_connecting(project_dir, tmp_path, monkeypatch): + from botlab import _cli + (tmp_path / "botlab-capabilities.json").write_text('{"hilProtocol": 1}') + monkeypatch.setattr(_cli, "ensure_browser_runtime", lambda: tmp_path) + monkeypatch.setenv("BOTLAB_TARGETS_FILE", str(tmp_path / "targets.json")) + calls = [] + def serve(*args, **kwargs): + state = kwargs["hil"].state() + calls.append(state) + assert args[2] == "127.0.0.1" + assert state["selectedTarget"] == "command-line" + assert not state["connected"] and state["session"] is None + kwargs["hil"].close() + monkeypatch.setattr(_cli, "serve_project", serve) + assert _cli.main(["run", str(project_dir), "--hil", "127.0.0.1:50052"]) == 0 + assert len(calls) == 1 diff --git a/tests/test_hil_projects.py b/tests/test_hil_projects.py new file mode 100644 index 0000000..3477005 --- /dev/null +++ b/tests/test_hil_projects.py @@ -0,0 +1,438 @@ +"""Persistent, robot-independent deployment and inference isolation contracts.""" +import base64 +import copy +import hashlib +import json +import os +import shutil +import subprocess +import sys +import threading +import time +from pathlib import Path + +import pytest + +from botlab._project import STANDARD_PYTHON_MAIN +from botlab.hil.agent import AgentState +from botlab.hil.artifacts import ProjectStore, build_digest, build_project, validate_artifact +from botlab.hil.protocol import HilError +from botlab.hil.worker import WorkerBackend + +MODEL = (Path(__file__).parent / "assets" / "identity.onnx").read_bytes() + + +class Backend: + def describe(self): + return {"backend": "onnxruntime", "provider": "test"} + + def load(self, path): + assert path.read_bytes() == MODEL + return object(), [{"name": "input", "dtype": "float32", "shape": [1]}], [ + {"name": "output", "dtype": "float32", "shape": [1]}] + + def infer(self, handle, inputs): + return {"output": inputs["input"]} + + def close(self, handle): + pass + + +class HangingBackend(Backend): + def infer(self, handle, inputs): + time.sleep(10) + return super().infer(handle, inputs) + + +def hanging_backend(): + return HangingBackend() + + +def artifact(project_id="project-one", name="Example", revision="first"): + digest = hashlib.sha256(MODEL).hexdigest() + model_path = f"models/{digest}.onnx" + manifest = {"schema": 1, "project": {"id": project_id, "name": name}, + "execution_kind": "model-service", "backend": "onnxruntime", + "project_digest": hashlib.sha256(revision.encode()).hexdigest(), + "control_hz": 50, "physics_hz": 200, + "models": [{"id": "policy", "sha256": digest, "path": model_path, + "size": len(MODEL), "config": {"inputName": "input"}}]} + manifest["build_id"] = build_digest(manifest) + return {"manifest": manifest, "files": {model_path: base64.b64encode(MODEL).decode()}} + + +@pytest.fixture +def state(tmp_path): + return AgentState(tmp_path / "cache", Backend(), root=tmp_path / "botlab") + + +def deploy(state, owner, value): + return state.dispatch(owner, {"op": "projects.deploy", "artifact": value}) + + +def load(state, owner, value): + return state.dispatch(owner, {"op": "projects.load", "project_id": value["manifest"]["project"]["id"], + "build_id": value["manifest"]["build_id"]}) + + +def test_persistent_multiple_projects_and_idempotent_deployment(state): + owner = object() + first, second = artifact(), artifact("second", "Example") + assert not deploy(state, owner, first)["already_deployed"] + assert deploy(state, owner, first)["already_deployed"] + deploy(state, owner, second) + assert len(state.dispatch(owner, {"op": "projects.list"})["projects"]) == 2 + restarted = AgentState(state.models_dir, Backend(), root=state.store.root) + assert restarted.loaded is None + assert len(restarted.dispatch(owner, {"op": "projects.list"})["projects"]) == 2 + assert not list(state.store.staging.iterdir()) + result = subprocess.run([sys.executable, "-c", + "import json,sys; from pathlib import Path; from botlab.hil.artifacts import ProjectStore; " + "print(json.dumps(ProjectStore(Path(sys.argv[1])).list()))", str(state.store.root)], + capture_output=True, text=True, check=True) + assert len(json.loads(result.stdout)) == 2 + + +def test_retry_repairs_initial_selection_after_interrupted_publish(state): + owner, value = object(), artifact() + deploy(state, owner, value) + project = state.store.project_path("project-one") + (project / "current").unlink() + (project / "project.json").unlink() + assert deploy(state, owner, value)["already_deployed"] + assert state.store.current("project-one") == value["manifest"]["build_id"] + + +def test_stop_unload_remove_and_run_log_are_separate(state): + owner, value = object(), artifact() + deploy(state, owner, value) + selection = load(state, owner, value)["loaded"] + session = state.dispatch(owner, {"op": "start"})["session"] + # A deployed model loads from the selected release, without browser bytes. + digest = value["manifest"]["models"][0]["sha256"] + state.dispatch(owner, {"op": "load", "session": session, "sha256": digest}) + result = state.dispatch(owner, {"op": "infer", "session": session, "seq": 0, + "model": digest, "inputs": {"input": {"dtype": "float32", "shape": [1], "data": [3]}}}) + assert result["outputs"]["output"]["data"] == [3] + state.dispatch(owner, {"op": "stop", "session": session}) + assert state.loaded == selection + assert state.models == {} + with pytest.raises(HilError, match="Unload"): + state.dispatch(owner, {"op": "projects.remove", "project_id": "project-one"}) + state.dispatch(owner, {"op": "projects.unload"}) + assert len(state.store.list()) == 1 + records = [json.loads(line) for line in next(state.store.projects.rglob("*.jsonl")).read_text().splitlines()] + assert [item["event"] for item in records] == ["started", "stopped"] + state.dispatch(owner, {"op": "projects.remove", "project_id": "project-one"}) + assert state.store.list() == [] + + +def test_load_rejects_undeployed_model_and_changed_release(state): + owner, value = object(), artifact() + deploy(state, owner, value) + load(state, owner, value) + session = state.dispatch(owner, {"op": "start"})["session"] + with pytest.raises(HilError, match="not part of the loaded deployment"): + state.dispatch(owner, {"op": "load", "session": session, "sha256": "a" * 64, + "data": base64.b64encode(b"different").decode()}) + state.dispatch(owner, {"op": "stop", "session": session}) + model_file = next(state.store.projects.rglob("*.onnx")) + model_file.write_bytes(bytes([MODEL[0] ^ 1]) + MODEL[1:]) + with pytest.raises(HilError, match="SHA-256"): + load(state, owner, value) + + +def test_rollback_and_connection_ownership(state): + owner, other = object(), object() + old, new = artifact(), artifact(revision="second") + deploy(state, owner, old) + deploy(state, owner, new) + assert state.store.current("project-one") == new["manifest"]["build_id"] + load(state, owner, old) + assert state.store.current("project-one") == old["manifest"]["build_id"] + for operation in ({"op": "projects.load", "project_id": "project-one"}, + {"op": "projects.unload"}, {"op": "start"}, + {"op": "projects.deploy", "artifact": new}): + with pytest.raises(HilError, match="another connection"): + state.dispatch(other, operation) + assert state.dispatch(other, {"op": "projects.list"})["loaded"] is not None + session = state.dispatch(owner, {"op": "start"})["session"] + with pytest.raises(HilError, match="Stop"): + load(state, owner, new) + state.dispatch(owner, {"op": "stop", "session": session}) + state.disconnect(owner) + assert state.loaded is None + assert load(state, other, new)["loaded"]["build_id"] == new["manifest"]["build_id"] + + +@pytest.mark.parametrize("mutation", ["path", "project", "hash", "extra", "external", "base64"]) +def test_invalid_artifact_never_changes_existing_deployment(state, mutation): + owner, valid = object(), artifact() + deploy(state, owner, valid) + value = copy.deepcopy(artifact(revision="second")) + manifest, files = value["manifest"], value["files"] + model = manifest["models"][0] + if mutation == "path": + model["path"] = "../../escape.onnx" + elif mutation == "project": + manifest["project"]["id"] = "../escape" + elif mutation == "hash": + files[model["path"]] = base64.b64encode(b"x" * len(MODEL)).decode() + elif mutation == "extra": + files["startup.py"] = base64.b64encode(b"unrelated").decode() + elif mutation == "base64": + files[model["path"]] = "!" + else: + external = b"\x3a\x03\x2a\x01\x68" + digest = hashlib.sha256(external).hexdigest() + model.update(sha256=digest, path=f"models/{digest}.onnx", size=len(external)) + value["files"] = {model["path"]: base64.b64encode(external).decode()} + manifest["build_id"] = build_digest(manifest) + with pytest.raises(HilError): + deploy(state, owner, value) + assert state.store.current("project-one") == valid["manifest"]["build_id"] + assert len(state.store.list()[0]["releases"]) == 1 + assert not list(state.store.staging.iterdir()) + + +def test_symlink_directory_is_not_followed(tmp_path): + store = ProjectStore(tmp_path / "botlab") + outside = tmp_path / "outside" + outside.mkdir() + (store.projects / "project-one").symlink_to(outside, target_is_directory=True) + with pytest.raises(HilError, match="symlinks"): + store.deploy(artifact()) + assert not list(outside.iterdir()) + + +def test_build_only_models_stable_identity_and_controller_sensitive(tmp_path): + from tests.test_project import CONTROLLER, PRESET + project = tmp_path / "original" + project.mkdir() + (project / "main.py").write_text(STANDARD_PYTHON_MAIN) + (project / "controller.py").write_text(CONTROLLER) + (project / "robot.preset.yaml").write_text(PRESET) + (project / "assets/robot").mkdir(parents=True) + (project / "assets/models").mkdir() + (project / "assets/robot/scene.xml").write_text("") + (project / "assets/models/policy.onnx").write_bytes(MODEL) + (project / "credentials.txt").write_text("never deploy") + (project / "assets/models/unrelated.onnx").write_bytes(b"not declared") + first = build_project(project) + assert build_project(project) == first + assert len(first["files"]) == 1 + renamed = tmp_path / "renamed" + shutil.move(project, renamed) + assert build_project(renamed) == first + (renamed / "controller.py").write_text(CONTROLLER.replace("Test Duck", "Another name")) + changed = build_project(renamed) + assert changed["manifest"]["project"]["id"] == first["manifest"]["project"]["id"] + assert changed["manifest"]["build_id"] != first["manifest"]["build_id"] + assert changed["files"] == first["files"] + validate_artifact(changed) + + +def test_build_accepts_matching_browser_sources_and_crlf(project_dir): + sources = {name: (project_dir / name).read_text() + for name in ("main.py", "controller.py", "robot.preset.yaml")} + expected = build_project(project_dir) + assert build_project(project_dir, expected_sources=sources) == expected + windows = {name: source.replace("\n", "\r\n") for name, source in sources.items()} + assert build_project(project_dir, expected_sources=windows) == expected + + +def test_build_reads_same_size_source_edits_despite_stale_bytecode(project_dir): + from botlab._project import load_project + # Simulate bytecode created by another SDK command before a rapid edit. + load_project(project_dir) + source = project_dir / "controller.py" + metadata = source.stat() + first = build_project(project_dir) + previous_prefix, previous_write = sys.pycache_prefix, sys.dont_write_bytecode + source.write_text(source.read_text().replace("control_hz=50", "control_hz=25")) + os.utime(source, ns=(metadata.st_atime_ns, metadata.st_mtime_ns)) + second = build_project(project_dir) + assert first["manifest"]["control_hz"] == 50 + assert second["manifest"]["control_hz"] == 25 + assert second["manifest"]["build_id"] != first["manifest"]["build_id"] + assert (sys.pycache_prefix, sys.dont_write_bytecode) == (previous_prefix, previous_write) + + +def test_failed_build_restores_interpreter_bytecode_settings(project_dir): + source = project_dir / "controller.py" + source.write_text("invalid python!\n") + previous = sys.pycache_prefix, sys.dont_write_bytecode + with pytest.raises(SyntaxError): + build_project(project_dir) + assert (sys.pycache_prefix, sys.dont_write_bytecode) == previous + + +@pytest.mark.parametrize("first_build", [True, False]) +def test_hil_build_and_release_compile_share_import_lock(project_dir, monkeypatch, first_build): + from botlab import _project + entered, proceed, second_started, second_entered = (threading.Event() for _ in range(4)) + original = _project._import_controller + prefixes = [] + errors, results = [], [] + def gated_import(*args): + prefixes.append((sys.pycache_prefix, sys.dont_write_bytecode)) + if len(prefixes) == 1: + entered.set() + assert proceed.wait(5) + else: + second_entered.set() + return original(*args) + monkeypatch.setattr(_project, "_import_controller", gated_import) + def compile_project(build, second=False): + if second: + second_started.set() + try: + results.append(build_project(project_dir) if build else _project.load_project(project_dir)) + except Exception as exc: + errors.append(exc) + first = threading.Thread(target=compile_project, args=(first_build,)) + second = threading.Thread(target=compile_project, args=(not first_build, True)) + previous = sys.pycache_prefix, sys.dont_write_bytecode + first.start() + try: + assert entered.wait(2) + second.start() + assert second_started.wait(2) + assert not second_entered.wait(0.1) + # A waiting HIL build must not modify interpreter settings while a + # release compilation still owns the shared import/declaration state. + assert (sys.pycache_prefix, sys.dont_write_bytecode) == prefixes[0] + finally: + proceed.set() + first.join(timeout=5) + if second.ident is not None: + second.join(timeout=5) + assert not first.is_alive() and not second.is_alive() + assert not errors + assert len(results) == 2 + assert (sys.pycache_prefix, sys.dont_write_bytecode) == previous + + +@pytest.mark.parametrize("change", ["controller", "preset", "extra", "missing"]) +def test_build_rejects_browser_source_mismatch_before_identity_creation(project_dir, change): + sources = {name: (project_dir / name).read_text() + for name in ("main.py", "controller.py", "robot.preset.yaml")} + if change == "controller": + sources["controller.py"] += "\n# Unsaved browser change\n" + elif change == "preset": + sources["robot.preset.yaml"] = sources["robot.preset.yaml"].replace("actionScale: 1.0", "actionScale: 2.0") + elif change == "extra": + sources["helper.py"] = "# Browser-only file" + else: + del sources["robot.preset.yaml"] + with pytest.raises(HilError, match="Browser sources differ"): + build_project(project_dir, expected_sources=sources) + assert not (project_dir / ".botlab/project.json").exists() + + +@pytest.mark.parametrize("sources", [[], {}, {"main.py": 123}, {"main.py": "x" * (4 * 1024 * 1024 + 1)}]) +def test_browser_source_contract_is_bounded(project_dir, sources): + with pytest.raises(HilError, match="Browser sources"): + build_project(project_dir, expected_sources=sources) + + +def test_worker_hang_times_out_and_next_session_recovers(tmp_path): + worker = WorkerBackend("tests.test_hil_projects:hanging_backend", inference_timeout=0.1) + model = tmp_path / "model.onnx" + model.write_bytes(MODEL) + try: + handle, _, _ = worker.load(model) + with pytest.raises(HilError, match="timed out"): + worker.infer(handle, {"input": {"dtype": "float32", "shape": [1], "data": [2]}}) + assert worker.process is None + assert worker.load(model)[0] != handle + finally: + worker.reset() + + +def test_isolated_worker_runs_real_onnx(tmp_path): + pytest.importorskip("onnxruntime") + import math + worker = WorkerBackend("onnxruntime") + model = tmp_path / "identity.onnx" + model.write_bytes(MODEL) + try: + handle, inputs, outputs = worker.load(model) + shape = inputs[0]["shape"] + data = [float(index) for index in range(math.prod(shape))] + result = worker.infer(handle, {inputs[0]["name"]: { + "dtype": "float32", "shape": shape, "data": data}}) + assert result[outputs[0]["name"]]["data"] == data + finally: + worker.reset() + + +def test_stop_terminates_hung_worker_without_blocking_management(tmp_path): + worker = WorkerBackend("tests.test_hil_projects:hanging_backend", inference_timeout=10) + state = AgentState(tmp_path / "models", worker, root=tmp_path / "botlab") + owner, value = object(), artifact() + deploy(state, owner, value) + load(state, owner, value) + session = state.dispatch(owner, {"op": "start"})["session"] + digest = value["manifest"]["models"][0]["sha256"] + state.dispatch(owner, {"op": "load", "session": session, "sha256": digest}) + errors = [] + def infer(): + try: + state.dispatch(owner, {"op": "infer", "session": session, "seq": 0, "model": digest, + "inputs": {"input": {"dtype": "float32", "shape": [1], "data": [2]}}}) + except HilError as exc: + errors.append(exc) + thread = threading.Thread(target=infer) + thread.start() + try: + time.sleep(0.1) + started = time.monotonic() + assert state.dispatch(object(), {"op": "hello"})["device"]["state"] == "running" + state.dispatch(owner, {"op": "stop", "session": session}) + assert time.monotonic() - started < 2 + thread.join(timeout=2) + assert not thread.is_alive() + assert errors + assert state.calls == 0 + assert state.loaded is not None + finally: + worker.reset() + thread.join(timeout=2) + + +def test_independent_management_connection_can_remove_only_inactive_deployments(state): + owner, manager = object(), object() + old, new, other = artifact(), artifact(revision="second"), artifact("other-project") + for value in (old, new, other): + deploy(state, owner, value) + selection = load(state, owner, new)["loaded"] + for build in (None, selection["build_id"]): + with pytest.raises(HilError, match="Unload"): + state.dispatch(manager, {"op": "projects.remove", "project_id": "project-one", "build_id": build}) + assert state.loaded == selection + assert state.project_owner is owner + state.dispatch(manager, {"op": "projects.remove", "project_id": "project-one", + "build_id": old["manifest"]["build_id"]}) + assert [item["build_id"] for item in state.store.summary("project-one")["releases"]] == [selection["build_id"]] + state.dispatch(manager, {"op": "projects.remove", "project_id": "other-project"}) + state.disconnect(manager) + assert state.loaded == selection + assert state.project_owner is owner + assert len(state.store.list()) == 1 + + +def test_running_owner_protects_even_inactive_release_from_other_management(state): + owner, manager = object(), object() + active, inactive = artifact(), artifact("inactive-project") + for value in (active, inactive): + deploy(state, owner, value) + selection = load(state, owner, active)["loaded"] + session = state.dispatch(owner, {"op": "start"})["session"] + with pytest.raises(HilError, match="Stop"): + state.dispatch(manager, {"op": "projects.remove", "project_id": "inactive-project"}) + state.disconnect(manager) + assert state.session == session + assert state.owner is state.project_owner is owner + assert state.loaded == selection + assert len(state.store.list()) == 2 diff --git a/tests/test_host.py b/tests/test_host.py index 06b2669..e46c3c2 100644 --- a/tests/test_host.py +++ b/tests/test_host.py @@ -150,3 +150,109 @@ def test_option_parameter_membership(session): def test_invalid_option_parameters(session, spec): with pytest.raises(BotLabError): session.declare_project("Options", 50, 200, {"mode": spec}) +def test_read_project_file_needs_a_directory(session): + with pytest.raises(BotLabError, match="needs a project directory"): + _host.read_project_file("config/runtime.yaml") + + +def test_read_config_feeds_every_declaration_chain(project_dir): + """The four config-driven chains end to end: project(), Sensor, + commands(parameters=...) and control_profile(...) all read their + declaration data from config/*.yaml through the vendored SDK.""" + from botlab._project import load_project + + config = project_dir / "config" + config.mkdir() + (config / "runtime.yaml").write_text( + "name: Config Demo\ncontrol_hz: 50\nphysics_hz: 200\n", encoding="utf-8" + ) + (config / "parameters.yaml").write_text( + "forward_velocity: {default: 0.0, range: [-0.5, 0.5]}\n" + "yaw_rate: {default: 0.0, range: [-1, 1]}\n" + "policy_command: {default: 0, range: [0, 4]}\n", + encoding="utf-8", + ) + (config / "observation.yaml").write_text( + "depth_camera:\n" + " source: assets/robot/scene.xml\n" + " config: {outputMode: depth}\n" + " name: DepthCamera\n", + encoding="utf-8", + ) + (config / "io.yaml").write_text( + "mode_command:\n" + " parameters: [forward_velocity, yaw_rate]\n" + " input_mode: keyboard\n", + encoding="utf-8", + ) + (config / "control.yaml").write_text( + "state_node: state-machine\n" + "vx: forward_velocity\n" + "yaw_rate: yaw_rate\n" + "event: policy_command\n" + "motion_states: [0]\n" + 'states: {"0": WALK}\n' + "actions:\n" + " - {name: sit, event_value: 1, allowed_states: [0], target_states: [3]}\n", + encoding="utf-8", + ) + (project_dir / "controller.py").write_text( + "from botlab import (PolicyController, Robot, Sensor, control_profile,\n" + " declare_control, project, read_config)\n" + "\n" + 'runtime = read_config("config/runtime.yaml")\n' + "project(**runtime, parameters=read_config(\"config/parameters.yaml\"))\n" + 'robot = Robot("assets/robot/scene.xml", preset="robot.preset.yaml")\n' + 'Sensor(**read_config("config/observation.yaml")["depth_camera"])\n' + "declare_control(control_profile(robot, **read_config(\"config/control.yaml\")))\n" + "\n" + "\n" + "def create_controller(sim):\n" + ' command = robot.commands(**read_config("config/io.yaml")["mode_command"])\n' + " return PolicyController(robot, control=command.action())\n", + encoding="utf-8", + ) + + loaded = load_project(project_dir) + manifest = loaded.manifest + assert manifest["project"]["name"] == "Config Demo" + assert manifest["runtime"]["controlHz"] == 50 + sensor = next(r for r in manifest["resources"] if r["kind"] == "sensor") + assert sensor["id"] == "DepthCamera" + assert sensor["config"] == {"outputMode": "depth"} + command = next(n for n in loaded.compiled_plan["nodes"] if n["type"] == "obs-commands")["data"] + assert command["parameterNames"] == ["forward_velocity", "yaw_rate"] + assert command["cmdMin"] == [-0.5, -1] + assert command["cmdMax"] == [0.5, 1] + + # load_project closes its session on the way out; the path sandbox holds + # inside a live declaration window. + session = _host.begin_project_declaration(lambda preset_id: None, project_dir) + assert _host.read_project_file("config/runtime.yaml").startswith("name: Config Demo") + with pytest.raises(BotLabError, match="inside the project directory"): + _host.read_project_file("../escape.yaml") + with pytest.raises(BotLabError, match="was not found"): + _host.read_project_file("config/missing.yaml") + session.finish_declaration() + _host.end_project_declaration() + + +def test_localized_parameter_labels(): + spec = _host._declared_parameter("mode", { + "label": {"en": " Mode ", "zh": "模式"}, + "default": 2, + "options": [{"label": {"en": "Walk", "zh": "行走"}, "value": 2}], + }) + assert spec["label"] == {"en": "Mode", "zh": "模式"} + assert spec["options"] == [{"label": {"en": "Walk", "zh": "行走"}, "value": 2}] + + +@pytest.mark.parametrize(("label", "message"), [ + ({"zh": "速度"}, 'must include an "en" entry'), + ({"en": "Speed", "fr": "Vitesse"}, "unsupported locales"), + ({"en": "Speed", "zh": " "}, "zh must be a non-empty string"), + (["Speed"], "must be a non-empty string or a"), +]) +def test_invalid_localized_parameter_labels(label, message): + with pytest.raises(BotLabError, match=message): + _host._declared_parameter("speed", {"default": 0, "label": label}) diff --git a/tests/test_native_run.py b/tests/test_native_run.py index 0e0aa38..a32b7d7 100644 --- a/tests/test_native_run.py +++ b/tests/test_native_run.py @@ -6,14 +6,11 @@ import os import subprocess import sys +import tomllib from pathlib import Path import pytest -# tomllib 是 3.11+ 的标准库;本模块只在一处用它读 pyproject。3.10 下整模块跳过 -# (CI 矩阵的 3.11–3.13 分支仍完整覆盖)。 -tomllib = pytest.importorskip("tomllib") - REPO_ROOT = Path(__file__).resolve().parent.parent PENDULUM_MJCF = """ @@ -91,9 +88,11 @@ def _run_native(project: Path, *extra_env: str) -> subprocess.CompletedProcess: ) -def test_pyproject_exposes_only_the_botlab_entry_point(): +def test_pyproject_exposes_botlab_and_target_agent_entry_points(): data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - assert data["project"]["scripts"] == {"botlab": "botlab._cli:main"} + assert data["project"]["scripts"] == {"botlab": "botlab._cli:main", + "botlab-agent": "botlab.hil.agent:main", + "botlab-hil-gateway": "botlab.hil.gateway:main"} extras = data["project"]["optional-dependencies"]["runtime"] assert {"numpy", "mujoco", "onnxruntime", "websockets"} <= { requirement.split(">=")[0].split("[")[0] for requirement in extras diff --git a/tests/test_project.py b/tests/test_project.py index a23dded..1cd88a0 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -13,6 +13,7 @@ from botlab._host import BotLabError from botlab._project import ( canonical_json, + evict_project_modules, is_standard_python_main, load_project, python_release_project_digest, @@ -312,3 +313,181 @@ def test_public_angular_velocity_marks_body_frame(project_dir: Path): ] assert len(angular_nodes) == 1 assert angular_nodes[0]["data"]["rootAngularVelocityFrame"] == "body" +# -- nested config/ declarations -------------------------------------------- +# +# The same config/*.yaml split the editor templates ship: declaration data +# lives in yaml, controller.py only references it through read_config. The +# CLI has to collect those files into the release like the browser does. + +NESTED_CONTROLLER = ''' +from botlab import Model, PolicyController, Robot, concat, project, read_config + +runtime = read_config("config/runtime.yaml") +project( + runtime["name"], + control_hz=runtime["control_hz"], + physics_hz=runtime["physics_hz"], + parameters=read_config("config/parameters.yaml"), +) +robot = Robot("assets/robot/scene.xml", preset="robot.preset.yaml") +actor = Model("assets/models/policy.onnx") + +def create_controller(sim): + observation = concat([ + robot.projected_gravity(), + robot.joint_position(default=robot.default_angles), + ]) + action = actor(observation).action() + control = robot.default_angles + robot.action_scale * action[robot.motor_order] + return PolicyController(robot, control=control) +''' + +RUNTIME_YAML = "name: Nested Config Duck\ncontrol_hz: 50\nphysics_hz: 200\n" +PARAMETERS_YAML = "speed:\n default: 0.4\n range: [0, 0.5]\n step: 0.05\n unit: m/s\n" + +# cf149d18… is pinned byte-for-byte in BotLab's pythonRelease.test.ts; change +# the fixtures and both pins must be recomputed together. +PINNED_DIGEST_HEX = "cf149d18d1f6b4363b3a258d93c85cd6d2b7e848f0e5c1456027d20c7259c4d4" + + +@pytest.fixture() +def nested_config_project(tmp_path: Path) -> Path: + (tmp_path / "controller.py").write_text(NESTED_CONTROLLER, encoding="utf-8") + (tmp_path / "main.py").write_text(MAIN, encoding="utf-8") + (tmp_path / "robot.preset.yaml").write_text(PRESET, encoding="utf-8") + config_dir = tmp_path / "config" + config_dir.mkdir() + (config_dir / "runtime.yaml").write_text(RUNTIME_YAML, encoding="utf-8") + (config_dir / "parameters.yaml").write_text(PARAMETERS_YAML, encoding="utf-8") + robot_dir = tmp_path / "assets" / "robot" + robot_dir.mkdir(parents=True) + (robot_dir / "scene.xml").write_text("", encoding="utf-8") + model_dir = tmp_path / "assets" / "models" + model_dir.mkdir(parents=True) + (model_dir / "policy.onnx").write_bytes(IDENTITY_ONNX) + return tmp_path + + +def test_nested_config_project_reads_yaml_declarations(nested_config_project: Path): + project = load_project(nested_config_project) + assert project.manifest["project"]["name"] == "Nested Config Duck" + assert project.manifest["runtime"]["controlHz"] == 50 + assert project.manifest["runtime"]["physicsHz"] == 200 + assert project.manifest["parameters"]["speed"]["default"] == 0.4 + # config/*.yaml travel in files next to the entrypoints, assets stay out + assert set(project.files) == { + "main.py", + "controller.py", + "robot.preset.yaml", + "config/runtime.yaml", + "config/parameters.yaml", + } + assert "assets/robot/scene.xml" in project.asset_paths + + package = create_release_package(project) + archive = zipfile.ZipFile(io.BytesIO(package)) + names = archive.namelist() + assert "project/config/runtime.yaml" in names + assert "project/config/parameters.yaml" in names + + +def test_nested_python_package_is_collected_and_evicted(tmp_path: Path): + package_dir = tmp_path / "helpers" + package_dir.mkdir() + (package_dir / "__init__.py").write_text("", encoding="utf-8") + (package_dir / "math.py").write_text("scale = 3.0\n", encoding="utf-8") + (tmp_path / "controller.py").write_text(CONTROLLER, encoding="utf-8") + (tmp_path / "main.py").write_text(MAIN, encoding="utf-8") + (tmp_path / "robot.preset.yaml").write_text(PRESET, encoding="utf-8") + robot_dir = tmp_path / "assets" / "robot" + robot_dir.mkdir(parents=True) + (robot_dir / "scene.xml").write_text("", encoding="utf-8") + model_dir = tmp_path / "assets" / "models" + model_dir.mkdir(parents=True) + (model_dir / "policy.onnx").write_bytes(IDENTITY_ONNX) + + project = load_project(tmp_path) + assert set(project.files) == { + "main.py", + "controller.py", + "robot.preset.yaml", + "helpers/__init__.py", + "helpers/math.py", + } + # nested modules cache under dotted names; eviction must drop those too + import sys + + sys.modules["helpers"] = object() + sys.modules["helpers.math"] = object() + evict_project_modules(tmp_path) + assert "helpers" not in sys.modules + assert "helpers.math" not in sys.modules + + +def test_walk_skips_dot_entries_written_between_builds(nested_config_project: Path): + first = load_project(nested_config_project) + # the CLI writes .botlab/project.json (project identity) into a project + # after the first build, and macOS drops .DS_Store anywhere — neither may + # change the collected file set or the digest + identity = nested_config_project / ".botlab" + identity.mkdir() + (identity / "project.json").write_text('{"id": "abc"}\n', encoding="utf-8") + (nested_config_project / ".DS_Store").write_bytes(b"\x00\x01") + second = load_project(nested_config_project) + assert second.files == first.files + assert second.project_digest == first.project_digest + + +def test_is_project_text_file_mirrors_editor_paths(): + from botlab._project import is_project_text_file + + for relative in ( + "main.py", + "controller.py", + "robot.preset.yaml", + "pkg/helpers.py", + "config/runtime.yaml", + "config/nested/deep.toml", + "config/x.preset.yaml", + "README.md", + "notes/说明.md", + # 根级 scene.xml 在编辑器侧也是文本文件(xml 在扩展名白名单里) + "scene.xml", + ): + assert is_project_text_file(relative), relative + for relative in ( + "assets/scene.xml", + "assets/notes.md", + "botlab/config.yaml", + "pkg/botlab/helpers.py", + ".DS_Store", + "config/.hidden.yaml", + "scene notes/x.md", + "config/x.MD", + "botlab.project.json", + "/etc/passwd", + "a/../b.yaml", + "policy.onnx", + ): + assert not is_project_text_file(relative), relative + + +def test_project_digest_pinned_with_browser_publisher(): + # The hex is pinned in BotLab's pythonRelease.test.ts over byte-identical + # literals: config/*.yaml inside files is what keeps the CLI-built and + # browser-published digests of one project equal. + manifest = { + "botlab": 1, + "project": {"entry": "main.py", "name": "Nested Config Duck"}, + "parameters": { + "speed": {"default": 0.4, "max": 0.5, "min": 0, "step": 0.05, "unit": "m/s"} + }, + "resources": [], + } + files = { + "main.py": MAIN, + "controller.py": NESTED_CONTROLLER, + "config/runtime.yaml": RUNTIME_YAML, + "config/parameters.yaml": PARAMETERS_YAML, + } + assert python_release_project_digest(manifest, files) == PINNED_DIGEST_HEX diff --git a/tests/test_sync_sdk.py b/tests/test_sync_sdk.py index fd68b38..2de2ab7 100644 --- a/tests/test_sync_sdk.py +++ b/tests/test_sync_sdk.py @@ -12,6 +12,8 @@ SYNTHETIC_PACKAGE = { "src/domain/python/botlab/__init__.py": "# sdk init\n", "src/domain/python/botlab/_signal.py": "# sdk module\n", + # read_config 解析器是镜像面的一部分:CLI 工程读 config/*.yaml 靠它。 + "src/domain/python/botlab/_config.py": "# config reader\n", } PRESETS_MODULE = 'PRESETS = {"g1": {"kind": "robot", "config": {}}}\n' @@ -61,6 +63,7 @@ def test_mirror_carries_only_flat_sdk_and_presets_verbatim(upstream, dist_root): sdk = dist_root / "src" / "botlab" / "sdk" assert (sdk / "__init__.py").read_text(encoding="utf-8") == "# sdk init\n" assert (sdk / "_signal.py").read_text(encoding="utf-8") == "# sdk module\n" + assert (sdk / "_config.py").read_text(encoding="utf-8") == "# config reader\n" assert not (sdk / "_runtime").exists() assert (dist_root / "src" / "botlab" / "_runtime" / "run.py").read_text( encoding="utf-8" diff --git a/uv.lock b/uv.lock index 28d16a1..a1cf001 100644 --- a/uv.lock +++ b/uv.lock @@ -1,12 +1,11 @@ version = 1 revision = 3 -requires-python = ">=3.10" +requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.14'", "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", + "python_full_version < '3.12'", ] [[package]] @@ -18,52 +17,258 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/58/0a/a10b45aab35b175aded078a462dc8d0c698f5b13946e7cb0869097b78bb6/absl_py-2.5.0-py3-none-any.whl", hash = "sha256:0f17b89f2a4eaaedc4f28c622998aa690564b3012a396a4ffad0821007fe03ba", size = 137410, upload-time = "2026-07-03T10:57:46.735Z" }, ] +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/4c/bdccd81e9ee225b69c60e7766c9a5b05364f118f4d383713b89a682d772d/aiohttp-3.14.5.tar.gz", hash = "sha256:5558a7f5a05af9ecf744af91e5baefc436f93c9333e656c27ec253f9a6bbe178", size = 8078052, upload-time = "2026-10-11T01:05:12.408Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/f3/8997f18890a92c79f77fcfbb4f78ca17c2d4ab109e9eb6d31b4e9de194a0/aiohttp-3.14.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d51db97c96384fbfcaf8f4c65922183a68b94f891c3c10c862ef5f6df2adbb1f", size = 832358, upload-time = "2026-10-11T00:59:42.195Z" }, + { url = "https://files.pythonhosted.org/packages/8e/42/084651e9efb5cadb99265f786df60f2a7b353cead7bfd2c87003cb4867ad/aiohttp-3.14.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ae53924aa853a7a2ca20ed4142c7c6b56338e4d4cd999e2980075b9efc2e257a", size = 559636, upload-time = "2026-10-11T00:59:43.677Z" }, + { url = "https://files.pythonhosted.org/packages/3d/fe/92838944e601f0fe195fd3f3ada37e29fbee3d19bfd323fe15937ec79d36/aiohttp-3.14.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2c473a355f9239efcb72c92d5abfd8fcdb0cc78c8e9af607e72ca12dbb36593", size = 552588, upload-time = "2026-10-11T00:59:45.592Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b8/dd9b95c5c20ceae1b47738e656bd79e9883e3117a9cc8b143901eb604e9a/aiohttp-3.14.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e8fa6644e541fcd7e02430588c7fc93b602c1778ea0bc345505db76b61cfb4", size = 1928224, upload-time = "2026-10-11T00:59:47.294Z" }, + { url = "https://files.pythonhosted.org/packages/c6/47/70010cd2ba8746968d53d433ff328c33f06a66c99da5f3de759c64a6b2eb/aiohttp-3.14.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:579f97d5120f2971876d2ddca2968135f6944d00c44c3a6590ad7d86ca9b403f", size = 1877674, upload-time = "2026-10-11T00:59:49.472Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a1/b026f071dbd87f0bdba86b92d47e46f3899e3ad7143b6e0fe7e7887090b4/aiohttp-3.14.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:24409db442e2fb6e766bc7f3943851a8381dec3098140e43bb2e843b79e31b12", size = 1985850, upload-time = "2026-10-11T00:59:51.296Z" }, + { url = "https://files.pythonhosted.org/packages/36/d1/f7b6f6f8c3cb5a71b53baeddc2e70c224c28b12a23a55c42994c504019b7/aiohttp-3.14.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5e8f97c0488ffda3082766ac0f2c8150a9a58c4d05788330e479cfd449b37939", size = 2105471, upload-time = "2026-10-11T00:59:53.191Z" }, + { url = "https://files.pythonhosted.org/packages/42/74/2a2b22953de15c6c8a80debf26db3047e4e8a5a5db6dd7c2c6c01eec0605/aiohttp-3.14.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50a195903119008fe9cc68710535eb37f556ffffd6a7759afe70a2c145587045", size = 1929915, upload-time = "2026-10-11T00:59:55.609Z" }, + { url = "https://files.pythonhosted.org/packages/33/ad/f80e8d33933d0eacd0217efa3b7fb32c9f48ed480ed0db53cf9948bf474f/aiohttp-3.14.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0133c3c3b54a0bf1e71fa5c1ad95c93f07fd54e24ef1fe182f5122e1573d2bf1", size = 1773832, upload-time = "2026-10-11T00:59:57.611Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/0273d239f3e3bb69438f209c64c82e1f98dc60d5db264795e41f7dd05dcb/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c172db893e516e1358e65a95ee20b7ce7173963eefe318b6ab2a2220688b999e", size = 1901001, upload-time = "2026-10-11T01:00:00.605Z" }, + { url = "https://files.pythonhosted.org/packages/16/ed/7dd439d26c654645bc34ca3c9a822fc11c1c51801fed1cefdfe1cc41c1f6/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f2a7966bda23dd85051f1661ce0ace38d6890e05ec6c357ecae9d2479cba377e", size = 1907218, upload-time = "2026-10-11T01:00:02.699Z" }, + { url = "https://files.pythonhosted.org/packages/32/dd/86249c3b8248562a17fd3e22ee0c164378d65766f01aceef62cf52783710/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4887d130a7bbfed3a85493bb5a25e5b5b558d40c1d986dd16970d2bb26d63793", size = 1958650, upload-time = "2026-10-11T01:00:04.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/0b/ca4d53d68f683ce195807fd0aeb063bf6d1c6b39343fff234c9524e8d5a9/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:225c579c23b68b343cccea27a7e06e3bd8ec23a09c30b427eb3f1e4ca6239b20", size = 1760057, upload-time = "2026-10-11T01:00:06.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/42/005437ffd7fa56c2ce3347add40404b654a32754ce91f2e2a14b9e5ab2bc/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ab52d8f1fc1b64821c1fbad64a647ed6203627004059a6d1ed4f0858a1499703", size = 1978081, upload-time = "2026-10-11T01:00:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/3a5b66fe1b7b23d3c6524350817ed55de5feb7fbb4cccba7cbe044d118dd/aiohttp-3.14.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb131d775a1573c1aee66656bd78b023577bbdb6cb8349a07773bd4f73e68a6e", size = 1922954, upload-time = "2026-10-11T01:00:11.035Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/d08a554f281d42fd7c6b2b6dd7738f3e3a7a9f657ddd87e11ca2091cdfe3/aiohttp-3.14.5-cp311-cp311-win32.whl", hash = "sha256:e87046c8ff77a8decdb6a41d8ab25824b47531b2da933aeab0c1e21c7acff329", size = 488101, upload-time = "2026-10-11T01:00:12.933Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/52b5e65f02b33686ae49f1518548ae4f5a7adaf31d9d3c54fa12a143137b/aiohttp-3.14.5-cp311-cp311-win_amd64.whl", hash = "sha256:6f275c11d1aa6d4c458e05a68be084efe3c55a113d99e3f46a318098e52948fc", size = 512575, upload-time = "2026-10-11T01:00:14.743Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b9/bb75012635f3defb0f7cbb7c4ae391e36bb9cb3fbdca3b9944e2ebebf743/aiohttp-3.14.5-cp311-cp311-win_arm64.whl", hash = "sha256:b032a0023eb41d768ce77d83210ab2a3c389bc0b09313273c7e1eca48c10a755", size = 493179, upload-time = "2026-10-11T01:00:16.961Z" }, + { url = "https://files.pythonhosted.org/packages/d5/94/6ba86efddcb616c811b40e6a0dfdd862738f647e4e3860a961075d5e9ed8/aiohttp-3.14.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:df37b620684e19b5e25724412518ccafc3b1a49cdac706fdbd2f983fad943450", size = 817499, upload-time = "2026-10-11T01:00:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e1/7bca6d84dabd228aa8eb4b7f9feac2586aaa9be5505d7d65bf287a615c43/aiohttp-3.14.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ef60869969180ec2464f1349aff07138ae35ca2200f0946cb3552e49e8f301a8", size = 549467, upload-time = "2026-10-11T01:00:20.854Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/11b89f45ca486252dd67dd5f3231fec04bf5518da39a95cb3997619f17fb/aiohttp-3.14.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d079c0a0135c36e7beb6f1c88087c8f108dc5891cdd0b5eafa778421bda70ed2", size = 548067, upload-time = "2026-10-11T01:00:22.659Z" }, + { url = "https://files.pythonhosted.org/packages/22/ff/c6615806c14aab34f82b9424ccde8ce6e417315fd57ce1c5b4d4747888e1/aiohttp-3.14.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:abfda5cb094a829f7bc25216a32f7db2e85cc65bd59910f8e7b40b3d9b224764", size = 1896245, upload-time = "2026-10-11T01:00:24.638Z" }, + { url = "https://files.pythonhosted.org/packages/39/b2/25a8c971ae6a92c8394d77e42422d7f38989e05cf41a5ceb92d73d67ab7e/aiohttp-3.14.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9cc882cf8619109583c906b4d4a85d6a111a98afa34b7a450d1e08118d016820", size = 1872180, upload-time = "2026-10-11T01:00:26.838Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d5/99f93ea36cc5205e47c1e5a803e087f2ad21b5430b5db2e942cb6e988a37/aiohttp-3.14.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7457580535e019e1247ea35d6a02bf081ad30c26d0cbc210c93f6c3ab67a0835", size = 1937261, upload-time = "2026-10-11T01:00:28.74Z" }, + { url = "https://files.pythonhosted.org/packages/40/a6/9ac9c9e6695040bd73d2584a1b59a9388f6433c76d5294a7bf591e21ffe5/aiohttp-3.14.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c5ed596aedb9c42afd3fe0aae3117725378ac73d2cc5ddc735056fbdb96c5d02", size = 2064994, upload-time = "2026-10-11T01:00:30.623Z" }, + { url = "https://files.pythonhosted.org/packages/da/e4/aa172eb534b7f02f1f8ff1c3213347eaf3cf218db91a727c6863c22f1035/aiohttp-3.14.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20f085697d7e911f1f73c43ed03fafbed1e7121797e2eb5428efa80398060584", size = 1919630, upload-time = "2026-10-11T01:00:32.548Z" }, + { url = "https://files.pythonhosted.org/packages/06/7d/4eedafc5bababa8932636c141e346806966eade12c0b7e5946d43bf8218b/aiohttp-3.14.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74b0a9c8270f9b0a11410e124ff8d4f18bfc1f1837440ec84da5ae7b50927b5d", size = 1710016, upload-time = "2026-10-11T01:00:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/b2/94/eee018537ba19da0ceb2ac79cab83faed4ac49568e08376e2799043f2538/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:19e2ba471507c34f8252402ab50f5ab512398b9ea8c8f1cb26beb3f75793ba30", size = 1858112, upload-time = "2026-10-11T01:00:36.581Z" }, + { url = "https://files.pythonhosted.org/packages/68/76/354653a306547238f3427283905972d796ba7c292ba9977abec9f2b6f260/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d418ce2af40c6bb685b3f663e9e8de27cb0a22431d8e88a167348d7f01878073", size = 1896371, upload-time = "2026-10-11T01:00:38.478Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ec/63e8c7136b570e356345ad3174e3820fdc973ea10712cb6c649bf875755a/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:70cb4008ac2ed1e0ca9e824deb4b53d3aa0d939109698ebf1e723a84337bd794", size = 1905836, upload-time = "2026-10-11T01:00:40.527Z" }, + { url = "https://files.pythonhosted.org/packages/57/d8/11365bda144b127928cd42533d0eff78a55613c9e28f81941bd6630ea887/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:a23fe35d776bc03cb495938b9594450d047e3bc08c5255315a82323e9cb7d2dd", size = 1696534, upload-time = "2026-10-11T01:00:42.686Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3d/82df0461b18e00b2998f205c03e0d3010222478c43640aceb8e03dcd7e8f/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3e0eb43bed3c6801a6cee315195377789e90b2a72c2277a475b578535312488d", size = 1933970, upload-time = "2026-10-11T01:00:44.71Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/6ddfe0aacd931c17b53533336d97e9d11a98b96d6ae815a9da0b19f82ccf/aiohttp-3.14.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be7dd397d64ca3e1869626fa9318aaebb54b7bf93bc72d7a205448d83e4f748", size = 1899501, upload-time = "2026-10-11T01:00:46.629Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8e/189bdd9ae4793059bb09f6dc880f211a6c6c7859cebcbf33912dbfab7dd7/aiohttp-3.14.5-cp312-cp312-win32.whl", hash = "sha256:eb324e2009fb54db30a071dad7caf6998ee2879c4704007efb244514dad1fec1", size = 480370, upload-time = "2026-10-11T01:00:48.468Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ce/1f08114679d49655b30a6e0a29858375c94b82c1de1a0bd0a20c2fee8b02/aiohttp-3.14.5-cp312-cp312-win_amd64.whl", hash = "sha256:2cc38a4f2b516bef1714e690df87a0e043faf1a7693c82d860091684453d5111", size = 506141, upload-time = "2026-10-11T01:00:50.272Z" }, + { url = "https://files.pythonhosted.org/packages/79/d4/c7b4f60b16a1b7e43249fa9031ae05e7e8c341ba4b7d1866f914dafeaa0e/aiohttp-3.14.5-cp312-cp312-win_arm64.whl", hash = "sha256:a63afd1f757de949028387e65a7127b61ad0f775432dbb0e62816ae619fe69ac", size = 488108, upload-time = "2026-10-11T01:00:52.321Z" }, + { url = "https://files.pythonhosted.org/packages/d3/e1/2841e020ebb7aefae5513586193e011e06313d9a6bdbd296622afbbce204/aiohttp-3.14.5-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:9ad7e6aa38c20da1be697874349c4c273c8a03b7887169665081706398d0439a", size = 538932, upload-time = "2026-10-11T01:00:54.3Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a6/7fb8ea8fe96bcc7b7c7a36d10f021d99d01a8dc8a4b3f0ddacecfad9a80e/aiohttp-3.14.5-cp313-cp313-android_24_x86_64.whl", hash = "sha256:f59c7673465908cbe506117176156c127f29f917677afceada34957179221d91", size = 553329, upload-time = "2026-10-11T01:00:56.442Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/d056e3c27647dc25af1a592cf356245382ea7c808171b9dac7677afedfc8/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b5416552740edf07234cc9437d0706f2acb67b93c198670b1a68e1b2b587dec", size = 521367, upload-time = "2026-10-11T01:00:58.345Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e4/95226147e11d4db916fd1d495dcf85af8e3816e38333e42718241196e848/aiohttp-3.14.5-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:43351bdb5e4c3cb7d1772368e988534e869a74db7778079a83782c11c69535c7", size = 528322, upload-time = "2026-10-11T01:01:00.211Z" }, + { url = "https://files.pythonhosted.org/packages/17/cd/1d3c9192cafdb51cad62b2d3ded96cff9cc8af51893210aadd448a325389/aiohttp-3.14.5-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:c8c4478bef6d57fcfda15dae461ea3c9f06aa7b257c58df3f2300174ccbb185a", size = 539874, upload-time = "2026-10-11T01:01:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0c/dfa33aecc7d4d1dc75e05248f5eac5a0edf4d09e7b44d93ab62529b0c1db/aiohttp-3.14.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c2c30484dd1417ef98b51021ffa2cc0d7f3c78918adaaaab7e70817335ab3e02", size = 813836, upload-time = "2026-10-11T01:01:04.01Z" }, + { url = "https://files.pythonhosted.org/packages/33/17/4a63738052d20567d55529d6daa1b9480d906fd52930fbcf6d3fbed618f0/aiohttp-3.14.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:dab9ac5a67c8d1f070c00fa8fccb7cbd1b8dcc1a8d6b42f37540df9b3d4cc603", size = 548070, upload-time = "2026-10-11T01:01:06.035Z" }, + { url = "https://files.pythonhosted.org/packages/15/e5/b57e58695a757fd4c02497c033fced96a69c631b13866c43d336530c9670/aiohttp-3.14.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e1cc2bfaee8c214f06080a7c7d5772419b8a1108e8e5349236189811823fb02a", size = 545786, upload-time = "2026-10-11T01:01:07.817Z" }, + { url = "https://files.pythonhosted.org/packages/9a/68/8c2c67a3aedf46e00f3c42f04fbc6983de80d4ed5786151e33681ba45883/aiohttp-3.14.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74efb69332b85675b1eabd760a8cfc2e2cf42c60607c66f88014c1bdfb40942d", size = 1886492, upload-time = "2026-10-11T01:01:09.834Z" }, + { url = "https://files.pythonhosted.org/packages/ab/4b/74aab5e8d28c62e8f795b4fe8f38cf5586fd264a9a27bd2141ef6490333d/aiohttp-3.14.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:42b5e616946dbaf505e2bff18c9af2cd4ef9e7ef300ee58a6e951a5b7cf147ae", size = 1870057, upload-time = "2026-10-11T01:01:12.045Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f7/eafc3b1988302b1815d9fd4a21071be5c360d616c0a430d02fd92dc97688/aiohttp-3.14.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f9033b43f511f27547c557dcaba0177649e10a3725336ccd2cce0fdc1dc4850d", size = 1925119, upload-time = "2026-10-11T01:01:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/a1/04/78d8f294f74dd570f3898ff20402349fce524176045df98ba727d6846a68/aiohttp-3.14.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15a310d3c71398e3d7bfc93a1a73fbe664315cd9e9016b8efc1cff85eeab7155", size = 2053137, upload-time = "2026-10-11T01:01:16.344Z" }, + { url = "https://files.pythonhosted.org/packages/32/51/395d225ef36f5a50d8e548dcd3141bfdbcd31fb6eed859022c573d2c4d66/aiohttp-3.14.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1ffa3a523a36d8628f98c06492ae16a31a23d14c0b4ec721757b477319f656d6", size = 1911038, upload-time = "2026-10-11T01:01:18.653Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a4/2aec1aa06d82e8a244843b5dae31d78061e5e76744270a86dd0ee051c889/aiohttp-3.14.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fb6a6e919bfb703227bc1ce6579281b84b1a2ba57deb9794dfdbec7dcd1e40c", size = 1702205, upload-time = "2026-10-11T01:01:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/16/27/6051bfde7b6f418f70edd60d655fa426abb3355fa0981764739d87ecf160/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:43e1b7994a8b038125f722bff07492ef501110722c2727c408995d9fb864c421", size = 1848588, upload-time = "2026-10-11T01:01:22.918Z" }, + { url = "https://files.pythonhosted.org/packages/9e/44/55efc06fc26c4e6e1c095f231b4c222bf2d86d64a8eebbc24b2bb5958ea8/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:5f3e96071686755d9cd3600c3880183eb94b012178e92746d68101800f0ed8a3", size = 1887540, upload-time = "2026-10-11T01:01:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/48/dc/1502bfdc2a65760d386ac6a00090b0addaa8a3c9c60b3f8127fad3a9afb2/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:cd88b01f3d37b7a2a34f91d98f14720206f1ea3d540843fab2d649dd5fb91fec", size = 1895030, upload-time = "2026-10-11T01:01:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/93/7e/44174bb6288264418c9eec07a5e35180969c0d5a796c7af544db3cb8a33a/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f2ed8b64dc0c651c0f5a9c926777719770021251b8f336d97c4b80b660836ce1", size = 1693244, upload-time = "2026-10-11T01:01:29.39Z" }, + { url = "https://files.pythonhosted.org/packages/47/dd/b507d64e50db23888582fff08eda13998b12f9dea70c072edaac19218380/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a9918e58faf62ba2c7147927d06057aec78f42475aff5048047ec47e7265a600", size = 1926308, upload-time = "2026-10-11T01:01:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/5b51b4f63e3f2793151f4aea49c48fe1e00baeb7cec9c7a206de499f8de0/aiohttp-3.14.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:42f320d4a5b00b9af0bddcfec5407dc6f2d9816f006b2f79ebbaa31f16895df3", size = 1888406, upload-time = "2026-10-11T01:01:33.715Z" }, + { url = "https://files.pythonhosted.org/packages/ff/13/d5e818a5eaba9f822016727299f41c0e1075799f82a6433ec508a93ab867/aiohttp-3.14.5-cp313-cp313-win32.whl", hash = "sha256:3ae800a20947e2c2e53088047d021e6bf7d51560cc49f6a0737a1f79d2e3a13c", size = 479970, upload-time = "2026-10-11T01:01:35.677Z" }, + { url = "https://files.pythonhosted.org/packages/8d/d0/8eca2c65aa467320990d78fb2005f38ed3588944280c39ff9deb6423fef1/aiohttp-3.14.5-cp313-cp313-win_amd64.whl", hash = "sha256:d05e94cdfe0d15d0206f970722d2554780ce562787b21b218b275447f8751319", size = 505985, upload-time = "2026-10-11T01:01:37.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f8/4cdd65305d2fca14b886bea9ed2abb1fe726287872524e56e3d26692b47d/aiohttp-3.14.5-cp313-cp313-win_arm64.whl", hash = "sha256:f001b571ead90ca1770f1e616db255351a1703317f20374c361ef22f12c06d09", size = 487629, upload-time = "2026-10-11T01:01:39.477Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/3184a1d34a8be665569eadb7e9e764b4629e4f3413e241cb2e4d6fecf3b3/aiohttp-3.14.5-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:939042d5cda21d41a6f512e7cc8b8e33a2aebff863352251da495fbd91b673b5", size = 540673, upload-time = "2026-10-11T01:01:41.354Z" }, + { url = "https://files.pythonhosted.org/packages/60/2a/d35f3ba4cf157b072e3b674bf9983047ca5ea5173c995d32d877e1191d36/aiohttp-3.14.5-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6da32b5ff3fd78d244e37300463434c7145162bfd2b6e9e915ab164da37f7343", size = 554087, upload-time = "2026-10-11T01:01:43.719Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d2/61a33880ca4eaca95a9c60ca3f6beed15555af1028652dfaac601627787b/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1b438b73c38111818d0c9d6a5c2bfed8584c8e503a49ef085d70e874ec846738", size = 522613, upload-time = "2026-10-11T01:01:46.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/de579b299d2225dc2c6fd99d579d91f16c02a913fb5af9a3cf2fe9bd88ba/aiohttp-3.14.5-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:755933b107ea7a6a9ac916f635a70595a5b1a32fac10a8ff0b9f2ab88555550c", size = 529682, upload-time = "2026-10-11T01:01:48.477Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4a/ddb923564e15e053b6e060b0036e1694dcadcb13aa476c5a87dcad20e336/aiohttp-3.14.5-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b3cc509327c7b27f6f4727a8830f4004f6df7766e179f2f4b8e54e65c0bec5d3", size = 540120, upload-time = "2026-10-11T01:01:50.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/a640fbecaa53727a5900b892bdbe17b5f3e8cc88903e22864fe41b654def/aiohttp-3.14.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:7bd8ac754ebd6733a3e2a0dd1674c4d8ab086196803fd8dcd776f07b4e2607d9", size = 818795, upload-time = "2026-10-11T01:01:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/ef/b6/d52ca608859e271b5fa7944074802dc45f60e52c318a4ddc34edbf73586e/aiohttp-3.14.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e724a7b6091f0b1ac064f9d1b15ff9ec52e6033a86cdae649e5f086e32a3c0db", size = 549667, upload-time = "2026-10-11T01:01:54.65Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/05b8ac39a76ff4bca89f42a4c2471560c71f894ff6e4bc16158c951874e3/aiohttp-3.14.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c32e26310cc10e547f53cd13d39a369034f69dcb7d749d5cb0e5f67bc196b6ba", size = 548922, upload-time = "2026-10-11T01:01:56.547Z" }, + { url = "https://files.pythonhosted.org/packages/19/b0/5aa186d56ce2334dabe29b70bd99dc8ae926ee44184c0de64a53d415a4f3/aiohttp-3.14.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1eb8167961ec4dfcc8cb9dd50bd0ee72519f7ef496be95203e49e27b01618382", size = 1882422, upload-time = "2026-10-11T01:01:59.258Z" }, + { url = "https://files.pythonhosted.org/packages/db/f7/7d5c91bb9620db300c8ddb05337a9014301acb626223faff3abcb8ea47d7/aiohttp-3.14.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c1d60eafd9c7e8e74abd03a5b00df44e7febfe6d9b89b559c0a6551eef0699d4", size = 1843621, upload-time = "2026-10-11T01:02:01.417Z" }, + { url = "https://files.pythonhosted.org/packages/30/0a/b208953b96d8f24b75f6da704f508e6c5cf3022f52b60c61933df082e89c/aiohttp-3.14.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:137351bf20bbed9a65e839f4a4452ac377389bdb2f2857d2acffef38f5e9f2d1", size = 1927915, upload-time = "2026-10-11T01:02:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/f6/79/90ebcccb55e2d1e11a1fed581d83bb966e38fb35fb4b2577fdc980f8707a/aiohttp-3.14.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fba47bc2c3d7303c3d027c6cf4d07626c37b1314ac81f5820c31032e0ca1f677", size = 2036339, upload-time = "2026-10-11T01:02:06.046Z" }, + { url = "https://files.pythonhosted.org/packages/a6/66/55a8904b3a129fafdf94f9cc0a2e4ca09a3c914650be52355db7ad0bbdb6/aiohttp-3.14.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94684b879ac1d71e4238850c99b62dc1b28d9086b156a2555f082010b85a865c", size = 1888041, upload-time = "2026-10-11T01:02:08.384Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b8/96b25da7329a52e42c812b1e8b076386039ec4fc312afa043d173d8147fc/aiohttp-3.14.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:56572c42e3ecd636de8d2c3dd54cf5fc939cb5c32eb56297f176a0d366fac622", size = 1703849, upload-time = "2026-10-11T01:02:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/1b/43/fbf976e3ae4c038d6f5c84945ab2150298c2d71201674d4e53d158063e75/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a95529a92a446db351675f4aab518feaf5e99842f63f5dd17160c2b74f382db3", size = 1849703, upload-time = "2026-10-11T01:02:13.15Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7d/218e912f4c1d89bde7ac551409be57ad2f6121638e56942d395a7cb1fa58/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:3edbece0379b8b4aaa67619b8aa2399bb66fce372cd5911098a434ea77220aa0", size = 1852695, upload-time = "2026-10-11T01:02:15.295Z" }, + { url = "https://files.pythonhosted.org/packages/5a/42/252a1b9287e3b6e393a1c3bd1776f36af30f5f25f071bbf2b0cb7eba9116/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:56d9828f204331a5ca8850fcfe2bcce95a149f1f223f60cc7216e5524978e480", size = 1893493, upload-time = "2026-10-11T01:02:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/78/97/71cae83d5100556fad1521684f7cd1e3e578432644f850245ed3bd969310/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:20064a177a070d789ee64a50b01a9161d3468e989baacfc6c714aa685c4b332f", size = 1693468, upload-time = "2026-10-11T01:02:20.666Z" }, + { url = "https://files.pythonhosted.org/packages/1f/69/73d88e97a8b5f0ca7a946d0011c0de99fb188b1687c7948ecd0553dc5bf0/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:81c2b3dfd56c62bee6108e4852d5970b4cf9086390b6983f52b666e878c1f115", size = 1915398, upload-time = "2026-10-11T01:02:23.011Z" }, + { url = "https://files.pythonhosted.org/packages/05/f0/881644bcb15d4b258daea9b720a0af9dc4330496cc8d6ade9090cdd0cffc/aiohttp-3.14.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:09ec102b4b8c9a920275733bbc11fdbb615efe6f9231a06007c0218d336fb77a", size = 1871470, upload-time = "2026-10-11T01:02:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/7f/de/19d9ebbcce5aedaa3242d8a99ff8816a60bdb7629fb0084bf4a45bfd1f62/aiohttp-3.14.5-cp314-cp314-win32.whl", hash = "sha256:9c428eb2bd8817588d16a0ab898aa4eb5d141f896aa2b394cc79a4cf61d9a8e2", size = 485517, upload-time = "2026-10-11T01:02:27.579Z" }, + { url = "https://files.pythonhosted.org/packages/a9/74/8cdaf0e58c2588371670d5a9a8215bbb971d36940b6e5d051967dd05c07d/aiohttp-3.14.5-cp314-cp314-win_amd64.whl", hash = "sha256:6f967dde489ca6a8c02d093ab245d2cbf50ccb5c36adf0188b17b0ca39d24b67", size = 511755, upload-time = "2026-10-11T01:02:29.685Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6b/e0100e25502430a531c7cf1482a378d0b65bf728ab60c01ee270e56bc469/aiohttp-3.14.5-cp314-cp314-win_arm64.whl", hash = "sha256:1d2d981b53dd09a319e3570ef8cc3bbc3ef86f5a7abef0f6b2bff3867db3a9e7", size = 495151, upload-time = "2026-10-11T01:02:32.163Z" }, + { url = "https://files.pythonhosted.org/packages/9d/c2/ca2ead7b655688c53c03aeeb6e96e6851c9ff08d6be7b13802f53a6ae8fd/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9ce66feae6ac65327379460380549bf1b8df8e17c4e25df2a2bcf168272e3bed", size = 855396, upload-time = "2026-10-11T01:02:34.443Z" }, + { url = "https://files.pythonhosted.org/packages/a7/70/22206fea409255a240c926ce11de48de354ae2bb90ca44f709c05497585d/aiohttp-3.14.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:27c2322e03f66101acb09869ce1cf1efc04994ee95e1735b69827bf8c8b9d781", size = 567734, upload-time = "2026-10-11T01:02:36.644Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e5/79a36c118308b56f8667d67e05d2fb6dc638ab45985704cdb199631bedaa/aiohttp-3.14.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff75a7537413a86e7cafe98e0e1d6e3dc4b15c6349896e7d5c6b881bfdb6d550", size = 567379, upload-time = "2026-10-11T01:02:38.757Z" }, + { url = "https://files.pythonhosted.org/packages/63/a3/2ebec7dece3b1f02c30d2e484647f6f7b13952b1bb40a4cb285b849e8432/aiohttp-3.14.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c061aa954daaf57d2a4b8374f9fca621ef0e1b603584431c220c22458c59b6d", size = 2050686, upload-time = "2026-10-11T01:02:41.169Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/7e4d093db2f4450482652e7ef19a9e19919028f5135aa52bc4078c3beb80/aiohttp-3.14.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1612fa5857b37bf32e5c1eaeefb96e3b01e9c70679eec81f0934e8a600080863", size = 1915077, upload-time = "2026-10-11T01:02:43.563Z" }, + { url = "https://files.pythonhosted.org/packages/a6/88/bd40d09958442a0a1df67da81de496361d6e2afc04f9db2950d835da750b/aiohttp-3.14.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adbeee7d6fd4cf5fe0aece2fb3edc4243615d3180430ba8149d01a90670cac99", size = 2027204, upload-time = "2026-10-11T01:02:46.185Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/3a601c1f8d3103c1a60ea981f20924855da9f575d2007fc38f8898b792fb/aiohttp-3.14.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b2966998927d7bed9db12c0a4647b0c7b179755878fc9c357fe1ffd3e3b0c1a5", size = 2149520, upload-time = "2026-10-11T01:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/22/d0/4e41bfe1b1ce1cb6f6d2e59fa7a88ef5cf92c402b2d07e9018778ba9edbc/aiohttp-3.14.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e317e0fb6b16212c881d2205a7d87414c29acd69320b3aa6dce9d9c7b86fe4f", size = 2002039, upload-time = "2026-10-11T01:02:51.293Z" }, + { url = "https://files.pythonhosted.org/packages/6f/5e/72067019545c502b881b031153c437752ecef48d7d213bc0218ccebb4bfb/aiohttp-3.14.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50343c1757b4b6f6708eeaf24534b32f19dfb99fb1b762c00420867a62fc81e0", size = 1786164, upload-time = "2026-10-11T01:02:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/d9/fe/7741efd6119bfb7a00827fe6f7b84b4409de58d888ae21adc5a9a6824992/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:083673c7a94c3ea035caaa5ca04288bdb44887abfe1f5ba23294e6a4b03efd2d", size = 1970689, upload-time = "2026-10-11T01:02:55.888Z" }, + { url = "https://files.pythonhosted.org/packages/43/e7/342a13bf67f34d269bf2f7e870ecd72b99c832cc6f0a271a9210c0ebfb84/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2528cb4c6b92008c76ac9ac6298624069bb2db91ff4929905512d1d84485f658", size = 1951967, upload-time = "2026-10-11T01:02:58.467Z" }, + { url = "https://files.pythonhosted.org/packages/e7/d4/fdb3b27340617e5e64df18a70fa89097778652ea5c7c7e2f79def76999c3/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b1b8ece1e71132d2afba4dbc0c3d62c766e25165990b25db1196c04969eb3d84", size = 1968511, upload-time = "2026-10-11T01:03:00.883Z" }, + { url = "https://files.pythonhosted.org/packages/83/b2/e8f88298de78d1a951f36f9f966d38ec6ed1d4721303ba02064a545e8aa6/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fce9523df31cea6284f3e2c479876750d7687cf671d7b25d32b19effc0e86441", size = 1778497, upload-time = "2026-10-11T01:03:03.206Z" }, + { url = "https://files.pythonhosted.org/packages/22/68/9ccdb93d664345c546be7f34480b921774d8c0d98f47e70d7e03b115d475/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:09e0eb18c7e0c8777e2f9149de63799195b9b3ca1b5c81ba6f32f2c6b8628210", size = 1999676, upload-time = "2026-10-11T01:03:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/57/4a/a33cfa6dcb00e94194ae4fe710432ca4ed111e016356b4ba4d2f4c3a124c/aiohttp-3.14.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6774814fd5c338e72ee0da5cbb9432816df450e69c019f72b5d29bdec2a1792d", size = 1963437, upload-time = "2026-10-11T01:03:08.196Z" }, + { url = "https://files.pythonhosted.org/packages/f4/20/eacbecfea3b5c3dcbfc9b023e3a5460f43e5dda16d06a2877ebe7184c3f3/aiohttp-3.14.5-cp314-cp314t-win32.whl", hash = "sha256:33f706574e32c6e694f352a856e05caf18f7f2c871b3e87b41c55ea452b409ab", size = 503973, upload-time = "2026-10-11T01:03:10.481Z" }, + { url = "https://files.pythonhosted.org/packages/ff/78/18eec294f6c8c5dc845dcf6d730a0147d8d0f17e86138a7bdb85e43a30fa/aiohttp-3.14.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5ba14a839fbe87cf7c12a6b5661c05f324a296eb8363141edb3944ba63d4c9d3", size = 531190, upload-time = "2026-10-11T01:03:12.716Z" }, + { url = "https://files.pythonhosted.org/packages/9d/39/e53f8169acc85271ebd12b5b32ad7f1541b35639ccbe0f49034c64785d10/aiohttp-3.14.5-cp314-cp314t-win_arm64.whl", hash = "sha256:1061b364556e8172e8d46b0b183adeeb73e8c42d30ebc745591e1bd89acad52e", size = 510360, upload-time = "2026-10-11T01:03:15.08Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1c/06d89f58b2db3ee92dd377217659d587e06f973e57bf0a97a0d8a4586c0c/aiohttp-3.14.5-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:788ecaa9c10533b786ce5ba70c4f2df78ad41819fd00a6c99d92b66f9a32e1da", size = 540674, upload-time = "2026-10-11T01:03:17.483Z" }, + { url = "https://files.pythonhosted.org/packages/18/39/5e822e038f496f0540ada91e27099d48f9e7f919b6c6deb4cf6db36cc706/aiohttp-3.14.5-cp315-cp315-android_24_x86_64.whl", hash = "sha256:5c76f1802bab718a68ac3cce447160605c734551f95c67ae90fa1132b215cb29", size = 554183, upload-time = "2026-10-11T01:03:19.71Z" }, + { url = "https://files.pythonhosted.org/packages/9b/ff/0cf2619d902b5b160762422a7e5e02091295766a7fe4fcf5b9a655e386c1/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a6d02b4c38de03d9c7617813433e6a0fb6b522797974177d69d9dad431900833", size = 523160, upload-time = "2026-10-11T01:03:22.193Z" }, + { url = "https://files.pythonhosted.org/packages/86/99/3553abfc53a40849dbaacc3f54c730ec58410809ffa2d05885ae56108f2d/aiohttp-3.14.5-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:20726f9782d5c2744c1c66255842d1d163bb3edcf768b8de25216bf47f7b6ccf", size = 530311, upload-time = "2026-10-11T01:03:24.435Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a4/5d25f73754bc1e8f983ba704e86d641aea290c967f195f2aeebdaed2bd84/aiohttp-3.14.5-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:248d779ad720b49d4fb355720e60c9e5f444f95887bc16974fea48fc56c41789", size = 540274, upload-time = "2026-10-11T01:03:26.698Z" }, + { url = "https://files.pythonhosted.org/packages/29/a4/07eda5db2e3ee017d9590f36c12a94ec6f1f50516e8df78672373dfc7185/aiohttp-3.14.5-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:0a8ea271867e360ac985ae607f4a23ad9a38414b9aca1d49ec98839ae660e49f", size = 819239, upload-time = "2026-10-11T01:03:29.481Z" }, + { url = "https://files.pythonhosted.org/packages/cb/aa/a8723dd987a696dd48d4cf2f0088e589ce77caebff0b96f2a78c20424380/aiohttp-3.14.5-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:823c910f046f23f4c713b8d99a2242dc65f591cb45ee86418fa11762a3c2963c", size = 549832, upload-time = "2026-10-11T01:03:32.02Z" }, + { url = "https://files.pythonhosted.org/packages/29/5c/969a1b72692055fd2a419590c41847ec9144fefb97b75ff1cde5b6372891/aiohttp-3.14.5-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9b42db919715e91eb76acf3bc492a9a7ccd8bd9adc6745c1412b689735269f14", size = 549234, upload-time = "2026-10-11T01:03:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/8e3e07fd8a0d33d06955ff4e54a1cb92f4bce347ff55e441dc3e25a7b5e5/aiohttp-3.14.5-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3112585250b199296c26ca6e0131640b6a8d01bab8b232d2eb3763ed469de11", size = 1891314, upload-time = "2026-10-11T01:03:36.899Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b3/05a79ce2e25f024e93de30c94f39dc6aa6e2bc1e9c531a5c4b18dc61b7a4/aiohttp-3.14.5-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c147451b4a58e7050f7f7394e6c467867c84161560001f9ad4fb2d1446743946", size = 1884372, upload-time = "2026-10-11T01:03:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/94/52/0fd8af0717db109eea258191b326b5cb5847fb88928bfa6c862fd78b9ad3/aiohttp-3.14.5-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bf163cc701f3d4ac43ba7d97771bf5fd955220ef5500ef3ee847bc0ecfbf4ec1", size = 1930957, upload-time = "2026-10-11T01:03:42.172Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b3/fa78733da88812bf9fb193913fb0ce1548f8b6912047633fdf88a758ff8c/aiohttp-3.14.5-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8d40ce41991e9d56fab4f5dc4a51fe59bc3b5c77c27f4b148963064d00232e8", size = 2038886, upload-time = "2026-10-11T01:03:44.646Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f5/2fcc5e30053a938286f17d0edf3f0850b8061b984256fa7c26850b9c8809/aiohttp-3.14.5-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:276a4fc00b1d9ae492b802763a789c5b86328b989c5ea169f2faa447d6a11c7c", size = 1894470, upload-time = "2026-10-11T01:03:47.304Z" }, + { url = "https://files.pythonhosted.org/packages/62/2a/f87feb42abe8e6a7c03814dbcb711540849a1e90aa392055f3183c643610/aiohttp-3.14.5-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:50983e3be33d8c0942ab88cec3905b10602f64c469b20153c48c5d4e558dd016", size = 1716273, upload-time = "2026-10-11T01:03:50.121Z" }, + { url = "https://files.pythonhosted.org/packages/81/b2/adf1f960dd977722ed1347d33da512a1624807114f57a3b91f5cc828e081/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:16c8abd5bca220a47efe667d26f8460124c81810787e79ee87b242677563d9dd", size = 1857513, upload-time = "2026-10-11T01:03:52.959Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/ef8d910e641de4160026a513ace5888b7f92826bbc3fd90ced05d55a828e/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0790ec66fa4013e83c53b9025a45d454723da1a2fce28b3208c9b32d08af162f", size = 1887704, upload-time = "2026-10-11T01:03:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/7e/db/6c9142f941cba8d35be8e1fae6ea2bd390e754fc14076b8147aee1a4592f/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:cb11a971a3aea10f9b8373be628f1df932964fc6c6b174516d318a48c3ac4412", size = 1895684, upload-time = "2026-10-11T01:03:58.18Z" }, + { url = "https://files.pythonhosted.org/packages/e6/7c/6a6bd9a72e576d376c668333b00c51c6147aba4fb863ed6c94996d497eca/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:932ce7e694bbc29b2bf6f64f2343c27d148d4997c771d01bdade4639b6749ff4", size = 1706299, upload-time = "2026-10-11T01:04:00.8Z" }, + { url = "https://files.pythonhosted.org/packages/69/ec/d2cc494242f8c4d3d1cd70baf591742818a74386dc3b84af3195c5c4fced/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:a7d470cf7b206e6359fc77b1b860632fde400d5a2ed59cd0181b93a686bc81ee", size = 1916645, upload-time = "2026-10-11T01:04:03.892Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6e/e852c53647e815db09a1b6b5ab634f54bd736412397e11940feef4d2c89a/aiohttp-3.14.5-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:6e1d8637cf73eebc92eba2e11d4cfff98a3b562f2505bd75bba766d908926e8d", size = 1875408, upload-time = "2026-10-11T01:04:06.922Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f6/72ab6ef20c332399be593bac543d2c24a6d241e39d3540ce9f95a63e4bd2/aiohttp-3.14.5-cp315-cp315-win32.whl", hash = "sha256:fbdc5ec49f9ca3cd24955cf3520b10a4d4c901ba2572094c84274e9e7eb30534", size = 485590, upload-time = "2026-10-11T01:04:09.626Z" }, + { url = "https://files.pythonhosted.org/packages/06/eb/e9de75b8c6d2170c42c08ff303abf857ea8a6d9d9b6e99b5aba40f15e962/aiohttp-3.14.5-cp315-cp315-win_amd64.whl", hash = "sha256:a9d3983bd6ab7aa1cfd573544ae98df9b6cb6912a5185a198263e024a636861d", size = 511740, upload-time = "2026-10-11T01:04:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/fc/25/455f3c2785eb0d50748cffd0abd07500815f419a9495b14610b7622d8d2d/aiohttp-3.14.5-cp315-cp315-win_arm64.whl", hash = "sha256:e29347c142cf6e99e0dff5e2995ead1d50fa3b51bf37a7c726a7ccfe5419745a", size = 495171, upload-time = "2026-10-11T01:04:14.667Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6c/497f0a98782eebfcf0f02a7fbdef5428148bd426027140cbad494cf842b5/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:9bf1d5dcc15204d9ec8b8ea4c18fd66e6b80e5de1f4ecbafb3a2f2740f8039d4", size = 855638, upload-time = "2026-10-11T01:04:17.154Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c5/55c0cef2572af9b1ee81608f7c0a1bf74e6c9151a73b04915d933f192335/aiohttp-3.14.5-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:14f04769cfefe4734016a856a83af36133cd17779cef9ae817f812b8ba9d6d51", size = 567455, upload-time = "2026-10-11T01:04:19.702Z" }, + { url = "https://files.pythonhosted.org/packages/4d/47/e1a0e39f4a2b881f6071225afaf94a6547001b5aefc1566734d73c685934/aiohttp-3.14.5-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6e4251c0ba4624a68a2c11471a1ac54c3306876c21f0ae86de085cc9241c8905", size = 567836, upload-time = "2026-10-11T01:04:22.235Z" }, + { url = "https://files.pythonhosted.org/packages/c3/1d/817d85836f52b687160064a326e62e037dc42f1ad5b6b5188a70e5c134b5/aiohttp-3.14.5-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4f5cf4dc72a71c4cfa9751b4950be22f733626670230d46e7d606592aa22d59", size = 2051409, upload-time = "2026-10-11T01:04:24.821Z" }, + { url = "https://files.pythonhosted.org/packages/f7/25/e8ea6fc212a9aabce982917346ce8ecab0929c74b60232a056dd11c99da0/aiohttp-3.14.5-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dbf53ae2601b7fd5a93c3944deea3a78d40f495226d582c35ef7a433425ce2b2", size = 1936493, upload-time = "2026-10-11T01:04:27.456Z" }, + { url = "https://files.pythonhosted.org/packages/24/33/de0517f71f1a19feea4aff78a2ec4ec2d98634129ec30ead78fce2f8f81b/aiohttp-3.14.5-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:657291433bf4dd3142f3abac495764cd47d0c7c92087751e6666c6447e65fcef", size = 2021405, upload-time = "2026-10-11T01:04:30.276Z" }, + { url = "https://files.pythonhosted.org/packages/c4/11/ddaf2e7930543e9f0cad3a1c54e1af0c1bd2fae3973d568c4263d3b010e9/aiohttp-3.14.5-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e51a27980c3788e6e6b3325d694fdd4898087fa8a86b2763af77b39353da41e", size = 2142017, upload-time = "2026-10-11T01:04:33.022Z" }, + { url = "https://files.pythonhosted.org/packages/1d/7b/58784353c06de8adc20f426daad3d85dd86fd55331c713a3f4b638c94573/aiohttp-3.14.5-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c7583cd3dfdc7dcc927835b4f6c7faae7ecc1ba3ca5879321621ae2e6f8e84", size = 2002482, upload-time = "2026-10-11T01:04:35.94Z" }, + { url = "https://files.pythonhosted.org/packages/b9/f0/417d9535caa9e165ffe6a53e2347a78107e7c87dcb0e10aca315c3af0344/aiohttp-3.14.5-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6fdcd6af7e2e51d1ba1b4bea16e97b074bcb7b5dd0246a9d8201341bb28085a0", size = 1791774, upload-time = "2026-10-11T01:04:38.733Z" }, + { url = "https://files.pythonhosted.org/packages/98/01/25e49c2e8a01b9f0e19ca0a8448ad50aa2bdf96c8cd41e92bd45af044784/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:c8859a013ae0de1074660992139a1a440df3e6b219b86cf0d3f11c2692bb4fe3", size = 1970358, upload-time = "2026-10-11T01:04:41.66Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fc/c132fd3465b6c7e4ce0193154f602c3e6c46b680e4da7eb3bd0d8a40d1c7/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:8966ecac808dd5f473c9c4cefd10cd3ffda71c18a4d3493b7c7d2ae1803bf2cc", size = 1969192, upload-time = "2026-10-11T01:04:44.66Z" }, + { url = "https://files.pythonhosted.org/packages/95/4e/d58b45e7dba4eb607eca11fc0a4aa77ae0f39c11afa804635a61ce18cff4/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3093b72c215bda16ce961a6d073f6e71d46e022962a9d5d457c5d4d421c78b57", size = 1963618, upload-time = "2026-10-11T01:04:47.505Z" }, + { url = "https://files.pythonhosted.org/packages/a2/d9/f6ac50946efb3490428ef52b56e62c1c6b7f9c6ff3ec6083535526c83e60/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:149fb56caf7acb67073126f675d0958d9c4b3125fcd3f6d4877df98aa8a97ce9", size = 1784817, upload-time = "2026-10-11T01:04:50.287Z" }, + { url = "https://files.pythonhosted.org/packages/d0/2f/f255eb63da788cd8a452fe350869c03266ccad7d884925f6963c87808f24/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:293d3ae7c6a0ed176a42e59a1b5fde825ead65c835360f734148e96729f928d2", size = 1995956, upload-time = "2026-10-11T01:04:53.213Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9b/241aa3393eaafda0034470add1625f82a4c252901108723b283a9b0b32ca/aiohttp-3.14.5-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3f2dcc00191fd563e9075181a14ec31d7dd63223ced7582cc70a15a499de0c79", size = 1967794, upload-time = "2026-10-11T01:04:55.958Z" }, + { url = "https://files.pythonhosted.org/packages/5f/7f/a68e689288c9e4bfcf8d0979f2b12b774bf01861985420df6150eba5448e/aiohttp-3.14.5-cp315-cp315t-win32.whl", hash = "sha256:7779cd97e61ebe583ec2f1c5616cdd038aa08a4453b1848c67842176d054948e", size = 503876, upload-time = "2026-10-11T01:04:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/a4/78/49b0299da6d54de19fc6fdc6889d50233ac47d192a461624f4fc010fde83/aiohttp-3.14.5-cp315-cp315t-win_amd64.whl", hash = "sha256:0e6f16f5e49c4b8267988c05ab07760d7064cea57d077c3d068d04b0fbb992cb", size = 530957, upload-time = "2026-10-11T01:05:02.23Z" }, + { url = "https://files.pythonhosted.org/packages/21/d4/b0afc936aeb6d2f93157e3408b069ec5d7934429ae7d023de5e0953b1887/aiohttp-3.14.5-cp315-cp315t-win_arm64.whl", hash = "sha256:1aead151c3abbac6b32942e452020cb66d7efc099d253cc6c20f748e926c858b", size = 510408, upload-time = "2026-10-11T01:05:05.362Z" }, + { url = "https://files.pythonhosted.org/packages/68/30/173960c42b05a6c59f7558e4b12a4b0d9ba376cf6aa9bde7f9e08a30ca8d/aiohttp-3.14.5-py3-none-any.whl", hash = "sha256:efc21a454892828368b11c2c780de0ff8bc991f73f6b99c6b66e56205470929b", size = 279517, upload-time = "2026-10-11T01:05:08.523Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "botlab-python" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "pyyaml" }, + { name = "rfc8785" }, ] [package.optional-dependencies] dev = [ + { name = "aiohttp" }, { name = "mujoco" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "onnx" }, - { name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "onnxruntime", version = "1.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "onnxruntime" }, { name = "pytest" }, { name = "ruff" }, - { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "websockets" }, +] +gateway = [ + { name = "websockets" }, ] runtime = [ { name = "mujoco" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "onnxruntime", version = "1.23.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "onnxruntime", version = "1.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, - { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "onnxruntime" }, + { name = "websockets" }, +] +serve = [ + { name = "aiohttp" }, +] +target = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "onnxruntime" }, + { name = "websockets" }, ] [package.metadata] requires-dist = [ + { name = "aiohttp", marker = "extra == 'serve'", specifier = ">=3.9" }, { name = "botlab-python", extras = ["runtime"], marker = "extra == 'dev'" }, + { name = "botlab-python", extras = ["serve"], marker = "extra == 'dev'" }, { name = "mujoco", marker = "extra == 'runtime'", specifier = ">=3.2" }, { name = "numpy", marker = "extra == 'runtime'", specifier = ">=1.24" }, + { name = "numpy", marker = "extra == 'target'", specifier = ">=1.24" }, { name = "onnx", marker = "extra == 'dev'", specifier = ">=1.16" }, { name = "onnxruntime", marker = "extra == 'runtime'", specifier = ">=1.18" }, + { name = "onnxruntime", marker = "extra == 'target'", specifier = ">=1.18" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pyyaml", specifier = ">=6.0" }, + { name = "rfc8785", specifier = ">=0.1.4,<0.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, - { name = "websockets", marker = "extra == 'runtime'", specifier = ">=12" }, + { name = "websockets", marker = "extra == 'gateway'", specifier = ">=17.1" }, + { name = "websockets", marker = "extra == 'runtime'", specifier = ">=17.1" }, + { name = "websockets", marker = "extra == 'target'", specifier = ">=17.1" }, ] -provides-extras = ["runtime", "dev"] +provides-extras = ["target", "gateway", "serve", "runtime", "dev"] [[package]] name = "colorama" @@ -74,48 +279,10 @@ 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 = "coloredlogs" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "humanfriendly" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934", size = 46018, upload-time = "2021-06-11T10:22:42.561Z" }, -] - -[[package]] -name = "etils" -version = "1.13.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/9b/a0/522bbff0f3cdd37968f90dd7f26c7aa801ed87f5ba335f156de7f2b88a48/etils-1.13.0.tar.gz", hash = "sha256:a5b60c71f95bcd2d43d4e9fb3dc3879120c1f60472bb5ce19f7a860b1d44f607", size = 106368, upload-time = "2025-07-15T10:29:10.563Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/98/87b5946356095738cb90a6df7b35ff69ac5750f6e783d5fbcc5cb3b6cbd7/etils-1.13.0-py3-none-any.whl", hash = "sha256:d9cd4f40fbe77ad6613b7348a18132cc511237b6c076dbb89105c0b520a4c6bb", size = 170603, upload-time = "2025-07-15T10:29:09.076Z" }, -] - -[package.optional-dependencies] -epath = [ - { name = "fsspec" }, - { name = "importlib-resources" }, - { name = "typing-extensions" }, - { name = "zipp" }, -] - [[package]] name = "etils" version = "1.14.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/26/ce/6e067242fde898841922ac6fc82b0bb2fe35c38e995880bdffdfbe30182a/etils-1.14.0.tar.gz", hash = "sha256:8136e7f4c4173cd0af0ca5481c4475152f0b8686192951eefa60ee8711e1ede4", size = 108127, upload-time = "2026-03-04T17:41:36.291Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/5a/3d/589663aeeacd59bb2f3e8596bfd3e81cf0fb18d70bb433199041f469771b/etils-1.14.0-py3-none-any.whl", hash = "sha256:b5df7341f54dbe1405a4450b2741207b4a8c279780402b45f87202b94dfc52b4", size = 172934, upload-time = "2026-03-04T17:41:35.01Z" }, @@ -129,23 +296,116 @@ epath = [ ] [[package]] -name = "exceptiongroup" -version = "1.3.1" +name = "flatbuffers" +version = "25.12.19" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, ] [[package]] -name = "flatbuffers" -version = "25.12.19" +name = "frozenlist" +version = "1.8.0" source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4", size = 26661, upload-time = "2025-12-19T23:16:13.622Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] @@ -174,24 +434,12 @@ wheels = [ ] [[package]] -name = "humanfriendly" -version = "10.0" +name = "idna" +version = "3.20" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477", size = 86794, upload-time = "2021-09-17T21:40:39.897Z" }, -] - -[[package]] -name = "importlib-resources" -version = "7.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/06/b56dfa750b44e86157093bc8fca0ab81dccbf5260510de4eaf1cb69b5b99/importlib_resources-7.1.0.tar.gz", hash = "sha256:0722d4c6212489c530f2a145a34c0a7a3b4721bc96a15fada5930e2a0b760708", size = 44985, upload-time = "2026-04-12T16:36:09.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/db/55a262f3606bebcae07cc14095338471ad7c0bbcaa37707e6f0ee49725b7/importlib_resources-7.1.0-py3-none-any.whl", hash = "sha256:1bd7b48b4088eddb2cd16382150bb515af0bd2c70128194392725f82ad2c96a1", size = 37232, upload-time = "2026-04-12T16:36:08.219Z" }, + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, ] [[package]] @@ -208,16 +456,11 @@ name = "ml-dtypes" version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/12/72/307d7c4bd0600601c7133fba5cb78af7db968152951c1cd473abb1cda782/ml_dtypes-0.6.0.tar.gz", hash = "sha256:5e60251d32ced5598972e4d5e06a2f044341f9291402551a3f6f0ec44f9299b0", size = 3032327, upload-time = "2026-08-13T14:14:40.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/15/01285c64133ea38abf3b990a704d7d30e50daea2806d150bcc4163495d35/ml_dtypes-0.6.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:bad8d1dd5bed060a29332b99d63d0e5c2969081e1c6ea54adfbccfdfa783be44", size = 566808, upload-time = "2026-08-13T14:13:50.012Z" }, - { url = "https://files.pythonhosted.org/packages/e7/54/850d9b8b35549182f7c7f2cf742ce75c853ee880101bbc51cca0d62732e3/ml_dtypes-0.6.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:008382aeab529df5d3f00501ad9a7dcd64494d4b5b1971fc4c79019e6c1f5010", size = 356865, upload-time = "2026-08-13T14:13:51.339Z" }, - { url = "https://files.pythonhosted.org/packages/e9/15/844f5402145ce73bec8eb3afeb9f41d2bf99e0c8617c93f9e9886f26b419/ml_dtypes-0.6.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6ec0d244a5bba12239025389ad88bbfb45f9f10e25ab4f678e9a4768ebd47532", size = 412036, upload-time = "2026-08-13T14:13:52.494Z" }, - { url = "https://files.pythonhosted.org/packages/f8/63/efc9257a1ef0f53dfc76dedfe70d7d35118fbcdb810bb48cb7323ebd0b87/ml_dtypes-0.6.0-cp310-cp310-win_amd64.whl", hash = "sha256:03ce583adfce34ad33aa9e1fc7a8344dcf90ea776cc4ef0e5a48d4eae84e5d20", size = 433668, upload-time = "2026-08-13T14:13:53.668Z" }, { url = "https://files.pythonhosted.org/packages/b8/2c/318cd1a9014c63939ffe687e19559ae12831fcc37d66c71ad1f616f1ffd6/ml_dtypes-0.6.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:f4f59f83c82ab480e924b988e7b1b4eb4de836dfcf5390c6f59148d1a00e1d02", size = 566813, upload-time = "2026-08-13T14:13:55.053Z" }, { url = "https://files.pythonhosted.org/packages/d9/83/706b8a39449f0d55a7d5f7d07a169da4decfafae8a1f4983a9236d4b49e8/ml_dtypes-0.6.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7728c0420ec1c338564fc8b01015ff2d58567e70f17fedce5a0a7c0308c0d5b9", size = 356864, upload-time = "2026-08-13T14:13:56.249Z" }, { url = "https://files.pythonhosted.org/packages/2e/b1/135a7bf47633f5b9184f0d0316af819884124d12b40965064bd216266514/ml_dtypes-0.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c8e39b53e90afda8ce52859c93de4dba3e02b76d85dcf091cc469f9184c6dae", size = 412043, upload-time = "2026-08-13T14:13:57.614Z" }, @@ -255,37 +498,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5a/ff/bda40387b5c5c64254595f4d81a12351770856acc5de4e6d43606a31f161/ml_dtypes-0.6.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f6cb525101b6b903779188c1e9e9490c343b455ab822883e02cf01e5547338d2", size = 572666, upload-time = "2026-08-13T14:14:38.993Z" }, ] -[[package]] -name = "mpmath" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, -] - [[package]] name = "mujoco" version = "3.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "absl-py" }, - { name = "etils", version = "1.13.0", source = { registry = "https://pypi.org/simple" }, extra = ["epath"], marker = "python_full_version < '3.11'" }, - { name = "etils", version = "1.14.0", source = { registry = "https://pypi.org/simple" }, extra = ["epath"], marker = "python_full_version >= '3.11'" }, + { name = "etils", extra = ["epath"] }, { name = "glfw" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pyopengl" }, - { name = "websockets", version = "16.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "websockets", version = "17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "websockets" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6c/32/23fe5a5818255c7e05124c4fa250589e978772677ba150cd5fac3ed65cdf/mujoco-3.14.0.tar.gz", hash = "sha256:f75a0731f6fd829387a1ba9c9a7891efec429000517a6c3f9af98f41735d3b6f", size = 6963412, upload-time = "2026-09-22T17:31:49.787Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/8a/f7c10834add8014900de68e9c7354396e23c73e17707ede38d44aeafa282/mujoco-3.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a263a7e334e0b40db9fa69ee90ebe7cd390c01e8122d0722e9e0bd7fe3ded71", size = 26330094, upload-time = "2026-09-22T17:30:04.381Z" }, - { url = "https://files.pythonhosted.org/packages/cd/45/565dde59246de52a25d52a72e63fcbd7dfd1ce4d9c4851ebc779b93e7c74/mujoco-3.14.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a75ecc3db198c266ef7e4b5b7efe08ecf173414a8e521142c0a26cf997ae708", size = 27035016, upload-time = "2026-09-22T17:30:07.694Z" }, - { url = "https://files.pythonhosted.org/packages/d9/b9/fa676358507ad9bd3e68f58592ef5c8bffe1384c5ddad83e27afda9da167/mujoco-3.14.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:907562096e6f9a927b32221cf09530a7c0a06b2b3debfd42775a469cdd00aa08", size = 28552937, upload-time = "2026-09-22T17:30:11.541Z" }, - { url = "https://files.pythonhosted.org/packages/8f/7d/e72c164407f3ef2788d3bcf625945c1b68dd6d4d8287a2d9ffed882b0ba0/mujoco-3.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5fe30e8a7c1b48ef24a1cadc869b9c461b73705032a41ec2b4b1e5d6c29db9e", size = 18344549, upload-time = "2026-09-22T17:30:15.036Z" }, { url = "https://files.pythonhosted.org/packages/60/43/cdbe526ab7d58890ebdedb21eebf6745f8eddae54c27d6b81375180f8de9/mujoco-3.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ac43ebbfdf5522c556291cbc94a4d420ffed226f67e37a00dfcaf01525fa93bc", size = 26358865, upload-time = "2026-09-22T17:30:18.136Z" }, { url = "https://files.pythonhosted.org/packages/13/de/bf5c6c202f0a42309dff2ee258d4f81d4f1fa37a0ecdd50d9c066e931bfc/mujoco-3.14.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:068080a3c98f7514f1e17a2c414e40cd86f797679b182c21799584ea27300f9a", size = 27063763, upload-time = "2026-09-22T17:30:21.649Z" }, { url = "https://files.pythonhosted.org/packages/4e/8e/1e50dc6db3c99a5a02642e253641bddef9b1b7c65527660baaae83eb3375/mujoco-3.14.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:922983dfdf88d29f4eb0f451a1d18867156d9fc97e3df6e63b9933cc0eba4d38", size = 28582397, upload-time = "2026-09-22T17:30:24.94Z" }, @@ -317,68 +544,161 @@ wheels = [ ] [[package]] -name = "numpy" -version = "2.2.6" +name = "multidict" +version = "7.2.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6b/29ef8e752f5854dcc267ded0652c475bec99b86f255957a0d4fcad657b18/multidict-7.2.1.tar.gz", hash = "sha256:e8af3e6f1bdbc31fd762b094133b478b89988eaebc63cbe82b1d9e3bbd15cede", size = 369994, upload-time = "2026-10-24T20:41:35.457Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, - { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, - { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, - { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, - { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, - { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, - { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, - { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, - { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, - { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, - { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, - { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, - { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, - { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, - { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, - { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, - { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, - { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, - { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, - { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, - { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, - { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, - { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, - { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, - { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, - { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, - { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, - { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, - { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, - { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, - { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, - { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, - { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, - { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, - { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, - { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, - { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, - { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, - { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, - { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, - { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, - { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, - { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, - { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, - { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, - { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, + { url = "https://files.pythonhosted.org/packages/ef/24/4347a7502ad47cb05a1b8d4e219514b4f0b1e38d4e4734c053c20328949f/multidict-7.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:fd24c4425fdb8e822601335602375c6d0cb020f7a368e1d5842eb7925a9c8748", size = 173141, upload-time = "2026-10-24T20:40:02.481Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f2/2a8fcfe4c2c435101f9227b7f7ea81d786ffef993cadd2394e6926020ca9/multidict-7.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cecb2c778f00a65c6ca01410dd194d0e797a9eebb7c6dea0db87718719f293c7", size = 97197, upload-time = "2026-10-24T20:40:06.534Z" }, + { url = "https://files.pythonhosted.org/packages/7a/57/39be4ac4fa9c20e3af7dd7e89adf7dd404b4547fb4453c5c96082e197b9c/multidict-7.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0d852d3d9638f852158d7de7200dd77f7ff8d77fc8f3d396032f3d847883f631", size = 104843, upload-time = "2026-10-24T20:40:05.829Z" }, + { url = "https://files.pythonhosted.org/packages/63/da/6a5c78daa34e458ced17573b7527395c4c39ebb253220274382d361a1493/multidict-7.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:76a4f5132b8dc29eb3c768ee9b47e132a1d24fbc2e9259854c01af9beaff1d49", size = 490713, upload-time = "2026-10-24T20:40:41.715Z" }, + { url = "https://files.pythonhosted.org/packages/8a/75/b8252bc6411f7f4b9649b83e143789b7bc1dda44f656b09ce74f894ca027/multidict-7.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:295a15ccb8cfde9351dd6062b80af4e839460636b3c46813b4aa443905ded86a", size = 504189, upload-time = "2026-10-24T20:40:04.042Z" }, + { url = "https://files.pythonhosted.org/packages/c0/45/38e8191d0a7c7b56015dfc4c2910b731aa719c5ad3f369b2357567f03434/multidict-7.2.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:987f65f6c9321402bc7012f6c6d09ba501b88ddb1640f5b0b4a19f2caafe24a5", size = 465834, upload-time = "2026-10-24T20:40:08.058Z" }, + { url = "https://files.pythonhosted.org/packages/9f/55/378869573886f7efcac24d2c698938dadf973e0da40b812c8b0df847fc46/multidict-7.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:36b868758fb11cfa748c788b793994e73660abec90f83c780a678539c2c468bf", size = 522423, upload-time = "2026-10-24T20:40:07.427Z" }, + { url = "https://files.pythonhosted.org/packages/af/c5/8936fe02c11196994ac05c4876ad62336a66ef353ea2d1a2ca3adce71678/multidict-7.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b944091cfa7da4f7a9bd2587569cd90c94b89ec4721f8e87fe62504f2432b67", size = 544512, upload-time = "2026-10-24T20:40:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/d0/38/b8403d97ae5273002fa664c3c440c5cc186082fa0ba20fcc93885d13a686/multidict-7.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3b7542fad2706595c1b1853b46f2c3c4ae5cc5a6252b7225f294d607a29e1bdf", size = 510054, upload-time = "2026-10-24T20:40:05.525Z" }, + { url = "https://files.pythonhosted.org/packages/18/5d/473e1436ea8577cdfb42371c0507a244d51f8d8ef08bea0f9e6e63906478/multidict-7.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:363e5db93fc3f65f3218a79fe108d217b9d264181a4e993c77552bfe275d0393", size = 452816, upload-time = "2026-10-24T20:40:09.49Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b7/67376e6821fb28c21de1fb37731efd2e3a6a523bdefc4af6997bd043da7a/multidict-7.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5deadf03fb4a7842696d551f9f9a06dd4445bb43ca06b5f9736e4e1885561333", size = 490712, upload-time = "2026-10-24T20:40:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/1a/89/5144d8b36f133109ff36408089a475b9a3a3c1840176a39a9a8fb67a0000/multidict-7.2.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:0ec95a02f9f08246cc7f1d85ac425e4d5c233fd3f098f5be1b2cca16aa690186", size = 462856, upload-time = "2026-10-24T20:40:44.771Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7f/38310388c4c5e351dfc419a04e4c7a26cde14b0e1019bc72bbd1b0d95613/multidict-7.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3d6fce7cc4ef63d9a7633cbae43e737798412805a4c3ba876cdf472dc6ff9c51", size = 490108, upload-time = "2026-10-24T20:40:06.965Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0d/94162600e5bbf24bf33398f3b33ae2f78211375313dbdaaf1513b2554a66/multidict-7.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:37b161535e92776788957a165fcf38a80b33c1efde7b18cab287db4e4574df78", size = 508935, upload-time = "2026-10-24T20:40:10.882Z" }, + { url = "https://files.pythonhosted.org/packages/1d/40/a0cae932493346cb0d442ffa380e65491fdae8df338d8cdf2f39695c0947/multidict-7.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e47d058c2db95010f5d2e1f6037acebdfb0e2fa4507650e1041437985c91db38", size = 447620, upload-time = "2026-10-24T20:40:10.548Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/6ec5cd1c15574aca277ab08920f7e78a6a1e1c15912bfaf59b48683230f7/multidict-7.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:7238f7135481926d52a94c18a8cca633a9e72c1cbfccd5796548298a479b0656", size = 523896, upload-time = "2026-10-24T20:40:46.258Z" }, + { url = "https://files.pythonhosted.org/packages/ae/60/d164a2583548561f7e596f13bbd1c3492ea33a77d499d3ceaa2acf76d850/multidict-7.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c10ac5e3e76837180656079008f71add8339b00c3c26314092bcd1e8e449049f", size = 502961, upload-time = "2026-10-24T20:40:08.732Z" }, + { url = "https://files.pythonhosted.org/packages/30/6b/474aee537e0c87210ce8f4fd21558b461e8a8e74c79440de036022a9eeda/multidict-7.2.1-cp311-cp311-win32.whl", hash = "sha256:2b4d0c4e13d329ffee73d46128e32dd3b4aeb2f9c6ac9c8a89eb28d3b82dcea8", size = 84159, upload-time = "2026-10-24T20:40:12.222Z" }, + { url = "https://files.pythonhosted.org/packages/1f/02/41d769148f32c9bd535fd0ddd946c4e42f38acaddf7899f3bf49d9e67703/multidict-7.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:a789e3d27fc5665cc0477f10a35a89383313e4c9cf8b5e6af7ea71f039cdcc0e", size = 94895, upload-time = "2026-10-24T20:40:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/23/be/e04fcf29542d0db25cdec79acbe5662481c3ba6b760353c393f9c46156f2/multidict-7.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:2022bd3194968a027f64ed16f9779e62e71a823a9df3971a7328c1d45dfeb9ab", size = 92384, upload-time = "2026-10-24T20:40:47.827Z" }, + { url = "https://files.pythonhosted.org/packages/27/21/53c299f1ea289f5fd438e70d4f1f053f59b4485f5a410bcc85eecb553394/multidict-7.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cb43bb34346155d479acdc5423afccdff79ce1fc66adfcc9de995cbec8a13acf", size = 172948, upload-time = "2026-10-24T20:40:10.189Z" }, + { url = "https://files.pythonhosted.org/packages/9d/17/faacc27a01e364abed3c6218d8a32bc42fa0688c95f82c6b11e3ffe99a07/multidict-7.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dbda2baafe08f8431fd3333be02c261023fd454607e3bde5e7dc00565f7bebcb", size = 98039, upload-time = "2026-10-24T20:40:13.633Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e4/bd8838244fd1309165c79e74af9a6b9e9e42fd0f2c24c75bee665fc9ba69/multidict-7.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37773029ba034a2cc970b6eeb67dad3bff9d894393c51f6bb1c6b4f563a032c", size = 103911, upload-time = "2026-10-24T20:40:13.517Z" }, + { url = "https://files.pythonhosted.org/packages/0c/39/21dbe0be29cd44de626736c6f0915c0b75f153e0155340a253f375ac5747/multidict-7.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c85a0a54a19429a17ef449e967baffe7b5089cb4f6ef2894c9168a69fa024d05", size = 493430, upload-time = "2026-10-24T20:40:49.104Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/c081a1af93258029471636ea1c741141ece10f1045578a5f73ba99b694a0/multidict-7.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa0ecd064b0ae18d235283abb5adbc4e07304dba8f2aa7c0d61f28bebcc70d4c", size = 506972, upload-time = "2026-10-24T20:40:11.68Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/e9982c6b1e0d4bb19c4551cd56e50c1fbe90492bdf7893adf8d1ea39f373/multidict-7.2.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f278f118f9fdb5faba5a6792be397fbda094d1568efa4dfe8c3595ce4ce89eea", size = 470406, upload-time = "2026-10-24T20:40:14.984Z" }, + { url = "https://files.pythonhosted.org/packages/6d/2e/ab4136028044825995b3c9de4cf8f9ef82c2076b09b5c44c65e7e3eef0a1/multidict-7.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3d28ac22fe658ad7486d1532b3a9084fbf65b428db31f47212c5b0b6da826f1", size = 524691, upload-time = "2026-10-24T20:40:15.205Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ed/0a57de7a6b6444f301fc4cfc4b8eb0d1c36a33b8ff05717fab7700f205b1/multidict-7.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f3cf710144b1859f4128051cc27ea86eee83caac5648cf2ca0c1b835261cbbe", size = 549512, upload-time = "2026-10-24T20:40:50.756Z" }, + { url = "https://files.pythonhosted.org/packages/43/50/6bd8600b4843f0a9d939e0cc0f4160f4c727bd6c69e4f7e294a1caa93db8/multidict-7.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb8c1a3480c130c3a6422d5f16f3cad6552bf08c2089dd288ba7134649183b5", size = 516796, upload-time = "2026-10-24T20:40:13.481Z" }, + { url = "https://files.pythonhosted.org/packages/c2/77/31e78862081f54a06115927c1c3d81b1a57522fa0209fe1992fac25c6e75/multidict-7.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b81ff0f2d375988152614eaea4d5023c98ecb08f77df4bb1d96762504bb86fca", size = 451174, upload-time = "2026-10-24T20:40:16.492Z" }, + { url = "https://files.pythonhosted.org/packages/e8/bf/30a0c0911e4b61accb9995165acfdc5107dd335ad3ce003007afaf168dec/multidict-7.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b4821bd30b38144d8ab75df56af0b07e9d71327c9cf94c0c7a1965f865bb9615", size = 492916, upload-time = "2026-10-24T20:40:16.619Z" }, + { url = "https://files.pythonhosted.org/packages/5a/80/2192317e096489e9361f732beeee2dd213b827de71b038011dd8176e262f/multidict-7.2.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0eb7fa012ab47b13e96bffc1369642f520adcc4e3d3e170d7f1a2b39fb953bce", size = 468924, upload-time = "2026-10-24T20:40:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/65/fe/92a104d1d9731a97053193a3f3646a2ee286929fdb7f9c2d5077de782b33/multidict-7.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f9e78d98a29e969a001e953eb09167a48bbc71249e804dae5db987ee7e6531c9", size = 492920, upload-time = "2026-10-24T20:40:15.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/6b/165323c76d383afbf60da76a4e67f4b78635b09a77c2b83d7033e72d5ee8/multidict-7.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:eb5c4a5b1281985271b6c29be53f2c1a9decf11e30a0e2733439c4d9d61b9ebb", size = 510473, upload-time = "2026-10-24T20:40:17.824Z" }, + { url = "https://files.pythonhosted.org/packages/ff/61/e25786f40ab8a862102c198ad0e39cbca704aeeac096edd9cffc85122f8c/multidict-7.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2c4e6a97f79699f33e6dc13bddb5631781ee79a4a94c1822116d100e7d5c136c", size = 445954, upload-time = "2026-10-24T20:40:18.14Z" }, + { url = "https://files.pythonhosted.org/packages/a5/bb/deb193198dd1913e8feb54bc83dd13a0cf5425b1aaf7ac913e980537aca6/multidict-7.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5324411117bdb67cfaeb3d1d28fb783710fcca3d88b855696af99044d7219e4b", size = 528252, upload-time = "2026-10-24T20:40:54.068Z" }, + { url = "https://files.pythonhosted.org/packages/7b/9f/ae4d6c442e11aea5ff631f36e4e8d5ff4d1f4c330d2c2721cb729fa5eed6/multidict-7.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d615377a8e7bfc4f1983ede3f411c2ac2b0f7c3053f262d0437b7d8bcc4485e3", size = 508675, upload-time = "2026-10-24T20:40:17.249Z" }, + { url = "https://files.pythonhosted.org/packages/06/ca/bff250b81ada0df548da2b9c0abaf12b4fd864f40ee39e983cd9bada5799/multidict-7.2.1-cp312-cp312-win32.whl", hash = "sha256:30572b61c7932e4d3c2ad1e580de7db1f35b7a35d36db38f89397ce3f2e69096", size = 84510, upload-time = "2026-10-24T20:40:19.481Z" }, + { url = "https://files.pythonhosted.org/packages/68/d5/64108a33c21bde81454b5a400d16baa1378a7d01599de64b131c3e3e3114/multidict-7.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:781ca5dfc2f1d58e47f52216a0c1c7e1ba91ce8aa235459e20de2d402af77fb3", size = 95372, upload-time = "2026-10-24T20:40:19.679Z" }, + { url = "https://files.pythonhosted.org/packages/e0/00/b746ff0f4f70589be9eca08d37a211c3257d602e0ba95224dd0ee0577801/multidict-7.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:9306e0c5595f91874e685be0a9eb743d48ba069c888eea051bec3c67c08b5b8d", size = 92629, upload-time = "2026-10-24T20:40:55.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/f7/453ab156d423def05ac3a4a268f52975378520bf919ded61c9a8bb432b7d/multidict-7.2.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:fba33e264cdd2ec43b762b9960d1648478eecfc1ce6e8159e7573510f0412c0a", size = 103189, upload-time = "2026-10-24T20:40:18.599Z" }, + { url = "https://files.pythonhosted.org/packages/dc/f1/db4171d67615081b007ceab10af476cf7d66a09291fe43c11f1608f08d4a/multidict-7.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:f2087ee23a18d1d618a135eedcafba7dfc40c6be4e8e52d7f29a1c8a2344cfcc", size = 101149, upload-time = "2026-10-24T20:40:20.693Z" }, + { url = "https://files.pythonhosted.org/packages/be/7f/adae49eae252c26f2b7a2fe89984533e0bdb0072f29023c1f2ad7588886e/multidict-7.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f5812cfcf7daf3ace5fedf6e2ef8d4b342c92fb8ea3a87c1aed2b50453271093", size = 103294, upload-time = "2026-10-24T20:40:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/9fac67be204fc5570593f4dfa2395c542bd1643ffd090c8566de0d1d7f8e/multidict-7.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:57ac987ec5d26d89bbc48c03e486f659e1367d425c465552ac2e4c4af8219def", size = 171995, upload-time = "2026-10-24T20:40:57.102Z" }, + { url = "https://files.pythonhosted.org/packages/91/5f/7912bfb95ab00ed6bd588884ca44adf29f7f56de871247a535135af5a2b4/multidict-7.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:714fe5f3eabd153b1b6911d0b5d31cfc2b808ae6b9e59a32fab17a4e6cdad5a6", size = 97470, upload-time = "2026-10-24T20:40:19.915Z" }, + { url = "https://files.pythonhosted.org/packages/06/18/29874e132d78ce182305e818a1553c4ddae238471ba55903a1ea38b5c4ee/multidict-7.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4bebc9587cc29359e3b045518a7a09264fa882a7d165b0d143fdac8eeb7ea948", size = 103438, upload-time = "2026-10-24T20:40:21.892Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/0b181fac73aa9d0f24bc38ce24f56a9cb08c96109c3703d2f79ef15c2c2b/multidict-7.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12bbab225ce7f69df75409ab8005b11b4e9309fae863821e019e81b32e53a2a8", size = 493852, upload-time = "2026-10-24T20:40:22.383Z" }, + { url = "https://files.pythonhosted.org/packages/4f/2e/d9ee8ccc91fcde4ad9a6e8275b1df67c0e079349fe33fd30530706dc2707/multidict-7.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:335f715f8c111654f29e4bf19086bdf0245579d2b4f4f59ca8dd9b3003b859c1", size = 506390, upload-time = "2026-10-24T20:40:58.494Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f6/eca9192c0e598655258b2a3d14120894aecc338982670ca7ff4d4168b226/multidict-7.2.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9df532dd12c59a7a4d381646fc1bf5ccb4bcdbd01c0887d07ec769fae965ea9a", size = 469470, upload-time = "2026-10-24T20:40:21.363Z" }, + { url = "https://files.pythonhosted.org/packages/cf/1b/a4cc6da2651966c725298812d965b9d70918544214a5638c670837034f24/multidict-7.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f285f788acf2448a57c9b93d31103ac69bd03f92907e2ffbb23c4e2b42eb6", size = 523167, upload-time = "2026-10-24T20:40:23.155Z" }, + { url = "https://files.pythonhosted.org/packages/20/64/f3df5b32600c6dfcc1a949f15c8de2b89e44053f5c78bea05c6240e10af8/multidict-7.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:21d86f2aa58aa428fb9909d6b8587b75eea827287e3515b8506dca86b55e2c7d", size = 547031, upload-time = "2026-10-24T20:40:23.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/85/15918b8f73aeb770c534f22b7dac256dc70b6fc7aa0571856c82b17bf3c5/multidict-7.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd69c99affd1b4debc1f9a53058ea6e3a9b34f2e733840bb45891666cd8ede0a", size = 516056, upload-time = "2026-10-24T20:40:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/d6/eb/b74b3441b572218dd24068b67bf4f1f71934cfa5938f55c299425b1c41c0/multidict-7.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc45ff19520bc5321dcd9c6dd7df6f35ddd894eed35e5be65d1828a9c3f5e7fb", size = 449703, upload-time = "2026-10-24T20:40:22.96Z" }, + { url = "https://files.pythonhosted.org/packages/47/81/ac9bb12377260913c0384bdb60b2f9097b1ba580579a2f9697b9e21709f3/multidict-7.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cba29f12cd717f1cbbe220edf005666f8df0453347217748331a0c2f7522eca4", size = 492340, upload-time = "2026-10-24T20:40:24.498Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e9/bac55e20457d3e23879b65e8c46dca5ea90675eee73ca70e3fdc4984d53c/multidict-7.2.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:4f31ddf9c00e0e7db848b0236c717acbe8a51954d0d31b4d908c538ab3f0f5bc", size = 468205, upload-time = "2026-10-24T20:40:25.143Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ce/1dc1e0f8bc28f41edee6fd0e2116f1d7b077e8fc04f50617e65f3938cbaa/multidict-7.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7cb1a6bb70edcc06ea65cb9a35654f268ee39d9fc3a52240e6ba074db4d9c99e", size = 493634, upload-time = "2026-10-24T20:41:01.377Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/7a4e9f4e35d522715f8987a17d3a098582d0e879cfde621defa586330310/multidict-7.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2a322c8a6ca82a5a67ae6542e9c6a13ded9045cfd171dcccc5c213ac4fe0eab", size = 509128, upload-time = "2026-10-24T20:40:24.735Z" }, + { url = "https://files.pythonhosted.org/packages/60/cd/d142dc374de3585c57e2635659d45c3179cf47ded415794b2e7ead663373/multidict-7.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:678ac791b5820d9346eb13c8515fe0c9383659335e5cfc544bf6f54714ca1939", size = 444795, upload-time = "2026-10-24T20:40:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/6d/41/7121157290d436282cf2405cda15d4448921231f7291679f5a3f47669bbe/multidict-7.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0c454b677214fa604e9c4a38952c685dc88a62c96f5576a7ce550f213d1dc318", size = 526200, upload-time = "2026-10-24T20:40:26.763Z" }, + { url = "https://files.pythonhosted.org/packages/6f/fd/05e6e78a34023574db41c057676aa1c26925533fe0b60de9fb5ed5e1a101/multidict-7.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3ca60708f0ca9d994f46790c5a5c774771e2f1dcb75b12c70a5582f915d0e721", size = 507752, upload-time = "2026-10-24T20:41:02.846Z" }, + { url = "https://files.pythonhosted.org/packages/41/50/3bccfa9f3003303fda37a1f7131ab90dcf36ef7fa9af6611151f4b2e316c/multidict-7.2.1-cp313-cp313-win32.whl", hash = "sha256:fa949c81e3865c10f981dea72fd8d418b59edd13a047a0aee89ae64790113d53", size = 84246, upload-time = "2026-10-24T20:40:26.077Z" }, + { url = "https://files.pythonhosted.org/packages/74/6b/3f90a565e6eb94266d197d91cca5833b34ab89768a24f7f07b29bc186e09/multidict-7.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:ce0f884408938fbd72e764031d0e23a8f5e438a7bf73641089c7a6465cb84456", size = 95078, upload-time = "2026-10-24T20:40:27.34Z" }, + { url = "https://files.pythonhosted.org/packages/09/29/3acaf67a5d0f0ecce997f1f57cd14c8c634d05e1672df23b29d4f072262e/multidict-7.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:554ee1d2e71fb5b4059c0c4ee35d8cbc8af2a3147036d65a454c53fea07ff4eb", size = 92390, upload-time = "2026-10-24T20:40:28.055Z" }, + { url = "https://files.pythonhosted.org/packages/de/0c/2eff3c4f36f45eddf11fb10b35069d9ebcef751b16483a585527061354ac/multidict-7.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:3d22ebb716ac79bf32c2a1d15250f017faef719114af4376e8dd814d6633cf0a", size = 102695, upload-time = "2026-10-24T20:41:04.31Z" }, + { url = "https://files.pythonhosted.org/packages/e2/46/4b8667914db791922e826a847f80324cb1f0acba9b05343fcb53c5a237f9/multidict-7.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1d369ed297e79b417766327ece16bb7e89306c588df63c364a85d90d4aeac843", size = 101581, upload-time = "2026-10-24T20:40:27.297Z" }, + { url = "https://files.pythonhosted.org/packages/5a/40/80c50aa45b607086d43ff3d467c25450b85c7d9bb7e4bbcd521c3b030be9/multidict-7.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a9cc121b5ee2c8b4bd4b6b526d5a26bb782f4d620463f73707691b2004b8c349", size = 103677, upload-time = "2026-10-24T20:40:28.633Z" }, + { url = "https://files.pythonhosted.org/packages/f3/e0/c822cf8d92f33f0d6fc0f733b6f5b287956133024765f31b7151b68f6198/multidict-7.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:02009b402129a73e1f3da68f48eb999afbacd29f25aef0d5052b84ff5c2786a2", size = 172007, upload-time = "2026-10-24T20:40:29.243Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d8/b36e8bbc49a59f38e6411bdf96e07c29462460d77e59527309d72469b1f8/multidict-7.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cb7fc8820b206932ec6d8f8985aeba6edfd2932e13b6c4bf2434d6bcbbeef3e1", size = 97224, upload-time = "2026-10-24T20:41:05.627Z" }, + { url = "https://files.pythonhosted.org/packages/8e/3f/c014da364e2f9707945f921864a6b5c615e88a0ec0518c7999b3b2092405/multidict-7.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cadd02ca804d6ebe799a7af19197d9730094fe2cc74b70eb4f374f4b8c1886c1", size = 103824, upload-time = "2026-10-24T20:40:28.61Z" }, + { url = "https://files.pythonhosted.org/packages/c9/73/bcd1fff09ba644499fcd864e6f86a596e51e90f5504c326354bdba3bb8a0/multidict-7.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:846b17193f1c373d6f05ada894fadf56bce2473067caea0aab51828849894b31", size = 486848, upload-time = "2026-10-24T20:40:29.976Z" }, + { url = "https://files.pythonhosted.org/packages/52/12/d6731ea19a185f76a1dc6b76f239fa3567732c8575188b47f7a715742c21/multidict-7.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6efdeed2723431999cf3bcbc84c72dbe0f65d16bb1f84e15a657f5b60ff18ca6", size = 500914, upload-time = "2026-10-24T20:40:30.626Z" }, + { url = "https://files.pythonhosted.org/packages/71/db/332a5b62d4c2c26950aede813a0da97e42cbc16b656ac936eb2c6e01f58f/multidict-7.2.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f9c118e866164ce05498d9106a4bd01e745321403cd6f0b77a0b2587768f8c06", size = 462069, upload-time = "2026-10-24T20:41:06.892Z" }, + { url = "https://files.pythonhosted.org/packages/64/8d/c8c6826dc3e9b742bd21f3e84e1d8c46daffb584f45fd93429541e5e7f11/multidict-7.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc6f3dcb08c4b41e64f2d6cf272b5686d9144db585b2e138a8b1664376ab3921", size = 515356, upload-time = "2026-10-24T20:40:29.998Z" }, + { url = "https://files.pythonhosted.org/packages/92/c8/c13ffe0d352c4caaa17da0a74e9203c718606c62a1d62f98439feb1783d5/multidict-7.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:27cc258247b13008742f0e239933ad01dec5ed4e6dfc63849e30e04e1aa5bc82", size = 539793, upload-time = "2026-10-24T20:40:31.55Z" }, + { url = "https://files.pythonhosted.org/packages/3b/8f/bff861e3bd5a7cc13bd1f040d22aee8aa32b4f2755bba29b0c31a1d2ae25/multidict-7.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:822a7fcd33f6922b650e1b26594f09d53c5d011fe655fea7ded27b2728ce2a1b", size = 506944, upload-time = "2026-10-24T20:40:32.258Z" }, + { url = "https://files.pythonhosted.org/packages/a4/2f/f40fc4a7070798e31630ae0be583e0857fcab71a1bf80c37481ded49c553/multidict-7.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f30d6eaf3cb309b6c21096c6c2b4cf3800f195a78b1babae85f481ce851bdc2", size = 443550, upload-time = "2026-10-24T20:41:08.334Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7e/068b48b20c1df58dddb7885f5e1befd6def91c7b6048f2644ccd81f57fa9/multidict-7.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:edffdb3950d3a8b769ddaec3a0962fb7b76b3c96f317f6b88500455a537dded2", size = 486514, upload-time = "2026-10-24T20:40:31.371Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0d/a2f8b1fdd7cc2f497bb2fb0cc59dc7e2ced1fa4b0cd9b2912973e384ea77/multidict-7.2.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8c3ff4f5c376df7126695a52af61e6084b69c9e52dcb9f4713e71ce9819e1bfd", size = 459258, upload-time = "2026-10-24T20:40:32.999Z" }, + { url = "https://files.pythonhosted.org/packages/36/aa/74652a34732f16a951fce9ca118e659f1a9dbe102ee52d9bcea4a8001dad/multidict-7.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:d5738cd929f2af8651ebb284467acb58b1ec0f2f4d42489cd0597172bc82e15b", size = 487418, upload-time = "2026-10-24T20:40:33.649Z" }, + { url = "https://files.pythonhosted.org/packages/dd/91/ae90df6402911d310cee3f44a73914ea555d5219b1976a8f2842dc247eac/multidict-7.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:81a5f84a76401c4330e6db4e170dddd58860b30c4e8a44a181e5c2809c7f9c9d", size = 502308, upload-time = "2026-10-24T20:41:09.776Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f3/5a1ab9f594e652bece00f1243bf565063790838250054ef9310999c856f7/multidict-7.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:efe695cc2081633b02fcb95bc1b13070f6647c9ad52d42a8beeb3c2a7ea062dc", size = 439169, upload-time = "2026-10-24T20:40:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/38/a4/8c4fd4ecbea568de3b4605dc7323488f65a204475e1aac98db9d36fdbfd3/multidict-7.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0c52fdd8cb8194fcb6969a417149aa1d09d6e48b69fb1b34ee1b1e4cb51ec183", size = 519617, upload-time = "2026-10-24T20:40:34.488Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b5/4964b2d8f8b4ff03df7f24e8dc7d0de534fad0430e553ff0693aa2bcfb75/multidict-7.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:02d9c04d395a36eeebe89e45f2269614a4675b8e83fddcba15bba2be903971e5", size = 499402, upload-time = "2026-10-24T20:40:35.035Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f2/c7d5c514e066fc6f9b9a2a9583f46a3d8178c49b7e91cadcc59241f22c12/multidict-7.2.1-cp314-cp314-win32.whl", hash = "sha256:b97bffa5772b7e853303de094829fcd3a9bd49fddd70534651ee4933691f6587", size = 83635, upload-time = "2026-10-24T20:41:11.337Z" }, + { url = "https://files.pythonhosted.org/packages/08/8f/2ec03cddf5f283f979813f8c5d38f5df798e21cb07a124dfc311ed28d8e9/multidict-7.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:6e22756c6e2d4f57ead0d2e6be179d8a16d661bee424b81de0c6c0c1305ec39f", size = 94544, upload-time = "2026-10-24T20:40:34.202Z" }, + { url = "https://files.pythonhosted.org/packages/2e/75/3e9803944df2233cd43924c04ef057160a75c35e87f4fc083de7c855ab74/multidict-7.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b63acdf4df5a78a15223bbb510bb3e0f8234dfa807610ccb8796d0842db2c801", size = 92767, upload-time = "2026-10-24T20:40:35.787Z" }, + { url = "https://files.pythonhosted.org/packages/96/6d/e3a4b4de34cde67f98887ac0c134e2436e1eafdcef62e81f9f8b7ac8e189/multidict-7.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:15da10cf11fd8d8716720c95b93180e94c8317405d89d08524c579ef360128ac", size = 183485, upload-time = "2026-10-24T20:40:36.397Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b7/c5aa68302159285e544c7f1b687a48d2b000217b9a90aff880052d6cb867/multidict-7.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c753b820738cddc097892fee95e7cd716fbdfc9640414c5e9ce9463ad125321b", size = 103109, upload-time = "2026-10-24T20:41:12.496Z" }, + { url = "https://files.pythonhosted.org/packages/54/b5/7574cad64ee957cb65a32ac4f842663fbe948c1749f688a93eef0677644c/multidict-7.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69befda4b1ef57027ea95dc6ed733568642f330fb3203a3e5c5ef107d41e0a1f", size = 109785, upload-time = "2026-10-24T20:40:35.496Z" }, + { url = "https://files.pythonhosted.org/packages/38/7b/ced76f7d87cce5ed15ae404213c7f9fdc594d0caaedbbacc6b8cdfac81e6/multidict-7.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8648dd5dc4d5db32b5936dc5e85e9a4d79db1135513d40d1f54ca15503750324", size = 511246, upload-time = "2026-10-24T20:40:37.078Z" }, + { url = "https://files.pythonhosted.org/packages/51/91/b598741d494865ed002df52f37f9e92bf9ab5a0524098a3f7667c1bd6ac6/multidict-7.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5efddcdbcb73f5485dca2b6624edc8ee063604b77bd3a3eb772826063965819f", size = 545199, upload-time = "2026-10-24T20:40:37.773Z" }, + { url = "https://files.pythonhosted.org/packages/e4/1d/2e3c15a07da380a7845e7d0969fde25cd6fca078a09bf30cf6deb06a3480/multidict-7.2.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9c44bd978adddf39df5834c6a1efd7d075c84769a7a084168d035a2a276d9008", size = 489912, upload-time = "2026-10-24T20:41:13.782Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/f6e2668e393430240da34a13503c190493dac8bef352ce7e26e831dc7949/multidict-7.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cff7657e79224dea46f2f2e58eb99186656ca63ebe356212210c5c00e9beb814", size = 553445, upload-time = "2026-10-24T20:40:36.916Z" }, + { url = "https://files.pythonhosted.org/packages/05/ca/d7e0605545eebc8053167683fc6aa0d49114df2e75a2c474f2b65f8f083a/multidict-7.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7fb3bc8d58e1b7c1d9fa1d995e30a4ce9dbe49f4c63c755a1ffdecdfbd026ccb", size = 573757, upload-time = "2026-10-24T20:40:38.554Z" }, + { url = "https://files.pythonhosted.org/packages/1b/de/9a74903ffdbe3712ba392166935c91785ccdff82dc4b6a15b28a4df52a14/multidict-7.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66af55b07e4593790157e85f897f06983ded8a8692cb2fb048f3049c1d3fa172", size = 543116, upload-time = "2026-10-24T20:40:39.052Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9d/8eb3ee4465610cd599b08b3320262a3448dcd03299565f4c725ee1504bbc/multidict-7.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed6c107642a9acaa7ede32dcd428a6c87e302c5d555ecd3ec132ffa8c6155b4b", size = 478536, upload-time = "2026-10-24T20:41:15.442Z" }, + { url = "https://files.pythonhosted.org/packages/49/fa/346308a741f2c06b23da186022fd72a75b9923f4fe7c88705605e20506d2/multidict-7.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc9decb36abd00ed5d123db9df70eb9a0da45d6376d86d64f4a94bfd5a1b83fe", size = 526429, upload-time = "2026-10-24T20:40:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/eb/10/96986b2404bba1bdb54e364e682a2a9fc97a93c9f6598a2c35c065cbe28f/multidict-7.2.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5a7df927c7999d1d3196176a8d4139d3a0e1765936f5de8c768097275b2d617a", size = 488886, upload-time = "2026-10-24T20:40:39.949Z" }, + { url = "https://files.pythonhosted.org/packages/8c/5b/59b11e1e2f93d39b34c554bfe3beb35777aa976c35af078f23eda41fa809/multidict-7.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4eb2650aa240b62ab0c6d87238bee5d14aa4d411c0d69ad61be53e026e0785ba", size = 510875, upload-time = "2026-10-24T20:40:40.484Z" }, + { url = "https://files.pythonhosted.org/packages/83/af/79e0036cc9ecc160235f3bcbd337524cb44ebd266cfc77e7532d9bd06850/multidict-7.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:83fbd7cfaca614975efdbdcd9e2df45f3e987e819216468fb73e1db6f0365b98", size = 539190, upload-time = "2026-10-24T20:41:17.519Z" }, + { url = "https://files.pythonhosted.org/packages/a0/b5/342db062f9ae8816c1b940360c251dff21afeb88a9c4b1694c2b3348f2db/multidict-7.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a878fef2797a7c082aa47559af68299372edebc7367bd5128dff2d36e6d820e1", size = 474879, upload-time = "2026-10-24T20:40:39.953Z" }, + { url = "https://files.pythonhosted.org/packages/8a/60/1420f7daae59b331f5506e5b32a79f60fa475e636f53bf4e6cacad5782ec/multidict-7.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f332b16911ffc668baec6aaf8f08a09e1af34a8ee87ccac26bd722589e264645", size = 549769, upload-time = "2026-10-24T20:40:41.241Z" }, + { url = "https://files.pythonhosted.org/packages/ce/af/c4b548057bee365137be7b1ed12cb6339b6911ad95d99a83d4b26e9bcb2a/multidict-7.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:81c6fc88ac9bf6920a0cdf22d3c383964167f12d35eb630463fc6d5ac89e460d", size = 535840, upload-time = "2026-10-24T20:40:41.927Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/70710e4bc50b29ced1ed69ec3a99124c56ac34747c6a64b05611f7b5856a/multidict-7.2.1-cp314-cp314t-win32.whl", hash = "sha256:0c7b4d8cc9e6ae7070b8129bdb840215552d4e2689ca765d37eb208a24919209", size = 90136, upload-time = "2026-10-24T20:41:18.854Z" }, + { url = "https://files.pythonhosted.org/packages/ac/cb/a330b1474608eef92e6a0a5861d61b8f2d3152d1a71b65dad8b56edea150/multidict-7.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:b5fb6b048931ea925b484f0c7c01b007981415a00a6c62ee74bbf8d229d39c08", size = 101163, upload-time = "2026-10-24T20:40:41.847Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/1563cd1336111755f79a60d06ffecd11dfbce5eb8c32ece9eae888d5fbef/multidict-7.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:e64b86d16158954825d12799271979c6944c1fd1d03575b2f22e869c3f7d00dc", size = 98630, upload-time = "2026-10-24T20:40:42.671Z" }, + { url = "https://files.pythonhosted.org/packages/03/fd/8048552a28d6ae6e1e11198b0605a4d98eee58e10527a35a9fbb3de5b6f6/multidict-7.2.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f29885fef157a602d1816439e5da964c70ebcdbbcd4bb9d2d21b7e8bb5636604", size = 102703, upload-time = "2026-10-24T20:40:44.421Z" }, + { url = "https://files.pythonhosted.org/packages/f1/89/d16f1cb45c2c4d7dab10b199dad0c2907a7846326cbd141abb6c9dedc353/multidict-7.2.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:a92e3080d8889b0f7a80ea0c4a72181b4d4d5d3918a03136b4a459f51f5f59da", size = 101681, upload-time = "2026-10-24T20:41:20.058Z" }, + { url = "https://files.pythonhosted.org/packages/40/eb/2e14975652ca018dfed4bf14756f2aa9f71b74a0347aa52e8a4d0cac95a9/multidict-7.2.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:3ca4e2e9102d0788b94894a30a5e3561fd4818a0605d8748d8fe780f890aa069", size = 103843, upload-time = "2026-10-24T20:40:43.083Z" }, + { url = "https://files.pythonhosted.org/packages/46/76/9f80aa8013ac294dcbd9b51b0db0312d98f4db03dc92e56d6a404559332d/multidict-7.2.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:a0f999155c22c8929cc492949c08c6fe7ec8079a5e1bfdeb34b9a3e4991959d0", size = 172121, upload-time = "2026-10-24T20:40:44.051Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ffc7c5ff41445b51dd71072b066f1189682c52fb32e46a58351f413ddf11/multidict-7.2.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:f15205c67d4c45250fcff5da077f0ae54102b5bc39e9ee8bab90d99e09a728c5", size = 97233, upload-time = "2026-10-24T20:40:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/da52a85bde6db671d652b1d11113c507aa4021f2c8d431a5074108aa4d5e/multidict-7.2.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f07f06db2e333b68dd52560b8b0b12b6f8bf6053cedebf9af4aecfecd2dc1b32", size = 103929, upload-time = "2026-10-24T20:41:21.262Z" }, + { url = "https://files.pythonhosted.org/packages/84/77/4c98e263f94cd5b27f89cdf3b5989bed698fe3a34a6812777620a8a4c518/multidict-7.2.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d094951489bb7836342c005e790bd005e60b669aaf5c936b96dd5d68cbd2e23b", size = 490370, upload-time = "2026-10-24T20:40:44.571Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e2/36a02b000bc0605061eebe4fbfe3ee9c39ebe644bbd053e1635d5a0f9286/multidict-7.2.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0381074dc21225951278d7e4a7a2241f7fb86a4d8b94154dfee1411f49cf851", size = 502507, upload-time = "2026-10-24T20:40:45.371Z" }, + { url = "https://files.pythonhosted.org/packages/42/ea/1fbceb93d48c65a731bc2264c34d9374d3c811657b3616c480a3ff32b152/multidict-7.2.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2e279ad5a6e9347bc9e043fdbee180a0480c8fd52618c3258e983dd93d98ed49", size = 463742, upload-time = "2026-10-24T20:40:47.479Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2c/e8e8e98bd831b74a4a011d007c6990cbebc834557825948a4c192855dd4d/multidict-7.2.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e0f2b33a6ae233f4da38c77ea32f90aab2cb82ba819d7b6c4f1b68cd33d871b6", size = 515827, upload-time = "2026-10-24T20:41:22.557Z" }, + { url = "https://files.pythonhosted.org/packages/af/54/a777698e55878f569d3a9daa3658b6a5248608f99ca67fab94b5b94ceb95/multidict-7.2.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:57b5d0df4c60ab8b1159687610a1c3ae069ad2380d390a79dedff5c82318e714", size = 539980, upload-time = "2026-10-24T20:40:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/63/a8/a00489f6ca40003a95c97e30b556eb136f510664fb9f605d8c1c9e38dd39/multidict-7.2.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2592fb6c335d67f1912bf72788c02fd5dab305fc3c8c7eab078a5bec1aaa0f88", size = 508202, upload-time = "2026-10-24T20:40:47.003Z" }, + { url = "https://files.pythonhosted.org/packages/90/7a/67c29db7d0daec8548ba8412d98c1f45f6416171eaa208c5682764e2a227/multidict-7.2.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d4af0d863e72f3ea48e85d3aa1d1b426fd81e9544fd7976fc6d6753584a25b03", size = 448626, upload-time = "2026-10-24T20:40:49.019Z" }, + { url = "https://files.pythonhosted.org/packages/ff/7b/986c45a79400c784c7eb2a2e848f77bdb5b97f32cb8a03fb4cda67b5ea28/multidict-7.2.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8148c2aaaf4b85d7872f024c8bb0c9b9797cc76b10588e3594a69cb01057472a", size = 487794, upload-time = "2026-10-24T20:41:24.08Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a1/d1707dbd709fbd774eac819aa15bf825ae6abf4c4ffaee9eb77e429ba11b/multidict-7.2.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:7726c53b1d59eb57b009a9e8759d4f71049ebdacf30642a198a197f80cea451c", size = 459403, upload-time = "2026-10-24T20:40:47.645Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c8/56e3b24559ab63efc76c261e5aa6813616b2094886e9bb9de688cd1a386a/multidict-7.2.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:f62172cf387e92f4928b60e5936b02c06e3c8449ed7fb440978d6c9bcbfc85df", size = 489508, upload-time = "2026-10-24T20:40:48.541Z" }, + { url = "https://files.pythonhosted.org/packages/01/05/b1cbbdc23a7bcff56fb97d97e675892e9dd0bd941d8c6023c411ae6a1c26/multidict-7.2.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:03846be88267369908fcd9a91159358a3b142d740991c96e4f696d362fce516c", size = 502575, upload-time = "2026-10-24T20:40:50.968Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f2/946fc151a8e9e17a71f4bd69d5893576addadc8e2528999dd3c1a742b48f/multidict-7.2.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:785ef2f3e372dabc131f228a275f97eca92ad17cfab1ab138790e330d99abe3e", size = 444323, upload-time = "2026-10-24T20:41:25.534Z" }, + { url = "https://files.pythonhosted.org/packages/75/98/548bccbea2dcfe90acc130fa46124527b1460d60ba188d9b368309105a9f/multidict-7.2.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:2bac371da2c3554a99ba126f545e79ab8e8d9426a3e41df81d00ad725c1e2cbf", size = 520113, upload-time = "2026-10-24T20:40:49.455Z" }, + { url = "https://files.pythonhosted.org/packages/4a/98/25a588db57a7b4b786150aad4cb1f2aaad7aca13d20df60643461b58944e/multidict-7.2.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2d4cfbbb1023f0f6795b5395c400752fb23a5a09ba756aa9003886bbc47b3e12", size = 500566, upload-time = "2026-10-24T20:40:49.974Z" }, + { url = "https://files.pythonhosted.org/packages/df/49/e319d4dcd4da99f356ca963b41f9c3c48a71d76afe606d8e8f0c90827317/multidict-7.2.1-cp315-cp315-win32.whl", hash = "sha256:584ebfa26c9dcf3e465637a5a9afa2647bebb3022b50b1ff384834ba8e0380d1", size = 83644, upload-time = "2026-10-24T20:40:52.389Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3e/06a59c05402c2ff04eac97ef00783510b82b71128c67455a20b1b0eeecb4/multidict-7.2.1-cp315-cp315-win_amd64.whl", hash = "sha256:873d35366cc115be02a1c858f9b22a1a9f6182b99aab2171ee5276af02a153e3", size = 94542, upload-time = "2026-10-24T20:41:27.144Z" }, + { url = "https://files.pythonhosted.org/packages/d2/99/90e4c68ea5ca6ae89c682e3fa463d3fa7ebda5f6e2209a1b1f7b8ab1aa91/multidict-7.2.1-cp315-cp315-win_arm64.whl", hash = "sha256:f2cc27ec6a53b1d39bab44d2b9dd21cc31e796d01b5e18a76d569721282bb264", size = 92767, upload-time = "2026-10-24T20:40:50.934Z" }, + { url = "https://files.pythonhosted.org/packages/2a/39/66a05f6ce31217ee9ed9417c19433c7498e9468a15fae4cafd920284ef21/multidict-7.2.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:522f7d2351743804cdf5c5a304bc45f9d55e935dd87706fae2dbeafe0fed0b49", size = 183697, upload-time = "2026-10-24T20:40:51.79Z" }, + { url = "https://files.pythonhosted.org/packages/f9/88/185b62ccface5a6295934b24bae589f7817f79f54cd41b19267574bc1349/multidict-7.2.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:a3d9a59bcdf8531d46b56bcd512017533510c25e955c2c546efd0743bd840940", size = 103097, upload-time = "2026-10-24T20:40:53.594Z" }, + { url = "https://files.pythonhosted.org/packages/23/d6/bae59cc28b4bec2019e5a2fb0262a077982f68200dde08180f079a918a62/multidict-7.2.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:6966806cf917cbbeb9a8b5c581db52e19c9af0ef5fe009db54c1f5799a6e003f", size = 110045, upload-time = "2026-10-24T20:41:28.396Z" }, + { url = "https://files.pythonhosted.org/packages/36/a7/bee9b9f0ff28b751fd79d1c7630d6f88a9dead6218ef2f0e566f5d54ab0e/multidict-7.2.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:74d3e015c28b43b6c1a108eec324ea2efb185c359c459216cabd4cb98f7f02f6", size = 513704, upload-time = "2026-10-24T20:40:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/4e/80/0c2781349f44d137fa138820bb871598116758e9e27042aad2d75835dc5c/multidict-7.2.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2529873a3bf68c372532a9f8058bcf4199e78387e92cd6a01b73334bfe6a7e3f", size = 547454, upload-time = "2026-10-24T20:40:53.161Z" }, + { url = "https://files.pythonhosted.org/packages/16/60/8b97c48c728c980ab5d10abe12400d3a8633910a9cf99307f31100fe1366/multidict-7.2.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:223427392fdf993e198644ec40c404fd93f1cd2a61328d18842ecbf154d02c82", size = 490355, upload-time = "2026-10-24T20:40:55.976Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e5/966b412dd21c58d13f378e60e15e08d13dae669b3a22ed31b1f582b0ec9d/multidict-7.2.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90efd90a0f9b7ae3844a4fb3ae614321832d364ae19567b7cd312315fac4ec39", size = 553571, upload-time = "2026-10-24T20:41:29.67Z" }, + { url = "https://files.pythonhosted.org/packages/13/18/e0f099bdf895d1cc1dee64aecd4e761d4d537c4513875e765a6e5dda71b1/multidict-7.2.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d10e49b940e70e2a4e91f44098007dda0bacb78c3f8e31ba7a37ee2ce52269e6", size = 572533, upload-time = "2026-10-24T20:40:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/150bfda9b5a12defbcd0c48385fcceb3efb8257bd9872c8c11cf79643eb0/multidict-7.2.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba5e8d9310a794b853fb0e327d46a827b5b015576341219a591f88ed9d799c27", size = 544014, upload-time = "2026-10-24T20:40:54.676Z" }, + { url = "https://files.pythonhosted.org/packages/31/98/083d4980f3c3eb15663a91ddba983f1317d96e373ae86ac90e93c34140b2/multidict-7.2.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a7d120bdde5b213e67ed7dc80ea3adf4f014453fbc20195d619ba2ceb34084f3", size = 485204, upload-time = "2026-10-24T20:40:58.116Z" }, + { url = "https://files.pythonhosted.org/packages/06/2f/6990b941b9315f24575afa4ec4dada55c27fa3558ddc5962ca94322e93f4/multidict-7.2.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:27166b6ec73eac37696d66b2c6efbd44b6d04b597d655070959df54dc7c71c0b", size = 528125, upload-time = "2026-10-24T20:41:31.176Z" }, + { url = "https://files.pythonhosted.org/packages/d6/69/4efe79c2aa7f8cb42a826dfbffbec129358d81828f63158108eaf2e54603/multidict-7.2.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:13268013a3db11a214ccb6920f778d175b765f69a40509610b92e43602272718", size = 494332, upload-time = "2026-10-24T20:40:55.762Z" }, + { url = "https://files.pythonhosted.org/packages/d5/86/3704dd04ab0098e509a3dbed132269aca7897dd1d352638a333df5ebfc7c/multidict-7.2.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:65e4448dadc7c94a8643bb41361e45f3a73d46e36a008a9bc03e9b525f4d7c80", size = 511543, upload-time = "2026-10-24T20:40:56.457Z" }, + { url = "https://files.pythonhosted.org/packages/90/bb/2efa33d97e8b18533cb89b356a5782b61c28f66e21fb662c9853f71f96f4/multidict-7.2.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:68b97b721d95b77d75e718d1b62a227fa4b3e647bd2a9fb39552d1a79ffb67c0", size = 538955, upload-time = "2026-10-24T20:40:59.701Z" }, + { url = "https://files.pythonhosted.org/packages/4f/aa/12510e431bbc652891297aeae4a5b19dc9532b477bbe1eb5642c933825e5/multidict-7.2.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ec4ee5d2d624088775e61cada87bbe7629bfd936bfbd57f122a106970d8ab1ee", size = 481562, upload-time = "2026-10-24T20:41:32.578Z" }, + { url = "https://files.pythonhosted.org/packages/77/b0/3a9e0cc640a9f6031618f6b6415ba603040f68bce00ed36d659a2a8b6aba/multidict-7.2.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:4b338b45dc26ca4bac702e1278754f78613d4d0081e14e94e44fe8e261bf1750", size = 548933, upload-time = "2026-10-24T20:40:57.407Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c3/f4074a8e6c249cde81b5f29667161bdd4250f26174ac4bfbd45676f06b3a/multidict-7.2.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:9a53776adc1ba923c9e345fdea7b61553c44c17a9af5eba8d8def8c5dcac4dec", size = 537060, upload-time = "2026-10-24T20:40:57.916Z" }, + { url = "https://files.pythonhosted.org/packages/44/88/07382e85348d485db3f901ae7c0f3d33dd3562f6fe56d398631af0dad4c5/multidict-7.2.1-cp315-cp315t-win32.whl", hash = "sha256:fcfebcb537f419e415b6e2bfc77ddbd33abe3c036f0105410a25a14741df9c31", size = 90152, upload-time = "2026-10-24T20:41:01.076Z" }, + { url = "https://files.pythonhosted.org/packages/00/2f/45eb8700ee15716cc22f5b42592b4d5bc1ca65c880179c2c10e42fd6f0b4/multidict-7.2.1-cp315-cp315t-win_amd64.whl", hash = "sha256:25ebc4208d9098f77caeb16df895a0e56eb6c3b22870ac4e3ec43e0a9d6a33b6", size = 101193, upload-time = "2026-10-24T20:41:34.124Z" }, + { url = "https://files.pythonhosted.org/packages/7b/80/37d26f26d20b937344cfd68d9a1f57d8bc4f64adae416fc6828a7f8c8a10/multidict-7.2.1-cp315-cp315t-win_arm64.whl", hash = "sha256:4d61be3725016ef5db3543c95f1f062d7d150ba776582d959a982af88e75fa55", size = 98863, upload-time = "2026-10-24T20:40:59.146Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/1aaad0e5df33f1f5af21775c411349c0234a6edd581ea403bc81919a6eab/multidict-7.2.1-py3-none-any.whl", hash = "sha256:08ea94663d9e7c25d14d78222f7aee906c472d4837766bec62477c38f6e419dc", size = 28145, upload-time = "2026-10-24T20:40:59.159Z" }, ] [[package]] @@ -386,7 +706,7 @@ name = "numpy" version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.11.*'", + "python_full_version < '3.12'", ] sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } wheels = [ @@ -547,19 +867,13 @@ version = "1.23.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ml-dtypes" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "protobuf" }, { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/3f/62/bc2dfadb63ecf04cb2d65a6b17751863039d36c65de51d6a3128ab35f1e7/onnx-1.23.2.tar.gz", hash = "sha256:008cb0467b2bbee41448acc7da8b6f4e704624cb0d327a2d5adafc7ce19bc5b8", size = 6023090, upload-time = "2026-10-06T04:25:58.681Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/87/de/891c47041bfee534710591e1b993468adbcef03afc94bb81d076c9ef0670/onnx-1.23.2-cp310-cp310-macosx_13_0_universal2.whl", hash = "sha256:fcbbd53e3482434dbf2c27f4a8727ad4865e21bbc0b5530e7557669f8d8f587b", size = 9725172, upload-time = "2026-10-06T04:25:10.717Z" }, - { url = "https://files.pythonhosted.org/packages/50/97/1bd118d030ec888b1fb820613da54325a36b85a9f090a58316f33527124d/onnx-1.23.2-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:612f5dccea6d53c5517309c52496b6dae1115757e3b79f31be24d4c40fa45ca3", size = 8644570, upload-time = "2026-10-06T04:25:13.301Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d5/2f0fd67282eb297769097c1c5daf974498d4a828bafb81da19fc9045d6a0/onnx-1.23.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03334d6c834767c7acd37c7db51c98e98c8ceb61a964f6df96386e13272d2870", size = 8886659, upload-time = "2026-10-06T04:25:15.317Z" }, - { url = "https://files.pythonhosted.org/packages/25/f5/9b2a8f11852cb6a273cfbee6fedc3fcc9f1042073505dbd3c65f6a1210dc/onnx-1.23.2-cp310-cp310-win32.whl", hash = "sha256:fb3e892f19f3a793b9722587349941b074f74091ad33e794a7798fe03fdc0c9c", size = 7738100, upload-time = "2026-10-06T04:25:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/8b/3e/22cb5797df2aef3d6243ed2c40a3807e7ee3d313b9e22386fc1638b794e5/onnx-1.23.2-cp310-cp310-win_amd64.whl", hash = "sha256:0100e6c3f30db8ff10876d8cfd0cb27296166d5a612ab37c3998e07e83b3fde8", size = 7875310, upload-time = "2026-10-06T04:25:19.367Z" }, { url = "https://files.pythonhosted.org/packages/ea/27/b8793ea89e16ce16beb0e662d29ee8f4e100e9e95202968d08f1c08795d3/onnx-1.23.2-cp311-cp311-macosx_13_0_universal2.whl", hash = "sha256:419bbbe3fbdf45a7658ee0aa1a54cd170ea15f3e5a60ace6e8d94f1577b3674b", size = 9725398, upload-time = "2026-10-06T04:25:21.31Z" }, { url = "https://files.pythonhosted.org/packages/8a/2c/f9a5f186da571c396b660f97cc0e1aa85c5b76249abacda3de01b9f2e049/onnx-1.23.2-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:83b3fc8321303c9da62824730457ba2f7ae0970f0e2f7fc0117912df7f8a4826", size = 8644597, upload-time = "2026-10-06T04:25:23.451Z" }, { url = "https://files.pythonhosted.org/packages/12/4d/e8cafd5fbe5f5fde043676838a4754e6ff4cd00323ecc81b3345eca6f185/onnx-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c03ecf6b835d136108eeaeeafbd0026fc7b3cf98661409fbc6b63d5a29361348", size = 8886609, upload-time = "2026-10-06T04:25:25.379Z" }, @@ -580,56 +894,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/69/84/7bbd40fc36f701968351b4f4c14de5bde61ba8f75b88f93b23d013f32f3d/onnx-1.23.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1e6cbca3d808f811141ed0a0939e71b3a6c9fdefb2435f4a862ec776336718fe", size = 8089708, upload-time = "2026-10-06T04:25:56.893Z" }, ] -[[package]] -name = "onnxruntime" -version = "1.23.2" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -dependencies = [ - { name = "coloredlogs" }, - { name = "flatbuffers" }, - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, - { name = "packaging" }, - { name = "protobuf" }, - { name = "sympy" }, -] -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/d6/311b1afea060015b56c742f3531168c1644650767f27ef40062569960587/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:a7730122afe186a784660f6ec5807138bf9d792fa1df76556b27307ea9ebcbe3", size = 17195934, upload-time = "2025-10-27T23:06:14.143Z" }, - { url = "https://files.pythonhosted.org/packages/db/db/81bf3d7cecfbfed9092b6b4052e857a769d62ed90561b410014e0aae18db/onnxruntime-1.23.2-cp310-cp310-macosx_13_0_x86_64.whl", hash = "sha256:b28740f4ecef1738ea8f807461dd541b8287d5650b5be33bca7b474e3cbd1f36", size = 19153079, upload-time = "2025-10-27T23:05:57.686Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4d/a382452b17cf70a2313153c520ea4c96ab670c996cb3a95cc5d5ac7bfdac/onnxruntime-1.23.2-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f7d1fe034090a1e371b7f3ca9d3ccae2fabae8c1d8844fb7371d1ea38e8e8d2", size = 15219883, upload-time = "2025-10-22T03:46:21.66Z" }, - { url = "https://files.pythonhosted.org/packages/fb/56/179bf90679984c85b417664c26aae4f427cba7514bd2d65c43b181b7b08b/onnxruntime-1.23.2-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ca88747e708e5c67337b0f65eed4b7d0dd70d22ac332038c9fc4635760018f7", size = 17370357, upload-time = "2025-10-22T03:46:57.968Z" }, - { url = "https://files.pythonhosted.org/packages/cd/6d/738e50c47c2fd285b1e6c8083f15dac1a5f6199213378a5f14092497296d/onnxruntime-1.23.2-cp310-cp310-win_amd64.whl", hash = "sha256:0be6a37a45e6719db5120e9986fcd30ea205ac8103fd1fb74b6c33348327a0cc", size = 13467651, upload-time = "2025-10-27T23:06:11.904Z" }, - { url = "https://files.pythonhosted.org/packages/44/be/467b00f09061572f022ffd17e49e49e5a7a789056bad95b54dfd3bee73ff/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:6f91d2c9b0965e86827a5ba01531d5b669770b01775b23199565d6c1f136616c", size = 17196113, upload-time = "2025-10-22T03:47:33.526Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a8/3c23a8f75f93122d2b3410bfb74d06d0f8da4ac663185f91866b03f7da1b/onnxruntime-1.23.2-cp311-cp311-macosx_13_0_x86_64.whl", hash = "sha256:87d8b6eaf0fbeb6835a60a4265fde7a3b60157cf1b2764773ac47237b4d48612", size = 19153857, upload-time = "2025-10-22T03:46:37.578Z" }, - { url = "https://files.pythonhosted.org/packages/3f/d8/506eed9af03d86f8db4880a4c47cd0dffee973ef7e4f4cff9f1d4bcf7d22/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbfd2fca76c855317568c1b36a885ddea2272c13cb0e395002c402f2360429a6", size = 15220095, upload-time = "2025-10-22T03:46:24.769Z" }, - { url = "https://files.pythonhosted.org/packages/e9/80/113381ba832d5e777accedc6cb41d10f9eca82321ae31ebb6bcede530cea/onnxruntime-1.23.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da44b99206e77734c5819aa2142c69e64f3b46edc3bd314f6a45a932defc0b3e", size = 17372080, upload-time = "2025-10-22T03:47:00.265Z" }, - { url = "https://files.pythonhosted.org/packages/3a/db/1b4a62e23183a0c3fe441782462c0ede9a2a65c6bbffb9582fab7c7a0d38/onnxruntime-1.23.2-cp311-cp311-win_amd64.whl", hash = "sha256:902c756d8b633ce0dedd889b7c08459433fbcf35e9c38d1c03ddc020f0648c6e", size = 13468349, upload-time = "2025-10-22T03:47:25.783Z" }, - { url = "https://files.pythonhosted.org/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321", size = 17195929, upload-time = "2025-10-22T03:47:36.24Z" }, - { url = "https://files.pythonhosted.org/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b", size = 19157705, upload-time = "2025-10-22T03:46:40.415Z" }, - { url = "https://files.pythonhosted.org/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c", size = 15226915, upload-time = "2025-10-22T03:46:27.773Z" }, - { url = "https://files.pythonhosted.org/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77", size = 17382649, upload-time = "2025-10-22T03:47:02.782Z" }, - { url = "https://files.pythonhosted.org/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435", size = 13470528, upload-time = "2025-10-22T03:47:28.106Z" }, - { url = "https://files.pythonhosted.org/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f", size = 17196337, upload-time = "2025-10-22T03:46:35.168Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95", size = 19157691, upload-time = "2025-10-22T03:46:43.518Z" }, - { url = "https://files.pythonhosted.org/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b", size = 15226898, upload-time = "2025-10-22T03:46:30.039Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872", size = 17382518, upload-time = "2025-10-22T03:47:05.407Z" }, - { url = "https://files.pythonhosted.org/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088", size = 13470276, upload-time = "2025-10-22T03:47:31.193Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466", size = 15229610, upload-time = "2025-10-22T03:46:32.239Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145", size = 17394184, upload-time = "2025-10-22T03:47:08.127Z" }, -] - [[package]] name = "onnxruntime" version = "1.30.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", -] dependencies = [ { name = "flatbuffers" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, @@ -682,6 +950,134 @@ 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 = "propcache" +version = "0.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b3/9a/9fbf4e4ec0c2d7f1c32519fff782ef467859b8faa9fbc5331a96f6395d43/propcache-0.5.4.tar.gz", hash = "sha256:ff6b113f50bc066a698db5d944d2c6dc7507168dd3341e255a8892fd0715a558", size = 61545, upload-time = "2026-09-16T00:17:14.386Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/40/14b21e505b7921617466576423f188a5c9caddfdaa1cf4b2b8a83d8fe216/propcache-0.5.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:897d1ddf6716e8f47200f7aad9a0efa6cc7586df66c6defa572f9eab379c078e", size = 86393, upload-time = "2026-09-16T00:14:06.9Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4b/5a52e1a7b43563f7d408814194bb23cc8bf214eb6b86639b667a33a8d0d0/propcache-0.5.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9cbfff4423eef4cc6cafc021469641a2b835f610b2647a6c5281903e21b8670d", size = 50431, upload-time = "2026-09-16T00:14:08.025Z" }, + { url = "https://files.pythonhosted.org/packages/05/cf/b5248180bf056cc76acc60c9c6e8c0ebbfdbd1c6cffd31fd14996927b7c8/propcache-0.5.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fc24f209c1b7f7f688b66b98293954f5504279760999b58920ee12dd8471c1d", size = 52116, upload-time = "2026-09-16T00:14:09.114Z" }, + { url = "https://files.pythonhosted.org/packages/86/a8/7c6cd6bfead1a11f2e411e688640e6d26574cb0bde7dcaa7423b0b65ed7a/propcache-0.5.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:62530ca89187827e4a4fe733f971abe81a7542eeea48ff61995f19b64d7199c8", size = 238729, upload-time = "2026-09-16T00:14:10.357Z" }, + { url = "https://files.pythonhosted.org/packages/5c/b4/442715b2e980df51be52d203549279e027728f24c80b00b5e525e31cd5ea/propcache-0.5.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fc3f7599528db40b1efa0889a620116e2704144495273d66066e8164e45838", size = 246121, upload-time = "2026-09-16T00:14:11.735Z" }, + { url = "https://files.pythonhosted.org/packages/bc/5d/df0684fc2b1732a01a7bec26d7897369022712422d25b09c37ce7dbc88a2/propcache-0.5.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f2d880ff60f45898f4acfa152aac8d04e3ee627d90ff4003491bf92239d5757", size = 251735, upload-time = "2026-09-16T00:14:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/c7/06/519a5ebb48b6f94beb48396e55c905f12246a25c3a3608a7ec7bceabf50e/propcache-0.5.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e9368e87a3efc285e559131092c5db643eb8e56de4ee42064d5baec22ef2bb5", size = 235381, upload-time = "2026-09-16T00:14:14.398Z" }, + { url = "https://files.pythonhosted.org/packages/3c/07/1e0a9bb310830f2245edbd5cd3c6d24a783c053c4efd8e08e386e513c940/propcache-0.5.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:004e685b315646c410771836e72a44f143bbe624f29653a42687815069a303d5", size = 208973, upload-time = "2026-09-16T00:14:15.715Z" }, + { url = "https://files.pythonhosted.org/packages/62/5c/9324fab27d6088eecc47fe4332bf7aaf8c1ded93c36f558391e8a06d41a7/propcache-0.5.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:594eb4c6ec35e7179b058481f4e9f02521b56de16fa577c4b85c76fb1bf8a9f8", size = 233897, upload-time = "2026-09-16T00:14:17.25Z" }, + { url = "https://files.pythonhosted.org/packages/9c/a3/570d92fc952eae93b676f3a1568f4b89264102abd3c982ab6a9ebec58dcf/propcache-0.5.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2dba2f02d2d5c09ef8a0e6c1a42aeaa451f4be9898cb00b04fe98717da2eb23b", size = 223512, upload-time = "2026-09-16T00:14:18.87Z" }, + { url = "https://files.pythonhosted.org/packages/65/47/26810d889d89bba31db397e6a88f8984af775f5ed6bad0a29dce84324cff/propcache-0.5.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c3ef2818d63bc86071e9d2989ae75a1bc32b8f7059cfd9f5abbbee70c32e2ed6", size = 239043, upload-time = "2026-09-16T00:14:20.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/2d/f9c47691aa024c8299a3afacd78d22a01ab57eb627b481b6089708e71017/propcache-0.5.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:dd2ac8f5b643454c2cc6b6118b13da16e88f4a6434fc3ba61aca384029f04f36", size = 208218, upload-time = "2026-09-16T00:14:21.801Z" }, + { url = "https://files.pythonhosted.org/packages/74/6b/d510c0c378cabbf9d0ac7b663af6d00f2e9074073d93b20c85f24aa5c071/propcache-0.5.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:4054acf80d40456a0537f2913b349718649d8d6458a14ab7f48d0ce28c30869d", size = 240301, upload-time = "2026-09-16T00:14:23.121Z" }, + { url = "https://files.pythonhosted.org/packages/3d/80/c80f6adaaa1e51f0db2dce8c9b3714d94ec45e358a21f9a1910b10b40a80/propcache-0.5.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:40e94adb1e7d39ff28a8bd8d8b8fbd1df6b9f40976dbe379134f1ce058e532dd", size = 230785, upload-time = "2026-09-16T00:14:24.458Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e2/c32a7df3f39caa7f11b2eb37ea5b6960a6946f2bc7c4b8ff97bbdf6d6b6e/propcache-0.5.4-cp311-cp311-win32.whl", hash = "sha256:9f86f7259efe2c951f43e57d471c9b41daa5bfc7db9f67189059cf1ae6d77fd9", size = 42747, upload-time = "2026-09-16T00:14:25.715Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8a/3db6a3543d8101263b4c52978b6276a04ead2caff2c5ab880d934f47bd89/propcache-0.5.4-cp311-cp311-win_amd64.whl", hash = "sha256:e904d4d01f36bd6e197590be1533c44e06058771e0746dd073a8ebb3ef880858", size = 46268, upload-time = "2026-09-16T00:14:26.996Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/5222e2665bbf6e45847492ecbf3b9f3e4975a0ae300e5fd465df7d48ce55/propcache-0.5.4-cp311-cp311-win_arm64.whl", hash = "sha256:d42a9a856a4a6e2f6c10f1318c07e7daa498d6593abe745c71dae4521a26ca39", size = 43547, upload-time = "2026-09-16T00:14:28.143Z" }, + { url = "https://files.pythonhosted.org/packages/71/cd/348d58f142aebc4873345c6b31087629182ca6e0f2b3caeaa528cf882eba/propcache-0.5.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b28f41fa3b8c6900457f858ec5b03998f3a6d535fbc1bb2edec5961ea05ec429", size = 87285, upload-time = "2026-09-16T00:14:29.362Z" }, + { url = "https://files.pythonhosted.org/packages/df/f4/f3ffaee281b276da854ac1d7a6a506d26cbc62ea2e623756f1d0a4a1ba1a/propcache-0.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:dcbf346a318a5e30063f547630b02bb787ce2f45b6368d5da143660b6a3835d8", size = 50984, upload-time = "2026-09-16T00:14:30.473Z" }, + { url = "https://files.pythonhosted.org/packages/25/88/1d7df7201750b37765ef2b23bc1c526c028dadde80afa0f57a118fc01182/propcache-0.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:87a3caecf8095e48dc72f84bfa42e23a848cf410cc9cc13031fba4869b706a21", size = 52460, upload-time = "2026-09-16T00:14:31.692Z" }, + { url = "https://files.pythonhosted.org/packages/83/4f/48865bd02a16ee5236bc46166b2946f37b93e07b0eae355dac0be0b216ca/propcache-0.5.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60a64cbccaa11b7760ce705a14ada17ba459e7ca9f23ba587eb013821032d7ef", size = 251768, upload-time = "2026-09-16T00:14:32.908Z" }, + { url = "https://files.pythonhosted.org/packages/b0/19/3742a5eed62317b03b4002ee865dc9fd720308bdd0da1f29a5786c630311/propcache-0.5.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a74bfa37147cc08fb29df10bd9c16f40fa7f860cd3a6d2fff853323a94f6e17f", size = 257723, upload-time = "2026-09-16T00:14:34.267Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d5/ee6350fb0be9122bb6c67082a876d34b90d980d100c106af4b81023e04f4/propcache-0.5.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a4d7a54719b67338a305dca2ce6aafe366817df94ddfd4b5514374356f5ca546", size = 265597, upload-time = "2026-09-16T00:14:35.56Z" }, + { url = "https://files.pythonhosted.org/packages/85/9f/83a07b6ec0e043c050cfdd35fb0cf1b7897b91d554d6eea293740309afe7/propcache-0.5.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2814ecd8e818f487bee4b0f921bc4d1c176cc5fc71ac0f072d0fa67eda4ac14b", size = 250424, upload-time = "2026-09-16T00:14:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/33/2c/a763a8251f50fba042af0fb1f02bfec4b31381e40aff760db2be7b2e1f84/propcache-0.5.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6af4693716bfb03f1752ef1b30faa593db2c01d5272e9b8564a1549452a979ab", size = 216748, upload-time = "2026-09-16T00:14:38.369Z" }, + { url = "https://files.pythonhosted.org/packages/6a/e2/4d11bea8fd6a777149c6c20645f873952eab5de3a2497aa11648ec9ab6ab/propcache-0.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4fbc1a15dc8cd1689508758d626b372b1f09d28d9577667feaf9e6bfcd8efcbc", size = 246533, upload-time = "2026-09-16T00:14:39.82Z" }, + { url = "https://files.pythonhosted.org/packages/9f/36/6683597de4907e70c717e3588c541202c66086a72ff3db58be49de66e72c/propcache-0.5.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cdee8205a44d0be91bbac4c41b95d86641b72dfc7aef1279400e4fda3f26a937", size = 238173, upload-time = "2026-09-16T00:14:41.259Z" }, + { url = "https://files.pythonhosted.org/packages/85/84/cb08d79f1762daafeb2b030c470cd0c725c97b8ad67412457c6f35c53e9d/propcache-0.5.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9a2a8a50a93dee0268a860a07fa3b4bd968f8ce4dbd794957da772f395368526", size = 251128, upload-time = "2026-09-16T00:14:42.652Z" }, + { url = "https://files.pythonhosted.org/packages/c2/0d/41b848036db6621370c1f2e5471a7da8149c730f8552a5257567721f4576/propcache-0.5.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7ffafcbfc7b549ab940047e505c831eabac5e67de53e1bc174adbc5285c55944", size = 214821, upload-time = "2026-09-16T00:14:44.112Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/adfae4bf9c63bccf12e2d9690a175c6579047a6eec3b5a6a5f51428c15e2/propcache-0.5.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:d1f5a500bfcbb2c0ab85e98a0dcd70f5899d34efe365a0187700369a79603031", size = 254793, upload-time = "2026-09-16T00:14:45.429Z" }, + { url = "https://files.pythonhosted.org/packages/51/6f/eeca9647245d5f92e87d53e5f14335bb42fce1a7e6842c8045b364eded8b/propcache-0.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8a235f73d6e020855dc29dff012d920c02ee0feab8d73a24185a7569f4be1161", size = 247134, upload-time = "2026-09-16T00:14:46.976Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a9/424e38838793d37160b4379c702f61c74c598fc6cd17204adbe3c554f7a8/propcache-0.5.4-cp312-cp312-win32.whl", hash = "sha256:b3083bfe87f95c756e610bd8025f26cbd1cd4aaa03a422f2d65efb7a97cd53d8", size = 43073, upload-time = "2026-09-16T00:14:48.338Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/6e8ef26f6d510a7916064fec68d55fcbfbdf7eb01e377480d66a122152d8/propcache-0.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:98914de2c4d7f0f9f4a8c6ea4bf05841f4175796941e3ef7d47eb718f22311fb", size = 46190, upload-time = "2026-09-16T00:14:49.99Z" }, + { url = "https://files.pythonhosted.org/packages/08/b9/72028c5b56ced97f456de6aefa79435ca64d7f77af78ea8cf3c76fc5195f/propcache-0.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:8876b39961e33d912afe3c1bee18ee564fdad0206f873cc15d522756b7f50737", size = 43075, upload-time = "2026-09-16T00:14:51.155Z" }, + { url = "https://files.pythonhosted.org/packages/78/4c/3b1365d58a667689e067e13d055fcd92bdf8d9a2fca3d9201b47ed5b3631/propcache-0.5.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:36c0d9db44b523ef93d03341b1c42d69ff01d673c053d1b1c6c3a363bcaa39ba", size = 85290, upload-time = "2026-09-16T00:14:52.342Z" }, + { url = "https://files.pythonhosted.org/packages/8f/61/5f9c29c3aa67c30238c4eadf95149b1d983a48f69b86b0cff927a7d6df13/propcache-0.5.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1d52a05dc417279f7e5c7618c5dfbbc29923aaf9bc0a5c1802ddcebf54c61a0", size = 50027, upload-time = "2026-09-16T00:14:53.67Z" }, + { url = "https://files.pythonhosted.org/packages/25/7d/c1ab1ef09e9d4d835be5d58c0a32a1e1de8397abaa4e502a9d4141328cad/propcache-0.5.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44149f46500a0a41b95b4d99c2e586a77319539730607b9892974a092788b111", size = 51425, upload-time = "2026-09-16T00:14:54.826Z" }, + { url = "https://files.pythonhosted.org/packages/73/36/0093091ebb270fcd1bc1f6e095f93b2e0ed7f1011c28837dc2dbe5f96b99/propcache-0.5.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbab5f5ff6897c81f355d079010cdae85b02e5a0b518b5251523b8ad8ae9ac3c", size = 233595, upload-time = "2026-09-16T00:14:56.09Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/0de9d4c8e05ce0be71b436919a216bd7fc5cc6e2691c0602295efb22b9ed/propcache-0.5.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c3e98c55bde2bcf7db3c70d1aed7ae9aa8aebbf19a250c66645cde44cdb8b867", size = 240318, upload-time = "2026-09-16T00:14:57.674Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/2b35e91455209b85ee98f7859583e0814fab57d3af0f2381aaee34c37304/propcache-0.5.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:db3ae52ccc150dbc84704e9d642743897f3e1c54742ff34cacb661e52e3818a9", size = 246649, upload-time = "2026-09-16T00:14:59.352Z" }, + { url = "https://files.pythonhosted.org/packages/ed/74/08e6c1faf26ee2732023a3828787ba535557122774f4a386b1f715cbd8e0/propcache-0.5.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f85915e00dcb1cd9f2f890ead064ed40a27df06f0db65be427b29482ae357572", size = 234316, upload-time = "2026-09-16T00:15:00.696Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/08385733c9321c9bb78039d3ff31045e4fca962d9665023c4eb70f998819/propcache-0.5.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c2ba30a89035b57b73e00475de948521602f543d79ce01db10b04b36c4c76fc8", size = 204666, upload-time = "2026-09-16T00:15:02.019Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f4/e87bc7629af9a14a752b218764a78742d73c2c563ac58315da6841f0cbe4/propcache-0.5.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae58f361bd5dae942717c65d3413b478c70aea9c462599e7b9adad3731db3894", size = 225900, upload-time = "2026-09-16T00:15:03.394Z" }, + { url = "https://files.pythonhosted.org/packages/d9/6d/11014938d3fe9bea2ea2dcf930f26ed565bfb2f5be3c756362ea48c92636/propcache-0.5.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:96f7c5c15656040ddcbc51e56dc59b58aa25999d743c126abd425b9766ab43e9", size = 219988, upload-time = "2026-09-16T00:15:04.811Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/fbf17c589f92c0b3bbf6709a425661f8ef2ed0d46b38985a7d7b5a0f6b91/propcache-0.5.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7cc528e760a8af06f2b13e9b9f362cd90c7c718ea61228a96dbd31ba16ed7f47", size = 233611, upload-time = "2026-09-16T00:15:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/55/7e/dbd637572a279692e5518d117274a9331bf5faac59f191d30e82521a3ec7/propcache-0.5.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:425f8cc86ab5018b4b8d4a23bc8e74d964bd3d757c3702e301aa79be76c53f6c", size = 204333, upload-time = "2026-09-16T00:15:07.961Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5a/f99c92068f1e0f5c886899ce0e4a619db376ca98c5279d93f95bd86906af/propcache-0.5.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:a5793c7698a53f56f4a1889a4737c7eeb1b7ad0842fa6b1abca22913ff79c8c1", size = 235177, upload-time = "2026-09-16T00:15:09.334Z" }, + { url = "https://files.pythonhosted.org/packages/ee/28/95456fabd2daf6be89049a13fbf03341756014d2959c83d12957d4c49694/propcache-0.5.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c02c0e570c5c7e077b0181a9f3cdb7d4c3617d1cda6b5c95bd5d34022923d82c", size = 228982, upload-time = "2026-09-16T00:15:10.729Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/df90f62c9cf7c93ea235f6f9405143bba802914607317266dd81fc8d737e/propcache-0.5.4-cp313-cp313-win32.whl", hash = "sha256:3e413d7a4a9b4866b7a761d6060d434b64d23cd35122eda3b026a0bbe8196b25", size = 42611, upload-time = "2026-09-16T00:15:12.111Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/e0a7b84af04ec02d73a48aa71f091e1e4a2107e3074b7ce12195b66901f4/propcache-0.5.4-cp313-cp313-win_amd64.whl", hash = "sha256:0c889f6fa84957bc7e8b4eab71fd16a0455068d5045e3aa40c733071d2b2fd77", size = 45342, upload-time = "2026-09-16T00:15:13.519Z" }, + { url = "https://files.pythonhosted.org/packages/9a/70/50b031cafe72a5c1878b903ee87303f71313345566bf3d6ec202e5ddc9ec/propcache-0.5.4-cp313-cp313-win_arm64.whl", hash = "sha256:69fc35c0779522da366c563e5faf203ffc1f8ff0021d5b1337fa4efa5be73177", size = 42408, upload-time = "2026-09-16T00:15:14.788Z" }, + { url = "https://files.pythonhosted.org/packages/33/c9/07e227b930c8ae513b8ef1aae3793499be097bffcdf7aee4fb8b33db4cd1/propcache-0.5.4-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e6720ba44ad7e72174314d0e1fb0172494cff5c73a3a8a2159c3d2402ff15565", size = 85933, upload-time = "2026-09-16T00:15:16.073Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e1/6710bb44510c4e4a8e0f004bbaf3cecfd048141309c77bae56d4e5a6ebc1/propcache-0.5.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cfe0a92ae30151869e67a4b5f5e105e4e03ad30b3f38e5211b5bf77d0881993", size = 50179, upload-time = "2026-09-16T00:15:17.377Z" }, + { url = "https://files.pythonhosted.org/packages/e2/22/b533b493d7025456f44518b33e53e000021a20fe7c27b88cf3d341df7186/propcache-0.5.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d759d05634f1b038fb625a66662a8c85e5a8fec912da381b5149ddac107482b", size = 51942, upload-time = "2026-09-16T00:15:18.589Z" }, + { url = "https://files.pythonhosted.org/packages/f1/74/70ac8430e28f21e442c7bcb964eb46c4363f6881ade4aa0e978bfd8d503a/propcache-0.5.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:251c63dd46a0659bb875cb254dc4c1e79ee91a847c737cd62373295afc2235dc", size = 232647, upload-time = "2026-09-16T00:15:19.905Z" }, + { url = "https://files.pythonhosted.org/packages/72/95/f222f13b6fe623310be0eb61a673bf26df439ce27e563ca8e422d0818777/propcache-0.5.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a8d5ff04eb1f85698a78d20c62a14676e7b960dcafde09a388d60ad377d355d", size = 241541, upload-time = "2026-09-16T00:15:21.3Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3e/763e370340db16115c5e63ad46e21ef0770a7f06928b3d3b62d8f8edfca4/propcache-0.5.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b9100a93b372418d8688f3f2a3e5b45c64d70ca4d6176e121aca1e3bfc1e32f", size = 245332, upload-time = "2026-09-16T00:15:22.802Z" }, + { url = "https://files.pythonhosted.org/packages/96/d3/e97cd6f5de2176bd90ed4076c7a9b5e09d0f0b9687d00a576507988bb62c/propcache-0.5.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc07876cfb079b6f6f36d21ce75784ad6c2c6b563eeac0ed26c2fa2669b85df9", size = 232757, upload-time = "2026-09-16T00:15:24.374Z" }, + { url = "https://files.pythonhosted.org/packages/f9/4c/6766e5f60bcda26d244333aa71d0a702c1c9b21b251d543c7af5953d1eee/propcache-0.5.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0951315a6b3142ee2167404d707743f0157c110091342b1aa0accac5cf0e4acf", size = 204389, upload-time = "2026-09-16T00:15:25.667Z" }, + { url = "https://files.pythonhosted.org/packages/b8/5e/ec4bb09a70b26ea99d76a8292c3383b960b296de2b347ac9986678f1761c/propcache-0.5.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bee7d3aed13d56f54e681df38c3a23031bc9e3863f687d9d598825c9146acd7d", size = 228217, upload-time = "2026-09-16T00:15:27.11Z" }, + { url = "https://files.pythonhosted.org/packages/e1/7d/b53922ba7d9e5bf797324e63aa05906ec240871899f779628df068743e2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4e985382be6d15da8d0c2710a6fa7b9070fc9ecdeefb7f580e88373984ec8be3", size = 216947, upload-time = "2026-09-16T00:15:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/ff/39/b62eee45e5ea4de094a258cbb3b01c1e856ca51ddfd95b43135c5effd1eb/propcache-0.5.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9e9ab13760aa8b6d0881ae7cb04fd891d8d490cd2554ea8e79bb278399169bcc", size = 233457, upload-time = "2026-09-16T00:15:29.977Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a9/feec61ed296d993db9dd097e0f6723e3f576a647722367547495e4c5b05c/propcache-0.5.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1b2f3bec4261a94019575481c726c29850f72e27907773c75b1de421e20e9f9d", size = 204131, upload-time = "2026-09-16T00:15:31.74Z" }, + { url = "https://files.pythonhosted.org/packages/92/4d/411ef380cddad28dc001f1c6d75ec72c76cd3817030f68ec1ccfba0ec6c1/propcache-0.5.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:720cf832eb2d0b0dfee129cb3335a26f6ce3cc45ee1187e8f0731758caa16792", size = 234820, upload-time = "2026-09-16T00:15:33.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/c988229753629ef1cfd5198337a83e624780ea2b3787efe9e747c05aad2d/propcache-0.5.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fb0a5be8d9aa213150e8d8148a42aca4984b285bcad1e69587dc4298edd929b", size = 228350, upload-time = "2026-09-16T00:15:34.533Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/5ef1c5cf98591da3c5b952b39e6a298084cc1ce353bc70f85e82397a5036/propcache-0.5.4-cp314-cp314-win32.whl", hash = "sha256:30cc1cebaf9aef49db06357a50398323ae04d70460c0491837d026ab7d6452ea", size = 43578, upload-time = "2026-09-16T00:15:35.957Z" }, + { url = "https://files.pythonhosted.org/packages/1e/9e/a0ac821a2229186af5e2e3c3635a78abb23cfddca57f38513ab5d70420f3/propcache-0.5.4-cp314-cp314-win_amd64.whl", hash = "sha256:0a095db8e15a6020db149ecbed6461939fe74f6acaa3ae8b702a1fe8c38cd983", size = 46304, upload-time = "2026-09-16T00:15:37.655Z" }, + { url = "https://files.pythonhosted.org/packages/a1/19/c8d0d36a9d16cba5dcee67d389c9333b988c8986a653a61c00a451817a46/propcache-0.5.4-cp314-cp314-win_arm64.whl", hash = "sha256:45488d1a5f9ab5bd90aaa1ca20f50fe1922b8ffad71a2009d2adf41355897aac", size = 43440, upload-time = "2026-09-16T00:15:39.091Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e9/42f1da77cacfc184e6ec929557ef653b7961bbf6f1da460b9221273948b3/propcache-0.5.4-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:53eaa697c4d0422ff4cb714d00231b43352064d97b944033b30c1d57cc506ec0", size = 90672, upload-time = "2026-09-16T00:15:40.306Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2f/4b79940908c6ab8c795097c102999d7bc1f7e0b8604dfd1c232f9d99d67a/propcache-0.5.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:886b59c4d28ca97dd23b025fdfc50a0356be934efbbbca89ad26230067f86fe5", size = 52586, upload-time = "2026-09-16T00:15:41.575Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/02196ae6320c110235bb343f90dbd34be41f8b8964a3ee30db84ec12579e/propcache-0.5.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fa15757fea1dfcd5b7745cad9f4638929605531bd4018ab2adff7955f1a403d", size = 54335, upload-time = "2026-09-16T00:15:43.027Z" }, + { url = "https://files.pythonhosted.org/packages/6f/44/f48b9a131985659924df5fa5093f68fe72c7ee375329802989ba3126efc6/propcache-0.5.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6f0093ac3e9daada202c2082439d414a625c57184727a46e112a3fb2a81cb788", size = 297567, upload-time = "2026-09-16T00:15:44.373Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/418d956d2735139f77fc35262179f1f52c23aa666de5a8ab3819c1ae7854/propcache-0.5.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3cd3a7edb6b95b9b33998135ebfa18d709da82290fb8f27c858970b5a12c8b56", size = 297477, upload-time = "2026-09-16T00:15:46.048Z" }, + { url = "https://files.pythonhosted.org/packages/69/fd/ff811fdb6d3d3e67fd9bbfb75881675d34a42d0ef29a45d33e3e233dde07/propcache-0.5.4-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c174bfd1c48a1b51a3078e95586dde718374bac79719ab3541ec9e74aec40574", size = 302669, upload-time = "2026-09-16T00:15:47.458Z" }, + { url = "https://files.pythonhosted.org/packages/fc/57/527910c455b5ec62f6871bef45d4f79fea16cb8c966ba0d4a07f0339ddc4/propcache-0.5.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a219f0ac59817a9114dd2aa57c13180f993e819ba658c7ddab4b66ed1ee0d370", size = 287908, upload-time = "2026-09-16T00:15:48.99Z" }, + { url = "https://files.pythonhosted.org/packages/1d/86/f69ab82707534a0cb2057bdca04f9200a71214c7551800f9d34d6ac39e4f/propcache-0.5.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:17a7400cec0256f0a71ae71f9da398f9894c956ff6668a1c9d317b3367316320", size = 249804, upload-time = "2026-09-16T00:15:50.486Z" }, + { url = "https://files.pythonhosted.org/packages/27/19/60677af50d93be4256213de7cd487f056944c048b9c0b6f2e45b3a30f666/propcache-0.5.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:978f28401afbc76cdc3df9e1717b4229a06b626a1dcc75db4e1f2beb3884c3e9", size = 282344, upload-time = "2026-09-16T00:15:52.029Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f7/a0057808a91fb3b6a5f3602b528f0cdcb3d53e0ff8315d73fabdfdf8fec4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4a1f4f5ffa55dce6307631f3cb2948e117e665966ea512e0d502b16c24f567e7", size = 270167, upload-time = "2026-09-16T00:15:53.466Z" }, + { url = "https://files.pythonhosted.org/packages/83/c8/f4a865490df0dc0c8531d4e59ac411cb6dc24bb255d2396a6f1c60a368f4/propcache-0.5.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:213bb68d9ced5cf2bf717b1071bf2b09b4b04c426256f9fe6d054c60318424c4", size = 286551, upload-time = "2026-09-16T00:15:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/b0/67/b4faebde9da4e8173d0e5a30e8cd31335914af7ef350b988f27fec588cfd/propcache-0.5.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:286867fb156488c251a3721766e380ac4495e4fd6b51aaa1403d89ce7f4359d9", size = 249595, upload-time = "2026-09-16T00:15:56.505Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/52e1dd5636e9f5a27f6b5a4b4e2f33c322fd72afe956c397d82523ec4a80/propcache-0.5.4-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:445ee3bfb46e85838387fb3c536a73cc0b994dc192b004e40e170adc54aa2a7e", size = 286700, upload-time = "2026-09-16T00:15:57.985Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0e/30b2b324b93ff31a0bab539c102aae59e84e444031b2742150a7646aa1bb/propcache-0.5.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:48cb48c5346a97de792254af77715aa2529c2a1ebc5f586aa0aae44a02f1fe57", size = 280500, upload-time = "2026-09-16T00:15:59.487Z" }, + { url = "https://files.pythonhosted.org/packages/64/36/721bb59f682ff060d0c8df64274fca8cd0521b1a54506c2eedaef795b7f5/propcache-0.5.4-cp314-cp314t-win32.whl", hash = "sha256:03b229037d25b801e7af53fd52b9fc49d9439b036fca1e087e02780631adfa97", size = 46121, upload-time = "2026-09-16T00:16:01.349Z" }, + { url = "https://files.pythonhosted.org/packages/c1/86/0b1b80fa1ac3a0aac44e2922a6964fbe9cd52af5eab8fa933bf9e90b030c/propcache-0.5.4-cp314-cp314t-win_amd64.whl", hash = "sha256:8a1fc236528c457cd739c88abe823da851b7ab645d72792f88658114cc340c12", size = 49154, upload-time = "2026-09-16T00:16:02.901Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/9fe6f05a47cb550c823155052116f710064b6be5c6e8ec4e9faae7e18115/propcache-0.5.4-cp314-cp314t-win_arm64.whl", hash = "sha256:135036c5cfc93864affb0f9af9a27e5d7a71cb7bd745e7b6dbfc2d56cc30e827", size = 46005, upload-time = "2026-09-16T00:16:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/58/25/895a11d1e4c5c2acc6d816e2bece34e02d9dc92f2182ae276cd819e9e804/propcache-0.5.4-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:45bf2e730ab8905d0527fe05a86500f406e64305c34cc81ebe64b4617cab9760", size = 85634, upload-time = "2026-09-16T00:16:05.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/41/c0acd69271de7a1cf439e77d5d60c18575fd09bad56e798b95fa23458ea4/propcache-0.5.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:31eb43ba2edc704ab2ec27815315dd8a19def0fb16215be4cfe8d32fe78ffd51", size = 50084, upload-time = "2026-09-16T00:16:07.384Z" }, + { url = "https://files.pythonhosted.org/packages/a5/1a/ad561f99f90884089e6403b76c220610809429ba868a81a2e7ce115d32e0/propcache-0.5.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:174507f82d3594622acb1dd2dafecf2d899d6d506335494e7107767bf05f3aae", size = 51692, upload-time = "2026-09-16T00:16:08.956Z" }, + { url = "https://files.pythonhosted.org/packages/e9/07/057bdd3a9609ffad59b06239cceee784b047f6c720247bfaa36d2103e138/propcache-0.5.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e337653721d20ead710da33bf44487fbe8a0db8782714b60306481e9f95b51", size = 232947, upload-time = "2026-09-16T00:16:10.466Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dd/d36ad35986718530498a65e45e3713f9f0e6a580f192ef02d2ef7cae9b52/propcache-0.5.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d21d0d2c82bbfeb1677a9711f38df968f9837576102bb4add1bd449d28d88f1", size = 241250, upload-time = "2026-09-16T00:16:12.056Z" }, + { url = "https://files.pythonhosted.org/packages/fb/81/f1459415cdb6c10d46942779de39bb59a77b38e5a76bb1def9227962eb45/propcache-0.5.4-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccf4f7a79e26bb7efb06ecd50c177833b71df05cbc748701372325e6bcc17f6f", size = 245150, upload-time = "2026-09-16T00:16:13.596Z" }, + { url = "https://files.pythonhosted.org/packages/ce/4e/58b9b1460afc97a4c0b17ee89af701c4011d4d7f46470eba3aaff76a8069/propcache-0.5.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23278f808cd81d5ada7184a76606b925fb3389c60e1077b2cd7da7b1fcf0553c", size = 232166, upload-time = "2026-09-16T00:16:15.126Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c6/5a79e0eda3e7b6987d03d8c622ff6d52a42165a12e8418eb37694b9cc4b4/propcache-0.5.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e738ab81179510ce79b2eac9a6ecf47feffd9e76d1c72e403005dddb6e36c06c", size = 206085, upload-time = "2026-09-16T00:16:16.713Z" }, + { url = "https://files.pythonhosted.org/packages/4e/72/940aed42c73f9da345ca2de0f6e835c726498159abca5f1ef14fb0a2af8a/propcache-0.5.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a419ee85e654927baabda3929c03c0cc1112bf472ff0dfd6142f4e3a81ca4162", size = 228460, upload-time = "2026-09-16T00:16:18.352Z" }, + { url = "https://files.pythonhosted.org/packages/85/71/3f54e1535c8f323d91ba566044d7c2b39ff6f6a2f1d0bd9071779d07b9b3/propcache-0.5.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:b61805357d966680acf68b3b6d49772631ed9df44ebece10ff1460e117a7da8a", size = 218350, upload-time = "2026-09-16T00:16:20.064Z" }, + { url = "https://files.pythonhosted.org/packages/a8/f4/025890cc389ac3ec485ecec607d4a7ca47e15bfa2a465746ab98af602536/propcache-0.5.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:58134228927cee6c047d626c08e60a81be604a20578a12ce752cc5c9a84d4826", size = 233156, upload-time = "2026-09-16T00:16:21.624Z" }, + { url = "https://files.pythonhosted.org/packages/04/29/b39cae08c87c140d3d274f0a2c058cb5588e836175c3309e260b230ab07d/propcache-0.5.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:350b272b2279f4135a64fc0c304a5d08e28a137c9573442c606152446638a831", size = 206206, upload-time = "2026-09-16T00:16:23.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/61/e16462ef18a87247dc9ebbd5c606f46d5ce67e708bd9cc734dd0d9222564/propcache-0.5.4-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:45bebbe252550fec975ba3b62bc6f931643cfd3b5464ef47619cf3fef154e01c", size = 234469, upload-time = "2026-09-16T00:16:24.841Z" }, + { url = "https://files.pythonhosted.org/packages/9f/84/b6a1490922427204fc47df920ed002eec709621de6b79b11592bf45c623a/propcache-0.5.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ada748108a43d29b7c328ba7db3755327cd94f028bcc1a7ee3f0addcfacd9c38", size = 227311, upload-time = "2026-09-16T00:16:26.549Z" }, + { url = "https://files.pythonhosted.org/packages/ff/5c/5a59527582e9bcb694b2f08b9894134b65a0f5f79dbff174f054f5f74ed0/propcache-0.5.4-cp315-cp315-win32.whl", hash = "sha256:ee19113bce2f3acd46432050688b70f61acd6857d75abb9ec96341b7e9ced123", size = 43512, upload-time = "2026-09-16T00:16:28.313Z" }, + { url = "https://files.pythonhosted.org/packages/26/07/93cf699ed363681e754d7c3fad587fb09ef6b65618ee193332ad16a68d7b/propcache-0.5.4-cp315-cp315-win_amd64.whl", hash = "sha256:ceb3e879afac028f93d272c957814695dc5569e4904262dbee92f6c41bd5e4a3", size = 46264, upload-time = "2026-09-16T00:16:29.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/10/fef04fbdcd44a4a163cb5ff5674599c6d6fdefd64a5a459438f9ad2ba042/propcache-0.5.4-cp315-cp315-win_arm64.whl", hash = "sha256:c83acbce9f2b5e3f5f5eda9e53d2001fed22fcdfef81274a9e02d8fd53b70a30", size = 43395, upload-time = "2026-09-16T00:16:31.5Z" }, + { url = "https://files.pythonhosted.org/packages/70/f6/7e2f4dab0b92ab46111bd48cee9ee1e5f519514c44e3779ede5358d7ada0/propcache-0.5.4-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:a5e8ef588c109725dc713ba69aadcac00a1ef90c2ce9c0a8c7075128f569f47f", size = 89825, upload-time = "2026-09-16T00:16:43.115Z" }, + { url = "https://files.pythonhosted.org/packages/9f/8b/dfeff925cb6ced97ede701d5c6a99998da963c6f2e06abbf879c9dac5b54/propcache-0.5.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:4d86476a935c88963d9b8e1a9a0d38188790e9622169bfbafa173046846709d3", size = 52159, upload-time = "2026-09-16T00:16:44.754Z" }, + { url = "https://files.pythonhosted.org/packages/24/6c/924c810be5b7cf218ef47e707cf06d34adb4e3f3a31e3c24c55c6d945a88/propcache-0.5.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:f5470694918830da62fac9e69133b53d23b736d7070e587b27a4a2be37e08e68", size = 53956, upload-time = "2026-09-16T00:16:46.762Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b6/9ed0a5c939b58b6bed740a05b5d0f919f0b318d03284b4b6d81a0fe8a29a/propcache-0.5.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10ef33a68a61ce317e095fd2e202a592ea92392b90944a78c993f0d9a73ab06c", size = 295235, upload-time = "2026-09-16T00:16:48.577Z" }, + { url = "https://files.pythonhosted.org/packages/5a/eb/5ce886e902a2e781dddf110993d5329458a9b1a8626b876c65e5e25bf413/propcache-0.5.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5cacf3c9efd09df409dc33654dd077e1c245ba8fb747b0f0236ef41b7c49b589", size = 294463, upload-time = "2026-09-16T00:16:50.539Z" }, + { url = "https://files.pythonhosted.org/packages/f2/88/c98f49183ecd3e5b204a556f0ca47baa02c2206a500fe8c7ec1726297b0a/propcache-0.5.4-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:770e8209d018175fc0063936fa9583b6d27e88c5ad31543f3383d66080efdd62", size = 300081, upload-time = "2026-09-16T00:16:52.423Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/c5090f9e6f67cbc30a2b744c7bb0f8006dcba5ec1b0d82f866ae1cc7c5c4/propcache-0.5.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03969626faf0783a592dfa17e28eac06018bd0b44dafae6943d53b92421a7f72", size = 285360, upload-time = "2026-09-16T00:16:54.141Z" }, + { url = "https://files.pythonhosted.org/packages/ac/9c/34a55396910583ed07926669ab309dde2213a2dec05a7e946bb90ad66908/propcache-0.5.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef3b928d9c984322b5c44e6964d8dbc653da87d2d8ee1647fa6da43072e650a9", size = 248014, upload-time = "2026-09-16T00:16:56.062Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b5/c0a142b656093ca397039dd3fe166cbb87c945712b534546514a24cd2611/propcache-0.5.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7177c43eddf10a0893c4fec52ebb408fdcd7f7d63962caace9180d8f81b14ece", size = 280662, upload-time = "2026-09-16T00:16:58.044Z" }, + { url = "https://files.pythonhosted.org/packages/54/28/fab2809c2e337fe26becea9648e84d5cef46075c91b826acb13e4f9dd04e/propcache-0.5.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:420162a77f94eb1cf5ef7893f500016dabd548e73de956785a1dd899cc73006a", size = 266149, upload-time = "2026-09-16T00:16:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/3a/11/7ddf336288b2678a5f054f8da2e2bd1a719f5d4b7de714d9c6bd588a2313/propcache-0.5.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:3eb2e820e8e2101407da93f17c57cbb7d225461955fc60105daaba14cd421ee2", size = 283097, upload-time = "2026-09-16T00:17:01.459Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ae/351b1a5225f5473c411d9a612a229ae147cf0cf65c72ad838b87219ea8e8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:13e52b6e0bde97dee98ab66552dbff2931649c96f1ac432eac299fe689ec373b", size = 248160, upload-time = "2026-09-16T00:17:03.298Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d0/7f79f061e30d135bb615c9782c94a74652033d00b49254edbbf35a9165a8/propcache-0.5.4-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:12682126712ddc19b70ff819debbd279e58adf1f0c8f8f8138c18ade2044b284", size = 283036, upload-time = "2026-09-16T00:17:05.238Z" }, + { url = "https://files.pythonhosted.org/packages/53/3c/016f1cad8bf4c428d748cf399b2bac603026fbfd6966e6a5579b5c5b6956/propcache-0.5.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:3af0c8642b2da4815d86e631232ac8286e17644fad907c19508aa8e7cb4ba8ad", size = 279350, upload-time = "2026-09-16T00:17:06.881Z" }, + { url = "https://files.pythonhosted.org/packages/ea/60/d8f72cb24b412487ed4c397f539117d3b74c3c33dd32020e91fe00a958a8/propcache-0.5.4-cp315-cp315t-win32.whl", hash = "sha256:1df8d8561b21465c5dd56110a01caf897e026d065b4b84e98a488209094272ec", size = 45874, upload-time = "2026-09-16T00:17:08.567Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/8bae0a316d406644450522f2f3d44a4e19632f5f3bb60d1d0e6c53842616/propcache-0.5.4-cp315-cp315t-win_amd64.whl", hash = "sha256:02c0a34f16889cf800f10f0247a564d8ce6eeab6ffcd7c87198f769067eb8432", size = 48574, upload-time = "2026-09-16T00:17:10.077Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/bcc053f66a97355683884b448198e79580fae8e8fa4d96b9bb01614e9913/propcache-0.5.4-cp315-cp315t-win_arm64.whl", hash = "sha256:dc4242ca653c9b30ab51c5f8193323e7bc0928f897ee9103201e59a43abcb72e", size = 45625, upload-time = "2026-09-16T00:17:11.377Z" }, + { url = "https://files.pythonhosted.org/packages/f5/cd/785c64ed382f3f04201870267b02783f63b4678c2acfddc177a3ebcc2727/propcache-0.5.4-py3-none-any.whl", hash = "sha256:62c60aec739ed00124573cce1178138fd690c7676352d67a37328c1cf51d7468", size = 16338, upload-time = "2026-09-16T00:17:13.106Z" }, +] + [[package]] name = "protobuf" version = "7.36.2" @@ -715,27 +1111,16 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/e4/1ba6f44e491c4eece978685230dde56b14d51a0365bc1b774ddaa94d14cd/pyopengl-3.1.10-py3-none-any.whl", hash = "sha256:794a943daced39300879e4e47bd94525280685f42dbb5a998d336cfff151d74f", size = 3194996, upload-time = "2025-08-18T02:32:59.902Z" }, ] -[[package]] -name = "pyreadline3" -version = "3.5.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf", size = 100368, upload-time = "2026-05-14T17:55:04.471Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d", size = 85243, upload-time = "2026-05-14T17:55:03.262Z" }, -] - [[package]] name = "pytest" version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, { name = "iniconfig" }, { name = "packaging" }, { name = "pluggy" }, { name = "pygments" }, - { name = "tomli", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ @@ -748,15 +1133,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, - { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, - { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, - { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, - { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, - { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, @@ -806,6 +1182,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "rfc8785" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/2f/fa1d2e740c490191b572d33dbca5daa180cb423c24396b856f5886371d8b/rfc8785-0.1.4.tar.gz", hash = "sha256:e545841329fe0eee4f6a3b44e7034343100c12b4ec566dc06ca9735681deb4da", size = 14321, upload-time = "2024-09-27T16:33:31.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/78/119878110660b2ad709888c8a1614fce7e2fab39080ab960656dc8605bf6/rfc8785-0.1.4-py3-none-any.whl", hash = "sha256:520d690b448ecf0703691c76e1a34a24ddcd4fc5bc41d589cb7c58ec651bcd48", size = 9240, upload-time = "2024-09-27T16:33:29.683Z" }, +] + [[package]] name = "ruff" version = "0.16.7" @@ -831,72 +1216,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/4b/51327018d056f0dad2c2238f26d1fb0f53707a9d91b75dea6d1b3039f136/ruff-0.16.7-py3-none-win_arm64.whl", hash = "sha256:aab7f39e2c9df6c596216070f98eef1207b94f8516cca20c808826974971855b", size = 10412401, upload-time = "2026-09-10T18:04:04.098Z" }, ] -[[package]] -name = "sympy" -version = "1.14.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mpmath" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, -] - -[[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" @@ -906,135 +1225,10 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] -[[package]] -name = "websockets" -version = "16.1.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.11'", -] -sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/e7/d1671fb984f9dd844e1da5288070c7c23c9eaba3082d3871aae19c3ab8b9/websockets-16.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:49ae99bdfcae803a885c926bf14f886196e84925395bb3f568fef5c0f0979d7d", size = 179570, upload-time = "2026-07-17T22:48:24.032Z" }, - { url = "https://files.pythonhosted.org/packages/99/f5/70df723bf571f5e0b1b845e0a4ff1c966eeb84f667599fc251caa37d15a3/websockets-16.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5bfd1ac19b1b9986a9c95a82d5e23a391ebb09e12c34d7be6094b86efcc35731", size = 177252, upload-time = "2026-07-17T22:48:25.775Z" }, - { url = "https://files.pythonhosted.org/packages/90/72/2f14b2e167170b8bf1c8bb7f9b0d78000f470d41a2085a91f33e3917b6c9/websockets-16.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9246a0d063cfcbcc85f2359dd6876d681213f4790832272aa16641b4ed5d64d4", size = 177530, upload-time = "2026-07-17T22:48:27.337Z" }, - { url = "https://files.pythonhosted.org/packages/f3/18/a17e2f0cde02dc10154c808deed7e1d8528afff93612f70d3f0a5b19b011/websockets-16.1.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1214e673c404684b9bf7154f5cf43b45025b1a6160fac3a9e438e9c1a97e22cb", size = 186038, upload-time = "2026-07-17T22:48:28.756Z" }, - { url = "https://files.pythonhosted.org/packages/d5/b0/41de283899cf5929d637b72a508cdbc9aa40dc0f317c6b77613fd1000488/websockets-16.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90001d893bc368e302ef168d82130b4e4fdd27b85fa094682df9b667c2d48838", size = 187278, upload-time = "2026-07-17T22:48:30.328Z" }, - { url = "https://files.pythonhosted.org/packages/50/61/874aab5257e027f9f61b5004cec65e592babca7942b1bc09f38e72b7f1fd/websockets-16.1.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:130937b167a52af203c8d58e78d67705874e82759862e3b9671a452fec4abc87", size = 189936, upload-time = "2026-07-17T22:48:31.896Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1a/42173913ac5519607220849ed417c864d77384e4119f06dbba964a50f096/websockets-16.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c9f23004a3d40e89c01a7955d186a6cc83418d93b749701944ce2de3e95a1f3", size = 187796, upload-time = "2026-07-17T22:48:33.344Z" }, - { url = "https://files.pythonhosted.org/packages/1b/f4/37c1840bd89b529479aec41470b97b7c683b107ca90b6399ac5afb99dedf/websockets-16.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f55f0b01956a094c8587146d9558c91937e78789c333860ffaf35931a6e5dbc4", size = 186481, upload-time = "2026-07-17T22:48:34.843Z" }, - { url = "https://files.pythonhosted.org/packages/9e/70/652d9b964adcfbeb056f42e0ca6bece34d108fe75534e74df20643cae199/websockets-16.1.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6aaface73b9c71974c6497366d8b9628357f6c9749e09c4ea3610176c63f2ae3", size = 184351, upload-time = "2026-07-17T22:48:36.307Z" }, - { url = "https://files.pythonhosted.org/packages/13/f1/af3850e5d48d482921985be72ebcb169c6180b3a77b57bd612deebcee23b/websockets-16.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc0fad4933f427acd5b1cec210f3ea6dce7089e1724e4b9ec6ef47c6c04d1b3b", size = 186791, upload-time = "2026-07-17T22:48:37.762Z" }, - { url = "https://files.pythonhosted.org/packages/1d/40/1a4e3ed4969ec378dcad337e5f1472c5e292cb3e733bc392f0dc2e230abd/websockets-16.1.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f2769a0344a09e9ccf5b3cce538bc75a51b53eff3275d3896310c8552049195d", size = 185413, upload-time = "2026-07-17T22:48:39.127Z" }, - { url = "https://files.pythonhosted.org/packages/aa/3e/4e3fa1afe8f1a6a780434cd9ba8eb422632b044eff3dd73f6af67523c147/websockets-16.1.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:f70541f3104339f59f830522d94ebadb1bf47426287381623443d8bb1cdbf33d", size = 187178, upload-time = "2026-07-17T22:48:40.676Z" }, - { url = "https://files.pythonhosted.org/packages/71/ab/dd742766aa5dda7f349be0de49e4d565b84cf6f7f7fa02e07692f0f2bdd9/websockets-16.1.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:dc385593a42e31cd6fb60c19f0ecb015b386603818fc2c6c274fb42bd2bb4165", size = 185051, upload-time = "2026-07-17T22:48:42.098Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f5/76438c6560f416f1c0a7f587679fb97cc6e99ed336011d43ce2002dd27c1/websockets-16.1.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:387e8e4aa5df2f90b198fa3cad3478822a89cf905b6a6d6c97dc3664689640cc", size = 185846, upload-time = "2026-07-17T22:48:43.472Z" }, - { url = "https://files.pythonhosted.org/packages/62/12/5c0320f2127823d27b2d56d611d31b0b284ad4edcb41364d66bf4c92b537/websockets-16.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fd46fff7eb62c24804d234f0051c7a8ea81285ad63e0337d3dcf33ca82aee58a", size = 186066, upload-time = "2026-07-17T22:48:44.884Z" }, - { url = "https://files.pythonhosted.org/packages/a2/97/875986b857b955c3f9dd192cb8a1af81254dfb2ea22cc9590f0a1e020b8b/websockets-16.1.1-cp310-cp310-win32.whl", hash = "sha256:7883388947767080f094950b342b30d35a2a06b849cd967c422fa0db72b40ea9", size = 179940, upload-time = "2026-07-17T22:48:46.481Z" }, - { url = "https://files.pythonhosted.org/packages/54/82/1013a5fe7ddae8e102bc3b4b39db81d8d28fd02100a324ce6ede8cd832b1/websockets-16.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:d57685547e0060cc6fd90ee6a28405d6bd395e525545f13c8d7cd99c78afd79f", size = 180239, upload-time = "2026-07-17T22:48:48.043Z" }, - { url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" }, - { url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" }, - { url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" }, - { url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" }, - { url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" }, - { url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" }, - { url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" }, - { url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" }, - { url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" }, - { url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" }, - { url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" }, - { url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" }, - { url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" }, - { url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" }, - { url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" }, - { url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" }, - { url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" }, - { url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" }, - { url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" }, - { url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" }, - { url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" }, - { url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" }, - { url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" }, - { url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" }, - { url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" }, - { url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" }, - { url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" }, - { url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" }, - { url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" }, - { url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" }, - { url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" }, - { url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" }, - { url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" }, - { url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" }, - { url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" }, - { url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" }, - { url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" }, - { url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" }, - { url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" }, - { url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" }, - { url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" }, - { url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" }, - { url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" }, - { url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" }, - { url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" }, - { url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" }, - { url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" }, - { url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" }, - { url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" }, - { url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" }, - { url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" }, - { url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" }, - { url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" }, - { url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" }, - { url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" }, - { url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" }, - { url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" }, - { url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" }, - { url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" }, - { url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" }, - { url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" }, - { url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" }, - { url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" }, - { url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" }, - { url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" }, - { url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" }, - { url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" }, - { url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" }, - { url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" }, - { url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" }, - { url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" }, - { url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" }, - { url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" }, -] - [[package]] name = "websockets" version = "17.1" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", -] sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/7d/ad/66a74d42fb537bd44056483eae6cbb7ebb10b742c300a0bf8cee427556d4/websockets-17.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:88b882764ef65147a7a5ae13168dedbe225a04e2ff4858fe543f2c402f093e9c", size = 216984, upload-time = "2026-08-26T14:55:20.747Z" }, @@ -1186,6 +1380,139 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" }, ] +[[package]] +name = "yarl" +version = "1.25.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/16/e8be8e2fb175bbf41a0680381a319f1199fae256588241a2ac8677eafb49/yarl-1.25.1.tar.gz", hash = "sha256:03dd38de09bc213e9a8b29761eec33ee1d5318dac0e49d8af36e4d27830e23a7", size = 246245, upload-time = "2026-09-15T19:35:02.264Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/b3/2cea721d495ca57f8f414aa4867ee263f486b274e07463158cf52f02ba7f/yarl-1.25.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9d693bf4bf534e9ba3ae2780cfd577f5135629f7b5ac653490859d0b77864865", size = 143794, upload-time = "2026-09-15T19:30:26.946Z" }, + { url = "https://files.pythonhosted.org/packages/55/e6/cd145cff8e5cf60b8b3c41fbecfa2a45028a8dec3fbc52bec03595ff3d3b/yarl-1.25.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ab2054c5531af2a9ba7b69b8ec91e4f884420e83a8c5e579b013084cb57e5e5d", size = 103993, upload-time = "2026-09-15T19:30:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7c/fbb40fe2d53747c40aa36a9e2bd2178a202f942bda0b670f3306d4aefbce/yarl-1.25.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:564fdc7085d2245ab84f88882fdb1d6ac0723124bff6ded35bfb1c00f812630d", size = 104010, upload-time = "2026-09-15T19:30:31.069Z" }, + { url = "https://files.pythonhosted.org/packages/f0/0b/5a516f70641092283f57cf3670bdb75e7327bcc0dcb5038697e4dfbfd569/yarl-1.25.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acae6b45d1ace09b6ba3876da43b88366ef368f73b988c7f57e14231753d4420", size = 116444, upload-time = "2026-09-15T19:30:32.894Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/d1a627f827b0a404ae0f5647cab0534959081cde13b0756a783469fb3b5e/yarl-1.25.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1fb2a01ba8cd9c5d2c5dc1ec35e0fc951d04b4f037541d4ac090c993ce58b3d7", size = 107565, upload-time = "2026-09-15T19:30:35.188Z" }, + { url = "https://files.pythonhosted.org/packages/aa/cf/c8e0aaec886840a6c4480fe44eaec7cd4319f79a563b79321473be79c56f/yarl-1.25.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e92b6bcc741b86d67606c40d3cb9c7cc8e6c737f81e31f4a94efc204456c92e3", size = 125006, upload-time = "2026-09-15T19:30:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/50/26/0cce366d54a93cdc8342965dc7663385e4db161625cc1e8b18e786753d24/yarl-1.25.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:72c34ac7ad4314c19362d5ce27626dcc8429bd30bbf8c179f4234078851f9492", size = 128717, upload-time = "2026-09-15T19:30:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/95/0c/a71501bbc1a674ff72c4d6c2b75f4d9a5af819f5244c3a7558080a8802c5/yarl-1.25.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5add7b4ca7afeea91d52e4d4e4db3b1fe9885b71f07054560d8c4296b7441a2", size = 117728, upload-time = "2026-09-15T19:30:40.809Z" }, + { url = "https://files.pythonhosted.org/packages/e7/6f/c3267ca01defeed9ed9c4ff9b17bd54915432c405945233265b707475d1c/yarl-1.25.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:def538065f9e4d4cf1ae164bd59aba00dfa84f03923e0de4c3788f252d6bcd17", size = 116224, upload-time = "2026-09-15T19:30:42.818Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/fad57ee52d648431718ee0f1f68966a99c1352c3924688ffbdcc9d3fe51a/yarl-1.25.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a191bfdb30a79b98e5d175d75285f9fcb78bf0e46ba5efda042e1c72071a0de", size = 116299, upload-time = "2026-09-15T19:30:44.966Z" }, + { url = "https://files.pythonhosted.org/packages/2a/d4/6a8c1e29f33338687ca278cba0a8fbf6525a322c2c02a9a500ccbe041152/yarl-1.25.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:71f42c5b9a948c113bbdebfa544598321431d064ff959d32e99b1feb61d68345", size = 108625, upload-time = "2026-09-15T19:30:46.874Z" }, + { url = "https://files.pythonhosted.org/packages/e8/d2/3a35ae791c9cb6522c106923ff25c3230d999091e5e65511bca23bbd9914/yarl-1.25.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:72849d892954be4d09e569b8b831ac39ce58417fedc767d4308a0fe542018a40", size = 124515, upload-time = "2026-09-15T19:30:49.082Z" }, + { url = "https://files.pythonhosted.org/packages/be/fd/2b022109a6b4af0f7dc371cf7500af380b0d4f034010243e1b0ce218dc93/yarl-1.25.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:efb01a106f971cb3752856bca2318bbdf7f01bd8823779c461586cbe5ffd5258", size = 115711, upload-time = "2026-09-15T19:30:51.208Z" }, + { url = "https://files.pythonhosted.org/packages/18/59/f7586271136c3ddb0126bbfe661844699369b76b555fe38c4efe86870b2e/yarl-1.25.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a1daf47cd95a7c3a63456336bc5aaa8c86dd3a47d07ed3d0e76132ae4666a5a1", size = 122751, upload-time = "2026-09-15T19:30:53.535Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0b/07f7a2d881f7e16c385b47fc1753382600847cad305dcc7fa0c25828acf8/yarl-1.25.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9489e6abf47ba37f332075a91444c7cfedb03e6ce99fbb2f116bfe1ce810da3b", size = 117983, upload-time = "2026-09-15T19:30:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/aa/9d/8cdceec66a9b940700cb45931741403f045b162afd79bcb93c41cadd0972/yarl-1.25.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7306dee25b8a0e737363f347362b875094b4dc4e367311470656ae420fdbf8e", size = 102894, upload-time = "2026-09-15T19:30:57.606Z" }, + { url = "https://files.pythonhosted.org/packages/17/f1/7ec357db1d3ad2863542d71e8fe64a126bbec17b134cd7d30951196809a5/yarl-1.25.1-cp311-cp311-win_arm64.whl", hash = "sha256:abb1384477f5901d436b5d2e5465954de46ea6098f59163d243660b5c4461d35", size = 98609, upload-time = "2026-09-15T19:30:59.705Z" }, + { url = "https://files.pythonhosted.org/packages/75/b3/cd32ac66ae622b854c2df0ac52106dda220d361b65a64fde7d5b3684aa3f/yarl-1.25.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:94d7aa6debf92a1dd14cb5280b083a764169a13cfb23a452111160274ed989f4", size = 144798, upload-time = "2026-09-15T19:31:01.821Z" }, + { url = "https://files.pythonhosted.org/packages/61/fb/a2c52a8007c2051ba74662afb112ecf3d00346af4c25e33df9d80fd14fb8/yarl-1.25.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:83d4a37e4b95da4d8bda930d6d35b75b4cdadbacbb4980cae290ea3100b5d51d", size = 104583, upload-time = "2026-09-15T19:31:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/be/dd/ee38aec8e09fdf957e50d4085453fbe202f56c6c3b4cf07b81cdb4f09ee9/yarl-1.25.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e029648f9c951db30e98a7d7ec90835db88ec4b32820efe2a9bdc2287e032eb6", size = 104325, upload-time = "2026-09-15T19:31:06.338Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b3/058dbfb1857b484c9cf9cc135659f50b85ce66e03c99e44dc2f7b6161f55/yarl-1.25.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d781294bb815ecb5ea57ff6bbf8038e0a31a95fdf3e1788f66e0dc100d64b58", size = 115358, upload-time = "2026-09-15T19:31:08.593Z" }, + { url = "https://files.pythonhosted.org/packages/db/39/29693446cf0cf6b15a0e2f75a5d40f93c56819b05b0622196f45e95b5cc0/yarl-1.25.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e12c538e00e7c1b286a07061046b90e8124e6a9793efae2c70db6a4aad07faad", size = 107658, upload-time = "2026-09-15T19:31:10.802Z" }, + { url = "https://files.pythonhosted.org/packages/86/b3/3c4dd7e1af43b931fba95e0a722737f2ea94a6d199c802585282831d7abd/yarl-1.25.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e4de3ac4adbad3d0bc7c6f4360a7dbff5de2f15e3b723be3198074e17fd9c40", size = 122660, upload-time = "2026-09-15T19:31:12.84Z" }, + { url = "https://files.pythonhosted.org/packages/bd/b5/1b60dbc3cfc9c5712b15148c206748f2bc93953ffdbe25ea75b63dfc89c9/yarl-1.25.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:419f392a1da624877975709e3864dfe833af6cc7671b39318086d456e288380c", size = 126506, upload-time = "2026-09-15T19:31:15.088Z" }, + { url = "https://files.pythonhosted.org/packages/bc/7b/ca212cbe170ac8b96e45317ecbcf9c3c3ecf0cdec98d5b088a9c4088929b/yarl-1.25.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6f117789d22dce188e5754e8bc65b7e6ebf8cb73963b9fa761f672a5883769d", size = 117050, upload-time = "2026-09-15T19:31:17.241Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c3/72b4938cdbe619ad71ac156182faef4908846b84dc3ca4dbb4c4e6f84014/yarl-1.25.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80e47012e730da131c9f059c80936783f9659aae22dc31c03c0595590d11ed54", size = 114174, upload-time = "2026-09-15T19:31:19.294Z" }, + { url = "https://files.pythonhosted.org/packages/e8/43/268717870f9ba0cc9701a95181587f6dc8c5f387aab4aeecc83158f38a79/yarl-1.25.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e80f557716fd765439577131e526b8942ffc2c07bdbc5e39fa62f660ba1e963f", size = 114944, upload-time = "2026-09-15T19:31:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/da/84/baa5bf504d51fe062c4bcaf62936da97fffb43285978d0b39984824231fd/yarl-1.25.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f61964f235a43738bfac50da46fc4254943a7eea3051aeb0b6fc7c992c29fadc", size = 108263, upload-time = "2026-09-15T19:31:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/779a2ed9e0152a601a27039bed9aead3f0b79797a67e2c44bfa444622dd8/yarl-1.25.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e546fe1d4a93ebc2910f0d768baff19faa09843ab3f2036a67ed6e69fae4419d", size = 122184, upload-time = "2026-09-15T19:31:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/f8/1f/118e9e5b8f07694d63fd3222e801d7782270003f1a222aa798df3f8d5933/yarl-1.25.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cce0727fd5ac04d372fa9bbfde9febc2bcf209aadfcf0468e45dec72719895d1", size = 114001, upload-time = "2026-09-15T19:31:27.465Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/a4cf1cf372313734b17996d4007f9f73596e7a178b9485802e5494ecf484/yarl-1.25.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af4ea5b37403ef4e30f3927eaed540db942bde01d8d3ff083527c0704d1c9c68", size = 120565, upload-time = "2026-09-15T19:31:29.47Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ad94f93ca731bc9e44d321833ab96b82a4f9f5f63cf773f81a4aeea5ecc1/yarl-1.25.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:68782fdb4027b8d1eee25ec35e9a6db05e863b899eb0310b3a33b6c3fef55707", size = 117060, upload-time = "2026-09-15T19:31:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/bb/cc/51a7b4abf4ac593b8e7eb3794b28e5a35ae26eed8bc04787628d215af82f/yarl-1.25.1-cp312-cp312-win_amd64.whl", hash = "sha256:7d575b54cb3863ef9bc290ea4b009999d55dc237326131e4853cf33e888fee03", size = 102593, upload-time = "2026-09-15T19:31:33.329Z" }, + { url = "https://files.pythonhosted.org/packages/9d/21/0941a6b93a58b59a1ec75e5333bf06929b671309c43c0cd201c172d9c39f/yarl-1.25.1-cp312-cp312-win_arm64.whl", hash = "sha256:bc3ac7bf569f6b64dad04dd7808c7872dae8a97df657856eac05e9b7e3614a85", size = 97697, upload-time = "2026-09-15T19:31:35.855Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/2f3129bbcc9a5c8ba12cc2b29d8060a3bab9c8043c456cfd4b5ca3188890/yarl-1.25.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:25868beca8b6765f8f7d0e11fe6dd7c66dd4b0793b9500286d20cc92352126a5", size = 143623, upload-time = "2026-09-15T19:31:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/f1bc3390fdca352826676b531d0712736f156919090206700421d46b2c37/yarl-1.25.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:10b2fd95332f0d716d5eee3c9fb2ce8eada19082de7fee83d32e37992fd75c26", size = 104011, upload-time = "2026-09-15T19:31:40.25Z" }, + { url = "https://files.pythonhosted.org/packages/a8/aa/50acc5c3e5da04172ae3c281c75405af4d2ca911e16120ab0563f4dffb66/yarl-1.25.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0f12afda4eea8c8994a76d4df1875c765194f5fbe8a9d197929ea303caee29ec", size = 103677, upload-time = "2026-09-15T19:31:42.46Z" }, + { url = "https://files.pythonhosted.org/packages/30/d2/7d1e0ab9f8390e1fbcede5a6dbf70d23c96ad09b8c5567f3a514d1ddb0e2/yarl-1.25.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14b79a30a93a3ce2e8832603fd0ab780ada281b0ba5110b519a634f2d7d7d1fc", size = 115392, upload-time = "2026-09-15T19:31:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/71/e1/5ba1e3a2a22139213655e760919038e8ed7e2d4a99826d0bbddb3beb96e5/yarl-1.25.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bd6340d20ae2c7ca719b87b426e808e90743b676d05d4c26c4fb5ca71f41184", size = 107493, upload-time = "2026-09-15T19:31:46.273Z" }, + { url = "https://files.pythonhosted.org/packages/f5/53/780653d5e0f73831f467cf13548912e5eec97f21dc49fc8daf21da027df4/yarl-1.25.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:126a2533570c554719ca40a1288fdee1700b6bc82e7131aa69fa85252d92e651", size = 122537, upload-time = "2026-09-15T19:31:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/03/92/d54fa70236c6036271c9c9c09fd978df5cbe3ef49ef6c46e9b833476d215/yarl-1.25.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a3faadac7d812ddac258feb57b9846b60c1b437c4f4b9ad42595c6f6fe4390df", size = 126170, upload-time = "2026-09-15T19:31:50.872Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b7/a82a49bf88340b837ef6972b508a1604ae377b9e6904b46b10cf5f1cf925/yarl-1.25.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be80550d9bfe83d9b62398a37081a90434e6df2d978ec345c3d2820de6beddab", size = 117012, upload-time = "2026-09-15T19:31:53.189Z" }, + { url = "https://files.pythonhosted.org/packages/ef/78/5d684b411e3f3602464ee9b538db48205038f8605872985f61efb809ced0/yarl-1.25.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e07595c7d6f4db270ceede356a1bd1c07a34f1c26f958d1ed0cd7b48e0d2bba3", size = 114950, upload-time = "2026-09-15T19:31:55.694Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/51d82b852c64f7fad0fc7a7ff3031517204887e874c722bbca839c0b23ac/yarl-1.25.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:eb96ed1ae6c7d072d60840c0434aef07a2df611812810807fbc54263a6053e9a", size = 115428, upload-time = "2026-09-15T19:31:57.966Z" }, + { url = "https://files.pythonhosted.org/packages/e4/49/9d1978049bf646b9ea918313926453c6901b71c92f097467777d47d36a88/yarl-1.25.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3feb99222553a8cbedfa52c2f59dd84c3f50d5b582c728d522caf8d72769a54b", size = 108428, upload-time = "2026-09-15T19:32:00.048Z" }, + { url = "https://files.pythonhosted.org/packages/43/35/7b8f1ebb45d7ec3dda7d1909bf44f458de41ef91e2937f107733582a5166/yarl-1.25.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a2ed0ba415ccdf08f14bf544cb78346d0f76086707ffee24921a2c84dbf1305a", size = 121961, upload-time = "2026-09-15T19:32:02.436Z" }, + { url = "https://files.pythonhosted.org/packages/63/d6/d8b689ab7ca26edeb85f6ff28812aac7a25376eefc1780e303a7bfbaceff/yarl-1.25.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2b49375d22299b0a834c2bca72f39aaecc270d96fb24c30424899676f487b22a", size = 114961, upload-time = "2026-09-15T19:32:04.456Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d5/1a1798ea4dc6b7ee3260010a27907ebc697c95dae99817d817ed446d24aa/yarl-1.25.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ef74070ac553c59eb4f04258722066d6c6135b7baa03b2e9f2da65c096e96d98", size = 120036, upload-time = "2026-09-15T19:32:06.5Z" }, + { url = "https://files.pythonhosted.org/packages/91/8d/b1b35ed7903da6669b1d367cb2c09436acd4ff508029b4f39a0c0c2058fc/yarl-1.25.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0a66db89ea473abeac4b70523cafd94db3772380e565f9d28af7a179b7af71fa", size = 117276, upload-time = "2026-09-15T19:32:09.401Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8f/4db01cef62caff0d7a4593ed694fb8a41a27a11158cab80d290221f13e57/yarl-1.25.1-cp313-cp313-win_amd64.whl", hash = "sha256:1f51020b2eb8a003c84925638ec63c21a750a4bddd3a22ec8eac6a742dadf1b9", size = 101945, upload-time = "2026-09-15T19:32:11.545Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/3ce00497c5c0babb74d4130c10c3828ccd215b4819d12020c42429f991ac/yarl-1.25.1-cp313-cp313-win_arm64.whl", hash = "sha256:b10dd0557ba422715b5206b3743192135a6022acca8baec51aa127d0a75db8fe", size = 97270, upload-time = "2026-09-15T19:32:14.127Z" }, + { url = "https://files.pythonhosted.org/packages/80/cf/54023edfab7aa773b860503db0c56e962ccab0922803ee97988c176ea090/yarl-1.25.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:a9ca696eb02e5c02a8afd872ada510eba9b7fe6e68b9572c2e9a9b1941e31e2e", size = 143975, upload-time = "2026-09-15T19:32:16.416Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a8/e6c1be0e6761d0f2d10bbf33a3e1e02b99dc83874d92945d7b461a72481e/yarl-1.25.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a5877f2255aab518ebe528289037699201d5dc5f045f2396cb30aa02db22f57f", size = 104018, upload-time = "2026-09-15T19:32:18.364Z" }, + { url = "https://files.pythonhosted.org/packages/6e/bb/dda344765ffd3430afe1a1c66c866a57fae67786537d4f14607df6505ac1/yarl-1.25.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7a5c3115595995779ee21f2567035793911c3802a43c74f3fbb0314929ec67ac", size = 104156, upload-time = "2026-09-15T19:32:20.459Z" }, + { url = "https://files.pythonhosted.org/packages/e5/5f/ed1538bcd06009fe990d6d283dd7667f639e62a81e35c6d8c6ef6c08fb3c/yarl-1.25.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77e5099b99b37f3cf79c246998ca9f7313a78054cd1809ec46bc1afad47e1c4c", size = 116025, upload-time = "2026-09-15T19:32:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/a2/af/2185daf56b99830d3356ecfada46faaa49945de6626e842b7728088d4980/yarl-1.25.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6efaf45df6a849cef613a03a94c845647456662f85438c886bb67a9c027c8c2c", size = 106985, upload-time = "2026-09-15T19:32:24.749Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/bc1ae564fb4b04a30b6a8f250e787772581c57e4c3d5cf07ac3359de3103/yarl-1.25.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5f90e44653c4e0f78501ed9bb7d3fce835a8d62b7c6ed0cb16557534087e743", size = 123030, upload-time = "2026-09-15T19:32:27.084Z" }, + { url = "https://files.pythonhosted.org/packages/6a/3e/e2afcde10d74e53b3fa889960991efb3019beda2b1682a01de720a302056/yarl-1.25.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:632da579b2d879f6bad20f2cfa35ded1efe2f4f77f8abb26a6234a5b236acd2f", size = 126765, upload-time = "2026-09-15T19:32:29.332Z" }, + { url = "https://files.pythonhosted.org/packages/a2/be/415b00c0fe5a0615b062a456b26623d7ec91c2bee20faea1a14045aa0469/yarl-1.25.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:30eec96e8a91bd588ce897c9543f6d5d8d34b28fbcba28a4dedf20ebeae9fe57", size = 117199, upload-time = "2026-09-15T19:32:31.49Z" }, + { url = "https://files.pythonhosted.org/packages/97/27/3d8c63ddd3e8bcfd033748ab93876678ce59bacd66e4cb1ed851c9c5b37e/yarl-1.25.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:12b6bc4906e11f5e1a1cdcb12296e7afbd366c783cc8073403cd2fb74334e453", size = 115187, upload-time = "2026-09-15T19:32:34.137Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/7a81d0be1a502a26a0d4326c6f2ecb736c824f570ea1c6529f2b0b227b50/yarl-1.25.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9d6ed3d17bccce4c05343e1ca8da13bc5c02c812a4e7282ddd05e8769322d3fc", size = 116085, upload-time = "2026-09-15T19:32:36.438Z" }, + { url = "https://files.pythonhosted.org/packages/f0/69/39fff459916aa0fab42215dc47b759586fd80f94aa56dfc4a7c15ba6e0dc/yarl-1.25.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:f38a70074041d3b7e138e452799f5174198bae5bd5ab2000917badf403908c5f", size = 107996, upload-time = "2026-09-15T19:32:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/c0/39/80b9a55a3335590451d9ecf3eb593a8c635351f4c905ef056d7e8a8fd9e7/yarl-1.25.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca89e4e21854ed27ec753297dde84b16c9f8e53b14a4866fb44457d643c19f8", size = 122549, upload-time = "2026-09-15T19:32:41.151Z" }, + { url = "https://files.pythonhosted.org/packages/42/7d/a179c6757818bb59372a4adafd09f7f26a3b4a0f04c3ae404b544c0b0c82/yarl-1.25.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:1ab7618921a93767387a4b83776f751588f5b5ae9bb5bc96620e2e2e00bca868", size = 115107, upload-time = "2026-09-15T19:32:43.072Z" }, + { url = "https://files.pythonhosted.org/packages/32/2b/a773ac867e4ab53a98ed98e5cefe3bae31e6f550252ca9d1de266f1a40c5/yarl-1.25.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0ae12ff2b805fa02c4dab838005caef735e39986322698c48588d3beacb65c62", size = 120666, upload-time = "2026-09-15T19:32:45.061Z" }, + { url = "https://files.pythonhosted.org/packages/bc/41/52be6505e85b0f76b4f85b01b5de7e06a0512201abc2c95e14e099549174/yarl-1.25.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90c30ed53546da833c700115c0064c22120d1b1560f474699fd31f22dd668233", size = 117505, upload-time = "2026-09-15T19:32:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/f5/01/349c0386caedbbe488d519f252df54efac8a1459282d466c474bdd84a620/yarl-1.25.1-cp314-cp314-win_amd64.whl", hash = "sha256:acfa7e22aa6c6e7a5996a41d275bfa01efa7ea56ab890590280e9063e2cf5c1b", size = 103446, upload-time = "2026-09-15T19:32:49.615Z" }, + { url = "https://files.pythonhosted.org/packages/5c/f0/8ec63180f77912f0dc4e5a42760cb8c08d20da1d5ace3578a01b84d1f3d8/yarl-1.25.1-cp314-cp314-win_arm64.whl", hash = "sha256:8e7d98cdbb6d71e726f7d525952867096053d1f290dd4e3c50d7d313a136f414", size = 99159, upload-time = "2026-09-15T19:32:51.686Z" }, + { url = "https://files.pythonhosted.org/packages/47/7d/92d2220d6886b70ab1ed8579533ac2af2dfac716d5d929001daff7986df9/yarl-1.25.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d21f0fa80a02d05299207eeaafef345d812ace96d5306e4ef265e1d419a615fa", size = 150071, upload-time = "2026-09-15T19:32:53.911Z" }, + { url = "https://files.pythonhosted.org/packages/64/fc/b245e448124bcda9340df38e3553fa222b50260fca027a84095e9bd8642d/yarl-1.25.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:17c9877a89fb6e2bca6f9087eb24cd7fb434653946ef5075e470d23d49b52287", size = 106780, upload-time = "2026-09-15T19:32:56.443Z" }, + { url = "https://files.pythonhosted.org/packages/51/e2/9a6ce2e334ebf218a30335ae76fb1696459430d42f733b8cb0d7d65b84d3/yarl-1.25.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:29273edf1530e397bd07cb784db1fbe0d2590b77569f2e24679a9c0a2d763b94", size = 107361, upload-time = "2026-09-15T19:32:58.827Z" }, + { url = "https://files.pythonhosted.org/packages/ed/70/66e8c76b569b450d16e190f15071c916c3df70b0e33927e415ac497cf0c2/yarl-1.25.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7abffdf37af1cec6a2ad69b827aa84320db5894791bc8ed932dc93fb274b7e9", size = 114396, upload-time = "2026-09-15T19:33:02.24Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/0d82838a05c57fdc05bc8b66e8c92dcc0df15e27463a5f163142d521c682/yarl-1.25.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2239a02249d9326655419e0168a28ca9008938eaab31dc29fc875c217927a6c0", size = 104882, upload-time = "2026-09-15T19:33:04.494Z" }, + { url = "https://files.pythonhosted.org/packages/86/d4/ea08615c4edaa6049a13a2f1128944d068d1893abda7d708d4d7ea01599a/yarl-1.25.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:664ec6a520b74a1df2810666eb67695fcb77fa663e6ea0a25aaf2e529cb24dfa", size = 119485, upload-time = "2026-09-15T19:33:06.583Z" }, + { url = "https://files.pythonhosted.org/packages/1a/82/0898bdce9b1ae403b308b9c733d0d24af4a3464270c2c081f457b16c3e0d/yarl-1.25.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4f1c91f5a5980a937ff8e238e98e6897e1ad74a4b1e2c0d68c73b5ffbb3f5c0b", size = 122490, upload-time = "2026-09-15T19:33:08.653Z" }, + { url = "https://files.pythonhosted.org/packages/d1/38/97d79b81c342b78246cfedb74809e68841f3198d21653e10d3232bd9c622/yarl-1.25.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c88edaec8c349ad4c5ad4c486a3defcc4b80ceb2f074436ffa0a87caf5e76a6", size = 115336, upload-time = "2026-09-15T19:33:11.056Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9d/2577896554cd310dc470adb6da0b7dd0b435cb63e2565204a7ac240e504c/yarl-1.25.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35dcbea443fafb3eece757ad4e514560ddeb6c34cfae1582c620d7b293d7feee", size = 111825, upload-time = "2026-09-15T19:33:13.204Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/7ac49d8ba84a5c4bd73415a4c949d22c749cb3762579b3d50e48019a78aa/yarl-1.25.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:882569ff613758cac762a457a5d72d6e211b28d4bcfea89d1d71ea942b02eac0", size = 114655, upload-time = "2026-09-15T19:33:15.553Z" }, + { url = "https://files.pythonhosted.org/packages/e5/18/e5942a16723f5b72f9b1297fd5a85a54f6300cd15c0dcb5005b90cd89156/yarl-1.25.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d0f1489233a254bb3643d2f05de7d59019254d81daeca6b9162fe9edef57e0c7", size = 106395, upload-time = "2026-09-15T19:33:17.599Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2d/549fa46240781513ebc47ae7eb418df428a163a2a3d644cc9cbb3ecb7846/yarl-1.25.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f41753a76f4f63927d03a0d8ba8f5ce0f2083bec29a8cfaccc55371b1564b96b", size = 119277, upload-time = "2026-09-15T19:33:19.973Z" }, + { url = "https://files.pythonhosted.org/packages/76/16/4763f78dcdc0b3b9fb3842b04afe72b9320857c6a69300c62a0eab03d119/yarl-1.25.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fb0eb4955adf0579001581f2f71a126e8781ba61bcd120f127b0401163c6c2d", size = 112504, upload-time = "2026-09-15T19:33:22.464Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/974e3edfe0d188393ce1cb9de400111c63fe61f4eb3b772a500d84c970d1/yarl-1.25.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a1e32763e641a1566507d90a8d3b19bfc3cc04a9d4e5ae3e32189874ed4b58a3", size = 116243, upload-time = "2026-09-15T19:33:24.788Z" }, + { url = "https://files.pythonhosted.org/packages/b0/aa/157b940428da80c104ca09666a740e51c94963df65d5b112e06b52e4d7a8/yarl-1.25.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65b5b2066651b7432d389e9799d979c703bcc6ef44266bb8153ef54e91e4aab3", size = 115822, upload-time = "2026-09-15T19:33:26.886Z" }, + { url = "https://files.pythonhosted.org/packages/7e/af/19fbdce41412e1b96825544cc52cd7029d3724655d0988237972f078bd29/yarl-1.25.1-cp314-cp314t-win_amd64.whl", hash = "sha256:734f6e5400352ac4254456003d462866c684703570929cff7a7bde015d0cb371", size = 107386, upload-time = "2026-09-15T19:33:29.009Z" }, + { url = "https://files.pythonhosted.org/packages/2a/99/f6431c8968e89be608d74b28ae2d024521b2953f27dd44e0dece5e04f67a/yarl-1.25.1-cp314-cp314t-win_arm64.whl", hash = "sha256:287e99ff5aa4dc1c7630bfc683ded6f106d756c99dec432a2d7f197a784f51c6", size = 102094, upload-time = "2026-09-15T19:33:31.151Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3b/4f51eab40c2eabea6c3d5b121dff4b8988dc35087732ffede12d2be8b8dd/yarl-1.25.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9b1bdaae98bc016825dd3c9d8ee1832f829b3341f9cc6ebd1a1b0a7fef7367cc", size = 143875, upload-time = "2026-09-15T19:33:33.52Z" }, + { url = "https://files.pythonhosted.org/packages/41/05/bbd58fc063f5f299a883f810760b265ca26c8167c91cb9a494d0fe2387e1/yarl-1.25.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e7011b8fb8c4054bf0c12e5edc6cd83778b0028e99ce59b18586ed036f92cfdc", size = 104028, upload-time = "2026-09-15T19:33:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/ee/2fba0aecb52e7020e189f684148783aa0b9cfa3b3bfb0b400646eef70ad4/yarl-1.25.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:f074e8d4aa0a5798920ddb6de3d08b228c614ff3724c3e8bd7577f4bafea867b", size = 104041, upload-time = "2026-09-15T19:33:38.86Z" }, + { url = "https://files.pythonhosted.org/packages/38/35/884beab53ed88c7247d1671972b5ef116f7351fe0c7e6de8c3558372cb16/yarl-1.25.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d42e7e3ca399555578b4d617e3a6ecf13371b3743a115995fa010c7bf341459", size = 116018, upload-time = "2026-09-15T19:33:44.265Z" }, + { url = "https://files.pythonhosted.org/packages/e2/cc/1a91b685afb55cc18608443ace95280e96e263a97565811732b6788d3269/yarl-1.25.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aa4ed3dd308548f9e707d9caaf005d2d7f8c1e7868f858dfeb47fe76e16b391d", size = 107033, upload-time = "2026-09-15T19:33:46.45Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c1/58b379fcb1d68d907b7fcf75200c44321896509b2a6a74abbb4b19d864d2/yarl-1.25.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42a66563d8cc056ee32e6191e05097a7b2b3bc302e0bc3133daf8710eb18bd26", size = 123257, upload-time = "2026-09-15T19:33:48.631Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2d/1fe96cf5c2aeab10095e48f38585cf5a8451fb7253234822398e52aa5336/yarl-1.25.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98d370568f393215d605304cdb77b3d5539bd192c75b623c7304c42c8d6d8273", size = 126745, upload-time = "2026-09-15T19:33:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/ad/60/8674394ce43f4dadae573a1d6f451716438e9eab7d7fe8d643c673a32d85/yarl-1.25.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23bf5b403c879a54964e0feac7285688e04bb220074878d737d331522da0a5bf", size = 117255, upload-time = "2026-09-15T19:33:53.456Z" }, + { url = "https://files.pythonhosted.org/packages/2f/72/0faa30e02605d56127d42bb987dcc97da3863b7bf70b9bfbf5f739c05e30/yarl-1.25.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d45673badd08456d0340e9364eddafe1c53a9d2896424294de4d7dd71ad3ee57", size = 115166, upload-time = "2026-09-15T19:33:55.669Z" }, + { url = "https://files.pythonhosted.org/packages/8c/90/9a46eac564c437e128285c5c1d7bb385d268394e9209d84f6bf4a14471ef/yarl-1.25.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:0136d640dfa9b0523853e411430a99f8a91eca85774c6420285a33b755bc6de3", size = 116082, upload-time = "2026-09-15T19:33:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/ad/38/4b1a686a3758878f93d2f1ea943f5a165f3555769cd16e761cfd0efdca17/yarl-1.25.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:59ba3a6e1aa8cfe5adf4bd270fd965db21955401b7ca6f1696010c55ed4daec2", size = 108048, upload-time = "2026-09-15T19:34:00.25Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/f348eb967f31a58348612a2b93bd8a2ab664548e2b5e879cac7f592f7201/yarl-1.25.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:87796fedc3ba97ec14fab55acb48584276e6c1e4c1e89c422bda62c838e754a9", size = 122775, upload-time = "2026-09-15T19:34:02.455Z" }, + { url = "https://files.pythonhosted.org/packages/ab/e9/4f7b79700f88cb9e8bb66f8b54f9bce1844c013a2c39fdc47112e9334c95/yarl-1.25.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:bd0912757081f89b107d6c00b2ff8a194401b0b87eadcf4481de2b865a8fd44f", size = 115096, upload-time = "2026-09-15T19:34:05.283Z" }, + { url = "https://files.pythonhosted.org/packages/cf/37/f9cb020331997d3eb887bd28d5410ecfd3d80bf163c23d7cec490d78dade/yarl-1.25.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:b51c159a9794633f5e0db7ecec7b2b6e3734eca1f5d17dc989ff3552a43ff78b", size = 120655, upload-time = "2026-09-15T19:34:07.382Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/52fceb22891a41f168db7ec22fd1d81e06b6a0b8d9f70921bd3e785defd0/yarl-1.25.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:319e070a01db9920fb63761843f96a104c8e2b9427266731810dc1e22595b17c", size = 117488, upload-time = "2026-09-15T19:34:09.988Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5c/ce6c4ff1247fcbe4b33d462c23a097106d909b173fde7042bc52290466e2/yarl-1.25.1-cp315-cp315-win_amd64.whl", hash = "sha256:a2059a2d891bd156bc5184e7ab7a56e78a84dfcfdeac8c501b552533ad1c36ee", size = 103434, upload-time = "2026-09-15T19:34:12.56Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/766a906b0704fb26d52b19dc22bed48a8ba0544203b70dcf44da350e8194/yarl-1.25.1-cp315-cp315-win_arm64.whl", hash = "sha256:a78b50b4f7918a3de71105d5c0b93bbc57bb8339a4d03a9dfd449f9068e76f3d", size = 99155, upload-time = "2026-09-15T19:34:15.132Z" }, + { url = "https://files.pythonhosted.org/packages/bd/d3/a1d09b32cb6ab14f66b44939f5b4255b8b9e747aef3974af1d5d80ccc2fd/yarl-1.25.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:b5402a340723fa7da00b5cff987ddab61276be6d11251ea71ae02bcac54890d8", size = 149284, upload-time = "2026-09-15T19:34:17.452Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3b/fe554d879692650bca70bfbc0df124e82e4d2bb7456f698c7756f1279a96/yarl-1.25.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:eda19ea5ee88742f47a2340816e6f2d40b53bed3ab5b69794769f36af9f35bb4", size = 106399, upload-time = "2026-09-15T19:34:21.474Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4b/e7af56177ac8d40094c82d7728224c0b8472157d50d362e5fb3b014b2bc8/yarl-1.25.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:75baa6cf9b6d1c52f3e111a130e202fd8cf0a5b3a066c3f73d615e885092e4ec", size = 106968, upload-time = "2026-09-15T19:34:23.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/4f/2df41fd738d46f23ef829ae8b4468d94bb6070038fc6dfab6165ed44fea8/yarl-1.25.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbcef5a9119ef653653132cccaf999b30a0af6f33bb0a4ba80bec30056868487", size = 114717, upload-time = "2026-09-15T19:34:25.879Z" }, + { url = "https://files.pythonhosted.org/packages/69/ea/002b66df53bbd1aed1c23358ff99c9bdc744fe3f4d2740e1b2fdd7192885/yarl-1.25.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7efc9f082dfed77c316edffa9deb52888e1bc6789171887cc1f68e06d65465c8", size = 105198, upload-time = "2026-09-15T19:34:28.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/7c/95c8bc0c8f97d71e59c94525ad60d76f5c57d3f2820f08137ca8b9f0542a/yarl-1.25.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fe01645169a2112aa1d4ebc3e4c5f029c5c8f97adfc32e5d37c993b39a994d75", size = 120271, upload-time = "2026-09-15T19:34:30.5Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7a/6fe9da56ec77927baa669fd86c39c567ce6205bab53d581082c6744c8ae7/yarl-1.25.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1c557dfd5e3db046053a0bdc72261ade790ebe8e2c7a41b36b0ca1f14cb95f3", size = 123572, upload-time = "2026-09-15T19:34:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/8b/83/35f222d17fa70a14c7c74fdf112ccf5515e0c2a87082b1f9b99f7693bf57/yarl-1.25.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ce4d6ccafb33d39bd78444612d14938ead674c25702ded2ee9c54a47735d225", size = 115228, upload-time = "2026-09-15T19:34:35.344Z" }, + { url = "https://files.pythonhosted.org/packages/da/6f/fbaaf619423578a7d898d0f226ae47bc1906c293865c416d83c17b828b0e/yarl-1.25.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80a063f8297fc796296f00f100be520f209b23dc98f93ce8eba6ee7122598209", size = 111618, upload-time = "2026-09-15T19:34:37.656Z" }, + { url = "https://files.pythonhosted.org/packages/ba/78/7383278f1b3cf8e0496bd95b3281a7b09b89217b6b428db24c6b99b3deca/yarl-1.25.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7cb414a73e21a7ab58254926073f2930cb22f5b4314ea4260a687e2b3fd4dce3", size = 114839, upload-time = "2026-09-15T19:34:40.099Z" }, + { url = "https://files.pythonhosted.org/packages/62/49/5506e5b6d29aab91bd845cc9016d88c3d3f81b81bc242b8100bdd5737825/yarl-1.25.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:85a18376073f8a39aa07be34f9fc77e2869aa72c55c441efdd2cf79a0407504d", size = 106212, upload-time = "2026-09-15T19:34:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/19877c193b7c5929f4b07118c18bbe390f3fadd0f59dd98c0cd12b31fa5c/yarl-1.25.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:77716e245c90f058466a05e6a465bb8600f767a8f4b18b4d40f3aff958e5f73c", size = 119985, upload-time = "2026-09-15T19:34:44.976Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6c/0a46fbbf9ecbcbd0cc20d2193394254b9e19817f22c814aab60f99847400/yarl-1.25.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:1e80dcf1446e1b080b1932b0d103c464a04112f5bc31f0f983ad418172063cde", size = 112081, upload-time = "2026-09-15T19:34:47.45Z" }, + { url = "https://files.pythonhosted.org/packages/ef/30/93f5d471230c74ccd06255d0842739f86551f937f9e63a5e947853c6244a/yarl-1.25.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:bdc8d8b8c22e9e43ac68316b5e6cf083dec537f4ec213cb4aa967b583bc3fa64", size = 116995, upload-time = "2026-09-15T19:34:49.972Z" }, + { url = "https://files.pythonhosted.org/packages/2b/80/c386593035ee3f9c6c6af0847b5578f2830c674794a9d7701b744a3ebd42/yarl-1.25.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbf531053a0935f2e871bcd4753f90313688772ff8c017f5ea402e315a78c1f", size = 115570, upload-time = "2026-09-15T19:34:52.628Z" }, + { url = "https://files.pythonhosted.org/packages/38/02/eef443559563ef8f2e10469387b8b1e97cb5efee95b288a56da60801f7ee/yarl-1.25.1-cp315-cp315t-win_amd64.whl", hash = "sha256:b13b88747769537f3d32e89e3a735da10c0a9e35d7322928c701b5f93d3afffd", size = 106811, upload-time = "2026-09-15T19:34:54.935Z" }, + { url = "https://files.pythonhosted.org/packages/88/91/41e284ca2cf5211e05dae031d126a3668aea88fa759df56e7e35c6ad25ba/yarl-1.25.1-cp315-cp315t-win_arm64.whl", hash = "sha256:783dd1467083f4d3f7722ad6a313f24c173e7571372738fcb7a6e6d1ba48df25", size = 101804, upload-time = "2026-09-15T19:34:57.231Z" }, + { url = "https://files.pythonhosted.org/packages/54/22/318c7980066769c6bcd9221ed2248294f5698811da099013098c670565ed/yarl-1.25.1-py3-none-any.whl", hash = "sha256:681c758b0490f9e96b78e5fa8e8dc6e648e9185bb6eaebe73183c33ea0c445f3", size = 63617, upload-time = "2026-09-15T19:34:59.616Z" }, +] + [[package]] name = "zipp" version = "4.1.0"