diff --git a/.agents/skills/wavebench/SKILL.md b/.agents/skills/wavebench/SKILL.md index 78d3dcb0..a0a77758 100644 --- a/.agents/skills/wavebench/SKILL.md +++ b/.agents/skills/wavebench/SKILL.md @@ -121,12 +121,12 @@ Reference 只从本入口直接链接,保持一层目录;详细命令和型 按阶段选择预检,不把以下命令作为每个任务的固定清单: ```bash -.venv/bin/python -m pip check +uv pip check --python .venv .venv/bin/wavebench run check --plan plans/.toml --config wavebench.toml .venv/bin/wavebench run verify --plan plans/.toml --config wavebench.toml ``` -`pip check` 用于依赖变化或环境诊断;`run check` 是离线检查;`run verify` 会连接仪器,只有已授权实时预检时执行。 +`uv pip check` 用于依赖变化或环境诊断;`run check` 是离线检查;`run verify` 会连接仪器,只有已授权实时预检时执行。 已授权执行的真实计划遵循 `run check → run verify → run plan → run report`,并同时检查步骤状态、质量门、期望指标、产物和最终设备状态。仅检查计划的请求到离线检查结果即完成。TUI 界面开发使用 `tui --fake`。 ## External research diff --git a/.agents/skills/wavebench/references/development-validation.md b/.agents/skills/wavebench/references/development-validation.md index 279a9024..7bc7b74a 100644 --- a/.agents/skills/wavebench/references/development-validation.md +++ b/.agents/skills/wavebench/references/development-validation.md @@ -13,6 +13,11 @@ ## 验证分层 +Core 新开发环境使用 `uv sync --locked --extra dev --extra analysis --extra tui`。 +按任务增加 `--extra pdf`、`--extra report3d` 或 `--extra docs`。 +已有仪器插件的环境不执行默认精确同步;优先新建隔离环境,或显式使用 `--inexact` 后核对插件状态。 +准备完成后可用 `uv run --no-sync` 执行命令,避免验证期间变更依赖。 + 按风险选择最窄的验证集合: 以下命令以 POSIX 虚拟环境路径为例;原生 Windows 将 `.venv/bin/python` 替换为 diff --git a/.agents/skills/wavebench/references/plugins.md b/.agents/skills/wavebench/references/plugins.md index daea6c74..6524c8c9 100644 --- a/.agents/skills/wavebench/references/plugins.md +++ b/.agents/skills/wavebench/references/plugins.md @@ -29,6 +29,10 @@ ## 安装边界 +安装、卸载和源码构建要求 PATH 中可用的 uv,目标虚拟环境不需要 pip。 +实际仪器环境与开发环境分开;不对装有受管插件的环境运行默认 `uv sync`,以免移除 Core 锁文件未声明的插件。 +混合开发环境显式使用 `--inexact`,随后检查依赖和受管插件状态。 + 只安装用户明确授权的可信本地目录或 wheel: ```bash diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b192c94f..5af547a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,8 +2,12 @@ name: CI on: push: - branches: [master, main] + branches: [master, main, Dev] pull_request: + workflow_call: + +permissions: + contents: read jobs: test: @@ -24,30 +28,33 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: pip + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + version: "0.12.15" + enable-cache: true - name: Install package and test dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[dev,analysis,pdf]" + run: uv sync --locked --python "${{ matrix.python-version }}" --extra dev --extra analysis --extra pdf - name: Run Ruff - run: python -m ruff check . + run: uv run --no-sync python -m ruff check . - name: Audit documentation if: matrix.python-version == '3.11' - run: python .agents/skills/wavebench-docs/scripts/audit_docs.py --quiet-warnings + run: uv run --no-sync python .agents/skills/wavebench-docs/scripts/audit_docs.py --quiet-warnings - name: Check generated Reference if: matrix.python-version == '3.11' - run: python scripts/generate_docs.py --check + run: uv run --no-sync python scripts/generate_docs.py --check - name: Report documentation impact if: matrix.python-version == '3.11' - run: python scripts/docs_impact.py + run: uv run --no-sync python scripts/docs_impact.py - name: Run unit tests - run: python -m pytest -q + run: uv run --no-sync python -m pytest -q test-windows: name: Windows Python ${{ matrix.python-version }} tests @@ -67,25 +74,83 @@ jobs: uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - cache: pip + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + version: "0.12.15" + enable-cache: true - name: Install package and Windows test dependencies - shell: pwsh - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[dev,analysis,tui]" - python -m pip check + run: uv sync --locked --python "${{ matrix.python-version }}" --extra dev --extra analysis --extra tui + + - name: Check dependencies + run: uv pip check --python .venv - name: Run Ruff - run: python -m ruff check . + run: uv run --no-sync python -m ruff check . - name: Run unit tests - run: python -m pytest -q + run: uv run --no-sync python -m pytest -q - name: Run offline CLI smoke tests shell: pwsh run: | - python -m wavebench --help - python -m wavebench run schema | Out-Null - python -m wavebench run template --list - python -m wavebench lock status TCPIP::192.0.2.10::INSTR + uv run --no-sync python -m wavebench --help + uv run --no-sync python -m wavebench run schema | Out-Null + uv run --no-sync python -m wavebench run template --list + uv run --no-sync python -m wavebench lock status TCPIP::192.0.2.10::INSTR + + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + - uses: astral-sh/setup-uv@v9.0.0 + with: + version: "0.12.15" + - name: Build sdist and wheel from sdist + run: uv build --no-sources --out-dir "${{ runner.temp }}/distributions" + - name: Check release identity and notes + shell: bash + run: | + args=() + if [[ "$GITHUB_REF_TYPE" == tag ]]; then args=(--tag "$GITHUB_REF_NAME"); fi + python scripts/check_release.py --artifacts "${{ runner.temp }}/distributions" "${args[@]}" + - uses: actions/upload-artifact@v4 + with: + name: distributions + path: ${{ runner.temp }}/distributions/* + if-no-files-found: error + + wheel-smoke: + name: Wheel ${{ matrix.os }} Python ${{ matrix.python-version }} + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.11", "3.12"] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - uses: astral-sh/setup-uv@v9.0.0 + with: + version: "0.12.15" + - uses: actions/download-artifact@v4 + with: + name: distributions + path: ${{ runner.temp }}/distributions + - name: Install and exercise wheel outside the checkout + run: | + version=$(python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") + python scripts/smoke_wheel.py --artifacts "${{ runner.temp }}/distributions" --version "$version" diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a3cffc00..f166a76d 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -2,7 +2,7 @@ name: Documentation on: push: - branches: [master, main] + branches: [master, main, Dev] paths: - ".agents/skills/wavebench-docs/**" - ".github/workflows/docs.yml" @@ -12,6 +12,7 @@ on: - "mkdocs.yml" - "plans/**" - "pyproject.toml" + - "uv.lock" - "scripts/docs_impact.py" - "scripts/generate_docs.py" - "src/wavebench/**" @@ -26,11 +27,16 @@ on: - "mkdocs.yml" - "plans/**" - "pyproject.toml" + - "uv.lock" - "scripts/docs_impact.py" - "scripts/generate_docs.py" - "src/wavebench/**" - "wavebench.example.toml" workflow_dispatch: + workflow_call: + +permissions: + contents: read jobs: build: @@ -46,21 +52,30 @@ jobs: uses: actions/setup-python@v5 with: python-version: "3.11" - cache: pip + + - name: Install uv + uses: astral-sh/setup-uv@v9.0.0 + with: + version: "0.12.15" + enable-cache: true - name: Install documentation dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -e ".[dev,docs]" + run: uv sync --locked --python 3.11 --extra dev --extra docs - name: Check generated Reference - run: python scripts/generate_docs.py --check + run: uv run --no-sync python scripts/generate_docs.py --check - name: Audit documentation - run: python .agents/skills/wavebench-docs/scripts/audit_docs.py --quiet-warnings + run: uv run --no-sync python .agents/skills/wavebench-docs/scripts/audit_docs.py --quiet-warnings - name: Build site - run: mkdocs build --strict + run: uv run --no-sync mkdocs build --strict - name: Report documentation impact - run: python scripts/docs_impact.py + run: uv run --no-sync python scripts/docs_impact.py + + - uses: actions/upload-artifact@v4 + with: + name: documentation-site + path: site/ + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..30153af9 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release candidate + +on: + push: + tags: ["v*"] + +permissions: + contents: read + +jobs: + ci: + uses: ./.github/workflows/ci.yml + docs: + uses: ./.github/workflows/docs.yml + draft: + name: Draft verified release + needs: [ci, docs] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: distributions + path: distributions + - name: Create draft with verified assets + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + run: | + gh release create "$RELEASE_TAG" distributions/* \ + --verify-tag --draft --title "WaveBench $RELEASE_TAG" \ + --notes-file "docs/releases/$RELEASE_TAG.md" diff --git a/CHANGELOG.md b/CHANGELOG.md index b9fd961f..d832cd3d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # WaveBench 更新日志 -这里记录已经打过 Git tag 的版本。内容以 tag 之前已经合入的代码、测试、发布说明和边界记录为事实源;路线图里的候选功能不算已发布能力。没有独立 tag 的 release notes 草稿只并入对应正式版本,不单列为版本。版本号和时间以仓库 tag 为准,当前开发分支的变化不放在历史版本里。 +版本变化以对应源码和发布说明为依据,正式发行状态以 [GitHub Releases](https://github.com/Scaxlibur/WaveBench/releases) 为准。未发布的设计继续保留在 RFC 中。 + +## v0.8.28 + +[完整发布说明](docs/releases/v0.8.28.md)。 + +- 新增示波器观察、波形期望检查及只读 MCP 建议入口,明确采集副作用与不完整验收状态。 +- 开发环境和受管插件安装采用 uv,保留旧 pip 安装记录的恢复兼容。 +- 统一运行时版本身份和插件版本比较,增加独立 wheel 安装与 tag 发布门禁。 +- 修复 Windows 分析结果原子替换的短暂占用失败。 + +## v0.8.27 — 2026-09-08 + +[完整发布说明](https://github.com/Scaxlibur/WaveBench/releases/tag/v0.8.27)。该版本包含信号处理流水线、批量和双通道分析、Source/RF Source 扩展、状态恢复、资源租约与原生 Windows 支持。 ## v0.8.0 — 2026-07-24 diff --git a/README.md b/README.md index a4b64353..bee43bea 100644 --- a/README.md +++ b/README.md @@ -69,19 +69,15 @@ WaveBench 是面向电子设计调试和日常实验的 Python 测量台。它 ## 无硬件快速开始 -以下命令只安装包、列出模板并打印一个示例 plan;不会连接仪器或打开输出。 +正式发行包从 [GitHub Releases](https://github.com/Scaxlibur/WaveBench/releases) 获取,安装与开发线入口见[安装说明](docs/getting-started/installation.md)。在已安装 WaveBench 的环境中运行以下命令,可生成一个示例 plan,不会连接仪器或打开输出。 ```bash -git clone https://github.com/Scaxlibur/WaveBench.git -cd WaveBench -python3 -m venv .venv -.venv/bin/python -m pip install -e . -.venv/bin/python -m wavebench run template --list -.venv/bin/python -m wavebench run template source-scope-sine --print -.venv/bin/python -m wavebench run check --plan plans/closure_sine_1k.toml +python -m wavebench --version +python -m wavebench run template --list +python -m wavebench run template source-scope-sine --output example.toml ``` -最后一条命令以 `safety_limits=ok / 安全上限=通过` 结束时,表示示例 plan 已通过离线检查。完整的预期结果、Windows 命令和下一步见[无硬件快速开始](docs/getting-started/quickstart.md)。 +按[无硬件快速开始](docs/getting-started/quickstart.md)准备演示配置并执行离线检查。以 `safety_limits=ok / 安全上限=通过` 结束时,表示示例 plan 已通过检查。 ## 支持范围 @@ -105,6 +101,17 @@ python3 -m venv .venv ## 文档与贡献 +Core 开发环境由 `uv.lock` 固定依赖版本: + +```bash +uv sync --locked --extra dev --extra analysis --extra tui +uv run --no-sync pytest -q +uv run --no-sync ruff check . +``` + +`pdf`、`report3d` 和 `docs` 可按需用 `--extra` 加入。默认同步会移除未声明的包; +已安装仪器插件的运行环境应与开发环境分开,详见[管理仪器插件](docs/how-to/manage-plugins.md)。 + - [文档首页](docs/index.md) - [示例计划及硬件边界](plans/README.md) - [管理仪器插件](docs/how-to/manage-plugins.md) @@ -121,4 +128,4 @@ WaveBench 使用 MIT 许可证。 Krill -感谢 [Krill AI](https://www.krill-code.com/) 对本项目的赞助。 \ No newline at end of file +感谢 [Krill AI](https://www.krill-code.com/) 对本项目的赞助。 diff --git a/docs/README_EN.md b/docs/README_EN.md index 75aafd49..d6db0981 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -6,15 +6,15 @@ The current complete documentation is maintained in Chinese. This page is an Eng ## Start without instruments +Install a release wheel with Python 3.11+ and [uv](https://docs.astral.sh/uv/getting-started/installation/). The [installation guide](getting-started/installation.md) separates release installations, fixed-tag source checkouts, and the unreleased `Dev` branch. Run the following with the installed environment's Python: + ```bash -python3 -m venv .venv -.venv/bin/python -m pip install -e . -.venv/bin/python -m wavebench run template --list -.venv/bin/python -m wavebench run template source-scope-sine --print -.venv/bin/python -m wavebench run check --plan plans/closure_sine_1k.toml +python -m wavebench --version +python -m wavebench run template --list +python -m wavebench run template source-scope-sine --output example.toml ``` -The last command succeeds with `safety_limits=ok / 安全上限=通过`. It performs no instrument I/O. See the [Chinese Quickstart](getting-started/quickstart.md) for expected output, Windows commands, and the next steps. +These commands perform no instrument I/O. Follow the [Chinese Quickstart](getting-started/quickstart.md) to create an offline configuration and check the plan. A successful check prints `safety_limits=ok / 安全上限=通过`. ## Safety boundary diff --git a/docs/concepts/plugin-model.md b/docs/concepts/plugin-model.md index d958ff75..44a1985f 100644 --- a/docs/concepts/plugin-model.md +++ b/docs/concepts/plugin-model.md @@ -6,7 +6,7 @@ WaveBench 支持 metadata、可执行插件和声明式 SCPI 三条插件路径 | 路径 | 用途 | 边界 | | --- | --- | --- | -| V1 metadata | 展示已有仪器 metadata | 不进入真实仪器执行。 | +| V1 metadata(已弃用) | 兼容展示已有仪器 metadata | 不进入真实仪器执行,不扩展新插件类别。 | | V2 executable plugin | 提供 descriptor 和 driver | 只在配置选中该 driver 时进入执行;第三方代码属于可信本地代码。 | | 声明式 SCPI TOML | 检查和描述只读 IDN probe | 不提供任意 SCPI 或写入。 | diff --git a/docs/development/contributing.md b/docs/development/contributing.md index 68e53d21..67c7d93c 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -13,3 +13,5 @@ WaveBench 的代码、文档、schema 和测试在同一仓库中维护。提交 按[测试说明](testing.md)选择检查:局部修改先做聚焦验证,文档或 Skill 修改检查相关内容,跨模块或合并评估执行集成检查。远端 CI 仍按仓库 workflow 执行,不要求每个编辑步骤重跑全量测试。 新增或修改用户可见行为时,更新唯一 canonical Reference,并用[文档工作流](documentation.md)进行 scoped review。插件专用流程见[插件开发](plugin-development.md)。 + +示波器观察的实现与回归合同见[示波器观察实现与验收](scope-observation.md)。发布步骤见[发布版本](releasing.md)。 diff --git a/docs/development/documentation.md b/docs/development/documentation.md index 8c0bfc79..ea7803d2 100644 --- a/docs/development/documentation.md +++ b/docs/development/documentation.md @@ -46,12 +46,14 @@ python scripts/docs_impact.py --base --head HEAD ## 站点预览与发布 +在不含受管仪器插件的 `Dev` 开发环境中运行;uv 与工作副本准备见[安装说明](../getting-started/installation.md)。 + ```bash -python -m pip install -e ".[docs]" -mkdocs serve -mkdocs build --strict +uv sync --locked --extra dev --extra docs +uv run --no-sync mkdocs serve +uv run --no-sync mkdocs build --strict ``` `mkdocs.yml` 只映射当前的开始使用、教程、How-to、Reference、Concepts、开发和 RFC 入口;archive 与旧入口不在主导航中。GitHub Actions 会在文档或潜在事实源变化时执行严格构建。 -当前站点采用 default branch 的 latest 视图。需要稳定版时,建议从发布 tag 或 release branch 构建独立站点版本;在尚无实际发布需求前,不维护历史文档副本。GitHub Pages 发布应在仓库 Pages 策略、域名和权限明确后,由单独的 deploy workflow 处理;本仓库当前只验证构建,不自动发布。 +本地站点反映当前 checkout;`Dev` 中尚未发布的变更应标明开发线状态。稳定版文档从对应发布 tag 构建,不以默认分支或开发分支替代。GitHub Pages 发布应在仓库 Pages 策略、域名和权限明确后,由单独的 deploy workflow 处理;本仓库当前只验证构建,不自动发布。 diff --git a/docs/development/plugin-development.md b/docs/development/plugin-development.md index c5c9045f..aff947cd 100644 --- a/docs/development/plugin-development.md +++ b/docs/development/plugin-development.md @@ -48,6 +48,8 @@ Core 内置 DG 回退驱动也提供该接口;内置版本随 Core 发布, ## 验证 +Core `0.8.28` 起,descriptor 的最低/最高 Core 版本与包的 `Requires-Dist` 均按 PEP 440 范围校验。显式安装的开发版可以落在兼容区间内,但 `0.8.28.dev1` 不满足 `>=0.8.28`;`<0.9` 也不接受 `0.9.0rc1`。两个声明须使用一致边界,缺少 Core 安装元数据的 `0+unknown` 不应视为兼容版本。 + ```bash python -m wavebench plugin package check python -m wavebench plugin install --dry-run diff --git a/docs/development/releasing.md b/docs/development/releasing.md new file mode 100644 index 00000000..3ae87dfc --- /dev/null +++ b/docs/development/releasing.md @@ -0,0 +1,38 @@ +# 发布版本 + +发布以一个确定提交和一组已经验证的发行物为单位。`pyproject.toml` 是唯一可编辑版本源;安装后的版本来自发行包 metadata。保留 Hatchling 构建后端。 + +## 准备发布提交 + +1. 选择正式版本,在无动态仪器插件的开发环境中更新 `pyproject.toml`、`uv.lock` 和 editable 安装元数据。 +2. 将发布说明写入 `docs/releases/v.md`,首行为 `# WaveBench v 发布说明`;从 CHANGELOG 链接到它。 +3. 同步安装示例、相关功能状态和固定版本链接。稳定版文档取自发布 tag,开发线使用独立入口。 +4. 运行源码检查、生成 Reference 检查、文档审计及严格构建。PR 和 `Dev` push 的 CI 还会构建发行物,并验证 Linux/Windows × Python 3.11/3.12 的独立 wheel 安装。 + +本地可在新的输出目录执行与 CI 相同的发行物检查: + +```bash +uv build --no-sources --out-dir /tmp/wavebench-release +python scripts/check_release.py --artifacts /tmp/wavebench-release --tag v0.8.28 +python scripts/smoke_wheel.py --artifacts /tmp/wavebench-release --version 0.8.28 +``` + +替换示例版本和输出目录。独立 smoke 会创建临时虚拟环境并解析 wheel 的运行依赖,可能访问软件源;不连接仪器,不复用开发环境依赖。现有离线 wheel/sdist 测试仍用于检查包布局。 + +## tag 验证与 Release 草稿 + +在获得发布授权、目标提交检查通过后,创建并推送与项目版本一致的 `v` tag。`Release candidate` 工作流复用 CI 和 Documentation 工作流,对该 tag 的提交重新验证: + +- 项目版本、锁文件、wheel/sdist metadata、tag 及发布说明一致。 +- 源码测试、静态检查、独立 wheel smoke 通过。 +- 生成 Reference、文档审计与 MkDocs 严格构建通过。 + +全部通过后,工作流下载同一次运行的 `distributions` artifact,附加到 GitHub Release 草稿,正文直接取自仓库中的发布说明,不重新构建发行包。站点保存在 `documentation-site` artifact 中;当前流程不部署 GitHub Pages。 + +维护者检查草稿正文、目标 tag 和附件后,再执行最终发布。工作流失败时不发布,修复后重新验证最终提交;不能借用旧提交的成功结果。已公开 tag 和发行物不覆盖,修复通过新版本交付。 + +发布后开发线改为下一版本的 `.devN`,重新同步锁文件与安装元数据。稳定使用说明继续指向固定 tag。 + +## 分支检查 + +`CI` 覆盖 PR 和 `master`、`main`、`Dev` 的 push。Documentation 在普通分支事件中保留路径筛选;tag 发布工作流直接调用它,不受路径筛选影响。将检查设为分支必需项时,避免选择可能因路径筛选而不运行的工作流。 diff --git a/docs/development/scope-observation.md b/docs/development/scope-observation.md new file mode 100644 index 00000000..f1abaafa --- /dev/null +++ b/docs/development/scope-observation.md @@ -0,0 +1,81 @@ +# 示波器观察实现与验收 + +本页面向 Core 实现与测试人员,记录 `0.8.28` 的执行合同。用户入口见 [CLI Reference](../reference/cli.md)和 [MCP 操作指南](../how-to/serve-mcp.md)。 + +## 实现入口与职责 + +CLI 的 `scope observe` 和 MCP 的 `scope.observe` / `scope.advise` 由 +`services/agent_observe.py`、`services/agent_advise.py`、`ScopeService` 与 `mcp_http.py` +共同实现。CLI 参数以 `cli_parser.py` 和离线 `--help` 为准。 +Core 表达观察、访问约束、会话和报告语义;型号通道、端接与传输差异由 descriptor 和 driver +提供,不在观察层维护型号范围表。 + +MCP 的 `doctor.config` 调用 `doctor_records()` 并返回结构化检查记录。 +它不读取波形或执行建议;实际查询合同以 doctor 与 driver 实现为准。 + +## 观察与建议 + +默认 `scope observe` 只获取身份、可用状态与输入耦合,不读取波形。观察路径收紧有效访问 +权限,原配置为 `disabled` 时在打开连接前拒绝。只有具备纯查询合同的状态才进入结果;缺少字段或 +capability 时保留不可用原因,不把部分状态当作完整快照。 + +MCP `scope.advise` 依据可用状态和调用方的 `expected_frequencies_hz` 提供显示或时基建议, +不会执行建议。无可用档位、测量值或期望频率时返回逐通道 `advice_unavailable`,表示无法 +评估当前设置。查询失败不能生成「无需调整」的判断。调用方提供的频率只标为 configured, +不能当作实测证据;带低周期告警的测量频率不能直接用于时基建议。 + +生成的 focus 建议遵循现有 CLI 格式:`--vertical-scale CHANNEL=V_PER_DIV`; +隐藏其它通道使用 `--hide-others`。建议的执行仍须经过命令自身的 access、capability 和 +运行时安全检查。 + +## 显式波形报告与预检 + +`scope observe --fetch-waveform` 会操作仪器,可能停止采集、启用通道显示、改变波形传输 +source/mode/format/points,以及按配置消费错误队列。它不恢复原来的运行状态。MCP 不提供 +这一读取路径。 + +`--target-cycles` 与 `--target-vertical-divisions` 必须是有限正数;expectation 的字段名、 +类型、有限性与取值范围由 `data/expectations.py` 校验。这些输入在创建仪器服务前拒绝。 +型号相关的通道支持验证可能需要连接后的纯查询预检;全部请求通道必须在第一次采集写入 +前完成验证。它不是「任意型号输入错误都在打开会话前拒绝」的保证。 + +离线 access、capability 或波形配置预检失败时不构造 driver 或 transport。观察路径的 +factory 构造阶段对所有 descriptor 启用 Core I/O 锁,禁止通过 context transport 查询或 +写入;构造与声明校验完成后才释放。需要在构造阶段进行设备初始化的旧插件不能使用这一 +入口,应将设备操作移入明确的执行方法。该约束只保护 Core transport,不提供 Python 沙箱。 + +报告在受控持续会话和独占资源租约内执行。最终高阻确认与波形读取共用会话和租约,避免 +遵守同一资源锁的其它进程在两者之间改变输入设置;资源锁不能阻止前面板或外部软件操作。 +不确定 I/O 或会话健康故障使剩余采集停止,并记录中止原因;不自动重连继续写入。 + +持续会话不证明波形来自同一次 acquisition。报告仍将 `same_acquisition` 标为 false, +跳过相位、相关性、延迟和交点,保留同步无关摘要。正式同步时序分析应使用 +`scope capture --synchronized` 产物和既有 `analysis.pair` 同步证明合同。 +`data.relationships` 的默认值同样不认定同步;其显式断言仅供已确认来源的内部分析。 + +## 期望检查与输出 + +`--expect ` 需要显式波形报告;示例格式为: + +```toml +[channels.1] +frequency_hz = 1000 +frequency_tolerance_ratio = 0.05 +vpp_v = 3.3 +duty_percent = 50 +``` + +汇总保留全部待验收通道。无法取得或评估波形时通道为 `unavailable`;全部不可用时总体为 +`unavailable`,有可用验收但不完整时为 `partial`,已确认的 `fail` 优先。 +无可执行期望指标时为 `skipped`。报告成功返回不等于所有期望通过,调用方须检查验收汇总 +以及 warnings 和逐通道结果。 + +## 维护与验收 + +聚焦验证覆盖生命周期关闭与借用、跨进程租约竞争、非法后续通道零采集写入、会话故障后的 +剩余通道中止、禁用配置及离线失败零构造、legacy factory I/O 拦截、真实 guard 的观察零写, +以及缺证据建议与同步默认值。测试只使用 fake +transport 和合成信号,不连接设备;实机 evidence 属于单独授权的插件验证。 + +相关合同见[安全模型](../concepts/safety-model.md)、[会话与恢复](../concepts/sessions-and-recovery.md)、 +[插件模型](../concepts/plugin-model.md)和[测试说明](testing.md)。 diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 8c567535..cdaf205e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,46 +1,74 @@ # 安装 WaveBench -本页用于在本地工作副本中安装 WaveBench,并确认 CLI 已可运行。完成后会得到一个独立虚拟环境和可验证的 `--help` 输出。 +本页对应 WaveBench `0.8.28`。发行物及发布状态以 [GitHub Releases](https://github.com/Scaxlibur/WaveBench/releases) 为准;旧版本应使用对应 tag 下的文档。 -## 前置条件 +## 安装发行包 -- Python 3.11 或更高版本。 -- 已检出的 WaveBench 源码。 +需要 Python 3.11 或更高版本,以及 PATH 中可用的 [uv](https://docs.astral.sh/uv/getting-started/installation/)。从对应 Release 下载 wheel,在源码仓库之外创建专用运行环境: -## 安装基础包 +```bash +uv venv .venv-runtime --python 3.12 +uv pip install --python .venv-runtime /path/to/wavebench-0.8.28-py3-none-any.whl +``` + +将 wheel 路径替换为实际下载位置。Bash 使用: ```bash -python3 -m venv .venv -.venv/bin/python -m pip install --upgrade pip -.venv/bin/python -m pip install -e . -.venv/bin/python -m wavebench --help +.venv-runtime/bin/python -m wavebench --version +.venv-runtime/bin/python -m wavebench --help ``` -PowerShell: +PowerShell 使用: ```powershell -py -3.11 -m venv .venv -.\.venv\Scripts\python.exe -m pip install --upgrade pip -.\.venv\Scripts\python.exe -m pip install -e . -.\.venv\Scripts\python.exe -m wavebench --help +.venv-runtime\Scripts\python.exe -m wavebench --version +.venv-runtime\Scripts\python.exe -m wavebench --help +``` + +版本应显示 `wavebench 0.8.28`。后续示例中的 `python` 指这个环境的解释器。运行环境不执行源码项目的 `uv sync`;动态插件的检查、升级和恢复见[管理仪器插件](../how-to/manage-plugins.md)。 + +## 从固定版本源码安装 + +需要仓库中的示例计划或修改代码时,使用固定 tag 的新工作副本: + +```bash +git clone --branch v0.8.28 https://github.com/Scaxlibur/WaveBench.git +cd WaveBench +uv sync --locked +uv run --no-sync wavebench --version ``` -最后一条命令列出 `run`、`scope`、`source`、`power`、`dmm`、`doctor`、`plugin` 等一级命令时,基础安装完成。 +`uv sync --locked` 按 `uv.lock` 准备 `.venv`,并以 editable 方式安装 Core。`uv run --no-sync` 使用已准备的环境,不在运行命令前调整依赖。tag 在正式发布准备完成后可用。 + +## 使用开发线 + +跟进未发布修改时,将克隆命令中的 `--branch v0.8.28` 换成 `--branch Dev`。开发线的行为以当前源码、版本查询和文档为准,不作为已发布版本的功能保证。修改包版本后重新同步环境,更新 editable 安装的元数据。 + +uv 不读取 `pip.conf` 或 `PIP_INDEX_URL`。使用镜像或私有软件源时,先按 [uv 软件源配置](https://docs.astral.sh/uv/concepts/indexes/)设置索引,再运行安装命令。 ## 可选组件 | Extra | 用途 | | --- | --- | -| `.[tui]` | 实验性终端控制面板。 | -| `.[analysis]` | 频响分析与校准所需的分析依赖。 | -| `.[pdf]` | 离线报告的 PDF 输出。 | -| `.[report3d]` | 报告中的交互式三维图。 | -| `.[dev]` | 本地测试和静态检查。 | +| `tui` | 实验性终端控制面板。 | +| `analysis` | 频响分析与校准所需的分析依赖。 | +| `pdf` | 离线报告的 PDF 输出。 | +| `report3d` | 报告中的交互式三维图。 | +| `dev` | 本地测试和静态检查。 | +| `docs` | 文档站点预览与构建。 | + +发行包安装时,在 wheel 路径后加所需 extras: -按实际任务安装,例如: +```bash +uv pip install --python .venv-runtime "/path/to/wavebench-0.8.28-py3-none-any.whl[analysis,tui]" +``` + +源码开发环境按实际任务同步,例如: ```bash -.venv/bin/python -m pip install -e ".[tui]" +uv sync --locked --extra dev --extra analysis --extra tui ``` -可选组件不连接仪器。安装后先继续[无硬件快速开始](quickstart.md),不要直接执行硬件写入命令。 +每次同步应列出所有需要的 extras;默认精确同步会移除未选择的可选组件和锁文件未声明的包。已有仪器插件的环境应采用专用运行方式。 + +安装后继续[无硬件快速开始](quickstart.md),确认离线命令可用后再配置实验台。 diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index 5de62161..87d6a1fe 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,60 +1,60 @@ # 无硬件快速开始 -本页用于在未连接任何仪器时确认 WaveBench 已安装,并验证一个现有 plan 可以通过离线检查。完成后可以看到模板列表、示例 TOML 和 `run check` 的明确成功结果。 +本页通过模板生成和离线检查确认 WaveBench 已可运行。不需要仪器或仓库示例文件;检查使用单独的演示配置。 -## 前置条件 +## 1. 确认安装 -- Python 3.11 或更高版本。 -- 一个新的 WaveBench 工作副本。 -- 不需要 `wavebench.toml`,也不需要连接仪器。 - -## 1. 创建虚拟环境并安装 - -在仓库根目录运行: +先按[安装说明](installation.md)准备发行包环境或源码环境。以下 `python` 指该环境的解释器;源码环境也可使用 `uv run --no-sync python`。 ```bash -python3 -m venv .venv -.venv/bin/python -m pip install -e . +python -m wavebench --version +python -m wavebench --help ``` -PowerShell: +版本应与所安装的发行包一致;帮助中应列出 `run`、`scope`、`source`、`power`、`dmm`、`doctor`、`plugin` 和 `capability` 等命令域。 + +## 2. 查看并打印一个模板 -```powershell -py -3.11 -m venv .venv -.\.venv\Scripts\python.exe -m pip install -e . +```bash +python -m wavebench run template --list +python -m wavebench run template source-scope-sine --print ``` -安装完成后,后续命令使用同一虚拟环境中的 Python;不依赖 shell 是否已激活虚拟环境。 +第一条命令应包含 `source-scope-sine`。第二条命令打印包含实验段和 `[[steps]]` 的 TOML,不加载配置或连接仪器。 -## 2. 检查命令入口 +## 3. 保存模板并离线检查 -```bash -.venv/bin/python -m wavebench --help -``` +将以下内容保存为 `offline.toml`。地址只用于离线示例,不能直接用于真实实验: -输出会列出 `run`、`scope`、`source`、`power`、`dmm`、`doctor`、`plugin` 和 `capability` 等命令域。PowerShell 中将前缀替换为 `.\.venv\Scripts\python.exe`。 +```toml +[connection] +backend = "lan" +resource = "TCPIP::192.0.2.10::INSTR" -## 3. 查看并打印一个模板 +[scope] +driver = "rtm2032" -```bash -.venv/bin/python -m wavebench run template --list -.venv/bin/python -m wavebench run template source-scope-sine --print -``` +[source] +driver = "dg4202" +resource = "TCPIP::192.0.2.11::INSTR" -第一条命令应包含 `source-scope-sine`。第二条命令会把 TOML 打印到标准输出,其中包含实验段和 `[[steps]]`;它不会写文件、加载配置或连接仪器。 +[safety_limits] +max_source_vpp = 1.0 +``` -## 4. 离线检查现有示例 +在可写目录运行,使用尚不存在的输出文件名: ```bash -.venv/bin/python -m wavebench run check --plan plans/closure_sine_1k.toml +python -m wavebench run template source-scope-sine --output example.toml +python -m wavebench run check --plan example.toml --config offline.toml ``` -成功时,输出末尾为 `safety_limits=ok / 安全上限=通过`。`run check` 会解析 plan、检查 schema、离线安全上限和声明的 capability;它不会打开 transport 或访问仪器。 +成功时,输出末尾为 `safety_limits=ok / 安全上限=通过`。`run check` 解析 plan,检查 schema、离线安全上限和声明的 capability,不打开 transport 或访问仪器。 ## 下一步 -- 想理解模板如何进入真实实验流程,阅读[从模板到报告](../tutorials/from-template-to-report.md)。 -- 已准备连接实验台时,先阅读[配置实验台](configure-bench.md)和[执行一次实验](../how-to/run-an-experiment.md)。 -- 需要查询字段时,运行 `python -m wavebench run schema`,并阅读[run plan Reference](../reference/run-schema.md)。 +- 理解模板如何进入真实实验流程,阅读[从模板到报告](../tutorials/from-template-to-report.md)。 +- 连接实验台前阅读[配置实验台](configure-bench.md)和[执行一次实验](../how-to/run-an-experiment.md)。 +- 查询字段时运行 `python -m wavebench run schema`,或阅读[run plan Reference](../reference/run-schema.md)。 -`run verify` 会对配置中的仪器执行只读身份查询,`run plan` 会执行真实实验;两者不属于本页的无硬件流程。 +`run verify` 会查询配置中的仪器,`run plan` 会执行真实实验;两者不属于本页的无硬件流程。 diff --git a/docs/how-to/manage-plugins.md b/docs/how-to/manage-plugins.md index 4a83bce0..cff41d52 100644 --- a/docs/how-to/manage-plugins.md +++ b/docs/how-to/manage-plugins.md @@ -2,12 +2,46 @@ 本页适用于需要检查、安装、升级、降级、卸载或恢复本地 WaveBench 仪器插件的维护者。可执行插件是可信 Python 代码,不是安全沙箱;只处理来源、版本和依赖均已确认的本地源码目录或 wheel。 +以下 uv 后端适用于 Core `0.8.28`。使用更早版本时查阅对应 tag 的文档;发行包与开发线的安装入口见[安装说明](../getting-started/installation.md)。 + ## 前置条件 - 在目标 WaveBench 虚拟环境中运行命令。 +- PATH 中已安装 [uv](https://docs.astral.sh/uv/getting-started/installation/);目标环境不需要 pip。 - 已确认插件与当前 WaveBench 版本兼容。 - 已确认源码 build backend 或 wheel 的来源可信。 +提示缺少 uv 时,安装 uv 并确认当前终端可以运行 `uv --version` 后重试。旧 pip 安装的插件无需重新安装;降回依赖 pip 的旧 Core 时,目标环境仍需提供 pip。 + +## 专用运行环境 + +在源码仓库之外的部署目录创建环境,安装经过确认的 Core wheel: + +```bash +uv venv .venv-runtime --python 3.12 +uv pip install --python .venv-runtime /path/to/wavebench.whl +.venv-runtime/bin/python -m wavebench --help +``` + +`/path/to/wavebench.whl` 代表实际 wheel 文件路径。PowerShell 使用 +`.venv-runtime\Scripts\python.exe` 替换最后一行的解释器路径。 +安装 Core 依赖可能访问软件源;后续受管插件安装仅处理本地包。 +以下命令中的 `python` 均指这个专用环境的解释器。 + +这个环境由 Core 插件账本管理动态插件,不执行源码项目的 `uv sync`。 +`uv pip install` 不会清理无关包;升级 Core 后仍须核验依赖和插件状态: + +```bash +uv pip install --python .venv-runtime /path/to/new-wavebench.whl +uv pip check --python .venv-runtime +python -m wavebench plugin installed +python -m wavebench plugin doctor --load +``` + +若临时共用源码开发环境,同步时须使用 `uv sync --locked --inexact` 并指定所需 extras。 +`--locked` 只阻止锁文件变更,不能阻止默认同步移除插件;`--inexact` 保留额外包, +但仍可能更新项目依赖。操作后检查依赖与插件状态。 + ## 检查并安装 ```bash @@ -16,7 +50,7 @@ python -m wavebench plugin install --dry-run python -m wavebench plugin install ``` -源码目录的检查会在临时目录构建 wheel,因此会执行受信任的 build backend。受管安装只使用当前解释器和明确给出的本地路径;它不联网下载依赖,也不修改 `wavebench.toml`。 +源码目录的检查会在临时目录构建 wheel,因此会执行受信任的 build backend。Core 使用 uv 并显式指定当前解释器,只处理给定的本地路径;关闭软件源、网络、自动 Python 下载和用户 uv 配置发现,不安装插件依赖,也不修改 `wavebench.toml`。源码构建使用目标环境中预先安装的 build backend。uv 的离线参数不构成第三方 build backend 的网络沙箱。 ## 查看已安装插件 diff --git a/docs/how-to/serve-mcp.md b/docs/how-to/serve-mcp.md index 98b81a7f..224afbd9 100644 --- a/docs/how-to/serve-mcp.md +++ b/docs/how-to/serve-mcp.md @@ -1,6 +1,6 @@ # 启动只读 MCP 服务 -WaveBench HTTP MCP 服务提供本机或受控网络中的离线信息和 run plan 检查。它不提供 raw SCPI、输出控制或 run 执行。 +WaveBench HTTP MCP 服务提供离线信息、run plan 检查和显式仪器查询。它不提供 raw SCPI、输出控制或 run 执行。 ## 启动服务 @@ -20,7 +20,18 @@ python -m wavebench mcp serve \ | `GET /tools` | Bearer token | 列出只读工具。 | | `POST /call`、`POST /mcp` | Bearer token | 调用 MCP 工具或 JSON-RPC 方法。 | -请求体上限为 1 MiB。当前工具仅包含 `run.schema`、`run.check` 和 `capture.inspect`;它们不会连接仪器。`run.check` 只接受项目内的 `plans/*.toml`,`capture.inspect` 只读取项目内的离线采集包。 +请求体上限为 1 MiB。以下工具表对应 Core `0.8.28`;安装版本的实际列表以 `GET /tools` 返回为准。仪器查询需要已确认的资源、接线和访问权限: + +| 工具 | 行为 | +| --- | --- | +| `run.schema` | 返回 run plan schema。 | +| `run.check` | 只接受项目内的 `plans/*.toml`,离线解析并校验 run plan。 | +| `capture.inspect` | 只读取项目内的离线采集包摘要。 | +| `doctor.config` | 查询配置中的仪器,返回结构化检查记录。 | +| `scope.observe` | 查询示波器身份、可用状态和输入耦合,不读取波形。 | +| `scope.advise` | 基于查询结果和显式期望频率生成显示/时基建议,不执行建议。 | + +缺少测量值、有效档位或期望频率时,建议保留 `advice_unavailable`;查询失败不能解释为无需调整。MCP 不提供 `scope observe --fetch-waveform` 的波形报告路径。执行建议须另行经过对应 CLI 的访问、能力和安全检查。开发与回归合同见[示波器观察实现与验收](../development/scope-observation.md)。 ## Verification diff --git a/docs/index.md b/docs/index.md index eacb32fc..c9042dbe 100644 --- a/docs/index.md +++ b/docs/index.md @@ -9,7 +9,7 @@ - [无硬件快速开始](getting-started/quickstart.md):安装后打印示例 plan,并完成一次不连接仪器的检查。 - [安装](getting-started/installation.md):创建虚拟环境并验证 CLI 入口。 -- [项目首页](https://github.com/Scaxlibur/wavebench#readme):了解适用范围、核心能力和安全摘要。 +- [项目首页](https://github.com/Scaxlibur/wavebench/tree/v0.8.28#readme):了解对应版本的适用范围、核心能力和安全摘要。 ## 连接实验仪器 @@ -17,7 +17,7 @@ - [配置 Reference](reference/configuration.md):当前配置表、访问策略和安全边界。 - [执行一次实验](how-to/run-an-experiment.md):`run check`、`doctor`、`run verify` 与 `run plan` 的顺序。 - [使用 RF 信号源](how-to/use-rf-source.md):从离线确认到 capability、安全与现场检查。 -- [示例计划目录](https://github.com/Scaxlibur/wavebench/blob/master/plans/README.md):每个示例的硬件副作用和适用范围。 +- [示例计划目录](https://github.com/Scaxlibur/wavebench/blob/v0.8.28/plans/README.md):每个示例的硬件副作用和适用范围。 ## 自动化一次测量 @@ -63,4 +63,6 @@ ## RFC、历史与发布 - [RFC 索引](rfcs/index.md):提案和设计裁决,不能用作当前能力承诺。 -- [更新日志](https://github.com/Scaxlibur/wavebench/blob/master/CHANGELOG.md):已发布版本的变更。 +- [本版发布说明](releases/v0.8.28.md):变化与升级注意事项。 +- [发布版本](development/releasing.md):版本、发行物与审核流程。 +- [更新日志](https://github.com/Scaxlibur/wavebench/blob/v0.8.28/CHANGELOG.md):已发布版本的变更。 diff --git "a/docs/project/rfcs/WaveBench_advisor\346\217\222\344\273\266RFC.md" "b/docs/project/rfcs/WaveBench_advisor\346\217\222\344\273\266RFC.md" new file mode 100644 index 00000000..9ce3a0cc --- /dev/null +++ "b/docs/project/rfcs/WaveBench_advisor\346\217\222\344\273\266RFC.md" @@ -0,0 +1,600 @@ +# WaveBench advisor 插件类别 RFC + +> 状态:`Draft` +> 目标:增加 `advisor` 插件类别;通用数据外发同意门与 decision artifact 归 Core +> 首版外部实现:TypeSafe Jev(System One model);完整覆盖 Choice、Score 和 Noul +> 实施状态:开发分支已实现 Core run 绑定 Python 接口,尚未发布;advisor API、CLI、配置、授权与 artifact 流程仍为提案 + +## 摘要 + +现有插件的 `kind`、capability 前缀、descriptor 和生命周期校验均绑定仪器语义, +没有 SCPI 和型号的 advisor 无法注册。本 RFC 引入**插件类别(category)**,新增 `advisor`; +通用安全能力归 Core,插件提供实现,Core 不为厂商加特例分支。 + +首版外部适配仅 Jev,Core 保留离线规则基线;wire 格式与 HTTP 行为归适配器, +Core 负责校验、授权、审计与展示。供应商扩展须另行评审,不在 Core 固定白名单。 + +## 约束 + +- 运行期离线:不引入必须联网才能工作的默认路径;CI 无网络、无 API key。 +- 可审计:任何会让数据离开本机的动作必须显式、留痕、可复核。 +- 确定性边界:概率型输出不得影响 `run.json.status`、质量门、`auto_recover`、capability/access policy。 +- 归属:Core 只做通用抽象;型号、厂商、私有协议留在插件。 +- 测试:全部离线、可确定性复现;插件契约在加载期可校验。 +- 兼容:保持现有 V2 仪器插件契约、安装账本与已安装插件的行为。 + +## 核心实现现状 + +设计基线为 V2 可执行插件。以下位置以仓库实现为事实源,不依赖易变的行号。 + +| 位置 | 现状 | +| --- | --- | +| `src/wavebench/instruments/api.py` | `InstrumentDescriptor` 使用 `wavebench.instrument.v2`,要求仪器 `kind`、非空 `models`、`capabilities`、`backends` 和可调用的 `factory` | +| `src/wavebench/instruments/registry.py` | 从 `wavebench.instruments` 发现 descriptor,校验 driver 身份、版本门与 capability,再纳入 instrument registry | +| `src/wavebench/instruments/factory.py` | 为配置选中的 driver 构造 `DriverContext`,通过 descriptor 的 factory 获得执行对象 | +| `src/wavebench/plugins/package_inspect.py` | 源包和 wheel 必须声明 `wavebench.instruments` entry point,执行离线包检查 | +| `src/wavebench/plugins/lifecycle.py` | 安装后使用 V2 registry 的 descriptor 加载及校验路径验证插件 | + +`wavebench.drivers` / `wavebench.instrument.v1` 是已弃用的 metadata 路径,只用于兼容展示。 +它不是 advisor 的加载或执行基础;本提案保持既有兼容行为,不扩展 V1,也不移除其运行时。 +当前路径与信任边界见[插件模型](../../concepts/plugin-model.md)和 +[插件 Reference](../../reference/plugins/index.md)。 + +## 当前问题 + +1. advisor 没有仪器 kind、没有型号、没有 SCPI,无法通过上述任何一条路径注册; +2. 把 `advisor` 加入 `PluginKind` 会迫使 `models` 必填与 `"{kind}."` 前缀规则例外放行,混合两套语义; +3. 数据外发目前没有 advisor 的 Core 级契约:请求中的状态、问题、候选项和评分标准都可能包含业务数据; +4. 判断结果没有统一 artifact 契约,无法与 run 目录、审计流程对齐。 + +## 公共模型 + +### 插件类别 + +类别为 `instrument | advisor`;各自拥有 entry point group、加载对象契约、validator 和能力命名规则。 + +`instrument` 保持现状;`advisor` 使用独立 entry point group `wavebench.advisor` 与独立 API 版本门 +`wavebench.advisor.v1`。首版不引入第三个类别。 + +### Advisor 插件契约 + +```python +AdvisorPlugin( + advisor_id: str, # 稳定 id + display_name: str, + provider: str, + capabilities: tuple[str, ...], # 实现能力以 "advisor." 开头;external_state 由 Core 派生 + summary: str, + egress: EgressDeclaration, + factory: Callable[[AdvisorContext], Advisor], + api_version: str = "wavebench.advisor.v1", + package: str = "wavebench", + origin: PluginOrigin = "builtin", +) + +EgressDeclaration( + transmits_off_machine: bool, + allowed_state_fields: tuple[str, ...], # 请求 state.fields 的键白名单;不证明 provider body 的映射 + endpoint_hosts: tuple[str, ...], # transmits_off_machine=False 时必须为空 + purpose: str, +) +``` + +不要求 `models` / `manufacturer` / `kind`;不要求 capability 与仪器 kind 对齐。 + +### 能力命名 + +| capability | 含义 | +| --- | --- | +| `advisor.route` | 后续候选:在代码拥有的封闭选项集里选一个入口 | +| `advisor.triage` | 对 run/采集给出有序档位或人工复核 | +| `advisor.cause` | 后续候选:从封闭原因表给出候选原因与概率 | +| `advisor.rank` | 后续候选:对候选集重排 | +| `advisor.external_state` | 声明会把 state 发到本机之外(触发同意门) | + +Core 拥有这些名字与各自答案契约(Choice / Score / Noul 形状);插件只提供实现。 + +首版只冻结 `advisor.triage` 与外发声明;`route`、`cause` 和 `rank` 是后续候选,不进入首版 +capability 白名单。capability 表示任务用途;Choice / Score / Noul 表示问题类型,三者均进入 +首版执行、校验与审计合同。同一请求可混合三种类型,各问题独立读取同一 state,不引用本次 +其它问题的答案;这一行为依据 [Jev primitives](https://docs.typesafe.ai/primitives)。 + +### 首版判断任务与规则基线 + +`capture-triage` 的完整示例使用以下三个问题提供人工复核建议;输入只读取现有离线产物,不查询仪器。 +建议不改变验收结果,不开启下一次采集,也不执行建议中的任何命令。 + +| question ID | 类型 | 任务判据 | +| --- | --- | --- | +| `triage` | Choice | 在 `inspect_summary`、`inspect_waveform`、`insufficient_evidence` 中选择复核入口 | +| `review_priority` | Score | 由低到高的三个等级:常规复核、重点复核、优先补齐证据;分数表示复核优先级,不表示仪器危险程度 | +| `needs_attention` | Noul | 当前记录是否需要额外关注;yes 包括采集证据缺失或质量告警,no 不代表实验验收通过 | + +问题集合、文案和评分判据由版本化任务模板确定,可选单种、多题或三类混合,不要求每次都发送 +示例中的三题。模板变更使已有授权失效;未包含的题目不要求返回。Jev 适配和公共 validator +必须覆盖三类;规则基线只承诺已登记模板,未知模板在准备前明确拒绝,不伪造通用推理能力。 + +文件路径、完整波形和设备身份不自动加入请求;状态字段及来源按下表构造。 + +run 状态由 Core 构造;补充输入仅提供 `operator_note`,不能覆盖派生字段。任务模板规定字段来源 +与采集步骤选择;候选不唯一时拒绝准备,不默认取第一个。本地 target 保存选择并受本次授权约束。 +独立 fixture 使用 standalone 目标,内容相似不继承 run 授权。 + +`capture-triage` 首版 state schema 如下;`state` 恰好包含对象 `fields` 和 `provenance`, +两者的键集合必须相同,拒绝未知字段。Core 在插件加载前构造并校验来源,插件不能修改。 + +| `fields` 键 | 必填/类型 | run 模式来源 | standalone 来源 | +| --- | --- | --- | --- | +| `capture_available` | 必填,JSON boolean | Core 从绑定快照提取 | fixture | +| `quality_warnings` | 必填,字符串数组,允许空数组 | Core 从绑定快照提取 | fixture | +| `operator_note` | 可选,字符串,允许空字符串 | 显式补充输入 | fixture | + +provenance:run 派生字段为 `{"kind":"run_snapshot","paths":["run.json"]}`, +`paths` 是排序、唯一、非空的实际依赖相对路径列表,须属于 target 的源清单,可包含参与判断的 missing 输入; +run 备注为 `{"kind":"operator"}`;standalone 字段为 `{"kind":"fixture"}`。拒绝额外键或来源不匹配。 +Core 为 CLI 的 fields 输入补齐 provenance,拒绝来源覆盖;来源仅用于本地审计,不表明文本可信或进入 body。 + +省略备注时 provenance 也须省略它,不能补成空字符串或 null;空字符串是显式输入。合法 standalone state: + +```json +{"fields":{"capture_available":true,"quality_warnings":[]},"provenance":{"capture_available":{"kind":"fixture"},"quality_warnings":{"kind":"fixture"}}} +``` + +缺少任一必填字段、null、错误类型或 provenance 键不匹配时,在调用前拒绝。 + +`rule_advisor`:采集不可用时返回 `insufficient_evidence`;有告警时为 `inspect_waveform`; +其它情况为 `inspect_summary`。对应 Score 为 2、1、0,分布为 one-hot;Noul 在不可用或有告警时 +为 1,其它情况为 0。规则记录忽略备注的限制,不模拟供应商 confidence;Jev 可结合备注。例如: + +```json +{"capture_available": true, "quality_warnings": ["low_cycle_count"], "operator_note": ""} +``` + +该输入的规则答案为 `inspect_waveform`、Score 1 和 Noul 1。离线 fixture 至少覆盖采集缺失、有告警、无告警、 +缺必填字段、合法省略备注和非法类型;缺必填字段与非法类型在调用前拒绝。基线返回对应选项的 one-hot 概率,表示 +确定性规则输出,不代表经过校准的统计置信度。 + +Core 用同一任务模板、请求与答案 validator 测试规则和独立实现;效果另用人工标注的留出报告集, +比较选项准确率、需复核样本漏检率、概率校准、耗时与失败率,不只比较 confidence。 +外部实现须保持基线复核召回率并改善建议准确率,才可主张优于基线;评估前确定样本规模、 +容许误差及标注协议。内置规则只证明合同可执行,不能单独证明类别必要性。 + +### 最小执行协议 + +`wavebench.advisor` entry point 返回一个 `AdvisorPlugin` descriptor,或无参数、返回该 +descriptor 的函数。entry point 名必须等于 `advisor_id`;重复 ID、未知 API 版本、未知 +capability 和不可调用的 factory 在加载期拒绝。独立 advisor registry 校验 descriptor, +不复用仪器的 `models` 或 backend 要求。 + +下面是拟定的类型协议;所有 JSON 对象均按后文 schema 校验后冻结为不可变值。 + +```python +class Advisor(Protocol): + def prepare(self, request: AdvisorRequest) -> PreparedRequest: ... + def execute(self, prepared: PreparedRequest, context: CallContext) -> AdvisorResponse: ... + +# AdvisorRequest: schema_version, call_id, target, task_id, task_version, +# state, questions, requested_model, inference_parameters +# AdvisorContext: 不含仪器对象、transport 或凭据的本地配置 +# PreparedRequest: 规范化请求、HTTP method/URL、语义 headers、精确 body bytes;离线实现无 HTTP 字段 +# CallContext: 单调时钟 deadline、显式授权的凭据引用;不提供仪器控制接口 +# AdvisorResponse: schema_version, call_id, reported_model, answers, usage(可选) +``` + +`schema_version` 首版为 `wavebench.advisor.request.v1` / `wavebench.advisor.response.v1`; +state 按上节 schema,questions 是数组,answers 以 question ID 为键,形状见下节适配示例。 +target、call_id、run 身份和本地路径不进入 provider body;响应 call_id 从本次执行上下文补入。 + +导入、entry point 求值、factory 构造、`prepare()` 和 CLI 预览阶段均不得联网、访问仪器或 +读取凭据。插件配置必须显式传入;实现不能隐式读取额外文件或环境变量作为请求数据。 +`prepare()` 本地转换供应商格式;Core 校验规范化请求与冻结输入结构相等,校验 endpoint、 +method、语义 headers 与 body bytes 并预览精确内容。供应商语义映射由适配器及离线合同测试核验, +Core 不证明 body 是 state 的正确投影,也不从 state 白名单推导其它 body 的许可。 +只有 Core 完成授权与审计后才调用 `execute()`,此时才解析凭据引用。 + +`execute()` 必须使用已准备的 method、URL、语义 headers 和原始 body bytes,禁止再次 +序列化、补充上下文、改变模型、自动重试或跟随重定向。仅允许传输层补充 Content-Length 等 +机械 headers,以及显式凭据引用对应的认证 header;认证内容不得承载额外业务数据。 +响应必须覆盖全部 question ID,禁止未知答案;Core 独立校验后才生成建议。 + +Core 将加载、构造、准备和执行放入可终止的独立 worker,按阶段施加有限的超时;调用方只能设置 +有限正数 `timeout_s`,默认 30 秒,限定加载与准备阶段的合计预算,以及授权后执行阶段的预算, +不含等待人工确认。人工确认后核验准备内容再执行。超时或取消终止 worker,不后台继续等待结果, +不自动重试;已经发送的远程请求无法撤销,artifact 标记 `delivery_unknown`。 +加载失败、准备失败、执行异常、超时和响应校验失败都作为结构化失败记录,不伪造答案或回退为成功。 + +这是一份可信本地插件合同。worker 用于超时控制,不提供任意 Python 代码的网络隔离; +恶意插件仍可能自行联网。包的信任、安装策略和代码审查是前置条件,同意门不能充当沙箱。 + +### Jev 适配与官方依据 + +以下 wire 映射于 2026-10-05 核对 [HTTP API](https://docs.typesafe.ai/api)、 +[Choice](https://docs.typesafe.ai/primitives/choice)、[Score](https://docs.typesafe.ai/primitives/score) +与 [Noul](https://docs.typesafe.ai/primitives/noul) 文档;属于拟定适配合同,未调用真实服务验收。 + +`prepare()` 生成发往 `https://api.typesafe.ai/v1/systemone` 的单次 POST。body 只含 `model`、 +`state`、`questions`:`requested_model` 映射为 model,state 取经批准的 `state.fields`, +问题数组按 `id` 转为 questions map,每项保留 type、instructions、criteria 并移除本地 `id`。 +本地 provenance、target、call_id 与源绑定不进入 body;问题和结构化判据中的业务数据同样 +接受完整预览与授权。当前文档对应的适配版本没有额外 inference_parameters,非空时在准备 +阶段拒绝;后续按新适配版本显式映射官方支持的参数,不能静默忽略或未经预览追加。 + +单个问题与三类混合请求使用同一映射。下面是混合请求的 questions 片段示例;所有文本均为 +WaveBench 任务模板,不是额外的供应商参数,独立问题不得依赖 question ID 隐含的语义: + +```json +{ + "triage": { + "type": "choice", "instructions": "根据采集记录和备注,选择人工复核入口。", + "criteria": {"inspect_summary": "记录可用且适合先看摘要", "inspect_waveform": "应重点检查波形", "insufficient_evidence": "先补齐采集证据"} + }, + "review_priority": { + "type": "score", "instructions": "根据记录可用性、告警与备注,评估人工复核优先级。", + "criteria": ["常规复核", "重点复核", "优先补齐证据"] + }, + "needs_attention": { + "type": "noul", "instructions": "当前采集记录是否需要额外关注?", + "criteria": {"true": "证据缺失、存在质量告警或备注提示需关注", "false": "现有记录与备注未提示额外关注事项"} + } +} +``` + +响应的 model 原样记录为 `reported_model`;Jev 适配器要求 Choice / Score 的原生 confidence +存在且为 `[0,1]` 内有限数字,再原样映射为 `provider_confidence`;Noul 没有此字段,其 noul +映射为 `probability_yes`。这是适配器的响应合同,见[官方定义](https://docs.typesafe.ai/confidence)。 +Score 的 HTTP legend/probabilities 使用十进制字符串等级键;若采用 SDK,整数键仅在适配 +边界转换为规范字符串,禁止转换后出现重复键或改变等级顺序。usage 只保存经校验的非负整数 +token 计数。`advisory` 同时记录 advisor、插件版本、requested/reported model 和任务版本。 + +以下是适配后的三题 answers 示例(人工构造,用于离线验收,不代表实测模型效果): + +```json +{ + "triage": { + "type": "choice", "choice": "inspect_waveform", + "probabilities": {"inspect_summary": 0.1, "inspect_waveform": 0.8, "insufficient_evidence": 0.1}, + "provider_confidence": 0.7 + }, + "review_priority": { + "type": "score", "score": 1.25, + "legend": {"0": "常规复核", "1": "重点复核", "2": "优先补齐证据"}, + "probabilities": {"0": 0, "1": 0.75, "2": 0.25}, + "provider_confidence": 0.63 + }, + "needs_attention": {"type": "noul", "probability_yes": 0.1} +} +``` + +该示例的 Core 指标为 0.8、0.75、0.8;Noul 倾向 no,Score 保持 1.25;原生 confidence 不参与等式或指标计算。 + +效果评估使用显式固定模型版本;交互调用可显式选择别名,但实际返回版本不能继承旧版本的 +校准结论。阈值配置关联实际模型与任务版本;无匹配配置时保存合法答案并强制人工复核, +不得猜测默认阈值。别名的实际版本只能在响应后观察,不据此重发请求或补做隐式模型查询。 + +适配器将非 2xx(含 401、422、429、529)记录为 `execution_failed` 与清理后的 HTTP 原因; +成功响应若缺答案、类型不符或不能完整解析则为 `invalid_response`,超时沿用 `timeout`。 +不填充替代答案、不消费部分结果。官方 SDK 支持重试;若采用 Python SDK,须配置 +`RetryPolicy(max_retries=0)`,见[重试定义](https://docs.typesafe.ai/sdk/python/api/retries)。 +还必须验证传输层没有自动重试/重定向且实际字节与 prepare 一致;无法保证时采用能发送精确 +body bytes 的 HTTP 路径。三种问题混合仍只消费一次调用额度,不拆成三个请求。 + +### 请求与答案校验 + +请求中的 task 与 question ID 非空且唯一;状态字段必须符合版本化任务 schema,拒绝未知字段、 +重复 JSON key、非法类型及非有限数字。每个问题包含 `id`、`type`、`instructions`,按类型 +提供 criteria;instructions 接受字符串、JSON 对象或数组,嵌套数据也属于预览与授权范围。 + +| 问题类型 | criteria 合同 | +| --- | --- | +| Choice | 必填选项映射;选项 ID 非空且唯一,WaveBench 要求 2~255 项;判据为字符串、对象、数组或 null | +| Score | 必填有序判据数组,2~10 项;每项为字符串、对象或数组,等级按原序为 `0..n-1` | +| Noul | 可省略;存在时为包含且仅包含字符串键 `true`、`false` 的对象,两个判据均为字符串、对象或数组;不要求 options/levels | + +表中选项/等级上限是本次核对的 Jev 适配限制,由适配版本维护,不成为其它未来实现的永久 +Core 常量。固定答案类型与完整性规则由 Core 校验,供应商限额在 prepare 前检查。 + +| 答案类型 | 必填字段 | 校验与解释 | +| --- | --- | --- | +| Choice | `type="choice"`、`choice`、`probabilities` | choice 属于候选集且为最高概率选项之一;分布恰好覆盖全部选项 | +| Score | `type="score"`、`score`、`legend`、`probabilities` | legend 与分布的键恰好为 `"0".."n-1"`;legend 值与对应请求判据结构相等;score 为等级的概率加权平均,位于 `[0,n-1]`,可为小数 | +| Noul | `type="noul"`、`probability_yes` | 只有 yes 概率;不要求概率映射 | + +所有概率及提供的 `provider_confidence` 都必须是 `[0,1]` 内的有限数字,bool 不视为数字; +Score 本身只受 `[0,n-1]` 约束,不能套用概率上限。概率分布总和与 1 的差及 Score 加权均值 +误差均不大于 `1e-6`,不静默归一化或修正答案。这是 WaveBench 的校验容差,不是供应商的 +精度保证;适配验收须使用保留精度的响应 fixture,发现不兼容时先修订合同,不能暗中扩大容差。 +响应类型须匹配问题;缺失/额外答案、未知选项、非法数值、错误 legend 或无效分布使整次响应 +为 `invalid_response`。单题错误不得降级成其它题成功,合法的不同题判断也不必相互推导一致。 + +三类答案可附带供应商实际返回的 `provider_confidence`;Core 允许省略,适配器可要求原生字段。 +Core 只校验类型与范围,供审计和效果比较,不复算、覆盖、维护公式白名单或参与阈值;规则省略,禁止伪造。 +不要求 `confidence_definition`;插件与模型版本标识来源,one-hot 或原生值不代表 WaveBench 校准的正确率。 + +每个 question 的 Core 配置明确 `review`、`accept`,满足 `0 <= review <= accept <= 1`; +禁止依赖跨问题、模型版本的隐式全局阈值。首版模板采用如下通用指标,不增加可选公式注册系统; +后续任务可通过有明确公式、版本与评估依据的策略修订调整指标,而不改写供应商原始答案: + +| 类型 | Core 指标与定义版本 | 展示含义 | +| --- | --- | --- | +| Choice | `top_probability` / `wavebench.top_probability.v1`:`max(probabilities.values())` | 所选选项概率;并列最高强制人工复核 | +| Score | `top_probability` / `wavebench.top_probability.v1`:`max(probabilities.values())` | 最可能等级的集中程度;独立展示原始小数 score,不能把 `score/(n-1)` 当置信度;并列最高强制人工复核 | +| Noul | `distance_from_half` / `wavebench.distance_from_half.v1`:`abs(2*p-1)` | 对 yes/no 的确定程度;`p>0.5` 为 yes,`p<0.5` 为 no,等于 0.5 不给方向并强制复核 | + +Score 指标是 WaveBench 的保守展示策略,不等同于供应商 confidence,也不证明 +加权 score 的正确率;即使均值相同,不同分布仍保留分别审查。Noul 的方向与确定程度分开, +接近 0 表示明确倾向 no,不应误报低置信。人工复核覆盖阈值判断,即使 accept 配为 0 也不能绕过。 + +指标 `>= accept` 只标为 `suggestion_ready`;`review <= metric < accept` 标为 +`human_review`;`metric < review` 标为 `insufficient_evidence`。这些标签仅控制展示和人工复核, +不启动实验操作,不改变安全门或质量门。指标、阈值和定义版本均保存到 artifact。 +模型答案与展示标签分别保存:例如高概率的 `insufficient_evidence` 选项可以获得 +`suggestion_ready` 标签,表示可展示「补齐证据」建议,不表示采集通过。问题逐题展示, +不合成为实验 pass/fail;缺少匹配的阈值配置时,metric 仍可计算,但标签只能为 `human_review`。 + +### 数据外发同意门(Core 拥有) + +默认关闭,由配置和交互式 CLI 显式开启。`transmits_off_machine` 是唯一外发判定来源; +`advisor.external_state` 由 Core 据此派生,不能由插件独立声明相反含义。本机以外的任何目的地 +都属于外发;首版禁止未声明的本地代理调用,网络型实现只接受声明的 HTTPS endpoint。 + +流程固定为:构造输入快照 → 完整请求生成 → 校验 → 预览 → 授权 → 保存请求与授权记录 → +核验源绑定与请求 → 发送。run 输入快照按下节的绑定合同构造。 +校验和预览覆盖 state、问题说明、候选项、评分标准、模型与推理参数;未声明的 state 字段或 +目的地在授权前拒绝。预览展示精确 body、method、URL、语义 headers、字节数及本地输入的 +untrusted span 与来源,不显示认证秘密;来源声明不证明 body 映射,映射按适配合同验收。 + +Core 将请求 envelope 按 UTF-8、键排序、无额外空格、拒绝 NaN 的规范 JSON 编码,其中 body +以 base64 保存,再计算 `request_sha256`;同时保存 body 的 `payload_sha256` 和字节数。 +envelope 包含 advisor/package/API 版本、完整 URL、method、语义 headers、模型与精确 body, +避免仅绑定 host 或状态白名单。请求准备后不可修改;发送前 Core 从实际交给执行器的对象重新 +计算摘要,与预览和授权记录核对。认证值不参与持久化摘要,凭据引用的身份参与绑定。 + +按次授权绑定 `call_id`、`request_sha256` 和本地 target;run 目标另绑定 `binding_sha256`。 +首版只支持按次授权;它只消费一次,摘要不同或记录无法保存则拒绝执行。 +同意记录包含范围、target、操作者、时间、endpoint、字段白名单、摘要与字节数; +`--accept-external-state` 表示请求交互确认,不是跳过预览或确认的布尔授权。 + +每次外发均重新准备、预览和确认;run 绑定相同或任何请求字段变化均不能复用授权, +包括备注从省略变为提供。首版不提供 run 级可复用授权或可变 state 模板;以后若引入, +须验证 state 变化在 body 中的完整影响,不能仅按本地 JSON 路径差异推定许可。 +run 身份绑定与发送前复核按下节执行;授权消费须原子化,并发执行不能消费同一授权。 +超时、发送结果未知或复制 `consent.json` 均不能恢复授权;复制文件只保留审计证据。 + +`run plan`、CI、MCP 等非交互入口拒绝外部执行,也不接受历史授权记录绕过限制;离线 advisor +和预览仍可使用。API key 永不进入请求业务字段、artifact、日志或错误信息;供应商异常原文及 +响应 headers 不直接写盘,Core 只保存清理后的错误码、阶段和消息。 + +### run 输入快照绑定 + +绑定用于确认本地实验目录与此次实际消费的输入内容。现有 `run.json` 不保证携带唯一 run ID; +目录名、experiment name、plan hash 及展示用 ID 均不能替代授权绑定。Core 不修改已有 +`run.json`,也不向原始产物补写 UUID;standalone 继续以 `call_id` 标识调用。 + +`run_binding` 的首版字段如下;`binding_sha256` 单独保存,不参与自身的计算。 + +| 字段 | 合同 | +| --- | --- | +| `schema` | 固定为 `wavebench.advisor.run_binding.v1` | +| `run_root` | Core 解析后的本机绝对目录;消除相对路径、`.`、`..` 与符号链接别名,使用本机路径规则,不自行将路径转为小写 | +| `task_id` / `task_version` | 定义输入提取规则的任务及版本 | +| `sources` | Core 生成的输入清单,按 `path` 排序且路径唯一;必须包含 `run.json` | + +每个 source 包含 run 根目录内的 POSIX 风格相对 `path` 和 `state`。`state="present"` +另含原始字节的 `sha256`;`state="missing"` 不含摘要,只用于任务明确定义可缺失且其缺失 +参与判断的输入。`run.json` 必须存在。无权限、越界、非普通文件或读取错误不能当作 missing。 +解析后的输入路径必须仍在 run 根目录内;清单不接受插件提供的额外路径。 + +先对各个存在的源文件计算 SHA-256,再将绑定记录按 UTF-8、无 BOM、键排序、无额外空白、 +保留 Unicode 字符且拒绝 NaN 的 JSON 编码计算 `binding_sha256`;摘要统一使用小写十六进制。 +它是小型绑定记录的摘要,不是整个 run 目录的摘要。`call_id`、审计时间、输出路径及摘要自身 +不进入记录。绑定结构、源路径和绑定摘要只保存在本地审计中,不进入 provider body。 + +#### 源范围与资源预算 + +源清单由版本化任务的 Core 输入构造器确定,覆盖构造 state 时实际使用的报告/metadata, +以及参与判断的缺失状态。字段来源与内容摘要必须使用同一次读取的字节;保存原始字节摘要 +而非重新序列化后的 JSON 摘要。输入提取只消费已读取的快照,不再次从磁盘取另一份内容。 + +首版 triage 不递归扫描 run,不读取或散列完整波形、截图、视频;`capture_available` 只描述 +任务检查的记录/metadata 是否可用,不宣称波形内容已完整验证。`decisions/`、独立审计目录、 +本次生成文件和未消费的报告不进入清单。通用 package loader 若会读取额外的频响或校准文件, +不能直接把其所有读取作为 triage 的隐式依赖。 + +首版任务预算为最多 32 个 source 条目(含 missing)、每次快照读取累计最多 16 MiB 原始字节, +含 `run.json`。读取时执行累计字节上限,不能仅依赖读取前的文件大小;超限明确拒绝,不能截断 +输入、跳过摘要或转用文件大小/mtime 作为身份。任务版本固定这些上限,插件和调用参数不能放宽。 +文件读取还须有有限的阶段超时;复用 `timeout_s`,快照构造计入准备预算,发送前复核计入执行 +预算,不包含人工确认等待。预算是应用层读取限制,不承诺 OS 内存硬限额。 + +初次读取同时用于解析和摘要;授权匹配直接比较该快照的绑定,不额外重复读取。保存请求与授权后, +执行前重新解析目标根目录、重建源清单并核验内容摘要和 missing 状态,只读取相关小型源文件。 +正常调用只需构造和发送前复核两次有界快照读取;预览不执行发送前复核。实施验收记录代表性输入 +的字节数与两次读取耗时,不引入跨快照摘要缓存。 + +#### 开发分支实现 + +`wavebench.services.advisor_run_binding` 提供 `BindingSource`、`capture_run_binding()` +与 `verify_run_binding()`。`capture_run_binding()` 读取 `run.json` 及 Core 显式选定的源;返回快照的 +`read_json()` / `read_bytes()` 供输入提取使用,`as_dict()` 与 `binding_sha256` 供本地审计使用。 +读取在独立子进程中执行,超时终止读取进程。此接口不生成授权、不调用 advisor,也不写入 run。 + +```python +from wavebench.services.advisor_run_binding import ( + BindingSource, capture_run_binding, verify_run_binding, +) + +# 清单由版本化 Core 任务定义;示例允许 summary.json 缺失。 +sources = [BindingSource("summary.json", optional=True)] +snapshot = capture_run_binding( + run_dir, task_id="triage", task_version="1", sources=sources, timeout_s=30, +) +summary = snapshot.read_json("summary.json") +# Core 用快照构造并冻结请求,完成预览和授权后,发送前再次调用: +verify_run_binding(snapshot, sources=sources, timeout_s=30) +``` + +源选择依赖当前 run 内容时,Core 须重新生成 `sources` 并传入复核;省略时按原清单复核。 +任一源的读取错误或绑定变化均抛出 `RunBindingError`,调用方必须停止发送。 +调用方负责传入阶段剩余时间,并检查请求、授权和目标的其它条件;以上示例不构成完整发送流程。 + +2026-10-05 本机离线合成输入测量,每组运行 5 次,取中位数;计时含子进程启动、读取、 +进程间传输和摘要计算。总源字节数 4098 时,构造/复核分别为 49.8/49.6 ms; +总源字节数 16777216(16 MiB)时分别为 207.5/210.8 ms。该结果不代表冷缓存、 +网络文件系统或其它平台的性能保证;原生 Windows 尚待验证。 + +#### 失效与一致性边界 + +复核发现根目录、清单、存在状态或内容变化时,停止本次调用;不能刷新输入后继续使用旧预览或 +旧授权。读取不稳定、失败或超限时同样拒绝执行。执行器仅接收冻结请求,不允许重新读取源文件。 +本合同不提供多文件原子快照或文件系统锁;最终复核后磁盘继续变化,不得改变本次发送字节, +审计始终描述本次使用的输入快照。 + +| 变化 | 绑定结果 | +| --- | --- | +| 相对/绝对路径或符号链接别名解析到同一根目录,源内容相同 | 相同;符号链接重新指向其他目录时失效 | +| run 移动、复制到另一目录,或同名目录位于不同位置 | 不同,须重新授权 | +| `run.json` 或任一已消费文件内容变化,包括只改 JSON 缩进 | 不同,须重新授权 | +| 可缺失输入由 missing 变为 present,或反向变化 | 不同,须重新授权 | +| 仅新增 advisor 审计文件、修改未消费文件或文件 mtime | 相同 | +| 同一路径下以完全相同字节替换文件 | 相同;本合同比较内容,不识别物理文件更替或实验发生次数 | + +绑定相同只满足授权的一项条件,仍须检查本次完整请求、目标选择及按次授权是否有效且未消费。 +路径绑定仅在本机解释,不承诺跨机器或移动目录后继承授权,也不建立全局实验身份。 + +### Decision artifact(Core 拥有) + +- schema `wavebench.decision.v1`,写入对应 run 目录的 `decisions/`,或显式独立审计目录,附加式; +- 内容:target(本地 run 绑定及任务选择,或 standalone 调用)、advisory(advisor/插件/任务版本、requested/reported model、duration、usage)、consent、state、 + questions、answers(各类型字段及适用时的 provider_confidence)、Core metrics、thresholds、recommendations; +- 每问题的指标与 accept/review 展示阈值由 Core 配置拥有,不交给插件; +- 写盘沿用既有 Windows 原子替换与重试约定;文件名含 UTC 时间戳与 advisor ID,独占创建; +- 本次不并入 `run report`。 + +`advisor ask` 必须恰好指定一个目标:`--run-dir` 指向含可验证 `run.json` 的已有 run,或 +`--audit-dir` 指向显式的独立审计目录。前者由 Core 构造 `run_binding` 与 `binding_sha256`;后者的 target 为 +`standalone`,含 Core 生成的 `call_id`。目标缺失、无效或不可写时,在插件加载前拒绝。 + +每次调用创建独占的 call 子目录;其中 `request.json` 保存校验后的请求、精确预览与摘要, +run 调用另保存完整绑定与源清单;`consent.json` 保存授权或拒绝,并关联 `binding_sha256`、 +本地 target 与本次请求摘要,`result.json` 保存终态。每个文件只写一次,使用既有原子 +写入约定;预览也保存 `preview_only` 结果。call 目录使用 UTC 时间、经校验的 advisor ID +和随机 call ID,不能由插件提供路径片段。 + +授权与请求记录必须在发送前持久化成功。终态覆盖 `completed`、`preview_only`、 +`denied`、`prepare_failed`、`execution_failed`、`timeout`、`invalid_response`、`cancelled`; +同时记录 `delivery` 为 `not_sent`、`sent` 或 `delivery_unknown`;本地实现始终为 `not_sent`。 +加载失败使用 `load_failed` 终态。发送后写终态失败时,CLI +报告审计失败并返回非零;已保存的请求与授权保留为未完成调用,后续检查只能报告未知状态, +不能推断成功或自动重新发送。加载或准备前失败可只生成失败结果;本地非法输入在创建 call +目录前返回错误,保证零插件调用。除 `completed`、`preview_only` 外的终态均返回非零。 + +发送前绑定复核失败使用 `denied` 终态:内容变化的原因为 `run_binding_changed`,读取失败或 +资源超限的原因为 `run_binding_unverifiable`;二者均为 `delivery=not_sent`、零 execute。 +复核阶段超时或取消仍使用 `timeout` / `cancelled` 终态,并明确 `delivery=not_sent`、零 execute。 +结果记录原摘要、可计算时的新摘要、变化或无法读取的相对路径及阶段,不复制源文件内容。 +已写入的请求和授权记录保持不变;初次快照错误在插件加载前拒绝,不冒充供应商执行失败。 + +### 内置确定性 advisor + +Core 内置 `rule_advisor`:纯规则、零依赖、`transmits_off_machine=False`。用于:让新类别在 CI 与 +离线下可测;提供效果比较的确定性基线;给第三方最小样例。合同测试中的第二个实现采用独立 +fake Jev provider,复用相同 validator,覆盖三类独立请求及混合响应,并记录 `prepare` / +`execute` 次数与最终发送字节。fake HTTP 边界验证 Jev 适配行为,不要求离线 CI 安装第三方 SDK。 + +### CLI 面 + +```text +wavebench plugin list --category advisor +wavebench plugin doctor --category advisor +wavebench plugin package check +wavebench plugin install|installed|remove <...> +wavebench advisor ask --advisor --task capture-triage --run-dir \ + [--state ] [--preview | --accept-external-state] [--timeout-s 30] +wavebench advisor ask --advisor --task capture-triage --state \ + --audit-dir \ + [--preview | --accept-external-state] [--timeout-s 30] +``` + +以上为提案语法;`--preview` 不联网,打印完整预览并保存审计记录。run 的 `--state` 仅接受 +可选 `operator_note`;standalone fixture 提供任务 fields,Core 补齐 provenance。 +补充输入读取后冻结,受本次完整请求授权约束,不作为 run 源文件。 +默认不加外发许可时只能执行本地 advisor。外部调用必须经交互确认;它与仪器 IDN probe +具有不同的数据外发边界,不能因 probe 为只读就视为已经获得外发授权。 + +## 兼容性 + +- 现有 V2 仪器插件的公开契约、entry point group 和账本解释保持兼容;包检查与生命周期新增独立 advisor 分支; +- 已安装插件不受影响;新增 advisor registry 不改变 V2 instrument registry 或 V1 metadata registry 的返回合同; +- 新类别使用独立版本门;advisor 插件对 Core 的版本门由实施版本决定; +- 一个包首版只允许声明一个类别;从「禁止混装」放宽到「允许」是向后兼容的,反向不是。 + +## 决策(提案结论) + +| # | 决策 | 结论 | +| --- | --- | --- | +| 1 | 是否引入插件类别抽象 | 引入 `instrument` / `advisor` 两个类别,独立 group 与版本门;不把 `advisor` 加入 `PluginKind` | +| 2 | 一个 wheel 能否同时提供两类插件 | 首版禁止;混装给出明确错误 | +| 3 | advisor 能否声明第三方运行时依赖 | 允许声明;依赖只从打包元数据 `Requires-Dist` 读取,`plugin doctor` 报告缺失;`plugin install` 保持离线 `--no-deps` | +| 4 | 同意门粒度 | 首版仅按次绑定完整请求摘要与本地目标;run 目标另绑定目录及源快照,发送前复核,不提供可复用 run 授权 | +| 5 | `advisor.external_state` 是否纳入 access policy | 本次不纳入;由 `[advisor]` 配置 + 同意门控制 | +| 6 | decision artifact 是否并入 `run report` | 本次不并入;只落盘,插件自渲染摘要 | +| 7 | 首版外部适配及问题类型 | Jev 的 Choice / Score / Noul 全部覆盖;问题集合由版本化任务模板选择 | +| 8 | confidence 与展示指标 | 供应商信息可选,适配器负责原生字段要求;Core 从答案计算通用版本化指标,不维护供应商公式白名单 | + +上述提案决策均不构成当前能力或对外承诺。 + +## 验收门 + +- 离线:全部测试无网络、无 API key、无第三方 SDK; +- 契约:未知 capability、缺 `purpose`、`transmits_off_machine=False` 却给出 endpoint,均在加载期拒绝; +- 类别边界:一个包同时声明两个 entry point group 必须被拒绝; +- 依赖:只从 `Requires-Dist` 读取;doctor 报缺失但不联网; +- 执行协议:规则与 fake provider 独立实现接受同一请求、响应 validator;加载、构造、准备和预览阶段的网络与仪器调用次数为 0; +- 同意门:未同意、完整请求摘要变化、endpoint 变化、未注册字段、仅预览及发送前写盘失败时,execute 次数为 0; +- 请求一致性:fake provider 捕获的 method、URL、语义 headers 与 body bytes 必须与预览摘要一致;问题、候选项、判据或模型变化均不能复用按次授权; +- 同意范围:每次外发均预览并确认精确 body;相同 run 绑定或请求也不能复用授权,备注省略/提供及值变化均需新的按次授权;非交互入口拒绝外部执行; +- run 绑定:同目录同任务同源字节及等价路径绑定一致;同名不同目录、移动/复制目录、源内容或 missing 状态变化均失效;新增 decisions、修改未消费文件或 mtime 不影响绑定;同路径同字节替换视为相同; +- 快照复核:确认后源变化、读取失败或超限均为 not_sent、零 execute;结果保存失败原因及可用的摘要,不覆盖原请求/授权,不自动刷新或重发;`run.json` 原始字节不变; +- 输入与成本:run 模式拒绝 fixture 覆盖派生字段;摘要与解析消费同一份字节;条目数 32 与累计字节 16 MiB 的边界及超限均有离线用例,不遍历目录或读取大体积波形,记录代表性两次读取的耗时与字节数; +- 授权消费:两个并发执行不能消费同一按次授权;源绑定相同、复制审计文件、超时或发送结果未知均不能恢复有效授权; +- state schema:必填字段与类型、空告警数组、备注省略/空字符串/null、未知字段、provenance 键集合、来源类型和源清单引用均有接受与拒绝用例;Core 来源不能由插件覆盖; +- 三类请求:Choice 单题、Score 单题、无 criteria 的 Noul、有 criteria 的 Noul 及三类混合均有 fixture;结构化判据、Choice 2/255/256 项、Score 2/10/11 级、重复 ID 与未知类型覆盖接受和拒绝边界; +- 三类答案:Score 小数及大于 1 的合法分数、完整 legend、SDK 等级键转换与冲突,Noul 的 0/0.5/1 均验证;缺失/额外答案、类型不符、未知选项、NaN/Inf、bool、错误分布与 legend 均拒绝,单题错误使混合调用失败; +- confidence:Core 接受三类答案省略 provider_confidence,提供值须合法且不改变指标;Jev Choice/Score 原生字段缺失或非法由适配器拒绝,Noul 与规则实现不伪造,不要求 confidence_definition; +- 展示:Choice/Score 并列最高、Noul 为 0.5 或缺少匹配阈值配置时强制复核;Noul 接近 0 展示 no;低置信标签与模型选项分别保存,Score 同均值不同分布不合并; +- Jev 适配:离线测试独立解析精确 body,核对 model、state 全部键和值及 questions 映射,确认 provenance/target/call_id 未外发,覆盖备注省略和结构化判据;混合问题只发一次,401/422/429/529/超时不自动重试;实际发送字节与 prepare 一致,别名实际返回版本如实记录; +- artifact:run 与独立调用均独占创建记录,不改 `run.json`/`summary.csv`/`steps/*`;目标缺失或不可写时零插件调用; +- 失败:准备、执行、超时、取消及响应校验失败均保留审计终态;发送后结果写入失败保留未完成记录并返回非零,不自动重试;凭据不出现在记录或清理后的错误中; +- 边界:advisor 结果不得影响 `run.json.status`、质量门、`auto_recover`、capability/access policy; +- 文档:新增 Development 页与 Reference 章节,并在 [RFC 索引](../../rfcs/index.md) 登记状态。 + +## 分期实施 + +| 阶段 | 内容 | +| --- | --- | +| 1 | 独立 advisor registry + descriptor/factory/执行协议 + 三类请求与答案 validator + 版本化任务模板、triage 规则与 fake Jev provider | +| 2 | 完整请求同意门 + 有界 run 源快照绑定与发送前复核 + run/独立调用审计写入器 + 每问题阈值配置 + 超时与失败处理 | +| 3 | `package_inspect` / `lifecycle` 支持 advisor 包 + `advisor ask` + 插件侧 Jev 三类问题适配与离线 HTTP 合同验收 | +| 4 | 文档(Development、Reference、概念页)与 RFC 索引登记 | + +阶段 1 与 2 不依赖网络与第三方 SDK,可合并为一个可离线验证的改动;阶段 3 触及生命周期,建议单独评审。 +阶段 2 的 run 快照与复核接口已实现,见上文及 `tests/test_advisor_run_binding.py`;runtime 集成仍待实施。 + +## 不做的事 + +- advisor 不参与任何写路径、安全门、质量门、capability 判定; +- 不为某个厂商在 Core 内加特例分支; +- 不提供「advisor 自动执行建议」的路径:建议始终由操作者显式执行。 + +## 风险 + +- 这是 Core 公共契约扩展,审查成本高,且与既有 MCP/agent 叙事部分重叠; +- 类别抽象是长期承诺:第三个类别出现前不应再泛化; +- 若插件生态长期只有一个 advisor 实现,抽象可能被判定为过度设计;内置 `rule_advisor` + 只能用于合同与基线验证,类别必要性仍需独立实现和具体任务效果比较支持。 + +## 未核验 + +- 动机实现的第三方运行时依赖清单与许可证状态; +- 该实现的服务区域与网络可达性对本地实验网络的影响; +- 非英语 state 在该实现上的准确率表现。 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index de43f36f..f633b635 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -6,6 +6,7 @@ ```bash python -m wavebench --help +python -m wavebench --version python -m wavebench run --help python -m wavebench --json ... ``` @@ -23,6 +24,23 @@ python -m wavebench --json ... `scope fetch` 读取已有波形,但仍是仪器 I/O;不要把它当作离线命令。每次硬件操作前确认接线、输入阻抗、输出状态和安全限制。WaveBench 不会自动执行 `*RST`,也不会因设置电压、幅度或频率而自动开启输出。 +`--version` 返回安装包 metadata 中的版本,不读取工作目录或上级目录的项目版本。未安装源码缺少发行元数据时显示 `0+unknown`;修改 editable 包的版本后需要重新安装或同步。 + +## 示波器观察 + +Core `0.8.28` 提供 `scope observe`。默认只查询身份、可用状态与输入耦合,不读取波形;配置为 `disabled` 时拒绝连接。 + +```bash +python -m wavebench scope observe --help +python -m wavebench --json scope observe --config wavebench.toml +``` + +`--fetch-waveform` 显式请求波形报告,可能停止采集、启用通道、改变传输设置并消费错误队列,不恢复原运行状态。`--expect ` 仅配合波形报告使用。通道支持、access 和 capability 在采集写入前校验;不确定 I/O 后停止剩余采集,不自动重连。 + +报告可能包含 `unavailable`、`partial`、`fail` 或 `skipped` 的验收汇总。命令成功返回不等于所有期望通过,调用方应检查汇总、warnings 和逐通道结果。多通道依次读取不证明同次采集,报告跳过需要同步证据的相位、相关性、延迟和交点分析。 + +开发与回归合同见[示波器观察实现与验收](../development/scope-observation.md)。 + ## JSON 输出与退出码 将 `--json` 放在命令行任意位置可请求机器可读输出。成功结果使用 `wavebench.cli.result.v1`,包含 `status`、`exit_code` 和 `result`;错误使用 `wavebench.error.v1`。普通成功输出写入标准输出,普通错误写入标准错误。 diff --git a/docs/reference/plugins/index.md b/docs/reference/plugins/index.md index 1a3c2b6f..dff7fafe 100644 --- a/docs/reference/plugins/index.md +++ b/docs/reference/plugins/index.md @@ -4,12 +4,15 @@ WaveBench 支持三条彼此独立的插件路径。它们的共同边界是:C | 路径 | 入口 | 是否进入真实执行 | | --- | --- | --- | -| V1 metadata | `wavebench.drivers` / `wavebench.instrument.v1` | 否。用于展示 metadata。 | +| V1 metadata(已弃用) | `wavebench.drivers` / `wavebench.instrument.v1` | 否。仅用于兼容展示 metadata。 | | V2 executable plugin | `wavebench.instruments` / `wavebench.instrument.v2` | 仅在配置选中 driver 后。 | | 声明式 SCPI TOML | 本地 TOML | 否;只允许显式只读 IDN probe。 | ## 加载与信任边界 +V1 metadata 保持已有兼容行为;新增可执行仪器插件使用 V2。本页的弃用标注不表示 V1 +运行时已经移除,也不增加 advisor 等尚未实现的插件类别。 + 默认插件命令优先查看内建 metadata。`plugin ... --load` 会导入第三方 V2 descriptor;只在可信环境中使用。Service 仅在实际打开已配置的 driver 时加载对应插件,未选中的坏插件不应阻断其它内建路径。 V2 entry point 名必须与 canonical `driver_id` 一致。可执行插件不能通过 alias 覆盖内建 driver;具体分发包、型号支持和 capability 以 descriptor 与插件仓库为准。 diff --git a/docs/releases/v0.8.28.md b/docs/releases/v0.8.28.md new file mode 100644 index 00000000..b484044e --- /dev/null +++ b/docs/releases/v0.8.28.md @@ -0,0 +1,45 @@ +# WaveBench v0.8.28 发布说明 + +本版本承接 `v0.8.27`,主要更新为示波器观察与建议、uv 环境及插件管理,以及版本身份和发行物验证。安装方式见[安装说明](https://github.com/Scaxlibur/WaveBench/blob/v0.8.28/docs/getting-started/installation.md),实际发行状态以 [GitHub Release](https://github.com/Scaxlibur/WaveBench/releases/tag/v0.8.28) 为准。 + +## 示波器观察与建议 + +新增 `scope observe`,默认查询身份、可用状态和输入耦合,不读取波形。HTTP MCP 新增 `doctor.config`、`scope.observe` 和 `scope.advise`,支持结构化仪器检查、示波器观察以及显示/时基建议。建议基于可用测量和显式期望频率,不自动执行;缺少证据时返回不可用原因。 + +`scope observe --fetch-waveform` 显式生成波形报告,并可通过 `--expect` 校验逐通道期望。验收汇总区分失败、部分可用、不可用和跳过,避免把查询失败或不完整结果判断为通过。 + +波形报告在同一受控会话和独占资源租约内完成高阻确认及读取。访问、能力和参数预检失败时拒绝采集;不确定 I/O 后停止剩余通道,不自动重连继续操作。观察路径还限制 factory 构造阶段的 Core transport I/O。 + +显式波形读取可能停止采集、启用通道、修改传输设置并消费错误队列,不恢复原运行状态。依次读取多个通道不证明同次采集,报告不会据此计算需要同步证据的相位、相关性、延迟和交点。MCP 不开放波形读取路径。具体用法与副作用见 [CLI Reference](https://github.com/Scaxlibur/WaveBench/blob/v0.8.28/docs/reference/cli.md)和 [MCP 指南](https://github.com/Scaxlibur/WaveBench/blob/v0.8.28/docs/how-to/serve-mcp.md)。 + +## uv 环境与插件管理 + +开发环境使用 `uv.lock` 固定依赖,通过 `uv sync --locked` 准备环境,通过 `uv run --no-sync` 执行命令。CI 也使用锁定环境,保留 Linux/Windows 的 Python 3.11/3.12 检查。 + +受管插件的本地安装与源码构建改用 uv,目标虚拟环境不再要求 pip。插件安装仍只处理指定本地包,不自动下载依赖;源码构建使用环境中预装的 build backend。uv 必须在 PATH 中可用。 + +旧 pip 安装记录继续参与健康检查、回滚和恢复。开发环境与仪器运行环境应分开:默认 `uv sync` 会清理未声明的插件,专用运行环境通过 `uv pip install` 更新 Core,并在更新后检查插件状态。迁移步骤见[管理仪器插件](https://github.com/Scaxlibur/WaveBench/blob/v0.8.28/docs/how-to/manage-plugins.md)。 + +## 版本身份与发布检查 + +- `wavebench --version` 和 `python -m wavebench --version` 返回已安装发行包的版本。运行时不再向上搜索 `pyproject.toml`;缺少发行元数据时显示 `0+unknown`。 +- 插件 descriptor 与安装包兼容范围统一按 PEP 440 判断,保留开发版与正式版的先后关系,拒绝未达到最低正式版本的预发布包。 +- `Dev` 直接推送触发 CI,相关文档变更触发文档检查。 +- CI 构建源码包和 wheel,并在仓库外的独立环境中安装 wheel 及其声明依赖,验证版本、命令入口、模板和离线 plan 检查。 +- tag 工作流校验 tag、项目版本、锁文件、发行物 metadata 和发布说明;源码、文档和发行物检查全部通过后,使用已验证的同一份附件生成 Release 草稿,最终发布由维护者审核完成。 + +安装文档区分发行包、固定 tag 源码和开发线。无硬件快速开始使用包内模板,不依赖源码仓库中的示例文件。 + +## 其它修复与开发接口 + +修复 Windows 上分析结果文件原子替换遇到短暂占用时直接失败的问题,使用既有的有限重试策略,保持原子写入与失败报告。 + +增加供 Core 内部使用的有界 run 输入快照绑定:显式选择来源,限制条目与总字节数,并检测读取过程中的身份或内容变化。Advisor 插件、外部模型执行及授权流程仍属于 RFC 范围;本版本不提供完整的 advisor 运行时。 + +## 升级注意事项 + +1. 安装或升级 Core 后,先执行 `--version` 和依赖检查,再检查已安装插件。包版本修改后,editable 环境须重新同步元数据。 +2. 需要受管插件操作时安装 uv;已有 pip 安装的插件不要求全部重装。降回依赖 pip 的旧 Core 时,目标环境仍需提供 pip。 +3. 自定义插件应核对 metadata 与 descriptor 的版本范围。预发布 Core 不能冒充对应正式版本;无安装元数据的源码树可能无法通过插件兼容检查。 +4. `scope observe` 的默认查询与 `--fetch-waveform` 的状态修改边界不同。调用方应显式选择,并检查验收状态,不能只看进程退出码。 +5. 仪器插件独立发行。Core 更新不提高外置插件的型号覆盖或实机验证等级;本次观察与建议的回归使用 fake transport 和合成信号。 diff --git a/docs/rfcs/index.md b/docs/rfcs/index.md index efdfacd0..75de537c 100644 --- a/docs/rfcs/index.md +++ b/docs/rfcs/index.md @@ -12,6 +12,13 @@ RFC 记录设计问题、约束、兼容性和验收门槛。它们不是当前 | `Implemented` | 已随正式版本发布,稳定用法应已同步到 Reference。 | | `Superseded` | 已由更严格或后续合同取代,保留历史原因。 | +## 进行中的提案(Draft) + +`Draft` 仍可修改,不能据此宣称接口已经提供。 + +- [advisor 插件类别](../project/rfcs/WaveBench_advisor插件RFC.md):`Draft`;提出插件类别抽象与 + 第二个类别 `advisor`,并把按次数据外发同意门与 decision artifact 收归 Core;实施边界见 RFC。 + ## 已接受的通用合同 - [transport 重放与 session 健康](../project/rfcs/WaveBench_transport重放与session健康RFC.md):`Accepted`;记录共享 session、重放与恢复合同。实现状态仍以正式 release 和当前 Reference 为准。 diff --git a/mkdocs.yml b/mkdocs.yml index dd6489d9..4481dc98 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,7 +2,7 @@ site_name: WaveBench site_description: 可审计的 Python 仪器测量台文档 repo_url: https://github.com/Scaxlibur/wavebench repo_name: Scaxlibur/wavebench -edit_uri: edit/master/ +edit_uri: "" exclude_docs: | README.md @@ -70,8 +70,11 @@ nav: - 开发: - 贡献: development/contributing.md - 测试: development/testing.md + - 示波器观察实现与验收: development/scope-observation.md + - 发布版本: development/releasing.md - 文档工作流: development/documentation.md - 插件开发: development/plugin-development.md - 新增仪器驱动: development/instrument-drivers.md - RFC: rfcs/index.md - - 更新日志: https://github.com/Scaxlibur/wavebench/blob/master/CHANGELOG.md + - 本版发布说明: releases/v0.8.28.md + - 更新日志: https://github.com/Scaxlibur/wavebench/blob/v0.8.28/CHANGELOG.md diff --git a/pyproject.toml b/pyproject.toml index a3c9a84d..e0ae93b5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "wavebench" -version = "0.8.27" +version = "0.8.28" description = "Lightweight VISA/SCPI measurement bench for contest debugging" readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/check_release.py b/scripts/check_release.py new file mode 100644 index 00000000..67ec90e8 --- /dev/null +++ b/scripts/check_release.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +"""Check release identity, notes and distribution metadata before publication.""" + +import argparse +from email.parser import BytesParser +from pathlib import Path +import tarfile +import tomllib +from zipfile import ZipFile + + +ROOT = Path(__file__).resolve().parents[1] + + +def check_release(root: Path, artifacts: Path, tag: str | None = None) -> str: + version = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))["project"]["version"] + if tag is not None and tag != f"v{version}": + raise ValueError(f"tag {tag!r} does not match project version {version}") + lock = tomllib.loads((root / "uv.lock").read_text(encoding="utf-8")) + locked = [p["version"] for p in lock["package"] if p["name"] == "wavebench"] + if locked != [version]: + raise ValueError(f"uv.lock WaveBench version differs: {locked}") + if tag is not None: + notes = root / "docs" / "releases" / f"v{version}.md" + if not notes.read_text(encoding="utf-8").startswith(f"# WaveBench v{version} 发布说明\n"): + raise ValueError("release notes heading does not match project version") + if f"docs/releases/v{version}.md" not in (root / "CHANGELOG.md").read_text(encoding="utf-8"): + raise ValueError("CHANGELOG must link to the release notes") + wheels = list(artifacts.glob("*.whl")) + sdists = list(artifacts.glob("*.tar.gz")) + if len(wheels) != 1 or len(sdists) != 1: + raise ValueError("expected exactly one wheel and one sdist") + with ZipFile(wheels[0]) as archive: + names = [n for n in archive.namelist() if n.endswith(".dist-info/METADATA")] + if len(names) != 1: + raise ValueError("wheel must contain exactly one METADATA") + wheel_metadata = archive.read(names[0]) + with tarfile.open(sdists[0]) as archive: + names = [n for n in archive.getnames() if n.count("/") == 1 and n.endswith("/PKG-INFO")] + if len(names) != 1: + raise ValueError("sdist must contain exactly one root PKG-INFO") + member = archive.extractfile(names[0]) + if member is None: + raise ValueError("sdist PKG-INFO must be a file") + sdist_metadata = member.read() + for payload in (wheel_metadata, sdist_metadata): + metadata = BytesParser().parsebytes(payload) + if metadata["Name"] != "wavebench" or metadata["Version"] != version: + raise ValueError(f"distribution identity differs: {metadata['Name']} {metadata['Version']}") + return version + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifacts", required=True, type=Path) + parser.add_argument("--tag") + args = parser.parse_args() + print(f"release identity verified: {check_release(ROOT, args.artifacts, args.tag)}") diff --git a/scripts/smoke_wheel.py b/scripts/smoke_wheel.py new file mode 100644 index 00000000..4aa34bbb --- /dev/null +++ b/scripts/smoke_wheel.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +"""Install one wheel with its declared dependencies outside the checkout and exercise offline CLI.""" + +import argparse +import os +from pathlib import Path +import subprocess +import sys +from tempfile import TemporaryDirectory + + +def smoke_wheel(wheel: Path, expected_version: str) -> None: + wheel = wheel.resolve(strict=True) + env = os.environ.copy() + env.pop("PYTHONPATH", None) + env.pop("PYTHONHOME", None) + env["PYTHONUTF8"] = "1" + with TemporaryDirectory(prefix="wavebench-wheel-") as directory: + root = Path(directory) + # An unrelated ancestor project must never override installed metadata. + (root / "pyproject.toml").write_text('[project]\nname = "unrelated"\nversion = "99.0"\n') + env["WAVEBENCH_LEASE_DIR"] = str(root / "leases") + + def run(*command: str) -> str: + result = subprocess.run(command, cwd=root, env=env, text=True, encoding="utf-8", + capture_output=True, check=False) + if result.returncode: + raise RuntimeError(f"{command!r}\n{result.stdout}\n{result.stderr}") + return result.stdout + + environment = root / "runtime" + run("uv", "venv", "--python", sys.executable, str(environment)) + binary = environment / ("Scripts" if os.name == "nt" else "bin") + python = str(binary / ("python.exe" if os.name == "nt" else "python")) + cli = str(binary / ("wavebench.exe" if os.name == "nt" else "wavebench")) + run("uv", "pip", "install", "--python", python, str(wheel)) + run("uv", "pip", "check", "--python", python) + run(python, "-I", "-c", """ +import pathlib, sys, sysconfig +from importlib.metadata import version +import wavebench +assert pathlib.Path(wavebench.__file__).resolve().is_relative_to( + pathlib.Path(sysconfig.get_paths()['purelib']).resolve()) +assert wavebench.__version__ == version('wavebench') == sys.argv[1] +""", expected_version) + # Isolated Python ignores PYTHONUTF8; set UTF-8 explicitly for captured Windows output. + for entry in ((cli,), (python, "-I", "-X", "utf8", "-m", "wavebench")): + if run(*entry, "--version").strip() != f"wavebench {expected_version}": + raise ValueError("CLI version differs from distribution") + for arguments in (("--help",), ("run", "schema"), ("run", "template", "--list"), + ("lock", "status", "TCPIP::192.0.2.10::INSTR")): + run(*entry, *arguments) + run(cli, "run", "template", "source-scope-sine", "--output", "example.toml") + (root / "offline.toml").write_text('''[connection] +backend = "lan" +resource = "TCPIP::192.0.2.10::INSTR" +[scope] +driver = "rtm2032" +[source] +driver = "dg4202" +resource = "TCPIP::192.0.2.11::INSTR" +[safety_limits] +max_source_vpp = 1.0 +''', encoding="utf-8") + run(cli, "run", "check", "--plan", "example.toml", "--config", "offline.toml") + print(f"isolated wheel smoke passed: {wheel.name} ({sys.version.split()[0]}, {sys.platform})") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifacts", required=True, type=Path) + parser.add_argument("--version", required=True) + args = parser.parse_args() + wheels = list(args.artifacts.glob("*.whl")) + if len(wheels) != 1: + parser.error("expected exactly one wheel") + smoke_wheel(wheels[0], args.version) diff --git a/src/wavebench/__init__.py b/src/wavebench/__init__.py index 1d181566..e6e2f9c1 100644 --- a/src/wavebench/__init__.py +++ b/src/wavebench/__init__.py @@ -1,26 +1,11 @@ """WaveBench package.""" from importlib.metadata import PackageNotFoundError, version -from pathlib import Path -import tomllib __all__ = ["__version__"] -def _version_from_pyproject() -> str | None: - for parent in Path(__file__).resolve().parents: - pyproject = parent / "pyproject.toml" - if not pyproject.exists(): - continue - try: - data = tomllib.loads(pyproject.read_text(encoding="utf-8")) - return str(data["project"]["version"]) - except Exception: - return None - return None - - try: - __version__ = _version_from_pyproject() or version("wavebench") -except PackageNotFoundError: # pragma: no cover - editable-tree fallback - __version__ = "0.4.1.dev0" + __version__ = version("wavebench") +except PackageNotFoundError: + __version__ = "0+unknown" diff --git a/src/wavebench/cli.py b/src/wavebench/cli.py index dfd2e5d4..ceb10428 100644 --- a/src/wavebench/cli.py +++ b/src/wavebench/cli.py @@ -10,6 +10,8 @@ from pathlib import Path import sys from tempfile import TemporaryFile +import tomllib +from typing import Any import numpy as np @@ -113,6 +115,8 @@ from .plugins.registry import build_plugin_registry, has_doctor_errors, plugin_doctor_records from .plugins.scpi import has_scpi_doctor_errors, load_scpi_plugin, probe_scpi_plugin, scpi_plugin_doctor_records from .services.scope_service import ScopeService +from .services.agent_observe import scope_observe_payload, scope_waveform_report_payload +from .services.agent_advise import scope_advise_from_observation, validate_scope_advice_targets from .services.source_service import SourceService from .services.rf_source_service import RfSourceService from .services.power_service import PowerService @@ -611,6 +615,137 @@ def _scope_error_check(args: argparse.Namespace) -> ErrorCheckSpec | None: raise ConfigError(str(exc)) from exc +def _load_scope_expectations(path: str | None) -> dict[int, dict[str, Any]] | None: + """读取 --expect 指定的 TOML 期望值文件;校验在读取阶段完成,早于任何仪器 I/O。""" + if path is None: + return None + expectation_path = Path(path) + try: + raw = tomllib.loads(expectation_path.read_bytes().decode("utf-8-sig")) + except OSError as exc: + raise ConfigError(f"failed to read scope expectation file: {expectation_path}") from exc + except tomllib.TOMLDecodeError as exc: + raise ConfigError(f"invalid scope expectation TOML: {expectation_path}") from exc + unknown = sorted(set(raw) - {"channels"}) + if unknown: + raise ConfigError(f"unknown scope expectation section(s): {', '.join(unknown)}") + channels = raw.get("channels") + if not isinstance(channels, dict) or not channels: + raise ConfigError("scope expectation file must define a non-empty [channels] table") + expectations: dict[int, dict[str, Any]] = {} + for key, value in channels.items(): + try: + channel = int(key) + except (TypeError, ValueError) as exc: + raise ConfigError("scope expectation channel keys must be numbers") from exc + if channel < 1: + raise ConfigError("scope expectation channels must be >= 1") + if not isinstance(value, dict): + raise ConfigError("scope expectation channel entries must be tables") + expectations[channel] = dict(value) + return expectations + + +def _expectation_frequencies( + expectations: dict[int, dict[str, Any]] | None, +) -> dict[int, float] | None: + if not expectations: + return None + values: dict[int, float] = {} + for channel, expectation in expectations.items(): + value = expectation.get("frequency_hz") + if isinstance(value, (int, float)) and not isinstance(value, bool) and value > 0: + values[channel] = float(value) + return values or None + + +def _run_scope_observe(args: argparse.Namespace) -> dict[str, Any]: + target_cycles, target_vertical_divisions = validate_scope_advice_targets( + target_cycles=10.0 if args.target_cycles is None else args.target_cycles, + target_vertical_divisions=( + 5.0 if args.target_vertical_divisions is None else args.target_vertical_divisions + ), + ) + channels = tuple(args.channels) if args.channels else None + expectations = _load_scope_expectations(args.expect) + if not args.fetch_waveform: + if expectations is not None: + raise ConfigError("scope observe --expect requires --fetch-waveform") + return scope_observe_payload( + config_path=args.config, + channels=channels, + allow_50ohm=args.allow_50ohm, + resource=args.resource, + ) + observation = scope_waveform_report_payload( + config_path=args.config, + channels=channels, + allow_50ohm=args.allow_50ohm, + expectations=expectations, + resource=args.resource, + ) + advice = scope_advise_from_observation( + observation, + expected_frequencies_hz=_expectation_frequencies(expectations), + target_cycles=target_cycles, + target_vertical_divisions=target_vertical_divisions, + ) + observation["recommendations"] = advice["recommendations"] + observation["agent_hints"] = advice["agent_hints"] + return observation + + +def _emit_scope_observe_result(payload: dict[str, Any], *, json_mode: bool) -> None: + if json_mode: + _emit_json_result(payload, status=str(payload.get("status", "ok"))) + return + print( + f"status={payload.get('status')} read_only={payload.get('read_only')} " + f"mutates_instrument={payload.get('mutates_instrument')}" + ) + identity = payload.get("identity") + if isinstance(identity, dict) and identity.get("status") == "ok": + print(f"idn={identity['data']['idn']}") + for channel_section in payload.get("channels", []) or []: + channel = channel_section.get("channel") + coupling = channel_section.get("coupling", {}) + coupling_value = coupling.get("data", {}).get("coupling") if coupling.get("status") == "ok" else "unavailable" + print(f"ch{channel} coupling={coupling_value}") + waveform = channel_section.get("waveform") + if isinstance(waveform, dict) and waveform.get("status") == "ok": + summary = waveform["data"]["summary"] + print( + f"ch{channel} waveform samples={summary.get('samples')} " + f"vpp_v={summary.get('voltage_vpp_v')} mean_v={summary.get('voltage_mean_v')} " + f"frequency_hz={summary.get('frequency_estimate_hz')}" + ) + for warning in summary.get("quality_warnings", []) or []: + print(f"ch{channel} quality_warning={warning}") + expectation = channel_section.get("expectation") + if isinstance(expectation, dict): + print(f"ch{channel} expectation={expectation.get('status')}") + for check in expectation.get("data", {}).get("checks", []) or []: + print( + f"ch{channel} check={check.get('metric')} status={check.get('status')} " + f"expected={check.get('expected')} actual={check.get('actual')}" + ) + waveform_source = payload.get("waveform_source") + if isinstance(waveform_source, dict) and not waveform_source.get("same_acquisition", True): + print(f"waveform_source same_acquisition=False reason={waveform_source.get('reason')}") + for relationship in payload.get("relationships", []) or []: + channels = relationship.get("channels") + frequency = relationship.get("frequency", {}) + print( + f"relationship ch{channels} ratio={frequency.get('ratio_high_over_low')} " + f"phase_deg={relationship.get('phase_degrees_at_left_frequency')}" + ) + for recommendation in payload.get("recommendations", []) or []: + command = recommendation.get("command") or recommendation.get("action") + print(f"recommendation {recommendation.get('id')} priority={recommendation.get('priority')} {command}") + for warning in payload.get("warnings", []) or []: + print(f"warning={warning}") + + def _scope_channel_display_request(args: argparse.Namespace) -> ScopeChannelDisplayRequest: try: return ScopeChannelDisplayRequest( @@ -1969,6 +2104,9 @@ def _main(argv: list[str] | None = None) -> int: print(f"summary={result.summary_path}") return 0 if args.domain == "scope": + if args.command == "observe": + _emit_scope_observe_result(_run_scope_observe(args), json_mode=args.json) + return 0 service = _load_service(args) if args.command == "idn": print(service.idn()) diff --git a/src/wavebench/cli_parser.py b/src/wavebench/cli_parser.py index f7c60b25..0e950730 100644 --- a/src/wavebench/cli_parser.py +++ b/src/wavebench/cli_parser.py @@ -2,6 +2,7 @@ import argparse +from . import __version__ from .discovery import DEFAULT_DISCOVERY_PORTS from .mcp_http import DEFAULT_MCP_HOST, DEFAULT_MCP_PORT, DEFAULT_MCP_TOKEN_ENV @@ -28,6 +29,7 @@ def add_scope_error_options(parser: argparse.ArgumentParser) -> None: def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="wavebench") + parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") parser.add_argument( "--json", action="store_true", @@ -1546,6 +1548,46 @@ def add_trace_reference(parser: argparse.ArgumentParser) -> None: fetch.add_argument("--allow-50ohm", action="store_true", help="Explicitly allow scope input coupling that may be 50 ohm; default requires high impedance") add_runtime_options(fetch) + observe = scope_sub.add_parser( + "observe", + help="Observe scope state; --fetch-waveform explicitly reads waveforms for summaries and checks", + ) + observe.add_argument( + "--channel", + dest="channels", + type=int, + action="append", + default=None, + help="Observed analog channel; repeat for multiple channels", + ) + observe.add_argument( + "--fetch-waveform", + action="store_true", + help=( + "Explicitly read waveforms. This is a write path: it may stop a running acquisition, " + "change waveform transfer source/mode/format/points and enable channel display" + ), + ) + observe.add_argument( + "--allow-50ohm", + action="store_true", + help="Explicitly allow scope input coupling that may be 50 ohm; default requires high impedance", + ) + observe.add_argument( + "--expect", + default=None, + metavar="PATH", + help="TOML file with per-channel [channels.N] expectation checks; requires --fetch-waveform", + ) + observe.add_argument("--target-cycles", type=float, default=None, help="Target cycles for advice, default 10") + observe.add_argument( + "--target-vertical-divisions", + type=float, + default=None, + help="Target vertical divisions for advice, default 5", + ) + add_runtime_options(observe) + capture = scope_sub.add_parser("capture", help="Capture waveform data into an acquisition package") capture.add_argument("--channel", type=int, action="append", default=None, help="Capture channel; repeat for multiple channels") capture.add_argument("--label", default="capture") diff --git a/src/wavebench/data/expectations.py b/src/wavebench/data/expectations.py new file mode 100644 index 00000000..4ee836b5 --- /dev/null +++ b/src/wavebench/data/expectations.py @@ -0,0 +1,492 @@ +from __future__ import annotations + +import math +from typing import Any + +import numpy as np + +from wavebench.errors import ConfigError +from wavebench.instruments.models import WaveformData + +# expectation 允许的字符串字段 +_STRING_FIELDS = ("label", "shape") +# expectation 允许的数值字段:名称 -> (下界, 上界, 下界是否排他) +_NUMBER_FIELDS: dict[str, tuple[float, float, bool]] = { + "frequency_hz": (0.0, math.inf, True), + "frequency_tolerance_ratio": (0.0, math.inf, False), + "vpp_v": (0.0, math.inf, True), + "vpp_tolerance_ratio": (0.0, math.inf, False), + "mean_v": (-math.inf, math.inf, False), + "offset_v": (-math.inf, math.inf, False), + "mean_tolerance_v": (0.0, math.inf, False), + "duty_cycle": (0.0, 1.0, False), + "duty_percent": (0.0, 100.0, False), + "duty_tolerance": (0.0, math.inf, False), + "symmetry_percent": (0.0, 100.0, False), + "symmetry_tolerance_percent": (0.0, math.inf, False), +} +# 同义字段,不允许同时出现,避免“哪个生效”依赖字典顺序 +_MUTUALLY_EXCLUSIVE = (("duty_cycle", "duty_percent"), ("mean_v", "offset_v")) +# 三角波对称度估计的默认滞回阈值(占 Vpp 的比例) +_SYMMETRY_HYSTERESIS_RATIO = 0.02 + + +def validate_expectation(raw: dict[str, Any]) -> dict[str, Any]: + """严格校验 expectation,返回规范化副本;任何非法输入都抛出 ConfigError。 + + 这里必须自证,不能依赖 MCP 发布的 JSON Schema:schema 只在客户端生效。 + """ + if not isinstance(raw, dict): + raise ConfigError("expectation entries must be objects / expectation 条目必须是对象") + known = set(_STRING_FIELDS) | set(_NUMBER_FIELDS) + unknown = sorted(set(raw) - known) + if unknown: + raise ConfigError( + f"unknown expectation field(s): {', '.join(unknown)} / expectation 存在未知字段" + ) + validated: dict[str, Any] = {} + for name in _STRING_FIELDS: + value = raw.get(name) + if value is None: + continue + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"expectation {name} must be a non-empty string") + validated[name] = value + for name, (low, high, exclusive_low) in _NUMBER_FIELDS.items(): + value = raw.get(name) + if value is None: + continue + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ConfigError(f"expectation {name} must be a number") + number = float(value) + if not math.isfinite(number): + raise ConfigError(f"expectation {name} must be finite") + if exclusive_low and number <= low: + raise ConfigError(f"expectation {name} must be > {low:g}") + if not exclusive_low and number < low: + raise ConfigError(f"expectation {name} must be >= {low:g}") + if number > high: + raise ConfigError(f"expectation {name} must be <= {high:g}") + validated[name] = number + for left, right in _MUTUALLY_EXCLUSIVE: + if left in validated and right in validated: + raise ConfigError(f"expectation must not set both {left} and {right}") + return validated + + +def evaluate_waveform_expectation( + waveform: WaveformData, + expectation: dict[str, Any], +) -> dict[str, Any]: + validated = validate_expectation(expectation) + summary = waveform.summary( + expected_frequency_hz=_number(validated, "frequency_hz"), + frequency_tolerance_ratio=_number(validated, "frequency_tolerance_ratio", default=0.05), + ) + checks: list[dict[str, Any]] = [] + _check_frequency(summary, validated, checks) + _check_vpp(summary, validated, checks) + _check_mean(summary, validated, checks) + _check_duty(summary, validated, checks) + _check_symmetry(waveform, validated, checks) + if not checks: + # 没有任何可执行检查时必须显式说明,不能伪装成 pass + return { + "status": "skipped", + "channel": waveform.channel, + "label": validated.get("label"), + "shape": validated.get("shape"), + "checks": [], + "message": "expectation contains no checkable metric", + } + statuses = {check["status"] for check in checks} + if "fail" in statuses: + status = "fail" + elif "warn" in statuses: + status = "warn" + else: + status = "pass" + return { + "status": status, + "channel": waveform.channel, + "label": validated.get("label"), + "shape": validated.get("shape"), + "checks": checks, + } + + +def expectation_summary(results: dict[int, dict[str, Any]]) -> dict[str, Any]: + """Keep confirmed failures; incomplete acceptance is never a pass.""" + statuses = {result["status"] for result in results.values()} + if "fail" in statuses: + status = "fail" + elif "unavailable" in statuses: + status = "partial" if statuses & {"pass", "warn"} else "unavailable" + elif "warn" in statuses: + status = "warn" + elif "pass" in statuses: + status = "pass" + else: + status = "skipped" + return { + "status": status, + "channels": {str(channel): result["status"] for channel, result in sorted(results.items())}, + } + + +def estimate_triangle_symmetry_percent( + waveform: WaveformData, + *, + expected_frequency_hz: float | None = None, + hysteresis_ratio: float = _SYMMETRY_HYSTERESIS_RATIO, +) -> float | None: + """估计三角波上升沿占整周期的百分比。 + + 真实示波器波形带有噪声、量化台阶和过冲,逐点比较相邻差分符号会被单个噪声样本打乱。 + 这里先用滑动平均抑制噪声,再用滞回(相对 Vpp)确认极值反转,并用期望频率约束周期长度。 + """ + times = waveform.times_s + values = np.asarray(waveform.voltages_v, dtype=np.float64) + if times.size != values.size or values.size < 8: + return None + span = float(np.max(values) - np.min(values)) + if span <= 1e-12: + return None + samples_per_cycle = _samples_per_cycle(times, expected_frequency_hz) + smoothed = _smooth(values, _smoothing_window(values.size, samples_per_cycle)) + extrema = _hysteresis_extrema(smoothed, hysteresis=max(span * hysteresis_ratio, 0.0)) + if len(extrema) < 3: + return None + # 滞回和滑窗都会把极值确认点推向信号内部;用相邻两段原始数据的拟合直线交点把 + # 极值时间还原回三角波的真实折点。 + refined = _refine_extrema(times, values, extrema) + min_period_s = 0.5 / expected_frequency_hz if expected_frequency_hz else None + fractions: list[float] = [] + for index in range(1, len(refined) - 1): + left_kind, left_time = refined[index - 1] + kind, peak_time = refined[index] + right_kind, right_time = refined[index + 1] + if (left_kind, kind, right_kind) != ("min", "max", "min"): + continue + period = right_time - left_time + if period <= 0 or (min_period_s is not None and period < min_period_s): + continue + fractions.append(float((peak_time - left_time) / period * 100.0)) + if not fractions: + return None + return float(np.median(np.asarray(fractions, dtype=np.float64))) + + +def _refine_extrema( + times: np.ndarray, + values: np.ndarray, + extrema: list[tuple[str, int]], +) -> list[tuple[str, float]]: + refined: list[tuple[str, float]] = [] + for position, (kind, index) in enumerate(extrema): + if position == 0 or position == len(extrema) - 1: + refined.append((kind, float(times[index]))) + continue + crossing = _segment_intersection_time( + times, + values, + extrema[position - 1][1], + index, + extrema[position + 1][1], + ) + refined.append((kind, crossing if crossing is not None else float(times[index]))) + return refined + + +def _segment_intersection_time( + times: np.ndarray, + values: np.ndarray, + previous_index: int, + index: int, + next_index: int, +) -> float | None: + rising = _fit_line(times, values, previous_index, index) + falling = _fit_line(times, values, index, next_index) + if rising is None or falling is None: + return None + slope_a, intercept_a = rising + slope_b, intercept_b = falling + if abs(slope_a - slope_b) <= 1e-18: + return None + crossing = (intercept_b - intercept_a) / (slope_a - slope_b) + if not math.isfinite(crossing): + return None + return float(crossing) + + +def _fit_line( + times: np.ndarray, + values: np.ndarray, + start_index: int, + stop_index: int, +) -> tuple[float, float] | None: + low, high = (start_index, stop_index) if start_index <= stop_index else (stop_index, start_index) + if high - low < 2: + return None + # 裁掉两端靠近折点的部分,避免拐角处的采样点把拟合斜率拽偏 + margin = max(1, int((high - low) * 0.15)) + begin = low + margin + end = high - margin + if end - begin < 1: + begin, end = low, high + x = np.asarray(times[begin : end + 1], dtype=np.float64) + y = np.asarray(values[begin : end + 1], dtype=np.float64) + if x.size < 2: + return None + slope, intercept = np.polyfit(x, y, 1) + return float(slope), float(intercept) + + +def _samples_per_cycle(times: np.ndarray, expected_frequency_hz: float | None) -> float | None: + if not expected_frequency_hz or expected_frequency_hz <= 0 or times.size < 2: + return None + duration = float(times[-1] - times[0]) + if duration <= 0: + return None + return float(times.size) / (duration * expected_frequency_hz) + + +def _smoothing_window(size: int, samples_per_cycle: float | None) -> int: + if samples_per_cycle is not None and samples_per_cycle > 64: + candidate = int(samples_per_cycle // 20) + else: + candidate = int(size // 50) + window = max(1, min(candidate, 51, size)) + if window > 1 and window % 2 == 0: + window -= 1 + return window + + +def _smooth(values: np.ndarray, window: int) -> np.ndarray: + if window <= 1: + return values + kernel = np.ones(window, dtype=np.float64) / window + pad = window // 2 + padded = np.pad(values, pad, mode="edge") + return np.convolve(padded, kernel, mode="valid") + + +def _hysteresis_extrema(values: np.ndarray, *, hysteresis: float) -> list[tuple[str, int]]: + """用滞回跟踪方向变化,返回 [(kind, index), ...],kind 为 min/max 且交替出现。 + + 方向未确认时锚点保持不变,否则单调斜坡会被“锚点跟着当前值走”抵消掉滞回判据。 + """ + extrema: list[tuple[str, int]] = [] + if values.size == 0: + return extrema + anchor = 0 + direction = 0 + for index in range(1, values.size): + value = float(values[index]) + if direction == 0: + if value >= float(values[anchor]) + hysteresis: + direction = 1 + elif value <= float(values[anchor]) - hysteresis: + direction = -1 + continue + if direction > 0: + if value > float(values[anchor]): + anchor = index + elif float(values[anchor]) - value > hysteresis: + if anchor > 0: + extrema.append(("max", anchor)) + direction, anchor = -1, index + else: + if value < float(values[anchor]): + anchor = index + elif value - float(values[anchor]) > hysteresis: + if anchor > 0: + extrema.append(("min", anchor)) + direction, anchor = 1, index + return extrema + + +def _check_frequency( + summary: dict[str, Any], + expectation: dict[str, Any], + checks: list[dict[str, Any]], +) -> None: + expected = _number(expectation, "frequency_hz") + if expected is None: + return + actual = summary.get("frequency_estimate_hz") + tolerance = _number(expectation, "frequency_tolerance_ratio", default=0.05) + low_confidence = any( + str(item).startswith("low_cycle_count") + for item in summary.get("quality_warnings", []) + ) + if not isinstance(actual, (int, float)) or actual <= 0: + checks.append(_check("frequency_hz", "warn", expected, actual, "frequency unavailable")) + return + error_ratio = abs(float(actual) - expected) / expected + if low_confidence: + checks.append( + _check( + "frequency_hz", + "warn", + expected, + float(actual), + "frequency low confidence because waveform contains too few cycles", + error_ratio=error_ratio, + tolerance_ratio=tolerance, + ) + ) + return + checks.append( + _check( + "frequency_hz", + "pass" if error_ratio <= tolerance else "fail", + expected, + float(actual), + "ok" if error_ratio <= tolerance else "frequency out of tolerance", + error_ratio=error_ratio, + tolerance_ratio=tolerance, + ) + ) + + +def _check_vpp( + summary: dict[str, Any], + expectation: dict[str, Any], + checks: list[dict[str, Any]], +) -> None: + expected = _number(expectation, "vpp_v") + if expected is None: + return + actual = summary.get("voltage_vpp_v") + tolerance = _number(expectation, "vpp_tolerance_ratio", default=0.10) + if not isinstance(actual, (int, float)): + checks.append(_check("vpp_v", "warn", expected, actual, "Vpp unavailable")) + return + error_ratio = abs(float(actual) - expected) / expected + checks.append( + _check( + "vpp_v", + "pass" if error_ratio <= tolerance else "fail", + expected, + float(actual), + "ok" if error_ratio <= tolerance else "Vpp out of tolerance", + error_ratio=error_ratio, + tolerance_ratio=tolerance, + ) + ) + + +def _check_mean( + summary: dict[str, Any], + expectation: dict[str, Any], + checks: list[dict[str, Any]], +) -> None: + expected = _number(expectation, "mean_v", fallback_field="offset_v") + if expected is None: + return + actual = summary.get("voltage_mean_v") + tolerance = _number(expectation, "mean_tolerance_v", default=0.05) + if not isinstance(actual, (int, float)): + checks.append(_check("mean_v", "warn", expected, actual, "mean unavailable")) + return + error = abs(float(actual) - expected) + checks.append( + _check( + "mean_v", + "pass" if error <= tolerance else "fail", + expected, + float(actual), + "ok" if error <= tolerance else "mean out of tolerance", + error_abs=error, + tolerance_abs=tolerance, + ) + ) + + +def _check_duty( + summary: dict[str, Any], + expectation: dict[str, Any], + checks: list[dict[str, Any]], +) -> None: + expected = _number(expectation, "duty_cycle") + if expected is None: + percent = _number(expectation, "duty_percent") + if percent is not None: + expected = percent / 100.0 + if expected is None: + return + actual = summary.get("duty_cycle") + tolerance = _number(expectation, "duty_tolerance", default=0.05) + if not isinstance(actual, (int, float)): + checks.append(_check("duty_cycle", "warn", expected, actual, "duty unavailable")) + return + error = abs(float(actual) - expected) + checks.append( + _check( + "duty_cycle", + "pass" if error <= tolerance else "fail", + expected, + float(actual), + "ok" if error <= tolerance else "duty out of tolerance", + error_abs=error, + tolerance_abs=tolerance, + ) + ) + + +def _check_symmetry( + waveform: WaveformData, + expectation: dict[str, Any], + checks: list[dict[str, Any]], +) -> None: + expected = _number(expectation, "symmetry_percent") + if expected is None: + return + actual = estimate_triangle_symmetry_percent( + waveform, + expected_frequency_hz=_number(expectation, "frequency_hz"), + ) + tolerance = _number(expectation, "symmetry_tolerance_percent", default=5.0) + if actual is None: + checks.append(_check("symmetry_percent", "warn", expected, actual, "symmetry unavailable")) + return + error = abs(actual - expected) + checks.append( + _check( + "symmetry_percent", + "pass" if error <= tolerance else "fail", + expected, + actual, + "ok" if error <= tolerance else "symmetry out of tolerance", + error_abs=error, + tolerance_abs=tolerance, + ) + ) + + +def _check(name: str, status: str, expected: Any, actual: Any, message: str, **extra: Any) -> dict[str, Any]: + return { + "metric": name, + "status": status, + "expected": expected, + "actual": actual, + "message": message, + **extra, + } + + +def _number( + expectation: dict[str, Any], + name: str, + *, + default: float | None = None, + fallback_field: str | None = None, +) -> float | None: + """读取已校验的数值字段。``fallback_field`` 只用于同义字段,两者不会同时出现。""" + value = expectation.get(name) + if value is None and fallback_field is not None: + value = expectation.get(fallback_field) + if value is None: + return default + return float(value) diff --git a/src/wavebench/data/relationships.py b/src/wavebench/data/relationships.py new file mode 100644 index 00000000..4aed22fd --- /dev/null +++ b/src/wavebench/data/relationships.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import math +from itertools import combinations +from typing import Any + +import numpy as np + +from wavebench.instruments.models import WaveformData + + +def analyze_waveform_relationships( + waveforms: dict[int, WaveformData], + *, + same_acquisition: bool = False, + max_correlation_points: int = 4096, + max_intersections: int = 64, +) -> list[dict[str, Any]]: + relationships: list[dict[str, Any]] = [] + for left_channel, right_channel in combinations(sorted(waveforms), 2): + relationships.append( + analyze_waveform_pair( + waveforms[left_channel], + waveforms[right_channel], + same_acquisition=same_acquisition, + max_correlation_points=max_correlation_points, + max_intersections=max_intersections, + ) + ) + return relationships + + +def analyze_waveform_pair( + left: WaveformData, + right: WaveformData, + *, + same_acquisition: bool = False, + max_correlation_points: int = 4096, + max_intersections: int = 64, +) -> dict[str, Any]: + """Summarize waveforms; timing requires an explicit shared-acquisition assertion. + + This helper does not validate capture provenance. Real capture timing analysis + must use the validated synchronization contract in ``pair_analysis``. + """ + if not isinstance(same_acquisition, bool): + raise ValueError("same_acquisition must be a boolean") + left_summary = left.summary() + right_summary = right.summary() + warnings: list[str] = [] + if same_acquisition: + common = _common_time_axis(left, right, max_points=max_correlation_points) + correlation = _correlation_payload(common, warnings=warnings) + intersections = _intersection_payload( + common, + warnings=warnings, + max_intersections=max_intersections, + ) + common_time = {**common["metadata"], "same_acquisition": True} + else: + # 跨 acquisition 的两个通道没有共同时间基准:相关性、交点、相位、延迟都不成立, + # 只看同步无关的频率比和幅度/均值关系。 + warnings.append("not_same_acquisition_timing_relationships_skipped") + correlation = _skipped_analysis("not_same_acquisition") + intersections = _skipped_analysis("not_same_acquisition") + common_time = { + "overlap": None, + "x_start_s": None, + "x_stop_s": None, + "duration_s": None, + "samples": 0, + "same_acquisition": False, + } + left_frequency = _trusted_frequency(left_summary, warnings=warnings, label=f"CH{left.channel}") + right_frequency = _trusted_frequency(right_summary, warnings=warnings, label=f"CH{right.channel}") + frequency_ratio = None + phase_degrees = None + if left_frequency is not None and right_frequency is not None: + lower = min(left_frequency, right_frequency) + upper = max(left_frequency, right_frequency) + if lower > 0: + frequency_ratio = float(upper / lower) + if not same_acquisition: + pass + elif ( + abs(left_frequency - right_frequency) / max(left_frequency, right_frequency) <= 0.01 + and common_time.get("overlap") is True + ): + # 约定:phase_degrees_at_left_frequency 表示 right 相对 left 的相位滞后,取值 [0, 360)。 + # 用基波拟合相位差而不是相关峰 lag:后者对截断窗口和幅度不对称有系统偏差, + # 且直接反相(right = -left)会被绝对值最大化吃掉 180°。 + phase_degrees = _fundamental_phase_degrees(common, frequency_hz=left_frequency) + elif frequency_ratio is not None and abs(frequency_ratio - 1.0) > 0.01: + warnings.append("phase_not_meaningful_for_different_frequencies") + return { + "channels": [left.channel, right.channel], + "left_channel": left.channel, + "right_channel": right.channel, + "common_time": common_time, + "frequency": { + "left_hz": left_frequency, + "right_hz": right_frequency, + "ratio_high_over_low": frequency_ratio, + }, + "voltage": { + "left_vpp_v": left_summary["voltage_vpp_v"], + "right_vpp_v": right_summary["voltage_vpp_v"], + "vpp_ratio_right_over_left": _safe_ratio( + right_summary["voltage_vpp_v"], + left_summary["voltage_vpp_v"], + ), + "mean_delta_right_minus_left_v": float( + right_summary["voltage_mean_v"] - left_summary["voltage_mean_v"] + ), + "rms_ratio_right_over_left": _safe_ratio( + right_summary["voltage_rms_v"], + left_summary["voltage_rms_v"], + ), + }, + "correlation": correlation, + "intersections": intersections, + "phase_degrees_at_left_frequency": phase_degrees, + "warnings": warnings, + } + + +def _common_time_axis( + left: WaveformData, + right: WaveformData, + *, + max_points: int, +) -> dict[str, Any]: + left_times = left.times_s + right_times = right.times_s + start = max(float(left_times[0]), float(right_times[0])) + stop = min(float(left_times[-1]), float(right_times[-1])) + if stop <= start: + return { + "time_s": np.array([], dtype=np.float64), + "left_v": np.array([], dtype=np.float64), + "right_v": np.array([], dtype=np.float64), + "metadata": { + "overlap": False, + "x_start_s": start, + "x_stop_s": stop, + "duration_s": 0.0, + "samples": 0, + }, + } + left_dt = left.header.x_increment + right_dt = right.header.x_increment + dt = max(value for value in (left_dt, right_dt) if value > 0) + count = int(np.floor((stop - start) / dt)) + 1 + count = max(2, min(count, max_points)) + common_times = np.linspace(start, stop, count, dtype=np.float64) + return { + "time_s": common_times, + "left_v": np.interp(common_times, left_times, left.voltages_v), + "right_v": np.interp(common_times, right_times, right.voltages_v), + "metadata": { + "overlap": True, + "x_start_s": start, + "x_stop_s": stop, + "duration_s": float(stop - start), + "samples": count, + }, + } + + +def _skipped_analysis(reason: str) -> dict[str, Any]: + return {"status": "skipped", "reason": reason} + + +def _fundamental_phase_degrees(common: dict[str, Any], *, frequency_hz: float) -> float | None: + """在 common_time 上拟合基波,返回 right 相对 left 的相位滞后(度,[0, 360))。""" + times = common["time_s"] + left = np.asarray(common["left_v"], dtype=np.float64) + right = np.asarray(common["right_v"], dtype=np.float64) + if frequency_hz <= 0 or times.size < 8: + return None + phase_left = _single_bin_phase(times, left, frequency_hz) + phase_right = _single_bin_phase(times, right, frequency_hz) + if phase_left is None or phase_right is None: + return None + return float((-math.degrees(_wrap_angle(phase_right - phase_left))) % 360.0) + + +def _single_bin_phase(times: np.ndarray, values: np.ndarray, frequency_hz: float) -> float | None: + # 非整数周期窗口内常数、cos、sin 不正交,必须联合拟合以消除 DC 泄漏。 + angle = 2.0 * math.pi * frequency_hz * (times - times[0]) + basis = np.column_stack((np.ones_like(angle), np.cos(angle), np.sin(angle))) + coefficients, _, rank, _ = np.linalg.lstsq(basis, values, rcond=None) + _, cosine, sine = coefficients + tolerance = np.finfo(np.float64).eps * max(float(np.max(np.abs(values))), 1.0) * 8 + if rank < 3 or math.hypot(cosine, sine) <= tolerance: + return None + return math.atan2(-sine, cosine) + + +def _wrap_angle(angle: float) -> float: + wrapped = (angle + math.pi) % (2.0 * math.pi) + return wrapped - math.pi + + +def _correlation_payload(common: dict[str, Any], *, warnings: list[str]) -> dict[str, Any]: + times = common["time_s"] + if times.size < 4: + warnings.append("insufficient_common_time_overlap") + return { + "normalized_pearson": None, + "max_cross_correlation": None, + "max_abs_cross_correlation": None, + "lag_at_max_correlation_s": None, + } + left = _normalize(common["left_v"]) + right = _normalize(common["right_v"]) + if left is None or right is None: + warnings.append("correlation_unavailable_for_flat_signal") + return { + "normalized_pearson": None, + "max_cross_correlation": None, + "max_abs_cross_correlation": None, + "lag_at_max_correlation_s": None, + } + pearson = float(np.mean(left * right)) + correlation = np.correlate(right, left, mode="full") / left.size + index = int(np.argmax(np.abs(correlation))) + lag_samples = index - (left.size - 1) + dt = float(np.median(np.diff(times))) + return { + "normalized_pearson": pearson, + "max_cross_correlation": float(correlation[index]), + "max_abs_cross_correlation": float(abs(correlation[index])), + "lag_at_max_correlation_s": float(lag_samples * dt), + } + + +def _intersection_payload( + common: dict[str, Any], + *, + warnings: list[str], + max_intersections: int, +) -> dict[str, Any]: + times = common["time_s"] + left = common["left_v"] + right = common["right_v"] + if times.size < 2: + return { + "mode": "none", + "count": 0, + "returned": 0, + "truncated": False, + "points": [], + } + diff = left - right + tolerance = max(float(np.max(np.abs(diff))) * 1e-9, 1e-12) + if bool(np.all(np.abs(diff) <= tolerance)): + warnings.append("waveforms_coincident_intersections_unbounded") + return { + "mode": "coincident", + "count": None, + "returned": 0, + "truncated": False, + "points": [], + } + points: list[dict[str, float | str]] = [] + count = 0 + last_time: float | None = None + for index in range(diff.size - 1): + d0 = float(diff[index]) + d1 = float(diff[index + 1]) + t0 = float(times[index]) + t1 = float(times[index + 1]) + if abs(d0) <= tolerance: + alpha = 0.0 + elif d0 * d1 < 0.0: + alpha = -d0 / (d1 - d0) + else: + continue + crossing_time = t0 + alpha * (t1 - t0) + if last_time is not None and abs(crossing_time - last_time) <= max(abs(t1 - t0) * 0.5, 1e-15): + continue + left_value = float(left[index] + alpha * (left[index + 1] - left[index])) + right_value = float(right[index] + alpha * (right[index + 1] - right[index])) + left_slope = _segment_slope(left, times, index) + right_slope = _segment_slope(right, times, index) + delta_slope = left_slope - right_slope + count += 1 + last_time = crossing_time + if len(points) < max_intersections: + points.append( + { + "time_s": float(crossing_time), + "voltage_v": float((left_value + right_value) / 2.0), + "left_slope_v_per_s": float(left_slope), + "right_slope_v_per_s": float(right_slope), + "delta_slope_v_per_s": float(delta_slope), + "direction": ( + "left_minus_right_rising" + if delta_slope > 0 + else "left_minus_right_falling" + if delta_slope < 0 + else "tangent_or_flat" + ), + } + ) + truncated = count > len(points) + if truncated: + warnings.append("intersections_truncated") + return { + "mode": "finite", + "count": count, + "returned": len(points), + "truncated": truncated, + "points": points, + } + + +def _segment_slope(values: np.ndarray, times: np.ndarray, index: int) -> float: + dt = float(times[index + 1] - times[index]) + if abs(dt) <= 1e-18: + return 0.0 + return float((values[index + 1] - values[index]) / dt) + + +def _normalize(values: np.ndarray) -> np.ndarray | None: + centered = values.astype(np.float64) - float(np.mean(values)) + rms = float(np.sqrt(np.mean(np.square(centered)))) + if rms <= 1e-12: + return None + return centered / rms + + +def _trusted_frequency(summary: dict[str, object], *, warnings: list[str], label: str) -> float | None: + frequency = summary.get("frequency_estimate_hz") + if not isinstance(frequency, (int, float)) or frequency <= 0: + warnings.append(f"{label}_frequency_unavailable") + return None + quality_warnings = summary.get("quality_warnings", []) + if any(str(item).startswith("low_cycle_count") for item in quality_warnings): + warnings.append(f"{label}_frequency_low_confidence") + return None + return float(frequency) + + +def _safe_ratio(numerator: object, denominator: object) -> float | None: + if not isinstance(numerator, (int, float)) or not isinstance(denominator, (int, float)): + return None + if abs(float(denominator)) <= 1e-18: + return None + return float(numerator) / float(denominator) diff --git a/src/wavebench/instruments/factory.py b/src/wavebench/instruments/factory.py index 6cd26a2a..87a9735d 100644 --- a/src/wavebench/instruments/factory.py +++ b/src/wavebench/instruments/factory.py @@ -58,7 +58,16 @@ def open_instrument_driver( serial_config: DmmConfig | None = None, access: AccessMode = "read_write", lease: ResourceLease | None = None, + force_deferred_io: bool = False, ) -> OpenedInstrument: + """Construct and validate a driver, optionally deferring all guarded I/O. + + Observation callers use force_deferred_io for legacy descriptors as well as + V2 descriptors. The latch is released only after factory validation succeeds. + """ + + if not isinstance(force_deferred_io, bool): + raise ConfigError("force_deferred_io must be bool") normalized_access = normalize_access_mode(access, "access") if lease is not None and lease.fingerprint != resource_fingerprint(resource, lease.lock_id): raise ConfigError("resource lease does not match configured instrument resource") @@ -82,7 +91,9 @@ def open_instrument_driver( strict_v2_capability_opt_in = bool( set(descriptor.capabilities) & SCOPE_STRICT_V2_CAPABILITIES ) - construction_latched = bounded_binary_profile_opt_in or strict_v2_capability_opt_in + construction_latched = ( + force_deferred_io or bounded_binary_profile_opt_in or strict_v2_capability_opt_in + ) backend = _select_backend(configured_backend, descriptor.backends) _validate_resource_scheme(resource, descriptor.resource_schemes) try: diff --git a/src/wavebench/instruments/registry.py b/src/wavebench/instruments/registry.py index f58051c8..fe3f8f0d 100644 --- a/src/wavebench/instruments/registry.py +++ b/src/wavebench/instruments/registry.py @@ -3,10 +3,11 @@ from dataclasses import dataclass from importlib.metadata import EntryPoint, entry_points from pathlib import Path -import re import sys +from packaging.specifiers import InvalidSpecifier, SpecifierSet from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion from wavebench import __version__ from wavebench.errors import ConfigError @@ -213,10 +214,14 @@ def _validate_descriptor( validate_scope_descriptor(descriptor) validate_source_descriptor(descriptor) validate_rf_source_descriptor(descriptor) - current = _version_tuple(__version__) - if current < _version_tuple(descriptor.wavebench_min_version) or current >= _version_tuple( - descriptor.wavebench_max_version - ): + try: + requirement = SpecifierSet( + f">={descriptor.wavebench_min_version},<{descriptor.wavebench_max_version}" + ) + compatible = requirement.contains(__version__, prereleases=True) + except (InvalidSpecifier, InvalidVersion) as exc: + raise ConfigError(f"invalid WaveBench compatibility version: {exc}") from exc + if not compatible: raise ConfigError( f"instrument driver {descriptor.driver_id!r} supports WaveBench " f">={descriptor.wavebench_min_version}, <{descriptor.wavebench_max_version}; " @@ -312,8 +317,3 @@ def _entry_point_belongs_to_current_environment(entry_point: EntryPoint) -> bool except (AttributeError, OSError, TypeError, ValueError): return False return True - - -def _version_tuple(value: str) -> tuple[int, ...]: - numbers = [int(item) for item in re.findall(r"\d+", value)] - return tuple((numbers + [0, 0, 0])[:3]) diff --git a/src/wavebench/mcp_http.py b/src/wavebench/mcp_http.py index 4f3a344a..7683eaf8 100644 --- a/src/wavebench/mcp_http.py +++ b/src/wavebench/mcp_http.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math import os from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer @@ -12,8 +13,11 @@ from wavebench.config import load_config from wavebench import __version__ from wavebench.data.packages import load_capture_package +from wavebench.doctor import doctor_records, has_doctor_errors from wavebench.errors import ConfigError, WaveBenchError from wavebench.logging import CommandLogger +from wavebench.services.agent_advise import scope_advise_payload +from wavebench.services.agent_observe import scope_observe_payload from wavebench.services.run_plan import load_run_plan, run_plan_schema_rows from wavebench.services.run_plan import format_run_plan_schema from wavebench.services.run_service import RunService @@ -50,12 +54,19 @@ class ToolSpec: description: str arguments: dict[str, Any] handler: Callable[[dict[str, Any], Path], dict[str, Any]] + read_only: bool = True + mutates_instrument: bool = False + raw_scpi: bool = False + instrument_state_effects: tuple[str, ...] = () def public_payload(self) -> dict[str, Any]: return { "name": self.name, "description": self.description, - "read_only": True, + "read_only": self.read_only, + "mutates_instrument": self.mutates_instrument, + "raw_scpi": self.raw_scpi, + "instrument_state_effects": list(self.instrument_state_effects), "arguments": self.arguments, } @@ -216,6 +227,119 @@ def _capture_inspect_tool(arguments: dict[str, Any], config_path: Path) -> dict[ } +def _optional_bool(arguments: dict[str, Any], name: str, default: bool) -> bool: + value = arguments.get(name, default) + if not isinstance(value, bool): + raise ConfigError(f"{name} must be a boolean / {name} 必须是布尔值") + return value + + +def _scope_observe_tool(arguments: dict[str, Any], config_path: Path) -> dict[str, Any]: + channel, channels = _scope_channel_arguments(arguments) + return scope_observe_payload( + config_path=_reject_sensitive_path(config_path, label="config"), + channel=channel, + channels=channels, + allow_50ohm=_optional_bool(arguments, "allow_50ohm", False), + ) + + +def _scope_advise_tool(arguments: dict[str, Any], config_path: Path) -> dict[str, Any]: + channel, channels = _scope_channel_arguments(arguments) + return scope_advise_payload( + config_path=_reject_sensitive_path(config_path, label="config"), + channel=channel, + channels=channels, + allow_50ohm=_optional_bool(arguments, "allow_50ohm", False), + expected_frequencies_hz=_expected_frequencies_argument( + arguments.get("expected_frequencies_hz") + ), + target_cycles=_optional_positive_number(arguments, "target_cycles", 10.0), + target_vertical_divisions=_optional_positive_number( + arguments, + "target_vertical_divisions", + 5.0, + ), + ) + + +def _scope_channel_arguments(arguments: dict[str, Any]) -> tuple[int | None, tuple[int, ...] | None]: + channel = arguments.get("channel") + if channel is not None and (isinstance(channel, bool) or not isinstance(channel, int)): + raise ConfigError("channel must be an integer / channel 必须是整数") + raw_channels = arguments.get("channels") + channels = None + if raw_channels is not None: + if not isinstance(raw_channels, list): + raise ConfigError("channels must be an array / channels 必须是数组") + channels = tuple(raw_channels) + if any(isinstance(item, bool) or not isinstance(item, int) for item in channels): + raise ConfigError("channels must contain integers / channels 必须包含整数") + return channel, channels + + +def _optional_positive_number(arguments: dict[str, Any], name: str, default: float) -> float: + value = arguments.get(name, default) + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(float(value)) + or value <= 0 + ): + raise ConfigError(f"{name} must be a finite positive number / {name} 必须是有限正数") + return float(value) + + +def _expected_frequencies_argument(raw: Any) -> dict[int, float] | None: + if raw is None: + return None + if not isinstance(raw, dict): + raise ConfigError("expected_frequencies_hz must be an object / expected_frequencies_hz 必须是对象") + parsed: dict[int, float] = {} + for key, value in raw.items(): + try: + channel = int(key) + except (TypeError, ValueError) as exc: + raise ConfigError( + "expected_frequencies_hz keys must be channel numbers / expected_frequencies_hz 键必须是通道号" + ) from exc + if channel < 1: + raise ConfigError("expected_frequencies_hz channel must be >= 1 / 通道必须 >= 1") + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ConfigError("expected_frequencies_hz values must be numbers / 频率必须是数字") + parsed[channel] = float(value) + return parsed + + +def _doctor_config_tool(arguments: dict[str, Any], config_path: Path) -> dict[str, Any]: + timeout_ms = arguments.get("timeout_ms") + if timeout_ms is not None and (isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int) or timeout_ms <= 0): + raise ConfigError("timeout_ms must be a positive integer / timeout_ms 必须是正整数") + records = doctor_records( + load_config(_reject_sensitive_path(config_path, label="config")), + timeout_ms=timeout_ms, + include_visa=False, + ) + return { + "status": "error" if has_doctor_errors(records) else "ok", + "read_only": True, + "mutates_instrument": False, + "raw_scpi": False, + "records": [ + { + "severity": record.severity, + "target": record.target, + "driver": record.driver, + "resource": record.resource, + "idn": record.idn, + "message": record.message, + "suggestion": record.suggestion, + } + for record in records + ], + } + + READ_ONLY_TOOLS: dict[str, ToolSpec] = { "run.schema": ToolSpec( name="run.schema", @@ -248,6 +372,78 @@ def _capture_inspect_tool(arguments: dict[str, Any], config_path: Path) -> dict[ }, handler=_capture_inspect_tool, ), + "scope.observe": ToolSpec( + name="scope.observe", + description=( + "Read configured scope identity, per-channel state snapshot, and input-coupling safety. " + "Never reads waveforms and never changes instrument state / " + "只读观察配置中的示波器身份、通道状态与输入耦合安全;不读取波形,不改变仪器状态" + ), + arguments={ + "type": "object", + "properties": { + "channel": {"type": "integer", "minimum": 1}, + "channels": { + "type": "array", + "items": {"type": "integer", "minimum": 1}, + "minItems": 1, + "uniqueItems": True, + }, + "allow_50ohm": {"type": "boolean", "default": False}, + }, + "additionalProperties": False, + }, + handler=_scope_observe_tool, + ), + "doctor.config": ToolSpec( + name="doctor.config", + description=( + "Run configured-instrument read-only doctor checks and return structured records / " + "对已配置仪器执行只读 doctor 检查并返回结构化结果" + ), + arguments={ + "type": "object", + "properties": { + "timeout_ms": {"type": "integer", "minimum": 1}, + }, + "additionalProperties": False, + }, + handler=_doctor_config_tool, + ), + "scope.advise": ToolSpec( + name="scope.advise", + description=( + "Observe the configured scope and recommend display/acquisition settings from the " + "read-only state snapshot and caller-provided expected frequencies; never reads waveforms " + "and never applies recommendations / " + "基于只读状态快照与调用方给出的期望频率建议显示/采集参数;不读取波形,不应用建议" + ), + arguments={ + "type": "object", + "properties": { + "channel": {"type": "integer", "minimum": 1}, + "channels": { + "type": "array", + "items": {"type": "integer", "minimum": 1}, + "minItems": 1, + "uniqueItems": True, + }, + "allow_50ohm": {"type": "boolean", "default": False}, + "target_cycles": {"type": "number", "exclusiveMinimum": 0, "default": 10}, + "target_vertical_divisions": { + "type": "number", + "exclusiveMinimum": 0, + "default": 5, + }, + "expected_frequencies_hz": { + "type": "object", + "additionalProperties": {"type": "number", "exclusiveMinimum": 0}, + }, + }, + "additionalProperties": False, + }, + handler=_scope_advise_tool, + ), } diff --git a/src/wavebench/plugins/lifecycle.py b/src/wavebench/plugins/lifecycle.py index 9f63aa9f..c3f41b2a 100644 --- a/src/wavebench/plugins/lifecycle.py +++ b/src/wavebench/plugins/lifecycle.py @@ -26,11 +26,13 @@ WheelEntryPoint, build_subprocess_environment, inspect_plugin_package, + require_uv, ) LEDGER_SCHEMA_VERSION = 2 JOURNAL_SCHEMA_VERSION = 2 +# Persisted pip_started/pip_finished stages remain readable across the uv migration. @dataclass(frozen=True) @@ -257,6 +259,7 @@ def install(self, path: str | Path, *, dry_run: bool = False) -> LifecycleResult ledger = self._load_ledger(environment) self._assert_first_install_allowed(package, ledger) return result + require_uv() with self._locked(): environment = self.environment() self._assert_no_pending_journal() @@ -290,6 +293,7 @@ def remove(self, driver_id: str, *, dry_run: bool = False) -> LifecycleResult: current.distribution, current.version, ) + require_uv() with self._locked(): self._assert_no_pending_journal() ledger = self._load_ledger(environment) @@ -308,7 +312,7 @@ def remove(self, driver_id: str, *, dry_run: bool = False) -> LifecycleResult: self._write_json(self.journal_path, journal) try: self._update_journal(journal, "pip_started") - self._pip_uninstall(record["distribution"]) + self._uninstall_distribution(record["distribution"]) self._update_journal(journal, "pip_finished") if self._distribution_inventory(record["distribution"]): raise ConfigError("plugin remove postflight failed / 插件卸载后检查失败") @@ -392,6 +396,7 @@ def _replace( ) if dry_run: return result + require_uv() with self._locked(): environment = self.environment() self._assert_no_pending_journal() @@ -458,7 +463,7 @@ def _run_install_transaction( self._write_json(self.journal_path, journal) try: self._update_journal(journal, "pip_started") - self._pip_install(cached_wheel) + self._install_wheel(cached_wheel) self._update_journal(journal, "pip_finished") postflight = self._postflight(record) record["installed_files_sha256"] = postflight["files_sha256"] @@ -489,7 +494,7 @@ def _rollback_uninstall( ) -> None: try: self._update_journal(journal, "rollback_started") - self._pip_uninstall(record["distribution"], allow_missing=True) + self._uninstall_distribution(record["distribution"], allow_missing=True) if self._distribution_inventory(record["distribution"]): raise ConfigError("rollback uninstall did not remove distribution") self._write_json(self.ledger_path, ledger) @@ -511,9 +516,9 @@ def _rollback_install( ) -> None: try: self._update_journal(journal, "rollback_started") - self._pip_install(wheel) - self._postflight(record) - self._write_json(self.ledger_path, ledger) + self._install_wheel(wheel) + restored = self._reinstalled_record(record) + self._write_json(self.ledger_path, self._with_record(ledger, restored)) self._remove_journal() except Exception as rollback_error: raise ConfigError( @@ -522,6 +527,15 @@ def _rollback_install( ) from rollback_error raise ConfigError(f"plugin postflight failed / 插件操作后检查失败: {original}") from original + def _reinstalled_record(self, record: dict[str, object]) -> dict[str, object]: + # Installer-generated RECORD changes; revalidate every cached wheel member first. + postflight = self._postflight({**record, "installed_record_sha256": ""}) + return { + **record, + "installed_files_sha256": postflight["files_sha256"], + "installed_record_sha256": postflight["record_sha256"], + } + @contextmanager def _inspected_input(self, path: str | Path) -> Iterator[PluginPackage]: with tempfile.TemporaryDirectory(prefix="wavebench-plugin-build-") as temporary: @@ -593,7 +607,15 @@ def _recover_journal( self._remove_journal() return LifecycleResult("recovered-to-before", driver_id, record["distribution"], record["version"]) - if stage == "rollback_started" and before_matches: + if stage == "rollback_started": + if not before_matches: + if before_record is None: + return self._recovery_required() + try: + restored = self._reinstalled_record(before_record) + except ConfigError: + return self._recovery_required() + before = self._with_record(before, restored) self._write_json(self.ledger_path, before) self._remove_journal() return LifecycleResult("recovered-after-rollback", driver_id, record["distribution"], record["version"]) @@ -1101,39 +1123,39 @@ def _distribution_inventory(self, distribution: str) -> tuple[dict[str, object], (), ) - def _pip_install(self, wheel: Path) -> None: + def _install_wheel(self, wheel: Path) -> None: self._run_command( [ - self.environment().python, - "-I", - "-m", + require_uv(), + "--no-config", + "--offline", + "--no-python-downloads", + "--no-cache", "pip", "install", - "--isolated", + "--python", + self.environment().python, "--no-index", "--no-deps", - "--no-cache-dir", - "--no-input", - "--disable-pip-version-check", - "--force-reinstall", + "--reinstall", str(wheel), ], action="plugin install / 插件安装", ) - def _pip_uninstall(self, distribution: str, *, allow_missing: bool = False) -> None: + def _uninstall_distribution(self, distribution: str, *, allow_missing: bool = False) -> None: try: self._run_command( [ - self.environment().python, - "-I", - "-m", + require_uv(), + "--no-config", + "--offline", + "--no-python-downloads", + "--no-cache", "pip", "uninstall", - "--isolated", - "--no-input", - "--disable-pip-version-check", - "--yes", + "--python", + self.environment().python, distribution, ], action="plugin remove / 插件卸载", @@ -1155,6 +1177,8 @@ def _run_command(self, command: list[str], *, action: str) -> subprocess.Complet ) except subprocess.TimeoutExpired as exc: raise ConfigError(f"{action} timed out / 操作超时") from exc + except OSError as exc: + raise ConfigError(f"{action} could not start / 无法启动操作: {exc}") from exc if completed.returncode != 0: detail = " ".join((completed.stderr or completed.stdout).split())[-1200:] raise ConfigError(f"{action} failed / 操作失败: {detail or 'no output'}") diff --git a/src/wavebench/plugins/package_inspect.py b/src/wavebench/plugins/package_inspect.py index 1819fa10..0669ece9 100644 --- a/src/wavebench/plugins/package_inspect.py +++ b/src/wavebench/plugins/package_inspect.py @@ -9,6 +9,7 @@ import json import os from pathlib import Path, PurePosixPath +import shutil import stat import subprocess import sys @@ -57,6 +58,14 @@ ) +def require_uv() -> str: + executable = shutil.which("uv") + if executable is None: + raise ConfigError("uv is required on PATH for plugin installation and builds / " + "插件安装和构建需要 PATH 中可用的 uv") + return executable + + def build_subprocess_environment() -> dict[str, str]: """Build a minimal, platform-safe environment for package subprocesses.""" @@ -218,18 +227,18 @@ def build_source_wheel( output.mkdir(parents=True, exist_ok=True) before = {item.resolve() for item in output.glob("*.whl")} command = [ + require_uv(), + "--no-config", + "--offline", + "--no-python-downloads", + "--no-cache", + "build", + "--wheel", + "--python", str(python_executable), - "-m", - "pip", - "--isolated", - "wheel", "--no-build-isolation", - "--no-deps", "--no-index", - "--disable-pip-version-check", - "--no-input", - "--no-cache-dir", - "--wheel-dir", + "--out-dir", str(output), str(source.path), ] @@ -245,6 +254,8 @@ def build_source_wheel( ) except subprocess.TimeoutExpired as exc: raise ConfigError("plugin wheel build timed out / 插件 wheel 构建超时") from exc + except OSError as exc: + raise ConfigError("cannot execute uv for plugin build / 无法执行 uv 构建插件") from exc if completed.returncode != 0: detail = _sanitize_subprocess_detail(completed.stderr or completed.stdout, source.path) raise ConfigError(f"plugin wheel build failed / 插件 wheel 构建失败: {detail}") @@ -591,7 +602,7 @@ def _validate_wavebench_requirement(dependencies: tuple[str, ...]) -> None: matches.append(requirement) if len(matches) != 1: raise ConfigError("plugin must declare exactly one active WaveBench dependency / 插件必须声明一个生效的 WaveBench 依赖") - if Version(__version__) not in matches[0].specifier: + if not matches[0].specifier.contains(Version(__version__), prereleases=True): raise ConfigError( f"plugin requires {matches[0]}; current WaveBench is {__version__} / " "插件与当前 WaveBench 版本不兼容" diff --git a/src/wavebench/services/advisor_run_binding.py b/src/wavebench/services/advisor_run_binding.py new file mode 100644 index 00000000..98eff002 --- /dev/null +++ b/src/wavebench/services/advisor_run_binding.py @@ -0,0 +1,338 @@ +"""Read-only, bounded run snapshots for advisor request preparation and revalidation. + +The caller is a trusted Core task input builder, not an advisor plugin. A binding +is evidence, not consent: this module neither loads grants nor executes advisors. +""" + +from __future__ import annotations + +import base64 +from dataclasses import dataclass, field +from hashlib import sha256 +import json +import math +import os +from pathlib import Path, PurePosixPath, PureWindowsPath +import stat +import subprocess +import sys +import time +from typing import Any, Iterable + +from wavebench.errors import DataError + + +BINDING_SCHEMA = "wavebench.advisor.run_binding.v1" +MAX_SOURCES = 32 +MAX_SOURCE_BYTES = 16 * 1024**2 +_CHUNK_BYTES = 64 * 1024 + + +class RunBindingError(DataError): + """Safe evidence for a failed read or a changed input snapshot.""" + + def __init__( + self, reason: str, *, code: str = "run_binding_unverifiable", + paths: tuple[str, ...] = (), expected_digest: str | None = None, + actual_digest: str | None = None, + ) -> None: + super().__init__(f"advisor run binding failed / advisor run 绑定失败: {reason}") + self.code = code + self.reason = reason + self.paths = paths + self.expected_digest = expected_digest + self.actual_digest = actual_digest + + def to_envelope(self, *, operation=None, details=None, cause=None): + evidence = { + **(details or {}), "reason": self.reason, "paths": list(self.paths), + "expected_digest": self.expected_digest, "actual_digest": self.actual_digest, + } + # Raw filesystem exceptions may disclose unrelated absolute paths. + return super().to_envelope(operation=operation, details=evidence) + + +@dataclass(frozen=True) +class BindingSource: + """One Core-selected, run-relative input; absence is opt-in.""" + + path: str + optional: bool = False + + +@dataclass(frozen=True) +class _SourceSnapshot: + spec: BindingSource + content: bytes | None = field(repr=False) + + def record(self) -> dict[str, str]: + if self.content is None: + return {"path": self.spec.path, "state": "missing"} + return { + "path": self.spec.path, "state": "present", + "sha256": sha256(self.content).hexdigest(), + } + + +@dataclass(frozen=True) +class RunBindingSnapshot: + """Immutable source bytes and local identity, built by capture_run_binding().""" + + run_root: str + task_id: str + task_version: str + _sources: tuple[_SourceSnapshot, ...] = field(repr=False) + _requested_root: str = field(repr=False, compare=False) + elapsed_s: float = field(compare=False) + + @property + def bytes_read(self) -> int: + return sum(len(source.content) for source in self._sources if source.content is not None) + + def as_dict(self) -> dict[str, Any]: + """A fresh JSON-ready binding; raw bytes and timing never enter its hash.""" + return { + "schema": BINDING_SCHEMA, "run_root": self.run_root, + "task_id": self.task_id, "task_version": self.task_version, + "sources": [source.record() for source in self._sources], + } + + @property + def binding_sha256(self) -> str: + encoded = json.dumps( + self.as_dict(), ensure_ascii=False, sort_keys=True, + separators=(",", ":"), allow_nan=False, + ).encode("utf-8") + return sha256(encoded).hexdigest() + + def read_bytes(self, path: str) -> bytes | None: + """Return the already-hashed bytes, never reopen the filesystem.""" + for source in self._sources: + if source.spec.path == path: + return source.content + raise KeyError(path) + + def read_json(self, path: str) -> Any: + """Parse the same snapshot strictly; optional absence is returned as None.""" + raw = self.read_bytes(path) + return None if raw is None else _parse_json(raw, path) + + +def capture_run_binding( + run_dir: str | Path, *, task_id: str, task_version: str, + sources: Iterable[BindingSource] = (), timeout_s: float = 30.0, +) -> RunBindingSnapshot: + """Capture run.json plus a bounded explicit source list without writing files. + + Reads run in a short-lived subprocess so stalled filesystem I/O can be + terminated. timeout_s is the remaining stage budget; no retries are made. + The parent retains at most 16 MiB of source bytes (IPC/JSON overhead is extra). + """ + started = time.monotonic() + for value in (task_id, task_version): + if not isinstance(value, str) or not value.strip() or value != value.strip(): + raise RunBindingError("invalid_task_identity") + if (isinstance(timeout_s, bool) or not isinstance(timeout_s, (int, float)) + or not math.isfinite(timeout_s) or timeout_s <= 0): + raise RunBindingError("invalid_timeout") + specs = [BindingSource("run.json")] + seen = {"run.json"} + for spec in sources: + if len(specs) >= MAX_SOURCES: + raise RunBindingError("source_count_limit") + if not isinstance(spec, BindingSource) or type(spec.optional) is not bool: + raise RunBindingError("invalid_source") + _validate_source_path(spec.path) + if spec.path in seen: + raise RunBindingError("duplicate_source", paths=(spec.path,)) + specs.append(spec) + seen.add(spec.path) + specs.sort(key=lambda item: item.path) + try: + # Keep the original absolute spelling so revalidation catches root-link retargeting. + requested_root = str(Path(run_dir).expanduser().absolute()) + request = json.dumps({ + "run_dir": requested_root, + "sources": [{"path": s.path, "optional": s.optional} for s in specs], + }) + remaining = timeout_s - (time.monotonic() - started) + if remaining <= 0: + raise RunBindingError("deadline_exceeded", code="timeout") + completed = subprocess.run( + [sys.executable, "-m", "wavebench.services.advisor_run_binding"], + input=request.encode("utf-8"), capture_output=True, timeout=remaining, check=False, + ) + except subprocess.TimeoutExpired: + raise RunBindingError("deadline_exceeded", code="timeout") from None + except KeyboardInterrupt: + raise RunBindingError("cancelled", code="cancelled") from None + except (OSError, ValueError, RuntimeError): + raise RunBindingError("snapshot_worker_unavailable") from None + if completed.returncode != 0: + raise RunBindingError("snapshot_worker_failed") + try: + result = json.loads(completed.stdout) + if "error" in result: + raise RunBindingError(result["error"], paths=tuple(result["paths"])) + snapshot = RunBindingSnapshot( + run_root=result["run_root"], task_id=task_id, task_version=task_version, + _sources=tuple( + _SourceSnapshot(spec, None if raw is None else base64.b64decode(raw, validate=True)) + for spec, raw in zip(specs, result["contents"], strict=True) + ), + _requested_root=requested_root, elapsed_s=time.monotonic() - started, + ) + except (ValueError, TypeError, KeyError): + raise RunBindingError("invalid_worker_response") from None + if time.monotonic() - started >= timeout_s: + raise RunBindingError("deadline_exceeded", code="timeout") + return snapshot + + +def verify_run_binding( + expected: RunBindingSnapshot, *, sources: Iterable[BindingSource] | None = None, + timeout_s: float = 30.0, +) -> RunBindingSnapshot: + """Re-read inputs, optionally with a fresh Core manifest; fail closed on changes.""" + try: + actual = capture_run_binding( + expected._requested_root, task_id=expected.task_id, task_version=expected.task_version, + sources=(s.spec for s in expected._sources if s.spec.path != "run.json") + if sources is None else sources, + timeout_s=timeout_s, + ) + except RunBindingError as exc: + raise RunBindingError( + exc.reason, code=exc.code, paths=exc.paths, + expected_digest=expected.binding_sha256, + ) from None + expected_digest = expected.binding_sha256 + actual_digest = actual.binding_sha256 + if actual_digest != expected_digest: + old = {s.spec.path: s.record() for s in expected._sources} + new = {s.spec.path: s.record() for s in actual._sources} + raise RunBindingError( + "source_snapshot_changed", code="run_binding_changed", + paths=tuple(path for path in sorted(old.keys() | new.keys()) if old.get(path) != new.get(path)), + expected_digest=expected_digest, actual_digest=actual_digest, + ) + return actual + + +def _validate_source_path(raw: str) -> None: + if (not isinstance(raw, str) or not raw or "\\" in raw or ":" in raw or "\x00" in raw + or raw.startswith("/") or any(p in {"", ".", ".."} for p in raw.split("/")) + or PureWindowsPath(raw).is_reserved()): + raise RunBindingError("invalid_source_path") + if PurePosixPath(raw).parts[0].casefold() == "decisions": + raise RunBindingError("audit_source_forbidden", paths=(raw,)) + + +def _parse_json(raw: bytes, path: str) -> Any: + def object_pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate key") + result[key] = value + return result + + def finite_float(value): + number = float(value) + if not math.isfinite(number): + raise ValueError("non-finite number") + return number + + def invalid_constant(value): + raise ValueError("non-finite number") + + try: + return json.loads( + raw.decode("utf-8"), object_pairs_hook=object_pairs, + parse_float=finite_float, parse_constant=invalid_constant, + ) + except (UnicodeError, ValueError, RecursionError): + raise RunBindingError("invalid_json", paths=(path,)) from None + + +def _identity(value: os.stat_result) -> tuple[int, ...]: + # As in analysis_io: compare stat with stat and fstat with fstat on Windows. + return value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns + + +def _read_source(root: Path, spec: BindingSource, remaining: int) -> bytes | None: + original = root / spec.path + try: + resolved = original.resolve() + relative = resolved.relative_to(root) + except (OSError, RuntimeError, ValueError): + raise RunBindingError("source_path_unavailable", paths=(spec.path,)) from None + if relative.parts and relative.parts[0].casefold() == "decisions": + raise RunBindingError("audit_source_forbidden", paths=(spec.path,)) + try: + before_path = resolved.stat() + except FileNotFoundError: + if spec.optional: + return None + raise RunBindingError("source_missing", paths=(spec.path,)) from None + if not stat.S_ISREG(before_path.st_mode): + raise RunBindingError("source_not_regular", paths=(spec.path,)) + if before_path.st_size > remaining: + raise RunBindingError("source_bytes_limit", paths=(spec.path,)) + flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NONBLOCK", 0) + flags |= getattr(os, "O_NOFOLLOW", 0) + with os.fdopen(os.open(resolved, flags), "rb") as handle: + before_fd = os.fstat(handle.fileno()) + if not stat.S_ISREG(before_fd.st_mode): + raise RunBindingError("source_not_regular", paths=(spec.path,)) + chunks = [] + size = 0 + while chunk := handle.read(min(_CHUNK_BYTES, remaining - size + 1)): + size += len(chunk) + if size > remaining: + raise RunBindingError("source_bytes_limit", paths=(spec.path,)) + chunks.append(chunk) + if (_identity(before_fd) != _identity(os.fstat(handle.fileno())) + or _identity(before_path) != _identity(resolved.stat()) + or original.resolve() != resolved): + raise RunBindingError("source_changed_during_read", paths=(spec.path,)) + return b"".join(chunks) + + +def _read_snapshot(run_dir: str, specs: tuple[BindingSource, ...]) -> dict[str, Any]: + try: + root = Path(run_dir).resolve(strict=True) + if not root.is_dir(): + raise RunBindingError("invalid_run_directory") + contents = [] + total = 0 + for spec in specs: + try: + raw = _read_source(root, spec, MAX_SOURCE_BYTES - total) + except OSError: + raise RunBindingError("source_unreadable", paths=(spec.path,)) from None + if raw is not None: + total += len(raw) + if spec.path == "run.json" and not isinstance(_parse_json(raw, spec.path), dict): + raise RunBindingError("run_must_be_object", paths=(spec.path,)) + contents.append(None if raw is None else base64.b64encode(raw).decode("ascii")) + if Path(run_dir).resolve(strict=True) != root: + raise RunBindingError("run_root_changed_during_read") + return {"run_root": str(root), "contents": contents} + except (OSError, ValueError, RuntimeError): + raise RunBindingError("run_directory_unavailable") from None + + +def _worker_main() -> None: + request = json.load(sys.stdin) + try: + result = _read_snapshot( + request["run_dir"], tuple(BindingSource(**s) for s in request["sources"]), + ) + except RunBindingError as exc: + result = {"error": exc.reason, "paths": list(exc.paths)} + sys.stdout.write(json.dumps(result, ensure_ascii=False)) + + +if __name__ == "__main__": # private subprocess entry point, not an advisor CLI + _worker_main() diff --git a/src/wavebench/services/agent_advise.py b/src/wavebench/services/agent_advise.py new file mode 100644 index 00000000..84d65fc1 --- /dev/null +++ b/src/wavebench/services/agent_advise.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import math +from pathlib import Path +from typing import Any + +from wavebench.errors import ConfigError +from wavebench.services.agent_observe import scope_observe_payload + + +def scope_advise_payload( + *, + config_path: str | Path, + channel: int | None = None, + channels: tuple[int, ...] | None = None, + allow_50ohm: bool = False, + expected_frequencies_hz: dict[int, float] | None = None, + target_cycles: float = 10.0, + target_vertical_divisions: float = 5.0, + resource: str | None = None, +) -> dict[str, Any]: + """只读建议:基于示波器状态快照和调用方提供的期望频率给出显示/时基建议。 + + 该函数不读取波形、不改变仪器状态,也不基于低置信度的测量频率下结论。 + 需要基于实测波形的建议时,使用 ``scope_waveform_report_payload`` 的结果调用 + ``scope_advise_from_observation``。 + """ + # 参数校验必须发生在打开任何仪器会话之前 + target_cycles, target_vertical_divisions = validate_scope_advice_targets( + target_cycles=target_cycles, + target_vertical_divisions=target_vertical_divisions, + ) + expected_frequencies = _normalize_expected_frequencies(expected_frequencies_hz) + observation = scope_observe_payload( + config_path=config_path, + channel=channel, + channels=channels, + allow_50ohm=allow_50ohm, + resource=resource, + ) + return scope_advise_from_observation( + observation, + expected_frequencies_hz=expected_frequencies, + target_cycles=target_cycles, + target_vertical_divisions=target_vertical_divisions, + ) + + +def scope_advise_from_observation( + observation: dict[str, Any], + *, + expected_frequencies_hz: dict[int, float] | None = None, + target_cycles: float = 10.0, + target_vertical_divisions: float = 5.0, +) -> dict[str, Any]: + target_cycles, target_vertical_divisions = validate_scope_advice_targets( + target_cycles=target_cycles, + target_vertical_divisions=target_vertical_divisions, + ) + expected = _normalize_expected_frequencies(expected_frequencies_hz) + recommendations = _recommendations( + observation, + expected_frequencies=expected, + target_cycles=target_cycles, + target_vertical_divisions=target_vertical_divisions, + ) + return { + "status": observation["status"], + "read_only": observation["read_only"], + "query_only": observation["query_only"], + "mutates_instrument": observation["mutates_instrument"], + "raw_scpi": False, + "applies_recommendations": False, + "instrument_state_effects": observation["instrument_state_effects"], + "observation": { + "channel": observation["observation"]["channel"], + "channels": observation["observation"]["channels"], + "fetch_waveform": observation["observation"]["fetch_waveform"], + }, + "expected_frequencies_hz": {str(item): value for item, value in sorted(expected.items())}, + "recommendations": recommendations, + "agent_hints": _agent_hints(observation, recommendations), + "warnings": observation["warnings"], + } + + +def validate_scope_advice_targets( + *, target_cycles: float, target_vertical_divisions: float, +) -> tuple[float, float]: + """Validate advice targets before observation or instrument I/O.""" + return ( + _positive_finite(target_cycles, name="scope.advise target_cycles"), + _positive_finite(target_vertical_divisions, name="scope.advise target_vertical_divisions"), + ) + + +def _normalize_expected_frequencies(values: dict[int, float] | None) -> dict[int, float]: + if values is None: + return {} + normalized: dict[int, float] = {} + for channel, value in values.items(): + if isinstance(channel, bool) or not isinstance(channel, int) or channel < 1: + raise ConfigError("expected frequency channel must be a positive integer") + normalized[channel] = _positive_finite(value, name=f"expected frequency for channel {channel}") + return normalized + + +def _positive_finite(value: Any, *, name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ConfigError(f"{name} must be a number") + number = float(value) + if not math.isfinite(number) or number <= 0: + raise ConfigError(f"{name} must be finite and > 0") + return number + + +def _recommendations( + observation: dict[str, Any], + *, + expected_frequencies: dict[int, float], + target_cycles: float, + target_vertical_divisions: float, +) -> list[dict[str, Any]]: + recommendations: list[dict[str, Any]] = [] + channels = observation.get("channels", []) + channel_profiles: dict[int, dict[str, Any]] = {} + for channel_section in channels: + channel = channel_section.get("channel") + if not isinstance(channel, int): + continue + recommendation_count = len(recommendations) + summary = _waveform_summary(channel_section) + snapshot = _scope_status_data(channel_section) + frequency_hz, source, confidence, withheld_reason = _frequency_for_advice( + summary, + expected_frequencies.get(channel), + ) + vertical_scale = _recommended_vertical_scale( + summary, + snapshot, + target_vertical_divisions=target_vertical_divisions, + ) + time_range = ( + _recommended_time_range(frequency_hz, target_cycles=target_cycles) + if frequency_hz is not None + else None + ) + channel_profiles[channel] = { + "channel": channel, + "frequency_hz": frequency_hz, + "frequency_source": source, + "frequency_confidence": confidence, + "time_range_s": time_range, + "vertical_scale_v_per_div": vertical_scale, + } + snapshot_channel = None if snapshot is None else snapshot.get("channel") + if isinstance(snapshot_channel, dict) and snapshot_channel.get("enabled") is False: + recommendations.append( + _command_recommendation( + "display_on", + "high", + channel, + "Channel display is off; enable it before human visual inspection.", + "display", + {"channel": channel, "state": "on"}, + ) + ) + if time_range is None and withheld_reason is not None: + # 低置信度测量又没有可用的期望频率时,明确说明为何不给时基建议 + recommendations.append( + { + "id": "timebase_advice_withheld", + "priority": "normal", + "channel": channel, + "action": "provide_expected_frequency", + "reason": withheld_reason, + "mutates_instrument_if_applied": False, + "raw_scpi": False, + } + ) + if time_range is not None or vertical_scale is not None: + reason = _focus_reason( + summary, + frequency_hz, + source, + confidence, + target_cycles=target_cycles, + ) + priority = "high" if _needs_focus(summary, channel, expected_frequencies) else "normal" + recommendations.append( + _command_recommendation( + "focus_channel", + priority, + channel, + reason, + "focus", + { + "channel": channel, + "time_range_s": time_range, + "vertical_scale_v_per_div": vertical_scale, + "frequency_confidence": confidence, + "hide_other_channels": False, + }, + ) + ) + if len(recommendations) == recommendation_count: + recommendations.append(_unavailable_advice(channel)) + span = _frequency_span(channel_profiles) + if span is not None and span["ratio_high_over_low"] > 10.0: + recommendations.append( + { + "id": "separate_timebase_profiles", + "priority": "high", + "action": "capture_or_observe_channels_separately", + "reason": ( + "Observed or expected channel frequencies span more than 10x; " + "do not judge every waveform shape on one timebase." + ), + "mutates_instrument_if_applied": False, + "raw_scpi": False, + "frequency_span": span, + "profiles": [ + profile + for _, profile in sorted(channel_profiles.items()) + if profile["time_range_s"] is not None + ], + } + ) + if not recommendations: + recommendations.append(_unavailable_advice(None)) + return recommendations + + +def _unavailable_advice(channel: int | None) -> dict[str, Any]: + recommendation: dict[str, Any] = { + "id": "advice_unavailable", + "priority": "normal", + "action": "obtain_scope_evidence", + "reason": ( + "No usable display settings, waveform metrics or expected frequency are available; " + "the current settings could not be assessed." + ), + "mutates_instrument_if_applied": False, + "raw_scpi": False, + } + if channel is not None: + recommendation["channel"] = channel + return recommendation + + +def _waveform_summary(channel_section: dict[str, Any]) -> dict[str, Any] | None: + waveform = channel_section.get("waveform", {}) + if waveform.get("status") != "ok": + return None + summary = waveform.get("data", {}).get("summary") + return summary if isinstance(summary, dict) else None + + +def _scope_status_data(channel_section: dict[str, Any]) -> dict[str, Any] | None: + status = channel_section.get("scope_status", {}) + if status.get("status") not in {"ok", "partial"}: + return None + data = status.get("data") + return data if isinstance(data, dict) else None + + +def _frequency_for_advice( + summary: dict[str, Any] | None, + expected_frequency_hz: float | None, +) -> tuple[float | None, str | None, str | None, str | None]: + """返回 (频率, 来源, 置信度, 撤回建议的原因)。 + + 低置信度的测量频率(``low_cycle_count`` 等质量告警)不能用来推导时基建议; + 此时优先回退到调用方提供的期望频率,没有期望频率就不给时基建议。 + """ + measured = _summary_frequency(summary) + if measured is not None and not _summary_frequency_low_confidence(summary): + return measured, "measured", "measured", None + if expected_frequency_hz is not None: + source = "expected" if measured is None else "expected_over_low_confidence_measurement" + return expected_frequency_hz, source, "configured", None + if measured is not None: + return ( + None, + None, + "low", + "measured frequency is low confidence (few cycles in window) and no expected frequency was provided", + ) + return None, None, None, None + + +def _summary_frequency(summary: dict[str, Any] | None) -> float | None: + if summary is None: + return None + value = summary.get("frequency_estimate_hz") + if ( + not isinstance(value, (int, float)) or isinstance(value, bool) + or not math.isfinite(value) or value <= 0 + ): + return None + return float(value) + + +def _summary_frequency_low_confidence(summary: dict[str, Any] | None) -> bool: + if summary is None: + return False + return any( + str(item).startswith("low_cycle_count") + for item in summary.get("quality_warnings", []) or [] + ) + + +def _recommended_time_range(frequency_hz: float, *, target_cycles: float) -> float: + return float(target_cycles / frequency_hz) + + +def _recommended_vertical_scale( + summary: dict[str, Any] | None, + snapshot: dict[str, Any] | None, + *, + target_vertical_divisions: float, +) -> float | None: + vpp = None if summary is None else summary.get("voltage_vpp_v") + if ( + isinstance(vpp, (int, float)) and not isinstance(vpp, bool) + and math.isfinite(vpp) and vpp > 0 + ): + return float(vpp) / target_vertical_divisions + scale = None + if snapshot is not None: + snapshot_channel = snapshot.get("channel") + if isinstance(snapshot_channel, dict): + scale = snapshot_channel.get("scale_v_per_div") + if ( + isinstance(scale, (int, float)) and not isinstance(scale, bool) + and math.isfinite(scale) and scale > 0 + ): + return float(scale) + return None + + +def _needs_focus( + summary: dict[str, Any] | None, + channel: int, + expected_frequencies: dict[int, float], +) -> bool: + if channel in expected_frequencies and summary is None: + return True + if summary is None: + return False + cycles = summary.get("estimated_cycles") + if isinstance(cycles, (int, float)) and (cycles < 5.0 or cycles > 25.0): + return True + points_per_cycle = summary.get("points_per_cycle") + if isinstance(points_per_cycle, (int, float)) and points_per_cycle < 20.0: + return True + return bool(summary.get("quality_warnings")) + + +def _focus_reason( + summary: dict[str, Any] | None, + frequency_hz: float | None, + frequency_source: str | None, + frequency_confidence: str | None, + *, + target_cycles: float, +) -> str: + parts: list[str] = [] + if frequency_hz is not None: + parts.append( + f"use {frequency_source} frequency {frequency_hz:.6g} Hz " + f"(confidence={frequency_confidence}) to show about {target_cycles:.3g} cycles" + ) + if summary is not None: + cycles = summary.get("estimated_cycles") + if isinstance(cycles, (int, float)): + parts.append(f"current window contains about {cycles:.3g} cycles") + points = summary.get("points_per_cycle") + if isinstance(points, (int, float)): + parts.append(f"current sampling density is about {points:.3g} points/cycle") + return "; ".join(parts) if parts else "focus the selected channel for visual inspection" + + +def _frequency_span(profiles: dict[int, dict[str, Any]]) -> dict[str, Any] | None: + values = [ + (channel, profile["frequency_hz"]) + for channel, profile in profiles.items() + if isinstance(profile.get("frequency_hz"), (int, float)) and profile["frequency_hz"] > 0 + ] + if len(values) < 2: + return None + low_channel, low = min(values, key=lambda item: item[1]) + high_channel, high = max(values, key=lambda item: item[1]) + return { + "low_channel": low_channel, + "low_hz": low, + "high_channel": high_channel, + "high_hz": high, + "ratio_high_over_low": float(high / low), + } + + +def _command_recommendation( + recommendation_id: str, + priority: str, + channel: int, + reason: str, + command: str, + parameters: dict[str, Any], +) -> dict[str, Any]: + return { + "id": recommendation_id, + "priority": priority, + "channel": channel, + "action": f"scope.{command}", + "reason": reason, + "command": _command_text(command, parameters), + "parameters": parameters, + "mutates_instrument_if_applied": True, + "raw_scpi": False, + } + + +def _command_text(command: str, parameters: dict[str, Any]) -> str: + if command == "display": + return ( + "wavebench scope display " + f"--channel {parameters['channel']} {parameters['state']}" + ) + pieces = ["wavebench", "scope", "focus", "--channel", str(parameters["channel"])] + if parameters.get("time_range_s") is not None: + pieces.extend(["--time-range", f"{parameters['time_range_s']:.12g}"]) + if parameters.get("vertical_scale_v_per_div") is not None: + pieces.extend([ + "--vertical-scale", + f"{parameters['channel']}={parameters['vertical_scale_v_per_div']:.12g}", + ]) + if parameters.get("hide_other_channels"): + pieces.append("--hide-others") + return " ".join(pieces) + + +def _agent_hints( + observation: dict[str, Any], + recommendations: list[dict[str, Any]], +) -> list[str]: + hints = list(observation.get("agent_hints", [])) + if any(item["id"] == "advice_unavailable" for item in recommendations): + hints.append("advise: insufficient evidence is not a recommendation to keep current settings") + if any(item["id"] == "separate_timebase_profiles" for item in recommendations): + hints.append("advise: run focus/observe per channel when frequencies differ greatly") + if any(item["id"] == "timebase_advice_withheld" for item in recommendations): + hints.append( + "advise: timebase advice withheld for at least one channel because the measured frequency " + "is low confidence and no expected frequency was provided" + ) + if observation.get("mutates_instrument"): + hints.append("advise: recommendations were computed from an explicit waveform read and were not applied") + else: + hints.append("advise: recommendations were computed without reading waveforms or changing instrument state") + return hints diff --git a/src/wavebench/services/agent_observe.py b/src/wavebench/services/agent_observe.py new file mode 100644 index 00000000..c0a3b796 --- /dev/null +++ b/src/wavebench/services/agent_observe.py @@ -0,0 +1,479 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from pathlib import Path +from typing import Any + +from wavebench.config import load_config +from wavebench.data.expectations import ( + evaluate_waveform_expectation, + expectation_summary, + validate_expectation, +) +from wavebench.data.relationships import analyze_waveform_relationships +from wavebench.errors import ( + ConfigError, ConnectionError, DataError, SessionHealthError, TransportIOError, WaveBenchError, +) +from wavebench.instruments.models import WaveformData +from wavebench.logging import CommandLogger +from wavebench.services.scope_service import ScopeService +from wavebench.transport.session import SessionHealth + +# 读取波形可能造成的仪器状态影响。读取前不恢复原采集状态,调用方必须先确认。 +_WAVEFORM_STATE_EFFECTS = [ + "a running acquisition may be stopped", + "waveform transfer source/mode/format/points may be changed", + "some drivers may enable the requested analog channel display before fetching", + "the previous acquisition run state is not restored", + "the instrument error queue may be consumed when check_errors is enabled", +] + + +@dataclass +class _ObservationExecution: + service: ScopeService + stop_reason: str | None = None + mutation_block_reason: str | None = None + + def io_failed(self, exc: Exception) -> None: + state = self.service.session_state + if ( + isinstance(exc, (SessionHealthError, TransportIOError, ConnectionError, OSError)) + or (state is not None and state.health is not SessionHealth.HEALTHY) + or not isinstance(exc, (ConfigError, DataError)) + ): + self.stop_reason = f"instrument I/O stopped after {type(exc).__name__}: {exc}" + + def check_health(self) -> None: + state = self.service.session_state + if state is not None and state.health is not SessionHealth.HEALTHY: + self.stop_reason = f"instrument I/O stopped because session health is {state.health.value}" + + +def scope_observe_payload( + *, + config_path: str | Path, + channel: int | None = None, + channels: tuple[int, ...] | None = None, + allow_50ohm: bool = False, + resource: str | None = None, +) -> dict[str, Any]: + """通过受限查询执行身份、可用 V2 快照和高阻安全判断。 + + 该函数不读取波形,观察阶段限制为文本查询;V2 快照以 descriptor 的 pure-read 合同为准。 + 插件属于可信本地代码,guard 的 I/O 限制不替代插件查询语义的合同验证。 + 需要波形、期望值检查或多通道关系时,请使用 ``scope_waveform_report_payload`` + (对应显式 CLI 命令 ``wavebench scope observe --fetch-waveform``)。 + """ + return _build_observation( + config_path=config_path, + channel=channel, + channels=channels, + allow_50ohm=allow_50ohm, + resource=resource, + fetch_waveform=False, + expectations=None, + ) + + +def scope_waveform_report_payload( + *, + config_path: str | Path, + channel: int | None = None, + channels: tuple[int, ...] | None = None, + allow_50ohm: bool = False, + expectations: dict[int, dict[str, Any]] | None = None, + resource: str | None = None, +) -> dict[str, Any]: + """显式读取波形并给出摘要、期望值检查和多通道关系。 + + 读取波形属于写操作:可能停止正在运行的采集、修改波形传输参数并打开通道显示。 + 所有通道和采集参数都在采集写入之前完成预检;型号相关通道验证可能需要只读查询。 + """ + normalized_expectations = _normalize_expectations(expectations) + return _build_observation( + config_path=config_path, + channel=channel, + channels=channels, + allow_50ohm=allow_50ohm, + resource=resource, + fetch_waveform=True, + expectations=normalized_expectations, + ) + + +def _build_observation( + *, + config_path: str | Path, + channel: int | None, + channels: tuple[int, ...] | None, + allow_50ohm: bool, + resource: str | None, + fetch_waveform: bool, + expectations: dict[int, dict[str, Any]] | None, +) -> dict[str, Any]: + config = load_config(config_path) + if resource: + config = config.with_resource(resource) + if not fetch_waveform and config.scope.access == "read_write": + config = replace(config, scope=replace(config.scope, access="read_only")) + observed_channels = _scope_channels( + channel=channel, + channels=channels, + default_channel=config.scope.default_channel, + ) + unknown_expectation_channels = sorted( + item for item in (expectations or {}) if item not in observed_channels + ) + if unknown_expectation_channels: + raise ConfigError( + "scope observe expectation channels must be observed channels: " + f"{', '.join(str(item) for item in unknown_expectation_channels)}" + ) + service = ScopeService(config=config, logger=CommandLogger()) + sections: dict[str, Any] = {} + warnings: list[str] = [] + fetched_waveforms: dict[int, WaveformData] = {} + expectation_results: dict[int, dict[str, Any]] = {} + execution = _ObservationExecution(service) + channel_sections: list[dict[str, Any]] = [] + access_validation = _attempt( + service.validate_observation_access, warnings=warnings, name="observation_access", + ) + if access_validation["status"] != "ok": + execution.stop_reason = "observation access/capability validation failed before session open" + if fetch_waveform: + validation = _attempt( + service.validate_observation_fetch, warnings=warnings, name="waveform_validation", + ) + if validation["status"] != "ok": + execution.stop_reason = "waveform configuration/access/capability validation failed before session open" + + def collect() -> None: + with service.session_context(observation=True): + sections["identity"] = _attempt( + lambda: {"idn": service.observation_identity()}, + warnings=warnings, name="identity", execution=execution, io=True, + ) + if fetch_waveform and execution.mutation_block_reason is None: + preflight = _attempt( + lambda: service.preflight_observation_fetch( + observed_channels, allow_50ohm=allow_50ohm, + ), + warnings=warnings, name="waveform_preflight", execution=execution, io=True, + ) + if preflight["status"] != "ok": + execution.mutation_block_reason = "all-channel waveform preflight failed" + for observed_channel in observed_channels: + channel_sections.append(observe_channel(observed_channel)) + + def observe_channel(observed_channel: int) -> dict[str, Any]: + return _observe_channel( + service, observed_channel, fetch_waveform=fetch_waveform, + allow_50ohm=allow_50ohm, warnings=warnings, + fetched_waveforms=fetched_waveforms, expectations=expectations or {}, + expectation_results=expectation_results, execution=execution, + ) + + lifecycle = _attempt(collect, warnings=warnings, name="session", execution=execution, io=True) + if lifecycle["status"] != "ok": + execution.stop_reason = "observation session unavailable or close failed" + sections["session"] = lifecycle + sections.setdefault("identity", lifecycle) + for observed_channel in observed_channels[len(channel_sections):]: + channel_sections.append(observe_channel(observed_channel)) + first_channel = channel_sections[0] + sections["scope_status"] = first_channel["scope_status"] + sections["coupling"] = first_channel["coupling"] + + payload: dict[str, Any] = { + "status": "ok" if not warnings else "partial", + "read_only": not fetch_waveform, + "query_only": not fetch_waveform, + "mutates_instrument": fetch_waveform, + "raw_scpi": False, + "instrument_state_effects": list(_WAVEFORM_STATE_EFFECTS) if fetch_waveform else [], + "config": { + "path": str(config.source_path), + "scope_driver": config.scope.driver, + "resource": config.connection.resource, + "backend": config.connection.backend, + "default_channel": config.scope.default_channel, + "waveform_points": config.waveform.points, + }, + "observation": { + "instrument": "scope", + "channel": observed_channels[0], + "channels": list(observed_channels), + "fetch_waveform": fetch_waveform, + "allow_50ohm": allow_50ohm, + }, + **sections, + "channels": channel_sections, + "warnings": warnings, + } + if fetch_waveform: + # 共享 session/lease 不能证明各通道来自同一次 acquisition。 + payload["waveform_source"] = { + "same_acquisition": False, + "reason": "channels are fetched channel-by-channel, not in one acquisition", + } + payload["relationships"] = [] + if len(fetched_waveforms) >= 2: + relationships = _attempt( + lambda: analyze_waveform_relationships(fetched_waveforms, same_acquisition=False), + warnings=warnings, name="relationships", + ) + if relationships["status"] == "ok": + payload["relationships"] = relationships["data"] + payload["expectations"] = expectation_summary(expectation_results) + payload["agent_hints"] = _agent_hints( + sections, + warnings, + channel_sections=channel_sections, + fetched_waveforms=fetched_waveforms, + expectation_results=expectation_results, + fetch_waveform=fetch_waveform, + ) + payload["status"] = "ok" if not warnings else "partial" + return payload + + +def _scope_channels( + *, + channel: int | None, + channels: tuple[int, ...] | None, + default_channel: int, +) -> tuple[int, ...]: + if channel is not None and channels is not None: + raise ConfigError("scope observe accepts either channel or channels, not both") + candidates = channels if channels is not None else (default_channel if channel is None else channel,) + if not candidates: + raise ConfigError("scope observe channels must not be empty") + for candidate in candidates: + if isinstance(candidate, bool) or not isinstance(candidate, int) or candidate < 1: + raise ConfigError("scope observe channel must be a positive integer") + if len(set(candidates)) != len(candidates): + raise ConfigError("scope observe channels must be unique") + return candidates + + +def _normalize_expectations( + expectations: dict[int, dict[str, Any]] | None, +) -> dict[int, dict[str, Any]]: + if expectations is None: + return {} + normalized: dict[int, dict[str, Any]] = {} + for channel, expectation in expectations.items(): + if isinstance(channel, bool) or not isinstance(channel, int) or channel < 1: + raise ConfigError("scope observe expectation channel must be a positive integer") + normalized[channel] = validate_expectation(expectation) + return normalized + + +def _observe_channel( + service: ScopeService, + channel: int, + *, + fetch_waveform: bool, + allow_50ohm: bool, + warnings: list[str], + fetched_waveforms: dict[int, WaveformData], + expectations: dict[int, dict[str, Any]], + expectation_results: dict[int, dict[str, Any]], + execution: _ObservationExecution, +) -> dict[str, Any]: + section: dict[str, Any] = { + "channel": channel, + "scope_status": _attempt( + lambda: service.observation_status(channel=channel), + warnings=warnings, + name=f"ch{channel}_scope_status", + execution=execution, io=True, + ), + "coupling": _attempt( + lambda: _coupling_payload(service, channel, allow_50ohm=allow_50ohm), + warnings=warnings, + name=f"ch{channel}_coupling", + execution=execution, io=True, + ), + } + if not fetch_waveform: + return section + section["waveform"] = _attempt( + lambda: _waveform_payload( + service, + channel, + allow_50ohm=allow_50ohm, + fetched_waveforms=fetched_waveforms, + execution=execution, + ), + warnings=warnings, + name=f"ch{channel}_waveform", + execution=execution, mutation=True, + ) + if channel in expectations and section["waveform"]["status"] == "ok": + result = _attempt( + lambda: evaluate_waveform_expectation( + fetched_waveforms[channel], + expectations[channel], + ), + warnings=warnings, + name=f"ch{channel}_expectation", + ) + section["expectation"] = result + elif channel in expectations: + section["expectation"] = { + "status": "unavailable", + "reason": "waveform unavailable", + } + warnings.append(f"ch{channel}_expectation_unavailable: waveform unavailable") + if channel in expectations: + result = section["expectation"] + expectation_results[channel] = ( + result["data"] if result["status"] == "ok" + else {**result, "channel": channel, "checks": []} + ) + return section + + +def _attempt( + call, *, warnings: list[str], name: str, + execution: _ObservationExecution | None = None, io: bool = False, mutation: bool = False, +) -> dict[str, Any]: + if execution is not None: + execution.check_health() + reason = execution.stop_reason or (execution.mutation_block_reason if mutation else None) + if reason and (io or mutation): + warnings.append(f"{name}_skipped: {reason}") + return {"status": "skipped", "reason": reason} + try: + data = call() + if execution is not None: + execution.check_health() + return {"status": "ok", "data": data} + except WaveBenchError as exc: + if execution is not None and io: + execution.io_failed(exc) + warnings.append(f"{name}_unavailable: {exc}") + return { + "status": "unavailable", + "error": {"type": type(exc).__name__, "message": str(exc)}, + } + except Exception as exc: + if execution is not None and io: + execution.io_failed(exc) + warnings.append(f"{name}_unavailable: {type(exc).__name__}: {exc}") + return { + "status": "unavailable", + "error": {"type": type(exc).__name__, "message": str(exc)}, + } + + +def _coupling_payload( + service: ScopeService, + channel: int, + *, + allow_50ohm: bool, +) -> dict[str, Any]: + return service.observation_input_safety(channel, allow_50ohm=allow_50ohm) + + +def _waveform_payload( + service: ScopeService, + channel: int, + *, + allow_50ohm: bool, + fetched_waveforms: dict[int, WaveformData], + execution: _ObservationExecution, +) -> dict[str, Any]: + try: + service.observation_input_safety(channel, allow_50ohm=allow_50ohm) + execution.check_health() + if execution.stop_reason: + raise ConfigError(execution.stop_reason) + waveform = service.fetch_waveform(channel=channel) + except Exception as exc: + execution.io_failed(exc) + if isinstance(exc, ConfigError): + execution.mutation_block_reason = f"waveform mutation stopped after ConfigError: {exc}" + raise + summary = waveform.summary( + expected_frequency_hz=service.config.waveform.expected_frequency_hz, + frequency_tolerance_ratio=service.config.waveform.frequency_tolerance_ratio, + ) + fetched_waveforms[channel] = waveform + return { + "channel": channel, + "summary": summary, + "raw_samples_included": False, + } + + +def _agent_hints( + sections: dict[str, Any], + warnings: list[str], + *, + channel_sections: list[dict[str, Any]], + fetched_waveforms: dict[int, WaveformData], + expectation_results: dict[int, dict[str, Any]], + fetch_waveform: bool, +) -> list[str]: + hints: list[str] = [] + if not fetch_waveform: + hints.append( + "read-only observation: waveforms were not read; use `wavebench scope observe --fetch-waveform` " + "when waveform summaries, expectations or multi-channel relationships are required" + ) + for channel_section in channel_sections: + waveform = channel_section.get("waveform", {}) + if waveform.get("status") != "ok": + continue + channel = channel_section.get("channel") + summary = waveform.get("data", {}).get("summary", {}) + for warning in summary.get("quality_warnings", []) or []: + hints.append(f"CH{channel}_waveform_quality_warning: {warning}") + cycles = summary.get("estimated_cycles") + if isinstance(cycles, (int, float)) and cycles < 5: + hints.append(f"CH{channel}: consider capturing a wider time window for robust periodic analysis") + if len(fetched_waveforms) >= 2: + hints.append( + "waveforms were read channel-by-channel, so they are not from one acquisition; " + "timing relationships (phase/correlation/intersections) were skipped. " + "Use `wavebench scope capture --channel ... --synchronized` for driver-proven single-acquisition capture" + ) + frequencies = _trusted_frequencies(fetched_waveforms) + if len(frequencies) >= 2 and min(frequencies) > 0 and max(frequencies) / min(frequencies) > 10: + hints.append( + "multi_channel_frequency_span_large: use separate time windows/profiles before judging waveform shape across channels" + ) + for channel, result in sorted(expectation_results.items()): + if result["status"] in {"warn", "fail"}: + hints.append(f"CH{channel}_expectation_{result['status']}: inspect expectation checks") + elif result["status"] == "skipped": + hints.append(f"CH{channel}_expectation_skipped: expectation contains no checkable metric") + elif result["status"] == "unavailable": + hints.append(f"CH{channel}_expectation_unavailable: acceptance could not be evaluated") + if sections.get("scope_status", {}).get("status") == "unavailable": + hints.append("pure-query V2 snapshot is unavailable; identity alone cannot assess scope settings") + if sections.get("coupling", {}).get("status") == "unavailable": + hints.append("do not run capture until input coupling safety is confirmed") + if warnings: + hints.append("treat this observation as partial and avoid state-changing actions") + return hints + + +def _trusted_frequencies(waveforms: dict[int, WaveformData]) -> list[float]: + frequencies: list[float] = [] + for waveform in waveforms.values(): + summary = waveform.summary() + value = summary.get("frequency_estimate_hz") + if not isinstance(value, (int, float)) or value <= 0: + continue + if any( + str(item).startswith("low_cycle_count") + for item in summary.get("quality_warnings", []) or [] + ): + continue + frequencies.append(float(value)) + return frequencies diff --git a/src/wavebench/services/scope_service.py b/src/wavebench/services/scope_service.py index a0d655e5..3c755041 100644 --- a/src/wavebench/services/scope_service.py +++ b/src/wavebench/services/scope_service.py @@ -6,7 +6,7 @@ import time import traceback from collections.abc import Iterator, Mapping -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from dataclasses import asdict, dataclass from pathlib import Path from typing import Any, TypeVar, cast @@ -14,9 +14,11 @@ import numpy as np -from wavebench.config import WaveBenchConfig +from wavebench.config import WaveBenchConfig, normalize_waveform_points from wavebench.data.package import new_package_dir -from wavebench.errors import ConfigError, DataError, SessionHealthError, WaveBenchError +from wavebench.errors import ( + ConfigError, DataError, SessionCloseError, SessionHealthError, WaveBenchError, +) from wavebench.instruments.api import InstrumentDescriptor, ScopeCouplingPolicy from wavebench.instruments.capabilities import require_capabilities from wavebench.instruments.contracts import ( @@ -61,6 +63,7 @@ WaveformData, ) from wavebench.instruments.registry import resolve_instrument_descriptor +from wavebench.instruments.scope_extension_capabilities import validate_scope_descriptor from wavebench.instruments.scope_extensions import ( ErrorCheckSpec, ScopeContinuousAcquisitionRequest, @@ -339,7 +342,7 @@ def _verify_scope_identity(self, scope: ScopeDriver, *, spec: OperationSpec) -> coordinator.complete_verification(authorization) return identity - def _open_scope(self) -> ScopeDriver: + def _open_scope(self, *, defer_driver_io: bool = False) -> ScopeDriver: self._prepare_session_open("scope") if self.lease is None: self.lease = ResourceLease( @@ -360,6 +363,7 @@ def _open_scope(self) -> ScopeDriver: options=getattr(self.config.scope, "options", {}), access=getattr(self.config.scope, "access", "read_write"), lease=self.lease, + force_deferred_io=defer_driver_io, ) self.descriptor = opened.descriptor self.transport = opened.transport @@ -374,15 +378,168 @@ def open_session(self) -> ScopeDriver: return self._open_scope() @contextmanager - def _scope_session(self) -> Iterator[ScopeDriver]: + def session_context(self, *, observation: bool = False) -> Iterator[ScopeService]: + """Retain one epoch and lease; borrowed sessions remain owned by their caller. + + Observation-owned sessions defer constructor I/O until factory validation. + """ + if self.session is not None: - yield self.session + if isinstance(self.transport, GuardedAuditedTransport) and self.transport.session_state is not self.session_state: + raise ConfigError("scope service and guarded transport require shared instrument session state") + lock = self.session_state.transaction_lock if self.session_state else nullcontext() + with lock: + yield self return - scope = self._open_scope() + owned_lease = self.lease is None or not self.lease.acquired + scope = self._open_scope(defer_driver_io=True) if observation else self._open_scope() + self.session = scope + body_error: BaseException | None = None try: - yield scope + lock = self.session_state.transaction_lock if self.session_state else nullcontext() + with lock: + yield self + except BaseException as exc: + body_error = exc + raise finally: - scope.close() + failures: list[tuple[str, BaseException]] = [] + # A plugin close may fail before delegating to its core transport. + # The guard's close is idempotent and invalidates the epoch first. + for component, close in ( + ("driver", scope.close), + ("transport", getattr(self.transport, "close", None)), + ("state", getattr(self.session_state, "close", None)), + ("lease", self.lease.release if owned_lease and self.lease else None), + ): + if callable(close): + try: + close() + except BaseException as exc: + failures.append((component, exc)) + self.session = None + if failures: + error = SessionCloseError(failures) + if body_error is not None: + body_error.add_note(str(error)) + else: + raise error from failures[0][1] + + @contextmanager + def _scope_session(self) -> Iterator[ScopeDriver]: + with self.session_context(): + assert self.session is not None + yield self.session + + def _observation_state(self) -> InstrumentSessionState: + state = self.session_state + if ( + not isinstance(self.transport, GuardedAuditedTransport) + or state is None + or self.transport.session_state is not state + ): + raise ConfigError("scope observation requires a core guarded session") + if self.transport.lease is None or not self.transport.lease.acquired: + raise ConfigError("scope observation requires an exclusive resource lease") + if state.health is not SessionHealth.HEALTHY: + raise SessionHealthError( + "scope observation requires a healthy session", + health=state.health.value, + io_kind="observation_preflight", + epoch_id=state.epoch_id, + ) + return state + + @contextmanager + def observation_queries(self, operation: str, *, max_queries: int = 128) -> Iterator[None]: + """Gate public transport I/O to text queries on the retained healthy epoch. + + This is a transport authorization boundary, not a Python plugin sandbox. + Legacy snapshots have no pure-read field contract and are not used here. + """ + + state = self._observation_state() + timeout_ms = self.config.connection.timeout_ms + with SessionTransactionCoordinator(state).authorize_normal( + operation_id=operation, + allowed_io=("query",), + fields=("scope.observation",), + timeout_ms=timeout_ms, + max_steps=max_queries, + context_id="scope_observation", + correlation_id=uuid4().hex, + phase="query", + absolute_deadline=time.monotonic() + timeout_ms / 1000.0, + ): + yield + + def observation_identity(self) -> str: + spec = self._require("scope.idn", "scope.idn") + with self._scope_session() as scope: + state = self._observation_state() + if "scope.identity" not in state.verified_fields: + return self._verify_scope_identity(scope, spec=spec) + with self.observation_queries("scope.observation.identity", max_queries=1): + return scope.idn() + + def observation_status(self, channel: int) -> dict[str, Any]: + """Read a descriptor's pure-query V2 snapshot without consuming error queues.""" + + with self._scope_session(): + self._observation_state() + return asdict(self.snapshot_v2(channel)) + + def observation_input_safety(self, channel: int, *, allow_50ohm: bool = False) -> dict[str, Any]: + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, expected_kind="scope", + ) + with self._scope_session(): + with self.observation_queries("scope.observation.input_safety"): + if "scope.channel_input_state_v2" in descriptor.capabilities: + state = assert_scope_input_state_safe( + self.channel_input_state_v2(channel), allow_50ohm=allow_50ohm, + ) + return { + "channel": channel, "coupling": state.coupling, + "termination": state.termination, "accepted_for_capture": True, + } + coupling = self.require_high_impedance(channel, allow_50ohm=allow_50ohm) + return {"channel": channel, "coupling": coupling, "accepted_for_capture": True} + + def validate_observation_access(self) -> None: + """Reject disabled access and missing identity support before any factory runs.""" + + self._require("scope.idn", "scope.idn") + + def validate_observation_fetch(self) -> None: + """Reject all offline-invalid fetch settings before report mutation starts.""" + + self._waveform_fetch_spec() + self.validate_observation_access() + descriptor = self.descriptor or resolve_instrument_descriptor( + self.config.scope.driver, expected_kind="scope", + ) + validate_scope_descriptor(descriptor) + if not {"scope.channel_input_state_v2", "scope.channel_coupling"} & set(descriptor.capabilities): + raise ConfigError("scope waveform observation has no safe query-only channel verifier") + profile = self._waveform_binary_profile() + if profile is not None: + try: + profile.operation_for("fetch") + except ValueError as exc: + raise ConfigError(str(exc)) from exc + + def preflight_observation_fetch(self, channels: tuple[int, ...], *, allow_50ohm: bool = False) -> None: + self.validate_observation_fetch() + # Standard waveform profiles do not declare an operation-specific channel + # range. Validate every requested channel through the guarded input query; + # unrelated display/focus profiles cannot prove fetch support. + with self._scope_session() as scope: + for channel in channels: + self.observation_input_safety(channel, allow_50ohm=allow_50ohm) + if self._waveform_binary_profile() is not None: + self._bounded_waveform_executor(scope) + self._session_preflight("scope.fetch_waveform", scope) def idn(self) -> str: spec = self._require("scope.idn", "scope.idn") @@ -1192,11 +1349,12 @@ def autoscale(self) -> None: check_errors=self.config.autoscale.check_errors, ) - def fetch_waveform(self, channel: int) -> WaveformData: + def _waveform_fetch_spec(self) -> OperationSpec: if self.config.waveform.format.lower() != "real": raise ConfigError("MVP-1 only supports waveform.format = 'real'") if self.config.waveform.byte_order.lower() != "lsbf": raise ConfigError("MVP-1 only supports waveform.byte_order = 'lsbf'") + normalize_waveform_points(self.config.waveform.points) bounded_profile = self._waveform_binary_profile() required = ["scope.fetch_waveform"] if bounded_profile is not None: @@ -1205,7 +1363,11 @@ def fetch_waveform(self, channel: int) -> WaveformData: required.append("scope.error_drain_v1") elif self.config.scope.check_errors: required.append("scope.errors") - self._require("scope.fetch_waveform", *required) + return self._require("scope.fetch_waveform", *required) + + def fetch_waveform(self, channel: int) -> WaveformData: + self._waveform_fetch_spec() + bounded_profile = self._waveform_binary_profile() with self._scope_session() as scope: if bounded_profile is not None: result = self._bounded_waveform_executor(scope).fetch( diff --git a/tests/source_v2_fixtures.py b/tests/source_v2_fixtures.py index 9056e221..527955ed 100644 --- a/tests/source_v2_fixtures.py +++ b/tests/source_v2_fixtures.py @@ -45,6 +45,25 @@ SourceWaveformKind, SupportState, ) +from wavebench.transport.contracts import ReplayPolicy + + +class TextTransport: + def __init__(self, resource: str) -> None: + self.resource = resource + + def record_event(self, direction: str, text: str) -> None: + del direction, text + + def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: + del command, replay + return "ok" + + def write(self, command: str) -> None: + del command + + def close(self) -> None: + pass def missing() -> Observed[object]: diff --git a/tests/test_advisor_run_binding.py b/tests/test_advisor_run_binding.py new file mode 100644 index 00000000..06409988 --- /dev/null +++ b/tests/test_advisor_run_binding.py @@ -0,0 +1,345 @@ +from dataclasses import FrozenInstanceError +from hashlib import sha256 +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import time + +import pytest + +from wavebench.errors import error_envelope +from wavebench.services import advisor_run_binding as binding +from wavebench.services.advisor_run_binding import ( + BindingSource, RunBindingError, capture_run_binding, verify_run_binding, +) + + +@pytest.fixture +def run_dir(tmp_path): + root = tmp_path / "run" + root.mkdir() + (root / "run.json").write_text( + '{"status": "completed", "steps": [], "experiment": {"name": "示例"}}', + encoding="utf-8", + ) + return root + + +def capture(root, sources=(), **kwargs): + return capture_run_binding( + root, sources=sources, task_id="capture-triage", task_version="1", **kwargs, + ) + + +def symlink(link, target, *, directory=False): + try: + link.symlink_to(target, target_is_directory=directory) + except OSError: + pytest.skip("symlink creation unavailable on this host") + + +def test_digest_covers_directory_task_and_exact_bytes(run_dir): + raw = (run_dir / "run.json").read_bytes() + (run_dir / "quality.json").write_text('{"warnings": ["low_cycle_count"]}') + source = BindingSource("quality.json") + snapshot = capture(run_dir, [source]) + expected = { + "schema": "wavebench.advisor.run_binding.v1", + "run_root": str(run_dir.resolve()), "task_id": "capture-triage", "task_version": "1", + "sources": [ + {"path": "quality.json", "state": "present", + "sha256": sha256((run_dir / "quality.json").read_bytes()).hexdigest()}, + {"path": "run.json", "state": "present", "sha256": sha256(raw).hexdigest()}, + ], + } + assert snapshot.as_dict() == expected + encoded = json.dumps(expected, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + assert snapshot.binding_sha256 == sha256(encoded.encode()).hexdigest() + assert snapshot.bytes_read == len(raw) + len((run_dir / "quality.json").read_bytes()) + assert snapshot.elapsed_s > 0 + assert (run_dir / "run.json").read_bytes() == raw + for kwargs in ({"task_id": "other", "task_version": "1"}, + {"task_id": "capture-triage", "task_version": "2"}): + assert capture_run_binding(run_dir, sources=[source], **kwargs).binding_sha256 != snapshot.binding_sha256 + + +def test_snapshot_parsing_never_reopens_or_mutates_sources(run_dir): + snapshot = capture(run_dir) + original = snapshot.read_bytes("run.json") + snapshot.read_json("run.json")["steps"].append({"untrusted": True}) + snapshot.as_dict()["sources"].clear() + (run_dir / "run.json").write_text('{"steps": ["changed"]}') + assert snapshot.read_bytes("run.json") == original + assert snapshot.read_json("run.json")["steps"] == [] + assert len(snapshot.as_dict()["sources"]) == 1 + with pytest.raises(FrozenInstanceError): + snapshot.task_id = "changed" + with pytest.raises(KeyError): + snapshot.read_bytes("not-consumed.json") + + +def test_changed_input_retains_both_digests_and_original_snapshot(run_dir): + snapshot = capture(run_dir) + old = snapshot.binding_sha256 + (run_dir / "run.json").write_text('{"steps": [], "status": "failed"}') + with pytest.raises(RunBindingError) as caught: + verify_run_binding(snapshot) + payload = error_envelope(caught.value) + assert payload["code"] == "run_binding_changed" + assert payload["details"]["paths"] == ["run.json"] + assert payload["details"]["expected_digest"] == old + assert payload["details"]["actual_digest"] != old + assert snapshot.read_json("run.json")["status"] == "completed" + + +def test_equivalent_root_and_manifest_order_are_stable(run_dir, monkeypatch): + monkeypatch.chdir(run_dir.parent) + sources = [BindingSource("b.json", optional=True), BindingSource("a.json", optional=True)] + absolute = capture(run_dir, sources) + relative = capture(Path("run") / ".", reversed(sources)) + assert absolute.binding_sha256 == relative.binding_sha256 + assert absolute.read_bytes("a.json") is None + assert absolute.read_json("a.json") is None + + +def test_same_name_in_another_directory_is_not_the_same_binding(run_dir, tmp_path): + original = capture(run_dir) + copy = tmp_path / "elsewhere" / "run" + shutil.copytree(run_dir, copy) + assert capture(copy).binding_sha256 != original.binding_sha256 + moved = tmp_path / "moved" + copy.rename(moved) + assert capture(moved).binding_sha256 != original.binding_sha256 + + +def test_root_alias_retarget_is_detected_even_with_identical_content(run_dir, tmp_path): + other = tmp_path / "other" + shutil.copytree(run_dir, other) + alias = tmp_path / "alias" + symlink(alias, run_dir, directory=True) + snapshot = capture(alias) + assert snapshot.binding_sha256 == capture(run_dir).binding_sha256 + alias.unlink() + symlink(alias, other, directory=True) + with pytest.raises(RunBindingError) as caught: + verify_run_binding(snapshot) + assert caught.value.code == "run_binding_changed" + + +@pytest.mark.parametrize("initially_present", [False, True]) +def test_optional_file_presence_is_evidence(run_dir, initially_present): + path = run_dir / "optional.json" + if initially_present: + path.write_text("{}") + snapshot = capture(run_dir, [BindingSource(path.name, optional=True)]) + if initially_present: + path.unlink() + else: + path.write_text("{}") + with pytest.raises(RunBindingError) as caught: + verify_run_binding(snapshot) + assert caught.value.code == "run_binding_changed" + assert caught.value.paths == (path.name,) + + +def test_irrelevant_files_mtime_and_identical_replacement_do_not_invalidate(run_dir): + snapshot = capture(run_dir) + path = run_dir / "run.json" + os.utime(path, (1, 1)) + (run_dir / "decisions").mkdir() + (run_dir / "decisions" / "result.json").write_text("{}") + (run_dir / "report.html").write_text("changed") + with (run_dir / "large-waveform.npy").open("wb") as output: + output.truncate(1024**3) + replacement = run_dir / "replacement" + replacement.write_bytes(snapshot.read_bytes("run.json")) + os.replace(replacement, path) + actual = verify_run_binding(snapshot) + assert actual.binding_sha256 == snapshot.binding_sha256 + assert actual.bytes_read == snapshot.bytes_read + + +@pytest.mark.parametrize("raw", [b"[]", b"null", b'{"x":1,"x":2}', + b'{"a":{"x":1,"x":2}}', b'{"x":NaN}', + b'{"x":Infinity}', b'{"x":1e999}', b'\xff', b'{']) +def test_invalid_run_json_is_rejected(run_dir, raw): + (run_dir / "run.json").write_bytes(raw) + with pytest.raises(RunBindingError) as caught: + capture(run_dir) + assert caught.value.reason in {"invalid_json", "run_must_be_object"} + assert caught.value.paths == ("run.json",) + + +@pytest.mark.parametrize("path", ["../outside.json", "/absolute.json", "a/../x.json", "./x", + "a//b", "a\\b", "C:/x", "CON", "", "a\x00b", + "decisions/request.json", "DECISIONS/request.json"]) +def test_unsafe_paths_are_rejected_before_starting_worker(run_dir, path, monkeypatch): + def forbidden(*args, **kwargs): + pytest.fail("invalid manifest started a worker") + monkeypatch.setattr(binding.subprocess, "run", forbidden) + with pytest.raises(RunBindingError): + capture(run_dir, [BindingSource(path, optional=True)]) + + +def test_symlinks_cannot_escape_or_include_audit_output(run_dir, tmp_path): + outside = tmp_path / "outside.json" + outside.write_text('{"private":"do-not-read"}') + alias = run_dir / "alias.json" + symlink(alias, outside) + with pytest.raises(RunBindingError) as caught: + capture(run_dir, [BindingSource(alias.name)]) + assert "do-not-read" not in str(error_envelope(caught.value)) + alias.unlink() + (run_dir / "decisions").mkdir() + target = run_dir / "decisions" / "request.json" + target.write_text("{}") + symlink(alias, target) + with pytest.raises(RunBindingError, match="audit_source_forbidden"): + capture(run_dir, [BindingSource(alias.name)]) + + +@pytest.mark.parametrize("optional", [False, True]) +def test_directory_is_not_a_missing_file(run_dir, optional): + (run_dir / "subdir").mkdir() + with pytest.raises(RunBindingError, match="source_not_regular"): + capture(run_dir, [BindingSource("subdir", optional=optional)]) + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="POSIX FIFO") +def test_fifo_is_rejected_without_blocking(run_dir): + os.mkfifo(run_dir / "source.json") + with pytest.raises(RunBindingError, match="source_not_regular"): + capture(run_dir, [BindingSource("source.json")], timeout_s=2) + + +def test_unreadable_optional_source_is_not_absence(run_dir, monkeypatch): + original = Path.stat + def denied(path, *args, **kwargs): + if path.name == "optional.json": + raise PermissionError("private host path") + return original(path, *args, **kwargs) + monkeypatch.setattr(Path, "stat", denied) + with pytest.raises(RunBindingError) as caught: + binding._read_snapshot(str(run_dir), (BindingSource("optional.json", optional=True),)) + assert caught.value.reason == "source_unreadable" + assert "private host path" not in str(error_envelope(caught.value)) + + +def test_missing_required_source_cannot_revalidate(run_dir): + snapshot = capture(run_dir) + (run_dir / "run.json").unlink() + with pytest.raises(RunBindingError) as caught: + verify_run_binding(snapshot) + assert caught.value.code == "run_binding_unverifiable" + assert caught.value.expected_digest == snapshot.binding_sha256 + assert caught.value.actual_digest is None + + +def test_count_limit_includes_run_and_missing_sources(run_dir): + sources = [BindingSource(f"missing/{i}.json", optional=True) for i in range(31)] + assert len(capture(run_dir, sources).as_dict()["sources"]) == 32 + with pytest.raises(RunBindingError, match="source_count_limit"): + capture(run_dir, sources + [BindingSource("extra.json", optional=True)]) + with pytest.raises(RunBindingError, match="duplicate_source"): + capture(run_dir, [BindingSource("run.json")]) + + +def test_cumulative_byte_limit_exact_boundary_and_one_byte_over(run_dir): + (run_dir / "run.json").write_bytes(b"{}") + source = run_dir / "notes.txt" + source.write_bytes(b"x" * (binding.MAX_SOURCE_BYTES - 2)) + snapshot = capture(run_dir, [BindingSource("notes.txt")]) + assert snapshot.bytes_read == binding.MAX_SOURCE_BYTES + with source.open("ab") as output: + output.write(b"x") + with pytest.raises(RunBindingError, match="source_bytes_limit"): + verify_run_binding(snapshot) + + +@pytest.mark.parametrize("grow", [False, True]) +def test_changes_during_read_and_growth_past_budget_are_rejected(run_dir, monkeypatch, grow): + path = run_dir / "source.json" + path.write_bytes(b"123") + before = path.stat() + fdopen = os.fdopen + class ChangingReader: + def __init__(self, handle): + self.handle = handle + self.changed = False + def __enter__(self): + return self + def __exit__(self, *args): + self.handle.close() + def fileno(self): + return self.handle.fileno() + def read(self, n): + if not self.changed: + self.changed = True + path.write_bytes(b"1234" if grow else b"456") + # Equal-size writes may share a timestamp on Windows; make the + # metadata change observable without relying on clock resolution. + os.utime(path, ns=(before.st_atime_ns, before.st_mtime_ns + 2_000_000_000)) + return self.handle.read(n) + monkeypatch.setattr(os, "fdopen", lambda fd, mode: ChangingReader(fdopen(fd, mode))) + reason = "source_bytes_limit" if grow else "source_changed_during_read" + with pytest.raises(RunBindingError, match=reason): + binding._read_source(run_dir.resolve(), BindingSource(path.name), 3) + + +@pytest.mark.parametrize("timeout", [0, -1, True, float("nan"), float("inf"), "30"]) +def test_invalid_timeout_rejected(run_dir, timeout): + with pytest.raises(RunBindingError, match="invalid_timeout"): + capture(run_dir, timeout_s=timeout) + + +def test_timeout_kills_and_reaps_a_stalled_worker(run_dir, monkeypatch): + run = subprocess.run + popen = subprocess.Popen + children = [] + def tracked(*args, **kwargs): + child = popen(*args, **kwargs) + children.append(child) + return child + def stalled(command, **kwargs): + return run([sys.executable, "-c", "import time; time.sleep(60)"], **kwargs) + monkeypatch.setattr(subprocess, "Popen", tracked) + monkeypatch.setattr(subprocess, "run", stalled) + started = time.monotonic() + with pytest.raises(RunBindingError) as caught: + capture(run_dir, timeout_s=0.1) + assert caught.value.code == "timeout" + assert time.monotonic() - started < 5 + assert len(children) == 1 and children[0].poll() is not None + + +@pytest.mark.parametrize("returncode,stdout", [(1, b""), (0, b"not JSON"), (0, b"{}")]) +def test_worker_failure_is_structured_without_stderr_leak(run_dir, monkeypatch, returncode, stdout): + monkeypatch.setattr(subprocess, "run", lambda *a, **k: subprocess.CompletedProcess( + a[0], returncode, stdout, b"unrelated-private-data", + )) + with pytest.raises(RunBindingError) as caught: + capture(run_dir) + assert "unrelated-private-data" not in str(error_envelope(caught.value)) + assert caught.value.code == "run_binding_unverifiable" + + +def test_cancelled_worker_does_not_return_a_snapshot(run_dir, monkeypatch): + def interrupted(*args, **kwargs): + raise KeyboardInterrupt + monkeypatch.setattr(subprocess, "run", interrupted) + with pytest.raises(RunBindingError) as caught: + capture(run_dir) + assert caught.value.code == "cancelled" + + +@pytest.mark.parametrize("sources", [[], [BindingSource("new.json", optional=True)]]) +def test_rebuilt_manifest_must_match_authorized_sources(run_dir, sources): + snapshot = capture(run_dir, [BindingSource("old.json", optional=True)]) + with pytest.raises(RunBindingError) as caught: + verify_run_binding(snapshot, sources=sources) + assert caught.value.code == "run_binding_changed" + assert "old.json" in caught.value.paths + assert caught.value.expected_digest == snapshot.binding_sha256 diff --git a/tests/test_agent_advise.py b/tests/test_agent_advise.py new file mode 100644 index 00000000..9ec150e2 --- /dev/null +++ b/tests/test_agent_advise.py @@ -0,0 +1,344 @@ +import shlex +from contextlib import nullcontext +from dataclasses import asdict +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +import pytest + +from wavebench.cli import _scope_focus_request +from wavebench.cli_parser import build_parser +from wavebench.errors import ConfigError +from wavebench.services.agent_advise import ( + _command_text, + scope_advise_from_observation, + scope_advise_payload, +) + + +def _write_config(root: Path) -> Path: + path = root / "wavebench.toml" + path.write_text( + """ +[connection] +resource = "TCPIP::scope::INSTR" + +[scope] +driver = "ds1104" +default_channel = 1 +""", + encoding="utf-8", + ) + return path + + +class _FakeScopeService: + def __init__(self, *, config, logger): + self.config = config + self.session_state = None + + def session_context(self, **kwargs): + return nullcontext(self) + + def validate_observation_access(self): + pass + + def idn(self): + return "RIGOL TECHNOLOGIES,DS1104Z Plus,123,1.0" + + def status(self, channel): + return _snapshot(channel) + + def require_high_impedance(self, channel, *, allow_50ohm=False): + return "DC" + + def observation_identity(self): + return self.idn() + + def observation_status(self, channel): + return asdict(self.status(channel)) + + def observation_input_safety(self, channel, *, allow_50ohm=False): + return {"channel": channel, + "coupling": self.require_high_impedance(channel, allow_50ohm=allow_50ohm), + "accepted_for_capture": True} + + +def _snapshot(channel: int): + from wavebench.instruments.models import ( + ScopeAnalogChannelSnapshot, + ScopeEdgeTriggerSnapshot, + ScopeHealthSnapshot, + ScopeIdentitySnapshot, + ScopeProbeSnapshot, + ScopeSnapshot, + ScopeTimebaseSnapshot, + ScopeWaveformMetadataSnapshot, + ) + + return ScopeSnapshot( + identity=ScopeIdentitySnapshot("RIGOL", "DS1104Z", "123", "1.0", ()), + health=ScopeHealthSnapshot(0, 0, 0, 1, 1, 1_000_000.0, False, False), + channel=ScopeAnalogChannelSnapshot( + channel, True, "DC", 8.0, 1.0, 0.0, 0.0, None, "NORM", 0.0, "", False, False, "SAMPLE" + ), + timebase=ScopeTimebaseSnapshot(0.001, 12, 0.0, 0.0012, 50.0, 0.0001, False), + probe=ScopeProbeSnapshot(channel, 10.0, None, None, 1_000_000.0, "P10", "PASSIVE"), + waveform=ScopeWaveformMetadataSnapshot( + channel, -0.0005, 0.0005, 1000, 1, 1e-6, -0.0005, 0.001, 0.0, 8 + ), + trigger=ScopeEdgeTriggerSnapshot("EDGE", channel, "AUTO", "POS", "DC", 0.0, "AUTO", "OFF", 1e-6), + ) + + +def _observation(*, fetch_waveform: bool, measured_frequency: dict[int, float] | None = None) -> dict: + def channel_section(channel: int, frequency: float | None, warnings: list[str]) -> dict: + section = { + "channel": channel, + "scope_status": { + "status": "ok", + "data": {"channel": {"enabled": True, "scale_v_per_div": 1.0}}, + }, + "coupling": {"status": "ok", "data": {"channel": channel, "coupling": "DC", "accepted_for_capture": True}}, + } + if frequency is not None: + section["waveform"] = { + "status": "ok", + "data": { + "summary": { + "frequency_estimate_hz": frequency, + "estimated_cycles": 2.4 if warnings else 120.0, + "points_per_cycle": 500.0, + "voltage_vpp_v": 1.0, + "quality_warnings": warnings, + } + }, + } + return section + + measured = measured_frequency or {} + channels = [ + channel_section( + 1, + measured.get(1), + ["low_cycle_count: 2.4"] if 1 in measured and len(measured) == 1 else [], + ), + channel_section(2, measured.get(2), []), + ] + return { + "status": "ok", + "read_only": not fetch_waveform, + "query_only": not fetch_waveform, + "mutates_instrument": fetch_waveform, + "raw_scpi": False, + "instrument_state_effects": ["a running acquisition may be stopped"] if fetch_waveform else [], + "observation": {"channel": 1, "channels": [1, 2], "fetch_waveform": fetch_waveform}, + "channels": channels, + "relationships": [], + "warnings": [], + "agent_hints": [], + } + + +def test_scope_advise_payload_is_read_only_and_uses_expected_frequencies(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService): + payload = scope_advise_payload( + config_path=config, + channels=(1, 2), + expected_frequencies_hz={1: 1000.0, 2: 50000.0}, + ) + + assert payload["read_only"] is True + assert payload["query_only"] is True + assert payload["mutates_instrument"] is False + assert payload["applies_recommendations"] is False + assert payload["instrument_state_effects"] == [] + focus = [item for item in payload["recommendations"] if item["id"] == "focus_channel"] + assert [item["channel"] for item in focus] == [1, 2] + assert focus[0]["parameters"]["time_range_s"] == pytest.approx(0.01) + assert focus[0]["parameters"]["frequency_confidence"] == "configured" + assert focus[1]["parameters"]["time_range_s"] == pytest.approx(0.0002) + span = payload["recommendations"][-1] + assert span["id"] == "separate_timebase_profiles" + assert span["frequency_span"]["ratio_high_over_low"] == pytest.approx(50.0) + + +def test_scope_advise_prefers_expected_frequency_over_low_confidence_measurement(): + observation = _observation(fetch_waveform=True, measured_frequency={1: 1000.0}) + + payload = scope_advise_from_observation( + observation, + expected_frequencies_hz={1: 2000.0}, + ) + + focus = [item for item in payload["recommendations"] if item["id"] == "focus_channel"] + assert focus[0]["parameters"]["time_range_s"] == pytest.approx(10.0 / 2000.0) + assert ( + focus[0]["parameters"]["frequency_confidence"] == "configured" + ) + assert focus[0]["parameters"]["time_range_s"] is not None + + +def test_scope_advise_withholds_timebase_advice_when_measurement_is_low_confidence(): + observation = _observation(fetch_waveform=True, measured_frequency={1: 1000.0}) + + payload = scope_advise_from_observation(observation) + + # 低置信度测量频率没有期望频率可回退时,不给基于该频率的时基建议 + withheld = [item for item in payload["recommendations"] if item["id"] == "timebase_advice_withheld"] + assert len(withheld) == 1 + assert withheld[0]["channel"] == 1 + assert "low confidence" in withheld[0]["reason"] + # 只允许保留与频率无关的垂直档位建议,时基建议必须为空 + focus = [ + item + for item in payload["recommendations"] + if item["id"] == "focus_channel" and item["channel"] == 1 + ] + assert [item["parameters"]["time_range_s"] for item in focus] == [None] + assert any("timebase advice withheld" in hint for hint in payload["agent_hints"]) + + +def test_scope_advise_uses_trusted_measured_frequency_when_confidence_is_good(): + observation = _observation(fetch_waveform=True, measured_frequency={1: 1000.0, 2: 50000.0}) + + payload = scope_advise_from_observation(observation) + + focus = [item for item in payload["recommendations"] if item["id"] == "focus_channel"] + assert focus[0]["parameters"]["frequency_confidence"] == "measured" + assert focus[0]["parameters"]["time_range_s"] == pytest.approx(0.01) + + +@pytest.mark.parametrize("value", [0.0, -1.0, float("nan"), float("inf")]) +def test_scope_advise_rejects_invalid_targets(value): + with pytest.raises(ConfigError, match="target_cycles"): + scope_advise_payload(config_path="wavebench.toml", target_cycles=value) + + +@pytest.mark.parametrize("value", [0.0, -1.0, float("nan"), float("inf")]) +def test_scope_advise_rejects_invalid_expected_frequencies(value): + with pytest.raises(ConfigError, match="expected frequency"): + scope_advise_from_observation(_observation(fetch_waveform=False), expected_frequencies_hz={1: value}) + + +@pytest.mark.parametrize("fetch_waveform", [False, True]) +def test_advice_commands_parse_with_real_cli(fetch_waveform): + observation = _observation( + fetch_waveform=fetch_waveform, + measured_frequency={1: 1000.0, 2: 50000.0} if fetch_waveform else None, + ) + observation["channels"][0]["scope_status"]["data"]["channel"]["enabled"] = False + payload = scope_advise_from_observation( + observation, expected_frequencies_hz={1: 1000.0, 2: 50000.0}, + ) + parser = build_parser() + commands = [item for item in payload["recommendations"] if "command" in item] + assert len(commands) == 3 + for recommendation in commands: + args = parser.parse_args(shlex.split(recommendation["command"])[1:]) + parameters = recommendation["parameters"] + if recommendation["action"] == "scope.display": + assert args.channel == parameters["channel"] + assert args.state == "on" + else: + request = _scope_focus_request(args) + assert request.channels == (parameters["channel"],) + assert request.time_range_s == pytest.approx(parameters["time_range_s"]) + assert len(request.vertical_scales) == 1 + assert request.vertical_scales[0].channel == parameters["channel"] + assert request.vertical_scales[0].scale_v_per_div == pytest.approx( + parameters["vertical_scale_v_per_div"] + ) + assert request.hide_others is False + + +@pytest.mark.parametrize("hide_others", [False, True]) +def test_focus_command_hide_others_uses_real_cli_flag(hide_others): + command = _command_text("focus", { + "channel": 2, + "vertical_scale_v_per_div": 0.25, + "hide_other_channels": hide_others, + }) + args = build_parser().parse_args(shlex.split(command)[1:]) + request = _scope_focus_request(args) + + assert request.channels == (2,) + assert request.vertical_scales[0].scale_v_per_div == 0.25 + assert request.hide_others is hide_others + + +def test_advice_without_evidence_does_not_recommend_keeping_settings(): + observation = _observation(fetch_waveform=False) + observation["status"] = "partial" + for section in observation["channels"]: + section["scope_status"] = {"status": "unavailable", "reason": "snapshot unsupported"} + payload = scope_advise_from_observation(observation) + assert [item["id"] for item in payload["recommendations"]] == [ + "advice_unavailable", "advice_unavailable", + ] + assert [item["channel"] for item in payload["recommendations"]] == [1, 2] + assert all("command" not in item for item in payload["recommendations"]) + assert payload["status"] == "partial" + + +def test_unavailable_channel_does_not_hide_other_channel_advice(): + observation = _observation(fetch_waveform=False) + observation["status"] = "partial" + observation["channels"][1]["scope_status"] = {"status": "unavailable"} + payload = scope_advise_from_observation(observation) + assert [(item["id"], item["channel"]) for item in payload["recommendations"]] == [ + ("focus_channel", 1), ("advice_unavailable", 2), + ] + + +def test_unavailable_snapshot_data_is_not_reused_as_evidence(): + observation = _observation(fetch_waveform=False) + observation["channels"] = [observation["channels"][0]] + observation["channels"][0]["scope_status"]["status"] = "unavailable" + payload = scope_advise_from_observation(observation) + assert payload["recommendations"][0]["id"] == "advice_unavailable" + + +def test_identity_only_v2_snapshot_returns_unavailable_advice(): + from wavebench.instruments.models import ( + SCOPE_SNAPSHOT_V2_FIELD_ORDER, ScopeIdentitySnapshot, ScopeSnapshotV2, + ) + + snapshot = ScopeSnapshotV2( + identity=ScopeIdentitySnapshot("FAKE", "Scope", "OFFLINE", "1.0"), + unavailable_fields=tuple( + field for field in SCOPE_SNAPSHOT_V2_FIELD_ORDER if not field.startswith("identity.") + ), + ) + observation = _observation(fetch_waveform=False) + observation["channels"] = [observation["channels"][0]] + observation["channels"][0]["scope_status"]["data"] = asdict(snapshot) + payload = scope_advise_from_observation(observation) + assert payload["recommendations"][0]["id"] == "advice_unavailable" + + +def test_configured_frequency_can_support_advice_without_snapshot(): + observation = _observation(fetch_waveform=False) + observation["channels"] = [observation["channels"][0]] + observation["channels"][0]["scope_status"] = {"status": "unavailable"} + payload = scope_advise_from_observation(observation, expected_frequencies_hz={1: 1000.0}) + recommendation = payload["recommendations"][0] + assert recommendation["id"] == "focus_channel" + assert recommendation["parameters"]["time_range_s"] == pytest.approx(0.01) + assert recommendation["parameters"]["frequency_confidence"] == "configured" + + +@pytest.mark.parametrize("invalid", [float("nan"), float("inf"), -float("inf")]) +def test_nonfinite_metrics_are_not_usable_advice_evidence(invalid): + observation = _observation(fetch_waveform=True, measured_frequency={1: 1000.0}) + section = observation["channels"][0] + observation["channels"] = [section] + section["scope_status"]["data"]["channel"]["scale_v_per_div"] = invalid + section["waveform"]["data"]["summary"].update( + frequency_estimate_hz=invalid, voltage_vpp_v=invalid, quality_warnings=[], + ) + payload = scope_advise_from_observation(observation) + assert payload["recommendations"][0]["id"] == "advice_unavailable" diff --git a/tests/test_agent_observe.py b/tests/test_agent_observe.py new file mode 100644 index 00000000..ea224eb7 --- /dev/null +++ b/tests/test_agent_observe.py @@ -0,0 +1,330 @@ +from contextlib import contextmanager +from dataclasses import asdict +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +import numpy as np +import pytest + +from wavebench.errors import ConfigError +from wavebench.data.expectations import evaluate_waveform_expectation +from wavebench.instruments.models import ( + ScopeAnalogChannelSnapshot, + ScopeEdgeTriggerSnapshot, + ScopeHealthSnapshot, + ScopeIdentitySnapshot, + ScopeProbeSnapshot, + ScopeSnapshot, + ScopeTimebaseSnapshot, + ScopeWaveformMetadataSnapshot, + WaveformData, + WaveformHeader, +) +from wavebench.services.agent_observe import ( + scope_observe_payload, + scope_waveform_report_payload, +) + + +def _write_config(root: Path) -> Path: + path = root / "wavebench.toml" + path.write_text( + """ +[connection] +resource = "TCPIP::scope::INSTR" + +[scope] +driver = "ds1104" +default_channel = 1 + +[waveform] +points = "def" +""", + encoding="utf-8", + ) + return path + + +def _snapshot(channel: int) -> ScopeSnapshot: + return ScopeSnapshot( + identity=ScopeIdentitySnapshot("RIGOL", "DS1104Z", "123", "1.0", ()), + health=ScopeHealthSnapshot(0, 0, 0, 1, 1, 1_000_000.0, False, False), + channel=ScopeAnalogChannelSnapshot( + channel, + True, + "DC", + 8.0, + 1.0, + 0.0, + 0.0, + None, + "NORM", + 0.0, + "", + False, + False, + "SAMPLE", + ), + timebase=ScopeTimebaseSnapshot(0.001, 12, 0.0, 0.0012, 50.0, 0.0001, False), + probe=ScopeProbeSnapshot(channel, 10.0, None, None, 1_000_000.0, "P10", "PASSIVE"), + waveform=ScopeWaveformMetadataSnapshot( + channel, + -0.0005, + 0.0005, + 1000, + 1, + 1e-6, + -0.0005, + 0.001, + 0.0, + 8, + ), + trigger=ScopeEdgeTriggerSnapshot("EDGE", channel, "AUTO", "POS", "DC", 0.0, "AUTO", "OFF", 1e-6), + ) + + +class _FakeScopeService: + """记录是否真的读取过波形,用于断言只读路径不碰仪器写路径。""" + + instances: list["_FakeScopeService"] = [] + + def __init__(self, *, config, logger): + self.config = config + self.session_state = None + self.fetched_channels: list[int] = [] + self.allow_50ohm_seen: list[bool] = [] + _FakeScopeService.instances.append(self) + + def idn(self): + return "RIGOL TECHNOLOGIES,DS1104Z Plus,123,1.0" + + def status(self, channel): + return _snapshot(channel) + + @contextmanager + def session_context(self, *, observation=False): + yield self + + def observation_identity(self): + return self.idn() + + def observation_status(self, channel): + return asdict(self.status(channel)) + + def observation_input_safety(self, channel, *, allow_50ohm=False): + return { + "channel": channel, + "coupling": self.require_high_impedance(channel, allow_50ohm=allow_50ohm), + "accepted_for_capture": True, + } + + def validate_observation_fetch(self): + pass + + def validate_observation_access(self): + pass + + def preflight_observation_fetch(self, channels, *, allow_50ohm=False): + for channel in channels: + self.observation_input_safety(channel, allow_50ohm=allow_50ohm) + + def require_high_impedance(self, channel, *, allow_50ohm=False): + self.allow_50ohm_seen.append(allow_50ohm) + return "DC" + + def fetch_waveform(self, channel): + self.fetched_channels.append(channel) + times = np.linspace(0.0, 0.005, 2000) + return WaveformData( + channel=channel, + header=WaveformHeader(x_start=0.0, x_stop=0.005, points=2000), + voltages_v=np.sin(2 * np.pi * 1000 * times), + ) + + +@pytest.fixture(autouse=True) +def _reset_instances(): + _FakeScopeService.instances = [] + yield + _FakeScopeService.instances = [] + + +def test_scope_observe_payload_is_strictly_read_only(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService): + payload = scope_observe_payload(config_path=config, channel=2) + + assert payload["status"] == "ok" + assert payload["read_only"] is True + assert payload["query_only"] is True + assert payload["mutates_instrument"] is False + assert payload["raw_scpi"] is False + assert payload["instrument_state_effects"] == [] + assert payload["observation"]["channel"] == 2 + assert payload["observation"]["channels"] == [2] + assert payload["identity"]["data"]["idn"].startswith("RIGOL") + assert payload["scope_status"]["data"]["channel"]["channel"] == 2 + assert payload["coupling"]["data"]["accepted_for_capture"] is True + # 只读路径不得读取波形,也不得暴露波形/关系字段 + assert "waveform" not in payload["channels"][0] + assert "relationships" not in payload + assert "expectations" not in payload + assert _FakeScopeService.instances[0].fetched_channels == [] + assert any("read-only observation" in hint for hint in payload["agent_hints"]) + + +def test_scope_observe_payload_supports_multiple_channels(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService): + payload = scope_observe_payload(config_path=config, channels=(1, 2)) + + assert payload["observation"]["channel"] == 1 + assert payload["observation"]["channels"] == [1, 2] + assert [item["channel"] for item in payload["channels"]] == [1, 2] + + +def test_scope_observe_payload_passes_allow_50ohm_to_the_safety_check(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService): + scope_observe_payload(config_path=config, channel=1, allow_50ohm=True) + + assert _FakeScopeService.instances[0].allow_50ohm_seen == [True] + + +def test_scope_waveform_report_is_an_explicit_write_path(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService): + payload = scope_waveform_report_payload(config_path=config, channel=1) + + assert payload["read_only"] is False + assert payload["query_only"] is False + assert payload["mutates_instrument"] is True + assert payload["instrument_state_effects"] + assert any("acquisition may be stopped" in item for item in payload["instrument_state_effects"]) + assert payload["waveform_source"]["same_acquisition"] is False + assert payload["channels"][0]["waveform"]["data"]["summary"]["samples"] == 2000 + assert _FakeScopeService.instances[0].fetched_channels == [1] + assert payload["expectations"] == {"status": "skipped", "channels": {}} + + +def test_scope_waveform_report_marks_multi_channel_timing_analysis_as_skipped(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService): + payload = scope_waveform_report_payload(config_path=config, channels=(1, 2)) + + assert payload["waveform_source"]["same_acquisition"] is False + relationship = payload["relationships"][0] + assert relationship["channels"] == [1, 2] + assert relationship["correlation"] == {"status": "skipped", "reason": "not_same_acquisition"} + assert relationship["phase_degrees_at_left_frequency"] is None + assert any("not from one acquisition" in hint for hint in payload["agent_hints"]) + + +def test_scope_waveform_report_evaluates_channel_expectations(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService): + payload = scope_waveform_report_payload( + config_path=config, + channel=1, + expectations={1: {"frequency_hz": 1000.0, "frequency_tolerance_ratio": 0.05}}, + ) + + assert payload["expectations"]["status"] == "pass" + assert payload["channels"][0]["expectation"]["data"]["checks"][0]["metric"] == "frequency_hz" + + +@pytest.mark.parametrize("failed_channels", [(2,), (1, 2)]) +@pytest.mark.parametrize("failure", ["waveform", "coupling", "evaluation"]) +def test_scope_waveform_report_preserves_unavailable_expectations(failed_channels, failure): + class FailingScopeService(_FakeScopeService): + def fetch_waveform(self, channel): + if failure == "waveform" and channel in failed_channels: + raise ConfigError("waveform read failed") + return super().fetch_waveform(channel) + + def require_high_impedance(self, channel, *, allow_50ohm=False): + if failure == "coupling" and channel in failed_channels: + raise ConfigError("coupling safety unavailable") + return super().require_high_impedance(channel, allow_50ohm=allow_50ohm) + + def evaluate(waveform, expectation): + if failure == "evaluation" and waveform.channel in failed_channels: + raise ConfigError("expectation evaluation failed") + return evaluate_waveform_expectation(waveform, expectation) + + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with ( + patch("wavebench.services.agent_observe.ScopeService", FailingScopeService), + patch("wavebench.services.agent_observe.evaluate_waveform_expectation", evaluate), + ): + payload = scope_waveform_report_payload( + config_path=config, + channels=(1, 2), + expectations={1: {"vpp_v": 2.0}, 2: {"vpp_v": 2.0}}, + ) + + expected_statuses = { + str(channel): "unavailable" if channel in failed_channels else "pass" + for channel in (1, 2) + } + # Any unsafe channel blocks the whole report before acquisition writes. + # An in-fetch ConfigError conservatively stops later mutations too. + if failure == "coupling" or (failure == "waveform" and 1 in failed_channels): + expected_statuses = {"1": "unavailable", "2": "unavailable"} + assert payload["status"] == "partial" + assert payload["expectations"] == { + "status": "unavailable" if set(expected_statuses.values()) == {"unavailable"} else "partial", + "channels": expected_statuses, + } + for channel in failed_channels: + assert payload["channels"][channel - 1]["expectation"]["status"] == "unavailable" + assert any(f"CH{channel}_expectation_unavailable" in hint for hint in payload["agent_hints"]) + + +def test_scope_waveform_report_rejects_invalid_expectation_before_any_instrument_io(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService): + with pytest.raises(ConfigError, match="unknown expectation field"): + scope_waveform_report_payload( + config_path=config, + channel=1, + expectations={1: {"frequncy_hz": 1000}}, + ) + + # 校验失败时连 service 都不应创建,更不会产生仪器写入 + assert _FakeScopeService.instances == [] + + +def test_scope_waveform_report_rejects_expectation_for_unobserved_channel(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService): + with pytest.raises(ConfigError, match="expectation channels must be observed channels"): + scope_waveform_report_payload( + config_path=config, + channel=1, + expectations={2: {"frequency_hz": 1000.0}}, + ) + + +def test_scope_observe_rejects_invalid_channel(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with pytest.raises(ConfigError, match="positive integer"): + scope_observe_payload(config_path=config, channel=0) + + +def test_scope_observe_rejects_ambiguous_channel_arguments(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + with pytest.raises(ConfigError, match="either channel or channels"): + scope_observe_payload(config_path=config, channel=1, channels=(2,)) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7e429702..dab0b22b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -140,11 +140,6 @@ def test_dmm_read_accepts_function(self): self.assertEqual(args.command, "read") self.assertEqual(args.function, "dcv") - def test_dmm_profile_accepts_subcommand(self): - args = build_parser().parse_args(["dmm", "profile"]) - self.assertEqual(args.domain, "dmm") - self.assertEqual(args.command, "profile") - def test_dmm_profile_prints_stable_fields(self): class StubDmmService: def measurement_profile(self): @@ -321,12 +316,6 @@ def set_dcv_impedance(self, impedance): ["previous_impedance=10M", "impedance=10G", "range_code=2", "changed=true"], ) - def test_dmm_function_status_accepts_subcommand(self): - args = build_parser().parse_args(["dmm", "function", "status"]) - self.assertEqual(args.domain, "dmm") - self.assertEqual(args.command, "function") - self.assertEqual(args.dmm_function_command, "status") - def test_dmm_function_set_accepts_function(self): args = build_parser().parse_args(["dmm", "function", "set", "acv"]) self.assertEqual(args.domain, "dmm") @@ -659,25 +648,9 @@ def test_source_v2_commands_accept_explicit_channels(self): ] ) output = build_parser().parse_args(["source", "output-v2", "--channel", "2", "on"]) - counter_configure = build_parser().parse_args( - [ - "source", - "counter-configure-v2", - "--input-id", - "counter", - "--coupling", - "dc", - ] - ) - counter_enable = build_parser().parse_args( - ["source", "counter-enable-v2", "--input-id", "counter"] - ) counter_disable = build_parser().parse_args( ["source", "counter-disable-v2", "--input-id", "counter"] ) - counter_measure = build_parser().parse_args( - ["source", "counter-measure-v2", "--input-id", "counter"] - ) harmonics = build_parser().parse_args( [ "source", @@ -804,15 +777,8 @@ def test_source_v2_commands_accept_explicit_channels(self): self.assertEqual(output.command, "output-v2") self.assertEqual(output.channel, 2) self.assertEqual(output.state, "on") - self.assertEqual(counter_configure.command, "counter-configure-v2") - self.assertEqual(counter_configure.input_id, "counter") - self.assertEqual(counter_configure.coupling, "dc") - self.assertEqual(counter_enable.command, "counter-enable-v2") - self.assertEqual(counter_enable.input_id, "counter") self.assertEqual(counter_disable.command, "counter-disable-v2") self.assertEqual(counter_disable.input_id, "counter") - self.assertEqual(counter_measure.command, "counter-measure-v2") - self.assertEqual(counter_measure.input_id, "counter") self.assertEqual(harmonics.command, "harmonics-configure-v2") self.assertEqual(harmonics.channel, 2) self.assertEqual(harmonics.order, 8) diff --git a/tests/test_instrument_registry.py b/tests/test_instrument_registry.py index a55fdb68..96dbc631 100644 --- a/tests/test_instrument_registry.py +++ b/tests/test_instrument_registry.py @@ -6,6 +6,7 @@ from wavebench.instruments.api import DriverContext, InstrumentDescriptor, OptionSpec from wavebench.instruments.factory import open_instrument_driver from wavebench.instruments.registry import InstrumentRegistry, build_instrument_registry +from wavebench.plugins.package_inspect import _validate_wavebench_requirement from wavebench.logging import CommandLogger from wavebench.transport.guarded import GuardedAuditedTransport @@ -197,6 +198,37 @@ def test_registry_rejects_kind_version_and_builtin_override(): assert "conflicts with built-in" in override_result.load_errors[0].message +@pytest.mark.parametrize("current,compatible", [ + ("0.8.27", False), + ("0.8.28.dev1", False), + ("0.8.28rc1", False), + ("0.8.28", True), + ("0.8.29.dev1", True), + ("0.9.0rc1", False), + ("0.9.0", False), + ("0+unknown", False), +]) +def test_descriptor_and_package_use_same_version_bounds(monkeypatch, current, compatible): + monkeypatch.setattr("wavebench.instruments.registry.__version__", current) + monkeypatch.setattr("wavebench.plugins.package_inspect.__version__", current) + descriptor = make_descriptor(wavebench_min_version="0.8.28", wavebench_max_version="0.9") + registry = InstrumentRegistry(builtins=(descriptor,)) + if compatible: + assert registry.resolve("example.scope") == descriptor + _validate_wavebench_requirement(("wavebench>=0.8.28,<0.9",)) + else: + with pytest.raises(ConfigError, match="supports WaveBench"): + registry.resolve("example.scope") + with pytest.raises(ConfigError, match="plugin requires"): + _validate_wavebench_requirement(("wavebench>=0.8.28,<0.9",)) + + +def test_invalid_descriptor_version_is_actionable(): + registry = InstrumentRegistry(builtins=(make_descriptor(wavebench_min_version="invalid"),)) + with pytest.raises(ConfigError, match="invalid WaveBench compatibility version"): + registry.resolve("example.scope") + + def test_migration_canonical_prefers_external_while_alias_keeps_builtin_fallback(): entry_point = FakeEntryPoint("rigol.dg4202", make_external_dg4202_descriptor()) registry = InstrumentRegistry(external_entry_points=(entry_point,)) diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 43538327..e507c1d3 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -5,7 +5,9 @@ import urllib.error import urllib.request from pathlib import Path +from types import SimpleNamespace from tempfile import TemporaryDirectory +from unittest.mock import patch from wavebench import __version__ from wavebench.cli import build_parser @@ -162,10 +164,26 @@ def test_tools_lists_only_read_only_mvp_tools(self): self.assertEqual(status, 200) names = {tool["name"] for tool in payload["tools"]} - self.assertEqual(names, {"run.schema", "run.check", "capture.inspect"}) + self.assertEqual( + names, + { + "run.schema", + "run.check", + "capture.inspect", + "scope.observe", + "doctor.config", + "scope.advise", + }, + ) self.assertFalse(any("raw" in name.lower() for name in names)) self.assertFalse(any("output" in name.lower() for name in names)) self.assertFalse(any(name.lower().endswith((".on", ".off")) for name in names)) + by_name = {tool["name"]: tool for tool in payload["tools"]} + # 所有 MCP 工具都必须是纯只读:不改变仪器状态 + for name, tool in by_name.items(): + self.assertTrue(tool["read_only"], name) + self.assertFalse(tool["mutates_instrument"], name) + self.assertEqual(tool["instrument_state_effects"], [], name) def test_call_run_schema_succeeds(self): with TemporaryDirectory() as tmp: @@ -335,7 +353,17 @@ def test_mcp_jsonrpc_tools_list_and_call(self): ) self.assertEqual(status, 200) names = {tool["name"] for tool in listed["result"]["tools"]} - self.assertEqual(names, {"run.schema", "run.check", "capture.inspect"}) + self.assertEqual( + names, + { + "run.schema", + "run.check", + "capture.inspect", + "scope.observe", + "doctor.config", + "scope.advise", + }, + ) status, called = self._request( server, @@ -353,6 +381,130 @@ def test_mcp_jsonrpc_tools_list_and_call(self): self.assertEqual(called["result"]["structuredContent"]["status"], "ok") self.assertEqual(called["result"]["content"][0]["type"], "text") + def test_call_scope_observe_succeeds_with_structured_read_only_payload(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + config = self._write_config(root) + server = self._start_server(config) + + with patch( + "wavebench.mcp_http.scope_observe_payload", + return_value={ + "status": "ok", + "read_only": True, + "mutates_instrument": False, + "raw_scpi": False, + "observation": {"channel": 2, "channels": [2, 3]}, + }, + ) as observe: + status, payload = self._request( + server, + "POST", + "/call", + token="test-token", + body={ + "tool": "scope.observe", + "arguments": {"channels": [2, 3]}, + }, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["result"]["status"], "ok") + self.assertTrue(payload["result"]["read_only"]) + self.assertFalse(payload["result"]["mutates_instrument"]) + observe.assert_called_once() + self.assertIsNone(observe.call_args.kwargs["channel"]) + self.assertEqual(observe.call_args.kwargs["channels"], (2, 3)) + # 只读工具不接受任何波形或期望值参数 + self.assertNotIn("fetch_waveform", observe.call_args.kwargs) + self.assertNotIn("expectations", observe.call_args.kwargs) + + def test_call_scope_observe_rejects_non_integer_channel(self): + with TemporaryDirectory() as tmp: + server = self._start_server(self._write_config(Path(tmp))) + + with self.assertRaises(urllib.error.HTTPError) as caught: + self._request( + server, + "POST", + "/call", + token="test-token", + body={ + "tool": "scope.observe", + "arguments": {"channel": "1"}, + }, + ) + + self.assertEqual(caught.exception.code, 400) + + def test_call_scope_advise_succeeds_without_applying_recommendations(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + config = self._write_config(root) + server = self._start_server(config) + + with patch( + "wavebench.mcp_http.scope_advise_payload", + return_value={ + "status": "ok", + "read_only": True, + "mutates_instrument": False, + "raw_scpi": False, + "applies_recommendations": False, + "recommendations": [{"id": "focus_channel"}], + }, + ) as advise: + status, payload = self._request( + server, + "POST", + "/call", + token="test-token", + body={ + "tool": "scope.advise", + "arguments": { + "channels": [1, 2], + "target_cycles": 8, + "expected_frequencies_hz": {"1": 1000}, + }, + }, + ) + + self.assertEqual(status, 200) + self.assertFalse(payload["result"]["applies_recommendations"]) + advise.assert_called_once() + self.assertEqual(advise.call_args.kwargs["channels"], (1, 2)) + self.assertEqual(advise.call_args.kwargs["target_cycles"], 8.0) + self.assertEqual(advise.call_args.kwargs["expected_frequencies_hz"], {1: 1000.0}) + + def test_call_doctor_config_returns_structured_records(self): + with TemporaryDirectory() as tmp: + root = Path(tmp) + config = self._write_config(root) + server = self._start_server(config) + record = SimpleNamespace( + severity="ok", + target="scope", + driver="ds1104", + resource="TCPIP::scope::INSTR", + idn="RIGOL,DS1104Z,123,1.0", + message="reachable", + suggestion="", + ) + + with patch("wavebench.mcp_http.doctor_records", return_value=[record]): + status, payload = self._request( + server, + "POST", + "/call", + token="test-token", + body={"tool": "doctor.config", "arguments": {"timeout_ms": 1000}}, + ) + + self.assertEqual(status, 200) + self.assertEqual(payload["result"]["status"], "ok") + self.assertEqual(payload["result"]["records"][0]["target"], "scope") + self.assertFalse(payload["result"]["mutates_instrument"]) + def test_mcp_jsonrpc_requires_token(self): with TemporaryDirectory() as tmp: server = self._start_server(self._write_config(Path(tmp))) diff --git a/tests/test_plugin_lifecycle.py b/tests/test_plugin_lifecycle.py index f5bf4b5f..43731966 100644 --- a/tests/test_plugin_lifecycle.py +++ b/tests/test_plugin_lifecycle.py @@ -6,6 +6,7 @@ import io import json import os +import shutil from pathlib import Path import subprocess import sys @@ -25,7 +26,7 @@ def _run(command: list[str], *, check: bool = True) -> subprocess.CompletedProce def _target_venv(root: Path) -> Path: target = root / "venv" - venv.EnvBuilder(with_pip=True).create(target) + venv.EnvBuilder(with_pip=False).create(target) python = target / ("Scripts/python.exe" if os.name == "nt" else "bin/python") purelib = _run( [str(python), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"] @@ -377,18 +378,18 @@ def descriptor(): return path -def _raw_pip_install(python: Path, wheel: Path) -> None: +def _raw_uv_install(python: Path, wheel: Path) -> None: _run( [ - str(python), - "-I", - "-m", + shutil.which("uv"), + "--no-config", + "--offline", "pip", "install", - "--isolated", + "--python", + str(python), "--no-index", "--no-deps", - "--disable-pip-version-check", str(wheel), ] ) @@ -603,7 +604,7 @@ def test_dry_run_does_not_modify_target_venv(tmp_path): def test_dry_run_refuses_unmanaged_distribution_without_writing_state(tmp_path): python = _target_venv(tmp_path) wheel = _plugin_wheel(tmp_path, version="0.1.0") - _raw_pip_install(python, wheel) + _raw_uv_install(python, wheel) lifecycle = PluginLifecycle(python_executable=python) with pytest.raises(ConfigError, match="unmanaged"): @@ -978,10 +979,39 @@ def test_source_directory_install_keeps_built_wheel_alive_until_cached(tmp_path) assert lifecycle.remove("example.source-scope").status == "removed" +def test_missing_uv_rejects_install_before_writing_state(tmp_path, monkeypatch): + python = _target_venv(tmp_path) + wheel = _plugin_wheel(tmp_path, version="0.1.0") + lifecycle = PluginLifecycle(python_executable=python) + monkeypatch.setattr("wavebench.plugins.package_inspect.shutil.which", lambda name: None) + + with pytest.raises(ConfigError, match="uv is required"): + lifecycle.install(wheel) + + assert not lifecycle.state_dir.exists() + + +def test_failed_rollback_preserves_recovery_journal(tmp_path, monkeypatch): + python = _target_venv(tmp_path) + broken = _plugin_wheel(tmp_path, version="0.1.0", broken_descriptor=True) + lifecycle = PluginLifecycle(python_executable=python) + + def fail_uninstall(*args, **kwargs): + raise ConfigError("uv uninstall failed") + + monkeypatch.setattr(lifecycle, "_uninstall_distribution", fail_uninstall) + with pytest.raises(ConfigError, match="recovery required"): + lifecycle.install(broken) + + journal = json.loads(lifecycle.journal_path.read_text(encoding="utf-8")) + assert journal["stage"] == "rollback_started" + assert not lifecycle.ledger_path.exists() + + def test_install_refuses_to_take_over_unmanaged_distribution(tmp_path): python = _target_venv(tmp_path) wheel = _plugin_wheel(tmp_path, version="0.1.0") - _raw_pip_install(python, wheel) + _raw_uv_install(python, wheel) lifecycle = PluginLifecycle(python_executable=python) assert lifecycle.installed()[0].status == "unmanaged" @@ -998,7 +1028,7 @@ def test_install_refuses_file_owned_by_another_distribution(tmp_path): include_entry_point=False, ) target = _plugin_wheel(tmp_path, version="0.1.0") - _raw_pip_install(python, foreign) + _raw_uv_install(python, foreign) lifecycle = PluginLifecycle(python_executable=python) with pytest.raises(ConfigError, match="overlaps files"): @@ -1016,7 +1046,7 @@ def test_install_refuses_unreadable_distribution_ownership(tmp_path): include_entry_point=False, ) target = _plugin_wheel(tmp_path, version="0.1.0") - _raw_pip_install(python, foreign) + _raw_uv_install(python, foreign) purelib = Path( _run( [str(python), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"] @@ -1041,7 +1071,7 @@ def test_install_refuses_distribution_without_record(tmp_path): include_entry_point=False, ) target = _plugin_wheel(tmp_path, version="0.1.0") - _raw_pip_install(python, foreign) + _raw_uv_install(python, foreign) purelib = Path( _run( [str(python), "-c", "import sysconfig; print(sysconfig.get_paths()['purelib'])"] @@ -1067,7 +1097,7 @@ def test_remove_refuses_files_shared_with_out_of_band_distribution(tmp_path): distribution="foreign-scope-plugin", include_entry_point=False, ) - _raw_pip_install(python, foreign) + _raw_uv_install(python, foreign) installed = lifecycle.info("example.scope") assert installed.status == "broken" @@ -1107,15 +1137,63 @@ def test_out_of_band_change_is_reported_as_missing_or_drifted(tmp_path): lifecycle = PluginLifecycle(python_executable=python) lifecycle.install(v1) - _raw_pip_install(python, v2) + _raw_uv_install(python, v2) assert lifecycle.info("example.scope").status == "drifted" with pytest.raises(ConfigError, match="healthy"): lifecycle.remove("example.scope") - _run([str(python), "-I", "-m", "pip", "uninstall", "--yes", "wavebench-example-scope"]) + _run([shutil.which("uv"), "--no-config", "--offline", "pip", "uninstall", + "--python", str(python), "wavebench-example-scope"]) assert lifecycle.info("example.scope").status == "missing" +@pytest.mark.parametrize("rollback", ("complete", "interrupted", "tampered")) +def test_failed_upgrade_restores_a_plugin_originally_installed_by_pip(tmp_path, monkeypatch, rollback): + python = _target_venv(tmp_path) + _run([str(python), "-m", "ensurepip"]) + lifecycle = PluginLifecycle(python_executable=python) + original = _plugin_wheel(tmp_path, version="0.1.0") + broken = _plugin_wheel(tmp_path, version="0.2.0", broken_descriptor=True) + + def install_with_pip(wheel): + _run([str(python), "-I", "-m", "pip", "install", "--isolated", + "--no-index", "--no-deps", "--force-reinstall", str(wheel)]) + + with monkeypatch.context() as legacy: + legacy.setattr(lifecycle, "_install_wheel", install_with_pip) + lifecycle.install(original) + _run([shutil.which("uv"), "pip", "uninstall", "--python", str(python), "pip"]) + + if rollback == "complete": + with pytest.raises(ConfigError, match="plugin postflight failed"): + lifecycle.upgrade(broken) + else: + install = lifecycle._install_wheel + + def interrupt_rollback(wheel): + install(wheel) + if wheel.name == original.name: + if rollback == "tampered": + module = Path(lifecycle.environment().purelib) / "wavebench_example_scope/__init__.py" + module.write_text("raise RuntimeError('tampered')\n", encoding="utf-8") + raise KeyboardInterrupt + + monkeypatch.setattr(lifecycle, "_install_wheel", interrupt_rollback) + with pytest.raises(KeyboardInterrupt): + lifecycle.upgrade(broken) + if rollback == "tampered": + with pytest.raises(ConfigError, match="recovery required"): + lifecycle.recover() + assert lifecycle.journal_path.exists() + return + assert lifecycle.recover().status == "recovered-after-rollback" + + restored = lifecycle.info("example.scope") + assert restored.version == "0.1.0" + assert restored.status == "healthy" + assert not lifecycle.journal_path.exists() + + def test_same_version_file_tampering_is_reported_as_drifted(tmp_path): python = _target_venv(tmp_path) wheel = _plugin_wheel(tmp_path, version="0.1.0") @@ -1206,7 +1284,7 @@ def test_recover_commits_exact_desired_state_after_pip_finished(tmp_path): ) lifecycle.state_dir.mkdir(mode=0o700, exist_ok=True) lifecycle._write_json(lifecycle.journal_path, journal) - lifecycle._pip_install(cached) + lifecycle._install_wheel(cached) result = lifecycle.recover() @@ -1224,7 +1302,7 @@ def test_recover_refuses_unknown_partial_state(tmp_path): with lifecycle._inspected_input(wheel) as package: cached = lifecycle._cache_wheel(package) record = lifecycle._package_record(package, cached) - lifecycle._pip_install(cached) + lifecycle._install_wheel(cached) purelib = Path(environment.purelib) (purelib / "wavebench_example_scope/__init__.py").write_text( "# partial state\n", diff --git a/tests/test_plugin_package_inspect.py b/tests/test_plugin_package_inspect.py index b19add4d..4c154599 100644 --- a/tests/test_plugin_package_inspect.py +++ b/tests/test_plugin_package_inspect.py @@ -34,6 +34,9 @@ def test_subprocess_environment_preserves_required_windows_context(monkeypatch: monkeypatch.setenv("USERPROFILE", r"C:\\Users\\tester") monkeypatch.setenv("ComSpec", r"C:\\Windows\\System32\\cmd.exe") monkeypatch.setenv("PIP_CONFIG_FILE", "should-not-survive") + monkeypatch.setenv("UV_CONFIG_FILE", "should-not-survive") + monkeypatch.setenv("UV_PYTHON", "wrong-python") + monkeypatch.setenv("UV_SYSTEM_PYTHON", "1") environment = build_subprocess_environment() @@ -44,6 +47,7 @@ def test_subprocess_environment_preserves_required_windows_context(monkeypatch: assert environment["USERPROFILE"] == r"C:\\Users\\tester" assert environment["ComSpec"].endswith("cmd.exe") assert environment["PIP_CONFIG_FILE"] != "should-not-survive" + assert not any(key.startswith("UV_") for key in environment) assert environment["PYTHONNOUSERSITE"] == "1" diff --git a/tests/test_project_metadata.py b/tests/test_project_metadata.py deleted file mode 100644 index c0e36c17..00000000 --- a/tests/test_project_metadata.py +++ /dev/null @@ -1,14 +0,0 @@ -import tomllib -from pathlib import Path - - -PROJECT_ROOT = Path(__file__).resolve().parents[1] - - -def test_dev_toolchain_is_reproducible() -> None: - metadata = tomllib.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - dev_dependencies = metadata["project"]["optional-dependencies"]["dev"] - - assert "hatchling==1.30.1" in dev_dependencies - assert "ruff==0.15.20" in dev_dependencies - assert metadata["tool"]["ruff"]["lint"]["select"] == ["E4", "E7", "E9", "F"] diff --git a/tests/test_release_artifacts.py b/tests/test_release_artifacts.py index 056e33ad..dc6be1f5 100644 --- a/tests/test_release_artifacts.py +++ b/tests/test_release_artifacts.py @@ -1,4 +1,5 @@ import os +import runpy import subprocess import sys import sysconfig @@ -7,9 +8,12 @@ from pathlib import Path from zipfile import ZipFile +import pytest + PROJECT_ROOT = Path(__file__).resolve().parents[1] ISOLATED_UTF8_FLAGS = ("-I", "-X", "utf8") +check_release = runpy.run_path(str(PROJECT_ROOT / "scripts/check_release.py"))["check_release"] def _run(command: list[str], *, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: @@ -105,6 +109,7 @@ def _assert_offline_artifact_smoke(python: Path, purelib: Path) -> None: assert origin.is_relative_to(purelib) for command in ( + ("--version",), ("--help",), ("run", "schema"), ("run", "template", "--list"), @@ -120,7 +125,9 @@ def test_sdist_excludes_runtime_data_and_vendor_manuals(tmp_path: Path) -> None: relative_members = [member.partition("/")[2] for member in members] assert any("/docs/project/" in member for member in members) - assert not any(member.startswith("data/") for member in relative_members) + assert not any(member.startswith(("data/", "tool-of-rei/", ".venv", ".git/")) + for member in relative_members) + assert "wavebench.toml" not in relative_members assert not any("/docs/instruments/" in member for member in members) @@ -129,6 +136,26 @@ def test_wheel_and_sdist_install_into_clean_offline_runtime(tmp_path: Path) -> N artifacts.mkdir() wheel = _build_artifact(target="wheel", output_dir=artifacts) sdist = _build_artifact(target="sdist", output_dir=artifacts) + version = check_release(PROJECT_ROOT, artifacts) + project = tmp_path / "project" + notes = project / "docs/releases" / f"v{version}.md" + notes.parent.mkdir(parents=True) + notes.write_text(f"# WaveBench v{version} 发布说明\n", encoding="utf-8") + for filename in ("pyproject.toml", "uv.lock"): + (project / filename).write_bytes((PROJECT_ROOT / filename).read_bytes()) + (project / "CHANGELOG.md").write_text(f"[Release](docs/releases/v{version}.md)\n") + assert check_release(project, artifacts, f"v{version}") == version + with pytest.raises(ValueError, match="does not match"): + check_release(PROJECT_ROOT, artifacts, "v99.0.0") + notes.write_text("# Wrong version\n") + with pytest.raises(ValueError, match="heading"): + check_release(project, artifacts, f"v{version}") + (project / "uv.lock").write_text('[[package]]\nname = "wavebench"\nversion = "99.0"\n') + with pytest.raises(ValueError, match="uv.lock"): + check_release(project, artifacts) + (project / "pyproject.toml").write_text('[project]\nversion = "99.0"\n') + with pytest.raises(ValueError, match="distribution identity"): + check_release(project, artifacts) with ZipFile(wheel) as archive: members = set(archive.namelist()) @@ -141,3 +168,8 @@ def test_wheel_and_sdist_install_into_clean_offline_runtime(tmp_path: Path) -> N python, purelib = _prepare_artifact_venv(tmp_path / label) _install_artifact(python, artifact) _assert_offline_artifact_smoke(python, purelib) + + with ZipFile(wheel, "a") as archive: + archive.writestr("incorrect.dist-info/METADATA", "Name: wavebench\nVersion: 99\n") + with pytest.raises(ValueError, match="exactly one METADATA"): + check_release(PROJECT_ROOT, artifacts) diff --git a/tests/test_scope_observation_contracts.py b/tests/test_scope_observation_contracts.py new file mode 100644 index 00000000..28aa5125 --- /dev/null +++ b/tests/test_scope_observation_contracts.py @@ -0,0 +1,616 @@ +"""Offline observation contracts using real factory, guard, epochs and leases.""" +from __future__ import annotations + +from dataclasses import replace +from pathlib import Path +import subprocess +import sys + +import numpy as np +import pytest + +from wavebench.config import load_config +from wavebench.errors import ( + ConfigError, ConnectionError, DataError, ResourceBusyError, + SessionCloseError, SessionHealthError, TransportIOError, +) +from wavebench.instruments.api import InstrumentDescriptor +from wavebench.instruments.builtin import BUILTIN_INSTRUMENTS +from wavebench.instruments.models import ( + SCOPE_SNAPSHOT_V2_FIELD_ORDER, ScopeAnalogChannelSnapshotV2, + ScopeChannelInputStateV2, ScopeIdentitySnapshot, ScopeSnapshotV2, + WaveformData, WaveformHeader, +) +from wavebench.instruments.registry import InstrumentRegistry +from wavebench.instruments.scope_extensions import ( + ScopeDescriptorExtensions, ScopeSnapshotProfileV2, + ScopeWaveformBinaryOperationProfile, ScopeWaveformBinaryProfile, +) +from wavebench.logging import CommandLogger +from wavebench.services.agent_observe import scope_observe_payload, scope_waveform_report_payload +from wavebench.services.resource_lease import ResourceLease +from wavebench.services.scope_service import ScopeService +from wavebench.transport.contracts import ( + CommandTransmission, ReplayPolicy, ResponseProgress, Synchronization, TransportPhase, +) +from wavebench.transport.guarded import GuardedAuditedTransport +from wavebench.transport.session import SessionHealth + + +RESOURCE = "TCPIP::offline-scope-contract.invalid::INSTR" +FIELDS = ( + "identity.manufacturer", "identity.model", "identity.serial_number", + "identity.firmware", "identity.options", "channel.channel", "channel.coupling", +) + + +class AnalysisFailureWaveform(WaveformData): + def summary(self, *args, **kwargs): + raise DataError("deterministic waveform analysis failed") + + +class ConcreteTransport: + resource = RESOURCE + + def __init__(self, harness): + self.harness = harness + self.events = [] + + def query(self, command, *, replay=ReplayPolicy.NO_REPLAY): + assert replay is ReplayPolicy.NO_REPLAY + self.events.append(("query", command)) + if self.harness.query_failure: + raise OSError("connection lost during safety query") + if command == "*IDN?": + return "RIGOL TECHNOLOGIES,DS1104Z Plus,OFFLINE,1.0" + if "COUPLING?" in command: + channel = int(command.rsplit("CH", 1)[1]) + self.harness.input_reads.append(channel) + if len(self.harness.input_reads) == self.harness.input_failure_at: + raise OSError("connection lost during current input check") + if self.harness.compete: + self.harness.check_competitor() + if self.harness.change_input and len(self.harness.input_reads) >= 3: + return "DC" + return "DCL" + if command.endswith(":COUPling?"): + return "DC" + if command.startswith("STATUS?"): + return "DCL" + if command == ":WAVeform:PREamble?": + return "0,0,1000,1,0.00001,0,0,0.01,128,0" + if command == ":SYSTem:ERRor?": + return '0,"No error"' + raise AssertionError(f"unexpected query: {command}") + + def write(self, command): + self.events.append(("write", command)) + if command == "FETCH CH1" and self.harness.failure in {"uncertain", "poisoned", "oserror"}: + if self.harness.failure == "oserror": + raise OSError("connection lost after dispatch") + raise TransportIOError( + "waveform write result unknown", operation="write", phase=TransportPhase.SENDING, + replay_policy=ReplayPolicy.NO_REPLAY, command_transmission=CommandTransmission.UNKNOWN, + response_progress=ResponseProgress.NONE, attempts=1, + synchronization=(Synchronization.PROVEN if self.harness.failure == "uncertain" + else Synchronization.LOST), + ) + + def query_bin_block(self, command, *, replay=ReplayPolicy.NO_REPLAY): + self.events.append(("binary_query", command)) + return np.full(1000, 128, dtype=np.uint8).tobytes() + + def record_event(self, direction, text): + pass + + def close(self): + self.events.append(("close", "")) + if self.harness.backend_close_failure: + raise OSError("backend close failed") + + +class QueryScope: + def __init__(self, harness, transport): + self.harness = harness + self.transport = transport + + def idn(self): + return self.transport.query("*IDN?") + + def channel_coupling(self, channel): + if channel not in {1, 2}: + raise DataError("channel outside fake scope range") + if self.harness.mutating_input: + self.transport.write("FORBIDDEN INPUT WRITE") + return self.transport.query(f"COUPLING? CH{channel}") + + def get_channel_input_state_v2(self, channel): + self.channel_coupling(channel) + return ScopeChannelInputStateV2(channel, "dc", "unknown", unavailable_fields=("impedance_ohm",)) + + def get_snapshot(self, channel): + # Legacy snapshots can consume state through text queries. Observation + # must use the descriptor's V2 contract instead of this method. + self.transport.query(":SYSTem:ERRor?") + raise AssertionError("legacy snapshot must not run") + + def get_snapshot_v2(self, channel, *, fields): + assert fields == FIELDS + if self.harness.mutating_snapshot: + self.transport.write("FORBIDDEN SNAPSHOT WRITE") + self.idn() + coupling = self.transport.query(f"STATUS? CH{channel}") + return ScopeSnapshotV2( + identity=ScopeIdentitySnapshot("RIGOL", "DS1104Z Plus", "OFFLINE", "1.0", ()), + channel=ScopeAnalogChannelSnapshotV2(channel, coupling=coupling), + unavailable_fields=tuple(field for field in SCOPE_SNAPSHOT_V2_FIELD_ORDER if field not in FIELDS), + ) + + def fetch_waveform(self, *, channel, points, check_errors): + assert points.upper() == "DEF" and not check_errors + self.harness.fetch_health.append(self.transport.session_state.health) + if channel == 1: + if self.harness.failure == "session_health": + raise SessionHealthError( + "session unavailable", health="uncertain", io_kind="write", + epoch_id=self.transport.session_state.epoch_id, + ) + if self.harness.failure == "connection": + raise ConnectionError("connection lost outside transport") + if self.harness.failure == "config": + raise ConfigError("fetch configuration invalid") + if self.harness.failure == "data": + raise DataError("malformed waveform after completed query") + self.transport.write(f"FETCH CH{channel}") + if channel == 1 and self.harness.failure == "returned_poisoned": + self.transport.session_state.degrade(SessionHealth.POISONED, reason="fake_lost_sync") + waveform_type = AnalysisFailureWaveform if channel == 1 and self.harness.failure == "analysis" else WaveformData + return waveform_type( + channel, WaveformHeader(0.0, 0.01, 1000), + np.sin(np.linspace(0.0, 20 * np.pi, 1000)), + ) + + def close(self): + if self.harness.driver_close_failure: + raise OSError("plugin close failed before delegation") + self.transport.close() + + +class Harness: + def __init__(self, root: Path, monkeypatch): + self.root = root + self.monkeypatch = monkeypatch + self.transports = [] + self.guards = [] + self.constructor_calls = 0 + self.constructor_io = None + self.input_reads = [] + self.fetch_health = [] + self.failure = None + self.query_failure = False + self.input_failure_at = None + self.mutating_input = False + self.mutating_snapshot = False + self.change_input = False + self.compete = False + self.driver_close_failure = False + self.backend_close_failure = False + self.config_path = root / "scope.toml" + monkeypatch.setenv("WAVEBENCH_LEASE_DIR", str(root / "leases")) + monkeypatch.setattr("wavebench.instruments.factory._open_transport", self.open_transport) + self.install() + + def open_transport(self, **kwargs): + assert kwargs["resource"] == RESOURCE + transport = ConcreteTransport(self) + self.transports.append(transport) + return transport + + def factory(self, context): + self.constructor_calls += 1 + transport = context.open_transport() + assert isinstance(transport, GuardedAuditedTransport) + self.guards.append(transport) + if self.constructor_io == "write": + transport.write(":STOP") + elif self.constructor_io == "query": + transport.query("*IDN?") + elif self.constructor_io == "binary_query": + transport.query_bin_block(":WAVeform:DATA?") + return QueryScope(self, transport) + + def install(self, *, access="read_write", builtin=False, capabilities=None): + if builtin: + descriptor = next(item for item in BUILTIN_INSTRUMENTS if item.driver_id == "rigol.ds1104") + original_factory = descriptor.factory + + def factory(context): + driver = original_factory(context) + self.guards.append(driver.transport) + return driver + + descriptor = replace(descriptor, factory=factory) + else: + descriptor = InstrumentDescriptor( + driver_id="test.observation-scope", kind="scope", display_name="Offline scope", + manufacturer="RIGOL", models=("DS1104Z Plus",), aliases=(), + capabilities=capabilities or ( + "scope.idn", "scope.snapshot", "scope.snapshot_v2", + "scope.channel_coupling", "scope.fetch_waveform", + ), + idn_patterns=("RIGOL",), backends=("pyvisa",), option_specs=(), + permissions=("instrument.io",), factory=self.factory, + scope_coupling_policy="switchable-termination", wavebench_min_version="0.8.24", + scope_extensions=ScopeDescriptorExtensions( + snapshot_profile_v2=ScopeSnapshotProfileV2(FIELDS, max_queries=2), + ) if capabilities is None or "scope.snapshot_v2" in capabilities else None, + ) + self.descriptor = descriptor + registry = InstrumentRegistry(builtins=(descriptor,)) + self.monkeypatch.setattr("wavebench.instruments.registry.build_instrument_registry", lambda **kwargs: registry) + self.config_path.write_text( + f'[connection]\nresource = "{RESOURCE}"\nbackend = "pyvisa"\n' + f'[scope]\ndriver = "{descriptor.driver_id}"\naccess = "{access}"\ncheck_errors = false\n' + '[waveform]\npoints = "def"\n', encoding="utf-8", + ) + + @property + def writes(self): + return [command for transport in self.transports for kind, command in transport.events if kind == "write"] + + def report(self, channels=(1, 2)): + return scope_waveform_report_payload(config_path=self.config_path, channels=channels) + + def service(self): + return ScopeService(load_config(self.config_path), CommandLogger()) + + def check_competitor(self): + # A separate process exercises the actual OS lock rather than an + # in-process mock. It tries to take the lease while a safety query runs. + result = subprocess.run( + [sys.executable, "-c", ( + "from wavebench.services.resource_lease import ResourceLease\n" + "from wavebench.errors import ResourceBusyError\n" + f"lease = ResourceLease({RESOURCE!r})\n" + "try:\n lease.acquire()\n" + "except ResourceBusyError:\n print('busy')\n" + "else:\n lease.release()\n print('acquired')\n" + )], capture_output=True, text=True, timeout=20, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "busy" + + +@pytest.fixture +def harness(tmp_path, monkeypatch): + return Harness(tmp_path, monkeypatch) + + +def test_report_retains_one_epoch_and_lease_through_all_preflight_and_fetch(harness): + harness.compete = True + payload = harness.report() + assert payload["status"] == "ok" + assert len(harness.guards) == len(harness.transports) == 1 + events = harness.transports[0].events + assert events.index(("query", "COUPLING? CH2")) < events.index(("write", "FETCH CH1")) + assert harness.input_reads == [1, 2, 1, 1, 2, 2] + assert harness.fetch_health == [SessionHealth.HEALTHY, SessionHealth.HEALTHY] + assert harness.guards[0].session_state.health is SessionHealth.CLOSED + assert harness.guards[0].lease.acquired is False + assert payload["waveform_source"]["same_acquisition"] is False + assert payload["relationships"][0]["correlation"]["status"] == "skipped" + + +def test_builtin_ds1104_invalid_later_channel_has_zero_writes(harness): + harness.install(builtin=True) + payload = harness.report((1, 5)) + assert payload["status"] == "partial" + assert [item["waveform"]["status"] for item in payload["channels"]] == ["skipped", "skipped"] + assert harness.writes == [] + assert len(harness.guards) == 1 + + +def test_query_only_preflight_blocks_a_plugin_input_write(harness): + harness.mutating_input = True + payload = harness.report() + assert harness.guards[0].access == "read_write" + assert harness.writes == [] + assert all(item["waveform"]["status"] == "skipped" for item in payload["channels"]) + assert any("SessionHealthError" in warning for warning in payload["warnings"]) + + +@pytest.mark.parametrize("access", ["read_write", "read_only", "disabled"]) +def test_readonly_observation_tightens_access_and_uses_v2_query_phase(harness, access): + harness.install(access=access) + payload = scope_observe_payload(config_path=harness.config_path, channel=1) + assert harness.writes == [] + assert payload["read_only"] and payload["query_only"] + if access == "disabled": + assert harness.guards == harness.transports == [] + assert harness.constructor_calls == 0 + assert payload["scope_status"]["status"] == "skipped" + return + assert harness.guards[0].access == "read_only" + assert payload["scope_status"]["status"] == "ok" + assert not any(command == ":SYSTem:ERRor?" for kind, command in harness.transports[0].events) + + +def test_readonly_snapshot_plugin_write_is_blocked(harness): + harness.mutating_snapshot = True + payload = scope_observe_payload(config_path=harness.config_path) + assert payload["scope_status"]["status"] == "unavailable" + assert harness.writes == [] + assert harness.guards[0].counters.blocked_write_requests == 1 + + +def test_legacy_snapshot_is_not_used_without_a_pure_query_field_contract(harness): + harness.install(capabilities=("scope.idn", "scope.snapshot", "scope.channel_coupling", "scope.fetch_waveform")) + payload = scope_observe_payload(config_path=harness.config_path) + assert payload["status"] == "partial" + assert payload["scope_status"]["status"] == "unavailable" + assert payload["identity"]["status"] == payload["coupling"]["status"] == "ok" + assert harness.writes == [] + + +@pytest.mark.parametrize("failure", [ + "uncertain", "poisoned", "oserror", "session_health", "connection", "returned_poisoned", "config", +]) +def test_report_stops_after_uncertain_io_or_fetch_config_error_without_reopen(harness, failure): + harness.failure = failure + payload = harness.report() + assert payload["status"] == "partial" + assert payload["channels"][1]["waveform"]["status"] == "skipped" + assert payload["channels"][1]["waveform"]["reason"] + if failure != "returned_poisoned": + expected_type = { + "uncertain": "TransportIOError", "poisoned": "TransportIOError", "oserror": "OSError", + "session_health": "SessionHealthError", "connection": "ConnectionError", "config": "ConfigError", + }[failure] + assert payload["channels"][0]["waveform"]["error"]["type"] == expected_type + assert "FETCH CH2" not in harness.writes + assert len(harness.guards) == 1 + + +def test_definitive_data_failure_can_continue_on_the_same_healthy_epoch(harness): + harness.failure = "data" + payload = harness.report() + assert payload["channels"][0]["waveform"]["status"] == "unavailable" + assert payload["channels"][1]["waveform"]["status"] == "ok" + assert harness.writes == ["FETCH CH2"] + assert len(harness.guards) == 1 + + +def test_deterministic_analysis_failure_preserves_later_channel_and_expectations(harness): + harness.failure = "analysis" + payload = scope_waveform_report_payload( + config_path=harness.config_path, channels=(1, 2), + expectations={1: {"vpp_v": 2.0}, 2: {"vpp_v": 2.0}}, + ) + assert payload["status"] == "partial" + assert payload["channels"][0]["waveform"]["error"]["type"] == "DataError" + assert payload["channels"][1]["waveform"]["status"] == "ok" + assert payload["expectations"]["channels"] == {"1": "unavailable", "2": "pass"} + assert harness.writes == ["FETCH CH1", "FETCH CH2"] + assert len(harness.guards) == 1 + + +def test_later_input_uncertainty_preserves_first_waveform_and_stops_mutation(harness): + harness.input_failure_at = 5 + payload = harness.report() + assert payload["channels"][0]["waveform"]["status"] == "ok" + assert payload["channels"][1]["coupling"]["error"]["type"] == "OSError" + assert payload["channels"][1]["waveform"]["status"] == "skipped" + assert harness.writes == ["FETCH CH1"] + assert len(harness.guards) == 1 + + +def test_current_input_is_rechecked_before_fetch(harness): + harness.change_input = True + payload = harness.report((1,)) + assert harness.input_reads == [1, 1, 1] + assert payload["channels"][0]["waveform"]["status"] == "unavailable" + assert "50 ohm" in payload["channels"][0]["waveform"]["error"]["message"] + assert harness.writes == [] + + +def test_unknown_input_termination_is_rejected_even_with_allow_50ohm(harness): + harness.install(capabilities=("scope.idn", "scope.channel_input_state_v2", "scope.fetch_waveform")) + payload = scope_waveform_report_payload(config_path=harness.config_path, allow_50ohm=True) + assert payload["channels"][0]["waveform"]["status"] == "skipped" + assert harness.writes == [] + + +@pytest.mark.parametrize("access", ["read_only", "disabled"]) +def test_mutating_report_does_not_enlarge_existing_access(harness, access): + harness.install(access=access) + payload = harness.report() + assert harness.guards == harness.transports == [] + assert harness.constructor_calls == 0 + assert harness.writes == [] + assert all(item["waveform"]["status"] == "skipped" for item in payload["channels"]) + + +@pytest.mark.parametrize("missing", ["scope.idn", "scope.fetch_waveform", "scope.channel_coupling"]) +def test_missing_required_capability_hard_blocks_mutation(harness, missing): + harness.install(capabilities=tuple(item for item in ( + "scope.idn", "scope.fetch_waveform", "scope.channel_coupling", + ) if item != missing)) + payload = harness.report() + assert harness.writes == [] + assert harness.guards == harness.transports == [] + assert harness.constructor_calls == 0 + assert all(item["waveform"]["status"] == "skipped" for item in payload["channels"]) + + +@pytest.mark.parametrize("setting", ['format = "ascii"', 'byte_order = "msbf"']) +def test_offline_waveform_settings_block_mutation_before_fetch(harness, setting): + harness.constructor_io = "write" + with harness.config_path.open("a", encoding="utf-8") as file: + file.write(setting + "\n") + payload = harness.report() + assert harness.writes == [] + assert harness.guards == harness.transports == [] + assert harness.constructor_calls == 0 + assert all(item["waveform"]["status"] == "skipped" for item in payload["channels"]) + + +def test_direct_offline_fetch_validation_never_runs_factory(harness): + harness.constructor_io = "write" + with harness.config_path.open("a", encoding="utf-8") as file: + file.write('format = "ascii"\n') + with pytest.raises(ConfigError, match="waveform.format"): + harness.service().validate_observation_fetch() + assert harness.constructor_calls == 0 + assert harness.transports == harness.guards == [] + + +def test_invalid_bounded_profile_never_runs_factory(harness): + harness.install(capabilities=( + "scope.idn", "scope.fetch_waveform", "scope.capture_waveform", "scope.channel_coupling", + )) + service = harness.service() + harness.constructor_io = "write" + # The profile omits capture_single although the capability declares it. + # Registry/static profile validation must fail before invoking its factory. + descriptor = replace(harness.descriptor, scope_extensions=ScopeDescriptorExtensions( + waveform_binary_profile=ScopeWaveformBinaryProfile(operations=( + ScopeWaveformBinaryOperationProfile( + operation_kind="fetch", response_max_bytes=1024, + operation_max_bytes=4096, query_max_count=4, resynchronization_max_bytes=0, + restore_order=("scope.waveform_source",), snapshot_max_steps=1, + restore_max_steps=1, verify_max_steps=1, + ), + )), + )) + registry = InstrumentRegistry(builtins=(descriptor,)) + harness.monkeypatch.setattr("wavebench.instruments.registry.build_instrument_registry", lambda **kwargs: registry) + service.descriptor = descriptor + with pytest.raises(ConfigError, match="match declared standard waveform capabilities"): + service.validate_observation_fetch() + with pytest.raises(ConfigError, match="match declared standard waveform capabilities"): + harness.report() + assert harness.constructor_calls == 0 + assert harness.transports == harness.guards == [] + + +def test_invalid_points_fail_before_any_factory_io(harness): + text = harness.config_path.read_text(encoding="utf-8").replace('points = "def"', 'points = "invalid"') + harness.config_path.write_text(text, encoding="utf-8") + with pytest.raises(ConfigError, match="waveform points"): + harness.report() + assert not harness.transports + + +@pytest.mark.parametrize("body_failure", [False, True]) +def test_borrowed_context_keeps_driver_state_and_lease_alive(harness, body_failure): + bootstrap = harness.service() + driver = bootstrap.open_session() + service = ScopeService( + bootstrap.config, bootstrap.logger, session=driver, descriptor=bootstrap.descriptor, + transport=bootstrap.transport, session_state=bootstrap.session_state, lease=bootstrap.lease, + ) + try: + def use_borrowed(): + with service.session_context(observation=True): + service.preflight_observation_fetch((1, 2)) + with service.session_context(): + service.fetch_waveform(1) + if body_failure: + raise DataError("borrowed body failed") + + if body_failure: + with pytest.raises(DataError, match="borrowed body failed"): + use_borrowed() + else: + use_borrowed() + assert service.session is driver + assert service.session_state.health is SessionHealth.HEALTHY + assert service.lease.acquired + with pytest.raises(ResourceBusyError): + ResourceLease(RESOURCE).acquire() + finally: + driver.close() + + +@pytest.mark.parametrize("body_failure", [False, True]) +def test_owned_context_cleans_up_plugin_and_backend_close_failures(harness, body_failure): + harness.driver_close_failure = harness.backend_close_failure = True + service = harness.service() + original = DataError("original body failure") + with pytest.raises(DataError if body_failure else SessionCloseError) as caught: + with service.session_context(): + if body_failure: + raise original + if body_failure: + assert caught.value is original + assert service.session is None + assert service.session_state.health is SessionHealth.CLOSED + assert not service.lease.acquired + lease = ResourceLease(RESOURCE).acquire() + lease.release() + + +def test_query_failure_stops_preflight_and_all_waveforms(harness): + harness.query_failure = True + payload = harness.report() + assert payload["identity"]["error"]["type"] == "OSError" + assert all(item["waveform"]["status"] == "skipped" for item in payload["channels"]) + assert harness.writes == [] + assert len(harness.guards) == 1 + + +def test_report_preserves_completed_sections_when_close_fails(harness): + harness.driver_close_failure = harness.backend_close_failure = True + payload = harness.report() + assert payload["status"] == "partial" + assert all(item["waveform"]["status"] == "ok" for item in payload["channels"]) + assert payload["session"]["error"]["type"] == "SessionCloseError" + assert harness.guards[0].session_state.health is SessionHealth.CLOSED + assert not harness.guards[0].lease.acquired + assert len(harness.guards) == 1 + + +def test_readonly_missing_identity_never_executes_factory(harness): + harness.install(capabilities=("scope.channel_coupling",)) + payload = scope_observe_payload(config_path=harness.config_path, channels=(1, 2)) + assert harness.constructor_calls == 0 + assert harness.transports == harness.guards == [] + assert payload["status"] == "partial" + assert all(item["scope_status"]["status"] == "skipped" for item in payload["channels"]) + + +@pytest.mark.parametrize("constructor_io", ["write", "query", "binary_query"]) +@pytest.mark.parametrize("channels", [(1, 2), (1, 5)]) +def test_observation_legacy_factory_io_is_latched_before_preflight(harness, constructor_io, channels): + harness.install(capabilities=("scope.idn", "scope.channel_coupling", "scope.fetch_waveform")) + harness.constructor_io = constructor_io + payload = harness.report(channels) + assert harness.constructor_calls == 1 + assert harness.transports[0].events == [("close", "")] + assert harness.writes == [] + assert harness.guards[0].session_state.health is SessionHealth.CLOSED + assert not harness.guards[0].lease.acquired + assert all(item["waveform"]["status"] == "skipped" for item in payload["channels"]) + assert payload["session"]["error"]["type"] == "ConfigError" + lease = ResourceLease(RESOURCE).acquire() + lease.release() + + +def test_observation_passive_legacy_factory_is_released_after_validation(harness): + harness.install(capabilities=("scope.idn", "scope.channel_coupling", "scope.fetch_waveform")) + payload = harness.report() + assert harness.constructor_calls == 1 + assert harness.guards[0].construction_latched is False + assert all(item["waveform"]["status"] == "ok" for item in payload["channels"]) + assert harness.writes == ["FETCH CH1", "FETCH CH2"] + + +def test_ordinary_legacy_open_keeps_existing_factory_io_behavior(harness): + harness.install(capabilities=("scope.idn", "scope.channel_coupling", "scope.fetch_waveform")) + harness.constructor_io = "write" + service = harness.service() + driver = service.open_session() + try: + assert harness.writes == [":STOP"] + finally: + driver.close() diff --git a/tests/test_scope_observe_cli.py b/tests/test_scope_observe_cli.py new file mode 100644 index 00000000..2eed4198 --- /dev/null +++ b/tests/test_scope_observe_cli.py @@ -0,0 +1,274 @@ +import io +import json +from contextlib import nullcontext, redirect_stderr, redirect_stdout +from dataclasses import asdict +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +import numpy as np +import pytest + +from wavebench.cli import _run_scope_observe, main +from wavebench.cli_parser import build_parser +from wavebench.errors import ConfigError +from wavebench.instruments.models import ( + ScopeAnalogChannelSnapshot, + ScopeEdgeTriggerSnapshot, + ScopeHealthSnapshot, + ScopeIdentitySnapshot, + ScopeProbeSnapshot, + ScopeSnapshot, + ScopeTimebaseSnapshot, + ScopeWaveformMetadataSnapshot, + WaveformData, + WaveformHeader, +) + + +def _write_config(root: Path) -> Path: + path = root / "wavebench.toml" + path.write_text( + """ +[connection] +resource = "TCPIP::scope::INSTR" + +[scope] +driver = "ds1104" +default_channel = 1 + +[waveform] +points = "def" +""", + encoding="utf-8", + ) + return path + + +def _snapshot(channel: int) -> ScopeSnapshot: + return ScopeSnapshot( + identity=ScopeIdentitySnapshot("RIGOL", "DS1104Z", "123", "1.0", ()), + health=ScopeHealthSnapshot(0, 0, 0, 1, 1, 1_000_000.0, False, False), + channel=ScopeAnalogChannelSnapshot( + channel, True, "DC", 8.0, 1.0, 0.0, 0.0, None, "NORM", 0.0, "", False, False, "SAMPLE" + ), + timebase=ScopeTimebaseSnapshot(0.001, 12, 0.0, 0.0012, 50.0, 0.0001, False), + probe=ScopeProbeSnapshot(channel, 10.0, None, None, 1_000_000.0, "P10", "PASSIVE"), + waveform=ScopeWaveformMetadataSnapshot( + channel, -0.0005, 0.0005, 1000, 1, 1e-6, -0.0005, 0.001, 0.0, 8 + ), + trigger=ScopeEdgeTriggerSnapshot("EDGE", channel, "AUTO", "POS", "DC", 0.0, "AUTO", "OFF", 1e-6), + ) + + +class _FakeScopeService: + instances: list["_FakeScopeService"] = [] + + def __init__(self, *, config, logger): + self.config = config + self.session_state = None + self.fetched_channels: list[int] = [] + _FakeScopeService.instances.append(self) + + def session_context(self, **kwargs): + return nullcontext(self) + + def validate_observation_access(self): + pass + + def validate_observation_fetch(self): + pass + + def preflight_observation_fetch(self, channels, *, allow_50ohm=False): + for channel in channels: + self.require_high_impedance(channel, allow_50ohm=allow_50ohm) + + def observation_identity(self): + return self.idn() + + def observation_status(self, channel): + return asdict(self.status(channel)) + + def observation_input_safety(self, channel, *, allow_50ohm=False): + return {"channel": channel, + "coupling": self.require_high_impedance(channel, allow_50ohm=allow_50ohm), + "accepted_for_capture": True} + + def idn(self): + return "RIGOL TECHNOLOGIES,DS1104Z Plus,123,1.0" + + def status(self, channel): + return _snapshot(channel) + + def require_high_impedance(self, channel, *, allow_50ohm=False): + return "DC" + + def fetch_waveform(self, channel): + self.fetched_channels.append(channel) + times = np.linspace(0.0, 0.005, 2000) + return WaveformData( + channel=channel, + header=WaveformHeader(x_start=0.0, x_stop=0.005, points=2000), + voltages_v=np.sin(2 * np.pi * 1000 * times), + ) + + +def _run(argv: list[str]) -> tuple[int, str, str]: + _FakeScopeService.instances = [] + stdout = io.StringIO() + stderr = io.StringIO() + with patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService): + with redirect_stdout(stdout), redirect_stderr(stderr): + code = main(argv) + return code, stdout.getvalue(), stderr.getvalue() + + +def test_scope_observe_read_only_prints_state_and_never_fetches_waveform(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + code, out, _ = _run(["scope", "observe", "--channel", "1", "--config", str(config)]) + + assert code == 0 + assert "read_only=True" in out + assert "mutates_instrument=False" in out + assert "ch1 coupling=DC" in out + assert "waveform" not in out + assert _FakeScopeService.instances[0].fetched_channels == [] + + +def test_scope_observe_fetch_waveform_evaluates_expectations_and_recommends(): + with TemporaryDirectory() as tmp: + root = Path(tmp) + config = _write_config(root) + expectation = root / "expect.toml" + expectation.write_text("[channels.1]\nfrequency_hz = 1000.0\n", encoding="utf-8") + code, out, _ = _run( + [ + "scope", + "observe", + "--channel", + "1", + "--fetch-waveform", + "--expect", + str(expectation), + "--config", + str(config), + ] + ) + + assert code == 0 + assert _FakeScopeService.instances[0].fetched_channels == [1] + assert "mutates_instrument=True" in out + assert "ch1 expectation=ok" in out + assert "check=frequency_hz status=pass" in out + assert "recommendation" in out + + +def test_scope_observe_rejects_expect_file_without_fetch_waveform(): + with TemporaryDirectory() as tmp: + root = Path(tmp) + config = _write_config(root) + expectation = root / "expect.toml" + expectation.write_text("[channels.1]\nfrequency_hz = 1000.0\n", encoding="utf-8") + code, _, err = _run( + ["scope", "observe", "--expect", str(expectation), "--config", str(config)] + ) + + assert code != 0 + assert "--fetch-waveform" in err + assert _FakeScopeService.instances == [] + + +def test_scope_observe_rejects_invalid_expectation_before_any_instrument_io(): + with TemporaryDirectory() as tmp: + root = Path(tmp) + config = _write_config(root) + expectation = root / "expect.toml" + expectation.write_text("[channels.1]\nfrequncy_hz = 1000.0\n", encoding="utf-8") + code, _, err = _run( + [ + "scope", + "observe", + "--channel", + "1", + "--fetch-waveform", + "--expect", + str(expectation), + "--config", + str(config), + ] + ) + + assert code != 0 + assert "unknown expectation field" in err + # 校验失败必须发生在打开仪器之前 + assert _FakeScopeService.instances == [] + + +def test_scope_observe_rejects_expectation_for_unobserved_channel(): + with TemporaryDirectory() as tmp: + root = Path(tmp) + config = _write_config(root) + expectation = root / "expect.toml" + expectation.write_text("[channels.2]\nfrequency_hz = 1000.0\n", encoding="utf-8") + code, _, err = _run( + [ + "scope", + "observe", + "--channel", + "1", + "--fetch-waveform", + "--expect", + str(expectation), + "--config", + str(config), + ] + ) + + assert code != 0 + assert "expectation channels must be observed channels" in err + + +def test_json_scope_observe_wraps_result_in_versioned_envelope(): + with TemporaryDirectory() as tmp: + config = _write_config(Path(tmp)) + code, out, _ = _run(["--json", "scope", "observe", "--channel", "1", "--config", str(config)]) + + assert code == 0 + envelope = json.loads(out) + assert envelope["schema"] == "wavebench.cli.result.v1" + assert envelope["result"]["read_only"] is True + assert envelope["result"]["observation"]["channels"] == [1] + + +@pytest.mark.parametrize("option", ["--target-cycles", "--target-vertical-divisions"]) +@pytest.mark.parametrize("value", ["0", "-1", "nan", "inf", "-inf"]) +@pytest.mark.parametrize("fetch_waveform", [False, True]) +def test_scope_observe_rejects_invalid_targets_before_loading_config(option, value, fetch_waveform): + argv = ["scope", "observe", f"{option}={value}"] + if fetch_waveform: + argv.append("--fetch-waveform") + with patch("wavebench.services.agent_observe.load_config") as load: + code, _, err = _run(argv) + + assert code != 0 + assert option.removeprefix("--").replace("-", "_") in err + load.assert_not_called() + assert _FakeScopeService.instances == [] + + +@pytest.mark.parametrize("target", ["target_cycles", "target_vertical_divisions"]) +@pytest.mark.parametrize("value", [True, "1", object()]) +def test_scope_observe_validates_target_types_before_observation(target, value): + args = build_parser().parse_args(["scope", "observe", "--fetch-waveform"]) + setattr(args, target, value) + _FakeScopeService.instances = [] + with ( + patch("wavebench.services.agent_observe.load_config") as load, + patch("wavebench.services.agent_observe.ScopeService", _FakeScopeService), + pytest.raises(ConfigError, match=target), + ): + _run_scope_observe(args) + + load.assert_not_called() + assert _FakeScopeService.instances == [] diff --git a/tests/test_source_arbitrary_v2.py b/tests/test_source_arbitrary_v2.py index aced82d4..e7dc7480 100644 --- a/tests/test_source_arbitrary_v2.py +++ b/tests/test_source_arbitrary_v2.py @@ -22,7 +22,6 @@ from wavebench.instruments.source_extensions import ( SOURCE_CONTRACT_VERSION, ArbitraryFacet, - Availability, Observed, OutputFacet, SourceArbitraryCapabilityProfile, @@ -64,7 +63,12 @@ from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import basic_facet, source_descriptor, source_extensions +from tests.source_v2_fixtures import ( + missing as _missing, + basic_facet, + source_descriptor, + source_extensions, +) class _TextTransport: @@ -87,10 +91,6 @@ def close(self) -> None: pass -def _missing() -> Observed[object]: - return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) - - def _digest(payload: bytes) -> str: return "sha256:" + sha256(payload).hexdigest() diff --git a/tests/test_source_burst_v2.py b/tests/test_source_burst_v2.py index 09d7ff55..73fd3471 100644 --- a/tests/test_source_burst_v2.py +++ b/tests/test_source_burst_v2.py @@ -21,7 +21,6 @@ from wavebench.instruments.source_extension_capabilities import validate_source_descriptor from wavebench.instruments.source_extensions import ( SOURCE_CONTRACT_VERSION, - Availability, BurstFacet, Observed, OutputFacet, @@ -46,7 +45,6 @@ SourceQueryEffect, SourceQueryExecutionRecord, SourceQueryItemOutcome, - SourceReasonCode, SourceRuntimeIdentity, SourceTriggerOutput, SourceTriggerSlope, @@ -57,32 +55,16 @@ ) from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService -from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import basic_facet, source_descriptor, source_extensions - - -class _TextTransport: - resource = "fake-source-burst-v2" - - def record_event(self, direction: str, text: str) -> None: - del direction, text - - def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: - del command, replay - return "ok" - - def write(self, command: str) -> None: - del command - - def close(self) -> None: - pass - - -def _missing() -> Observed[object]: - return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) +from tests.source_v2_fixtures import ( + TextTransport, + missing as _missing, + basic_facet, + source_descriptor, + source_extensions, +) def _burst( @@ -122,7 +104,7 @@ def __init__( raise_after_fire: bool = False, ) -> None: self.transport = GuardedAuditedTransport( - _TextTransport(), + TextTransport("fake-source-burst-v2"), session_state=session_state, ) self.output_enabled = output_enabled diff --git a/tests/test_source_counter_v2.py b/tests/test_source_counter_v2.py index 589c08ca..04d7dcae 100644 --- a/tests/test_source_counter_v2.py +++ b/tests/test_source_counter_v2.py @@ -56,32 +56,15 @@ ) from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService -from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import source_descriptor, source_extensions - - -class _TextTransport: - resource = "fake-source-counter-v2" - - def record_event(self, direction: str, text: str) -> None: - del direction, text - - def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: - del command, replay - return "ok" - - def write(self, command: str) -> None: - del command - - def close(self) -> None: - pass - - -def _missing() -> Observed[object]: - return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) +from tests.source_v2_fixtures import ( + TextTransport, + missing as _missing, + source_descriptor, + source_extensions, +) def _counter( @@ -124,7 +107,10 @@ def __init__( enabled: bool = False, postcondition_mismatch: bool = False, ) -> None: - self.transport = GuardedAuditedTransport(_TextTransport(), session_state=session_state) + self.transport = GuardedAuditedTransport( + TextTransport("fake-source-counter-v2"), + session_state=session_state, + ) self.counter = _counter(enabled=enabled) self.postcondition_mismatch = postcondition_mismatch self.configure_requests: list[SourceCounterConfigureRequest] = [] diff --git a/tests/test_source_cross_channel_v2.py b/tests/test_source_cross_channel_v2.py index ee78f007..4f0e8525 100644 --- a/tests/test_source_cross_channel_v2.py +++ b/tests/test_source_cross_channel_v2.py @@ -66,28 +66,15 @@ from wavebench.services.source_service import SourceService from wavebench.services.run_plan import load_run_plan from wavebench.services.run_service import RunInstrumentServices, RunService -from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import basic_facet, source_descriptor, source_extensions - - -class _TextTransport: - resource = "fake-source-cross-channel-v2" - - def record_event(self, direction: str, text: str) -> None: - del direction, text - - def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: - del command, replay - return "ok" - - def write(self, command: str) -> None: - del command - - def close(self) -> None: - pass +from tests.source_v2_fixtures import ( + TextTransport, + basic_facet, + source_descriptor, + source_extensions, +) class _CrossChannelWriteDriver: @@ -102,7 +89,7 @@ def __init__( feature: SourceFeature = SourceFeature.COMBINE, ) -> None: self.transport = GuardedAuditedTransport( - _TextTransport(), + TextTransport("fake-source-cross-channel-v2"), session_state=session_state, ) self.outputs = { diff --git a/tests/test_source_fm_modulation_v2.py b/tests/test_source_fm_modulation_v2.py index 87d68047..c19b4f8f 100644 --- a/tests/test_source_fm_modulation_v2.py +++ b/tests/test_source_fm_modulation_v2.py @@ -21,7 +21,6 @@ from wavebench.instruments.source_extension_capabilities import validate_source_descriptor from wavebench.instruments.source_extensions import ( SOURCE_CONTRACT_VERSION, - Availability, ModulationFacet, Observed, OutputFacet, @@ -46,7 +45,6 @@ SourceQueryEffect, SourceQueryExecutionRecord, SourceQueryItemOutcome, - SourceReasonCode, SourceRuntimeIdentity, SourceTypedObservation, SourceWaveformKind, @@ -54,32 +52,16 @@ ) from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService -from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import basic_facet, source_descriptor, source_extensions - - -class _TextTransport: - resource = "fake-source-fm-modulation-v2" - - def record_event(self, direction: str, text: str) -> None: - del direction, text - - def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: - del command, replay - return "ok" - - def write(self, command: str) -> None: - del command - - def close(self) -> None: - pass - - -def _missing() -> Observed[object]: - return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) +from tests.source_v2_fixtures import ( + TextTransport, + missing as _missing, + basic_facet, + source_descriptor, + source_extensions, +) def _fm_modulation( @@ -113,7 +95,7 @@ def __init__( postcondition_mismatch: bool = False, ) -> None: self.transport = GuardedAuditedTransport( - _TextTransport(), + TextTransport("fake-source-fm-modulation-v2"), session_state=session_state, ) self.output_enabled = output_enabled diff --git a/tests/test_source_harmonics_v2.py b/tests/test_source_harmonics_v2.py index ed07e4fd..1c273646 100644 --- a/tests/test_source_harmonics_v2.py +++ b/tests/test_source_harmonics_v2.py @@ -20,7 +20,6 @@ from wavebench.instruments.source_extension_capabilities import validate_source_descriptor from wavebench.instruments.source_extensions import ( SOURCE_CONTRACT_VERSION, - Availability, ComponentAmplitudeKind, HarmonicCompleteness, HarmonicFacet, @@ -46,39 +45,22 @@ SourceQueryEffect, SourceQueryExecutionRecord, SourceQueryItemOutcome, - SourceReasonCode, SourceRuntimeIdentity, SourceTypedObservation, SupportState, ) from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService -from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import basic_facet, source_descriptor, source_extensions - - -class _TextTransport: - resource = "fake-source-harmonics-v2" - - def record_event(self, direction: str, text: str) -> None: - del direction, text - - def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: - del command, replay - return "ok" - - def write(self, command: str) -> None: - del command - - def close(self) -> None: - pass - - -def _missing() -> Observed[object]: - return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) +from tests.source_v2_fixtures import ( + TextTransport, + missing as _missing, + basic_facet, + source_descriptor, + source_extensions, +) def _harmonics( @@ -110,7 +92,7 @@ def __init__( raise_after_disable: bool = False, ) -> None: self.transport = GuardedAuditedTransport( - _TextTransport(), + TextTransport("fake-source-harmonics-v2"), session_state=session_state, ) self.output_enabled = output_enabled diff --git a/tests/test_source_modulation_v2.py b/tests/test_source_modulation_v2.py index 4fe32514..70cb4647 100644 --- a/tests/test_source_modulation_v2.py +++ b/tests/test_source_modulation_v2.py @@ -21,7 +21,6 @@ from wavebench.instruments.source_extension_capabilities import validate_source_descriptor from wavebench.instruments.source_extensions import ( SOURCE_CONTRACT_VERSION, - Availability, ModulationFacet, Observed, OutputFacet, @@ -46,7 +45,6 @@ SourceQueryEffect, SourceQueryExecutionRecord, SourceQueryItemOutcome, - SourceReasonCode, SourceRuntimeIdentity, SourceTypedObservation, SourceWaveformKind, @@ -54,32 +52,16 @@ ) from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService -from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import basic_facet, source_descriptor, source_extensions - - -class _TextTransport: - resource = "fake-source-modulation-v2" - - def record_event(self, direction: str, text: str) -> None: - del direction, text - - def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: - del command, replay - return "ok" - - def write(self, command: str) -> None: - del command - - def close(self) -> None: - pass - - -def _missing() -> Observed[object]: - return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) +from tests.source_v2_fixtures import ( + TextTransport, + missing as _missing, + basic_facet, + source_descriptor, + source_extensions, +) def _modulation( @@ -114,7 +96,7 @@ def __init__( postcondition_mismatch: bool = False, ) -> None: self.transport = GuardedAuditedTransport( - _TextTransport(), + TextTransport("fake-source-modulation-v2"), session_state=session_state, ) self.output_enabled = output_enabled diff --git a/tests/test_source_pm_modulation_v2.py b/tests/test_source_pm_modulation_v2.py index 53a0afa1..db029722 100644 --- a/tests/test_source_pm_modulation_v2.py +++ b/tests/test_source_pm_modulation_v2.py @@ -21,7 +21,6 @@ from wavebench.instruments.source_extension_capabilities import validate_source_descriptor from wavebench.instruments.source_extensions import ( SOURCE_CONTRACT_VERSION, - Availability, ModulationFacet, Observed, OutputFacet, @@ -46,7 +45,6 @@ SourceQueryEffect, SourceQueryExecutionRecord, SourceQueryItemOutcome, - SourceReasonCode, SourceRuntimeIdentity, SourceTypedObservation, SourceWaveformKind, @@ -54,32 +52,16 @@ ) from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService -from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import basic_facet, source_descriptor, source_extensions - - -class _TextTransport: - resource = "fake-source-pm-modulation-v2" - - def record_event(self, direction: str, text: str) -> None: - del direction, text - - def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: - del command, replay - return "ok" - - def write(self, command: str) -> None: - del command - - def close(self) -> None: - pass - - -def _missing() -> Observed[object]: - return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) +from tests.source_v2_fixtures import ( + TextTransport, + missing as _missing, + basic_facet, + source_descriptor, + source_extensions, +) def _modulation( @@ -114,7 +96,7 @@ def __init__( postcondition_mismatch: bool = False, ) -> None: self.transport = GuardedAuditedTransport( - _TextTransport(), + TextTransport("fake-source-pm-modulation-v2"), session_state=session_state, ) self.output_enabled = output_enabled diff --git a/tests/test_source_pulse_v2.py b/tests/test_source_pulse_v2.py index 787d33e6..8a2b487f 100644 --- a/tests/test_source_pulse_v2.py +++ b/tests/test_source_pulse_v2.py @@ -21,7 +21,6 @@ from wavebench.instruments.source_extension_capabilities import validate_source_descriptor from wavebench.instruments.source_extensions import ( SOURCE_CONTRACT_VERSION, - Availability, Observed, OutputFacet, PulseFacet, @@ -43,39 +42,22 @@ SourceQueryEffect, SourceQueryExecutionRecord, SourceQueryItemOutcome, - SourceReasonCode, SourceRuntimeIdentity, SourceTypedObservation, SupportState, ) from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService -from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import basic_facet, source_descriptor, source_extensions - - -class _TextTransport: - resource = "fake-source-pulse-v2" - - def record_event(self, direction: str, text: str) -> None: - del direction, text - - def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: - del command, replay - return "ok" - - def write(self, command: str) -> None: - del command - - def close(self) -> None: - pass - - -def _missing() -> Observed[object]: - return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) +from tests.source_v2_fixtures import ( + TextTransport, + missing as _missing, + basic_facet, + source_descriptor, + source_extensions, +) def _pulse( @@ -104,7 +86,7 @@ def __init__( postcondition_mismatch: bool = False, ) -> None: self.transport = GuardedAuditedTransport( - _TextTransport(), + TextTransport("fake-source-pulse-v2"), session_state=session_state, ) self.output_enabled = output_enabled diff --git a/tests/test_source_pwm_modulation_v2.py b/tests/test_source_pwm_modulation_v2.py index 9cbce17f..1c19cab1 100644 --- a/tests/test_source_pwm_modulation_v2.py +++ b/tests/test_source_pwm_modulation_v2.py @@ -21,7 +21,6 @@ from wavebench.instruments.source_extension_capabilities import validate_source_descriptor from wavebench.instruments.source_extensions import ( SOURCE_CONTRACT_VERSION, - Availability, ModulationFacet, Observed, OutputFacet, @@ -46,7 +45,6 @@ SourceQueryEffect, SourceQueryExecutionRecord, SourceQueryItemOutcome, - SourceReasonCode, SourceRuntimeIdentity, SourceTypedObservation, SourceWaveformKind, @@ -54,32 +52,16 @@ ) from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService -from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import basic_facet, source_descriptor, source_extensions - - -class _TextTransport: - resource = "fake-source-pwm-modulation-v2" - - def record_event(self, direction: str, text: str) -> None: - del direction, text - - def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: - del command, replay - return "ok" - - def write(self, command: str) -> None: - del command - - def close(self) -> None: - pass - - -def _missing() -> Observed[object]: - return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) +from tests.source_v2_fixtures import ( + TextTransport, + missing as _missing, + basic_facet, + source_descriptor, + source_extensions, +) def _pwm_modulation( @@ -111,7 +93,7 @@ def __init__( postcondition_mismatch: bool = False, ) -> None: self.transport = GuardedAuditedTransport( - _TextTransport(), + TextTransport("fake-source-pwm-modulation-v2"), session_state=session_state, ) self.output_enabled = output_enabled diff --git a/tests/test_source_sweep_v2.py b/tests/test_source_sweep_v2.py index 88ccc42e..4cd25ba3 100644 --- a/tests/test_source_sweep_v2.py +++ b/tests/test_source_sweep_v2.py @@ -57,32 +57,16 @@ ) from wavebench.logging import CommandLogger from wavebench.services.source_service import SourceService -from wavebench.transport.contracts import ReplayPolicy from wavebench.transport.guarded import GuardedAuditedTransport from wavebench.transport.session import InstrumentSessionState -from tests.source_v2_fixtures import basic_facet, source_descriptor, source_extensions - - -class _TextTransport: - resource = "fake-source-sweep-v2" - - def record_event(self, direction: str, text: str) -> None: - del direction, text - - def query(self, command: str, *, replay: ReplayPolicy = ReplayPolicy.NO_REPLAY) -> str: - del command, replay - return "ok" - - def write(self, command: str) -> None: - del command - - def close(self) -> None: - pass - - -def _missing() -> Observed[object]: - return Observed.missing(Availability.NOT_QUERIED, SourceReasonCode.NOT_REQUESTED) +from tests.source_v2_fixtures import ( + TextTransport, + missing as _missing, + basic_facet, + source_descriptor, + source_extensions, +) def _sweep( @@ -136,7 +120,7 @@ def __init__( raise_after_fire: bool = False, ) -> None: self.transport = GuardedAuditedTransport( - _TextTransport(), + TextTransport("fake-source-sweep-v2"), session_state=session_state, ) self.output_enabled = output_enabled diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 00000000..e8fb66db --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,35 @@ +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +import runpy +from unittest.mock import patch + +import pytest + +import wavebench +from wavebench.cli import main + + +def test_installed_version_matches_distribution(): + assert wavebench.__version__ == version("wavebench") + + +def test_runtime_version_ignores_ancestor_project(tmp_path): + package = tmp_path / "lib" / "wavebench" + package.mkdir(parents=True) + source = package / "__init__.py" + source.write_text(Path(wavebench.__file__).read_text(encoding="utf-8"), encoding="utf-8") + (tmp_path / "pyproject.toml").write_text('[project]\nversion = "99.0"\n') + with patch("importlib.metadata.version", return_value="0.8.28"): + assert runpy.run_path(str(source))["__version__"] == "0.8.28" + + +def test_missing_metadata_reports_unknown(): + with patch("importlib.metadata.version", side_effect=PackageNotFoundError): + assert runpy.run_path(wavebench.__file__)["__version__"] == "0+unknown" + + +def test_cli_version_without_configuration(capsys): + with pytest.raises(SystemExit) as result: + main(["--version"]) + assert result.value.code == 0 + assert capsys.readouterr().out == f"wavebench {version('wavebench')}\n" diff --git a/tests/test_waveform_binary_factory.py b/tests/test_waveform_binary_factory.py index d247de7f..eaf161eb 100644 --- a/tests/test_waveform_binary_factory.py +++ b/tests/test_waveform_binary_factory.py @@ -128,7 +128,7 @@ def fetch_waveform_bounded(self, channel, points="dmax", *, baseline): return object() -def _open() -> object: +def _open(*, force_deferred_io: bool = False) -> object: return open_instrument_driver( driver_reference="example.waveform", expected_kind="scope", @@ -139,10 +139,12 @@ def _open() -> object: read_retry_attempts=1, read_retry_delay_ms=1, logger=CommandLogger(), + force_deferred_io=force_deferred_io, ) -def test_opt_in_factory_latch_blocks_all_instrument_io_until_validation(monkeypatch) -> None: +@pytest.mark.parametrize("force_legacy", [False, True]) +def test_opt_in_factory_latch_blocks_all_instrument_io_until_validation(monkeypatch, force_legacy) -> None: inner = _InnerTransport() errors: list[TransportIOError] = [] @@ -167,7 +169,7 @@ def factory(context): errors.append(raised.value) return _BoundedDriver() - descriptor = _descriptor(factory=factory, profile=_profile()) + descriptor = _descriptor(factory=factory, profile=None if force_legacy else _profile()) monkeypatch.setattr( "wavebench.instruments.factory.resolve_instrument_descriptor", lambda reference, expected_kind: descriptor, @@ -178,14 +180,14 @@ def factory(context): lambda **kwargs: None, ) - opened = _open() + opened = _open(force_deferred_io=force_legacy) assert len(errors) == 7 assert all(error.reason_code == "factory_construction_pending" for error in errors) assert all(error.attempts == 0 for error in errors) assert inner.queries == [] assert inner.writes == [] - assert opened.transport._has_verified_bounded_binary_backend() + assert opened.transport._has_verified_bounded_binary_backend() is (not force_legacy) assert opened.transport.query("*IDN?") == "ok" assert inner.queries == ["*IDN?"] diff --git a/tests/test_waveform_expectations.py b/tests/test_waveform_expectations.py new file mode 100644 index 00000000..60889f4f --- /dev/null +++ b/tests/test_waveform_expectations.py @@ -0,0 +1,227 @@ +import numpy as np +import pytest + +from wavebench.data.expectations import ( + estimate_triangle_symmetry_percent, + evaluate_waveform_expectation, + expectation_summary, + validate_expectation, +) +from wavebench.errors import ConfigError +from wavebench.instruments.models import WaveformData, WaveformHeader + + +def _waveform(channel: int, times: np.ndarray, values: np.ndarray) -> WaveformData: + return WaveformData( + channel=channel, + header=WaveformHeader(x_start=float(times[0]), x_stop=float(times[-1]), points=int(times.size)), + voltages_v=values, + ) + + +def test_square_wave_expectation_passes_frequency_vpp_mean_and_duty(): + times = np.linspace(0.0, 0.009999, 10_000) + values = np.where((times * 1000.0) % 1.0 < 0.5, 0.5, -0.5) + waveform = _waveform(1, times, values) + + result = evaluate_waveform_expectation( + waveform, + { + "label": "1k square", + "shape": "square", + "frequency_hz": 1000, + "frequency_tolerance_ratio": 0.02, + "vpp_v": 1.0, + "vpp_tolerance_ratio": 0.05, + "mean_v": 0.0, + "mean_tolerance_v": 0.02, + "duty_percent": 50, + "duty_tolerance": 0.02, + }, + ) + + assert result["status"] == "pass" + assert {check["metric"] for check in result["checks"]} == { + "frequency_hz", + "vpp_v", + "mean_v", + "duty_cycle", + } + + +def test_triangle_symmetry_expectation_passes_for_asymmetric_ramp(): + times = np.linspace(0.0, 0.0002, 5000) + period = 20e-6 + symmetry = 30.0 + phase = (times % period) / period + values = np.where( + phase < symmetry / 100.0, + -0.5 + phase / (symmetry / 100.0), + 0.5 - (phase - symmetry / 100.0) / (1.0 - symmetry / 100.0), + ) + values += 0.5 + waveform = _waveform(2, times, values) + + measured = estimate_triangle_symmetry_percent(waveform) + result = evaluate_waveform_expectation( + waveform, + { + "label": "50k triangle", + "shape": "triangle", + "frequency_hz": 50_000, + "vpp_v": 1.0, + "mean_v": 0.5, + "symmetry_percent": 30, + "symmetry_tolerance_percent": 3, + }, + ) + + assert measured is not None + assert abs(measured - 30.0) < 3.0 + assert result["status"] == "pass" + + +def test_expectation_warns_instead_of_failing_low_confidence_frequency(): + times = np.linspace(0.0, 0.0005, 200) + values = np.sin(2 * np.pi * 1000 * times) + waveform = _waveform(1, times, values) + + result = evaluate_waveform_expectation(waveform, {"frequency_hz": 1000}) + + assert result["status"] == "warn" + assert result["checks"][0]["status"] == "warn" + assert "low confidence" in result["checks"][0]["message"] + + +def test_expectation_summary_rolls_up_channel_statuses(): + summary = expectation_summary( + { + 1: {"status": "pass"}, + 2: {"status": "warn"}, + } + ) + + assert summary == {"status": "warn", "channels": {"1": "pass", "2": "warn"}} + + +@pytest.mark.parametrize(("statuses", "expected"), [ + ([], "skipped"), + (["skipped"], "skipped"), + (["unavailable"], "unavailable"), + (["unavailable", "skipped"], "unavailable"), + (["unavailable", "pass"], "partial"), + (["unavailable", "warn"], "partial"), + (["unavailable", "fail"], "fail"), +]) +def test_expectation_summary_accounts_for_unavailable_channels(statuses, expected): + results = {channel: {"status": status} for channel, status in enumerate(statuses, 1)} + + assert expectation_summary(results) == { + "status": expected, + "channels": {str(channel): result["status"] for channel, result in results.items()}, + } + + +def test_validate_expectation_rejects_unknown_field(): + # 拼错的字段名不能被静默忽略 + with pytest.raises(ConfigError, match="unknown expectation field"): + validate_expectation({"frequncy_hz": 1000}) + + +@pytest.mark.parametrize( + "expectation", + [ + {"frequency_hz": "1000"}, + {"frequency_hz": True}, + {"frequency_hz": float("nan")}, + {"frequency_hz": float("inf")}, + {"vpp_v": -1.0}, + {"vpp_v": 0.0}, + {"frequency_tolerance_ratio": -0.1}, + {"duty_cycle": 1.5}, + {"duty_percent": 120.0}, + {"symmetry_percent": 150.0}, + {"symmetry_tolerance_percent": -1.0}, + {"label": ""}, + ], +) +def test_validate_expectation_rejects_invalid_values(expectation): + with pytest.raises(ConfigError): + validate_expectation(expectation) + + +def test_validate_expectation_rejects_conflicting_synonyms(): + with pytest.raises(ConfigError, match="must not set both"): + validate_expectation({"duty_cycle": 0.5, "duty_percent": 50.0}) + with pytest.raises(ConfigError, match="must not set both"): + validate_expectation({"mean_v": 0.0, "offset_v": 0.0}) + + +def test_expectation_without_checkable_metric_is_skipped_not_passed(): + times = np.linspace(0.0, 0.001, 100) + waveform = _waveform(1, times, np.sin(2 * np.pi * 1000 * times)) + + # 只有 label/shape 时没有任何可执行检查,必须显式 skipped 而不是 pass + result = evaluate_waveform_expectation(waveform, {"label": "sine", "shape": "sine"}) + + assert result["status"] == "skipped" + assert result["checks"] == [] + assert "no checkable metric" in result["message"] + + +def test_evaluate_waveform_expectation_rejects_typo_before_any_check(): + times = np.linspace(0.0, 0.001, 100) + waveform = _waveform(1, times, np.sin(2 * np.pi * 1000 * times)) + + with pytest.raises(ConfigError, match="unknown expectation field"): + evaluate_waveform_expectation(waveform, {"frequncy_hz": 1000}) + + +def _triangle(times: np.ndarray, *, period: float, symmetry_percent: float, vpp: float) -> np.ndarray: + phase = (times % period) / period + rising = symmetry_percent / 100.0 + values = np.where( + phase < rising, + phase / rising, + 1.0 - (phase - rising) / (1.0 - rising), + ) + return values * vpp - vpp / 2.0 + + +@pytest.mark.parametrize("symmetry", [10.0, 50.0, 90.0]) +def test_triangle_symmetry_is_robust_to_noise_quantization_and_overshoot(symmetry): + period = 20e-6 + times = np.linspace(0.0, 20 * period, 20_000, endpoint=False) + period = float(times[1] - times[0]) * 1000.0 + clean = _triangle(times, period=period, symmetry_percent=symmetry, vpp=2.0) + + rng = np.random.default_rng(20260928) + # 1 mV 噪声 + 1 mV 量化台阶:逐点差分符号会被噪声打乱 + noisy = np.round(clean + rng.normal(0.0, 1e-3, clean.size), 3) + # 5 mV 噪声 + 轻微过冲 + overshoot = clean + rng.normal(0.0, 5e-3, clean.size) + 0.02 * np.sin(2 * np.pi * 5 / period * times) + + for values in (noisy, overshoot): + measured = estimate_triangle_symmetry_percent( + _waveform(1, times, values), + expected_frequency_hz=1.0 / period, + ) + assert measured is not None + assert abs(measured - symmetry) < 3.0, (symmetry, measured) + + +def test_triangle_symmetry_uses_expected_frequency_to_ignore_short_glitches(): + period = 20e-6 + times = np.linspace(0.0, 20 * period, 20_000, endpoint=False) + period = float(times[1] - times[0]) * 1000.0 + values = _triangle(times, period=period, symmetry_percent=10.0, vpp=2.0) + # 在上升沿插入一个远窄于半周期的毛刺;期望频率约束应把它排除在极值序列之外 + values[times.size // 2 : times.size // 2 + 20] += 0.3 + + measured = estimate_triangle_symmetry_percent( + _waveform(1, times, values), + expected_frequency_hz=1.0 / period, + ) + + assert measured is not None + assert abs(measured - 10.0) < 3.0 diff --git a/tests/test_waveform_relationships.py b/tests/test_waveform_relationships.py new file mode 100644 index 00000000..34d50ef6 --- /dev/null +++ b/tests/test_waveform_relationships.py @@ -0,0 +1,188 @@ +import numpy as np +import pytest + +from wavebench.data.relationships import analyze_waveform_pair, analyze_waveform_relationships +from wavebench.instruments.models import WaveformData, WaveformHeader + + +def _waveform(channel: int, values: np.ndarray, *, stop: float = 0.009) -> WaveformData: + return WaveformData( + channel=channel, + header=WaveformHeader(x_start=0.0, x_stop=stop, points=int(values.size)), + voltages_v=values, + ) + + +def test_waveform_pair_reports_frequency_voltage_and_phase_for_related_signals(): + t = np.linspace(0.0, 0.009, 1000) + left = _waveform(1, np.sin(2 * np.pi * 1000 * t), stop=float(t[-1])) + right = _waveform(2, 0.5 * np.sin(2 * np.pi * 1000 * (t - 0.00025)) + 0.2, stop=float(t[-1])) + + relationship = analyze_waveform_pair(left, right, same_acquisition=True) + + assert relationship["channels"] == [1, 2] + assert relationship["common_time"]["overlap"] is True + assert relationship["frequency"]["ratio_high_over_low"] == 1.0 + assert 0.45 < relationship["voltage"]["vpp_ratio_right_over_left"] < 0.55 + assert 0.19 < relationship["voltage"]["mean_delta_right_minus_left_v"] < 0.21 + assert relationship["correlation"]["max_abs_cross_correlation"] > 0.9 + assert relationship["intersections"]["mode"] == "finite" + assert relationship["intersections"]["count"] > 0 + assert relationship["phase_degrees_at_left_frequency"] is not None + + +def test_waveform_pair_skips_timing_analysis_when_not_same_acquisition(): + t = np.linspace(0.0, 0.009, 1000) + left = _waveform(1, np.sin(2 * np.pi * 1000 * t), stop=float(t[-1])) + right = _waveform(2, np.sin(2 * np.pi * 1000 * (t - 0.00025)), stop=float(t[-1])) + + relationship = analyze_waveform_pair(left, right, same_acquisition=False) + + assert relationship["common_time"]["same_acquisition"] is False + assert relationship["common_time"]["overlap"] is None + assert relationship["phase_degrees_at_left_frequency"] is None + # 跨采集的波形没有共同时间基准,相关性和交点必须整段跳过而不是给出看似精确的数字 + assert relationship["correlation"] == {"status": "skipped", "reason": "not_same_acquisition"} + assert relationship["intersections"] == {"status": "skipped", "reason": "not_same_acquisition"} + assert "not_same_acquisition_timing_relationships_skipped" in relationship["warnings"] + # 同步无关的量仍然保留 + assert relationship["frequency"]["ratio_high_over_low"] == 1.0 + + +def test_waveform_pair_reports_phase_lag_in_degrees(): + t = np.linspace(0.0, 0.004, 4000) + left = _waveform(1, np.sin(2 * np.pi * 1000 * t), stop=float(t[-1])) + + for expected_degrees in (0.0, 90.0, 180.0, 270.0): + right = _waveform( + 2, + np.sin(2 * np.pi * 1000 * t - np.deg2rad(expected_degrees)), + stop=float(t[-1]), + ) + + relationship = analyze_waveform_pair(left, right, same_acquisition=True) + + assert relationship["phase_degrees_at_left_frequency"] == pytest.approx( + expected_degrees, abs=0.5 + ) + + +def test_waveform_pair_reports_180_degrees_for_inverted_signal(): + t = np.linspace(0.0, 0.004, 4000) + left = np.sin(2 * np.pi * 1000 * t) + + relationship = analyze_waveform_pair( + _waveform(1, left, stop=float(t[-1])), + _waveform(2, -left, stop=float(t[-1])), + same_acquisition=True, + ) + + # 用相关峰绝对值选 lag 会把它报成 0° + assert relationship["phase_degrees_at_left_frequency"] == pytest.approx(180.0, abs=0.5) + + +def test_waveform_pair_phase_rejects_dc_leakage_in_noninteger_cycle_window(): + t = np.linspace(0.0, 0.0045, 4501) + left = _waveform(1, np.sin(2 * np.pi * 1000 * t), stop=float(t[-1])) + right = _waveform( + 2, np.sin(2 * np.pi * 1000 * t - np.pi / 2) + 5.0, stop=float(t[-1]), + ) + + relationship = analyze_waveform_pair(left, right, same_acquisition=True) + + assert relationship["frequency"]["left_hz"] == pytest.approx(1000.0, abs=0.1) + assert relationship["frequency"]["right_hz"] == pytest.approx(1000.0, abs=0.1) + assert relationship["phase_degrees_at_left_frequency"] == pytest.approx(90.0, abs=0.5) + + +def test_waveform_relationships_report_all_pairs_for_four_channels(): + t = np.linspace(0.0, 0.004, 500) + waveforms = { + channel: _waveform(channel, np.sin(2 * np.pi * 1000 * t + channel), stop=float(t[-1])) + for channel in range(1, 5) + } + + relationships = analyze_waveform_relationships(waveforms) + + assert len(relationships) == 6 + assert relationships[0]["channels"] == [1, 2] + assert relationships[-1]["channels"] == [3, 4] + + +def test_waveform_pair_warns_when_frequency_confidence_is_low(): + t = np.linspace(0.0, 0.0005, 100) + left = _waveform(1, np.sin(2 * np.pi * 1000 * t), stop=float(t[-1])) + right = _waveform(2, np.sin(2 * np.pi * 2000 * t), stop=float(t[-1])) + + relationship = analyze_waveform_pair(left, right, same_acquisition=True) + + assert relationship["frequency"]["left_hz"] is None + assert any("frequency_low_confidence" in warning for warning in relationship["warnings"]) + + +def test_waveform_pair_reports_intersection_points(): + t = np.linspace(0.0, 1.0, 1001) + left = _waveform(1, t - 0.25, stop=float(t[-1])) + right = _waveform(2, np.zeros_like(t), stop=float(t[-1])) + + relationship = analyze_waveform_pair(left, right, same_acquisition=True) + + intersections = relationship["intersections"] + assert intersections["mode"] == "finite" + assert intersections["count"] == 1 + assert intersections["returned"] == 1 + assert intersections["truncated"] is False + assert intersections["points"][0]["time_s"] == 0.25 + assert intersections["points"][0]["voltage_v"] == 0.0 + assert intersections["points"][0]["direction"] == "left_minus_right_rising" + + +def test_waveform_pair_can_truncate_many_intersections(): + t = np.linspace(0.0, 0.01, 2000) + left = _waveform(1, np.sin(2 * np.pi * 1000 * t), stop=float(t[-1])) + right = _waveform(2, np.zeros_like(t), stop=float(t[-1])) + + relationship = analyze_waveform_pair(left, right, same_acquisition=True, max_intersections=3) + + assert relationship["intersections"]["count"] > 3 + assert relationship["intersections"]["returned"] == 3 + assert relationship["intersections"]["truncated"] is True + assert "intersections_truncated" in relationship["warnings"] + + +def test_waveform_pair_marks_coincident_waveforms_as_unbounded_intersections(): + t = np.linspace(0.0, 0.001, 100) + values = np.sin(2 * np.pi * 1000 * t) + + relationship = analyze_waveform_pair( + _waveform(1, values, stop=float(t[-1])), + _waveform(2, values, stop=float(t[-1])), + same_acquisition=True, + ) + + assert relationship["intersections"]["mode"] == "coincident" + assert relationship["intersections"]["count"] is None + assert "waveforms_coincident_intersections_unbounded" in relationship["warnings"] + + +def test_relationship_entry_points_default_to_unproven_timing(): + t = np.linspace(0.0, 0.009, 1000) + waves = { + 1: _waveform(1, np.sin(2 * np.pi * 1000 * t)), + 2: _waveform(2, np.sin(2 * np.pi * 1000 * t - np.pi / 2)), + } + pair = analyze_waveform_pair(waves[1], waves[2]) + multiple = analyze_waveform_relationships(waves) + for result in (pair, multiple[0]): + assert result["common_time"]["same_acquisition"] is False + assert result["phase_degrees_at_left_frequency"] is None + assert result["correlation"]["status"] == "skipped" + assert result["intersections"]["status"] == "skipped" + assert result["frequency"]["ratio_high_over_low"] == 1.0 + + +@pytest.mark.parametrize("assertion", ["false", 1, None]) +def test_relationship_rejects_truthy_non_boolean_sync_assertions(assertion): + wave = _waveform(1, np.zeros(10)) + with pytest.raises(ValueError, match="same_acquisition"): + analyze_waveform_pair(wave, wave, same_acquisition=assertion) diff --git a/tests/test_windows_cli_import.py b/tests/test_windows_cli_import.py deleted file mode 100644 index e94626a8..00000000 --- a/tests/test_windows_cli_import.py +++ /dev/null @@ -1,7 +0,0 @@ -import importlib - - -def test_cli_imports_without_posix_fcntl() -> None: - module = importlib.import_module("wavebench.cli") - - assert callable(module.main) diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..760455f1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1737 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", + "python_full_version < '3.12'", +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/56/4744bcd0c82184e80c52b0ac4076c261a8ffa1f1b343ff2f6e89ce0e1cef/backrefs-8.0.tar.gz", hash = "sha256:b556cd7d36c3a3a2f256b89590b176b8eddfb73bcfaee3a3ddd84ea66d21ce50", size = 7013081, upload-time = "2026-07-26T19:54:24.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e3/fd/9bf53b6a6f6f519ffaac765df2f2a25e5c2fc6d32cfd2b2747099e72c911/backrefs-8.0-py310-none-any.whl", hash = "sha256:4a627b817fd2dce43b79ab48da63613340509381cd8ce0897078a0bce79a2ab8", size = 380377, upload-time = "2026-07-26T19:54:17.457Z" }, + { url = "https://files.pythonhosted.org/packages/e1/29/4bd7ae72a2634da00379c2b3bcc5439e7c94620235c6afea8af15229a973/backrefs-8.0-py311-none-any.whl", hash = "sha256:f0c35cf0102ba6b6070c12a492be3c1c1d3f5839529784b9a9565d6d04569a01", size = 392169, upload-time = "2026-07-26T19:54:18.782Z" }, + { url = "https://files.pythonhosted.org/packages/29/13/232505664e8e2a0c7a2eb0c505cfade9d715538f89a5d62bc4c272968f62/backrefs-8.0-py312-none-any.whl", hash = "sha256:87f0fae8c5f207fe9f4b2887efc71d42f4900ac78faa1af08d675ef303692dc5", size = 398084, upload-time = "2026-07-26T19:54:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/8a/69/47a3dc20abc4fa5486655fde681bd55e63211b46c886d8c02223d6468431/backrefs-8.0-py313-none-any.whl", hash = "sha256:601ce68ca12385dbda06ce264406b4c4210cf5b79fd0fd627592365c92f29a88", size = 400040, upload-time = "2026-07-26T19:54:21.194Z" }, + { url = "https://files.pythonhosted.org/packages/1c/cf/e5f9b68a5b0e939a2fb933a66c20180d0c9241bf8927f7a47fa48c1675e9/backrefs-8.0-py314-none-any.whl", hash = "sha256:9ec96efa080938be92323e8e730e57718c9c88eb15ad70bbef4e1766df591408", size = 411903, upload-time = "2026-07-26T19:54:23.221Z" }, +] + +[[package]] +name = "brotli" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ef/f285668811a9e1ddb47a18cb0b437d5fc2760d537a2fe8a57875ad6f8448/brotli-1.2.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:15b33fe93cedc4caaff8a0bd1eb7e3dab1c61bb22a0bf5bdfdfd97cd7da79744", size = 863110, upload-time = "2025-11-05T18:38:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/50/62/a3b77593587010c789a9d6eaa527c79e0848b7b860402cc64bc0bc28a86c/brotli-1.2.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:898be2be399c221d2671d29eed26b6b2713a02c2119168ed914e7d00ceadb56f", size = 445438, upload-time = "2025-11-05T18:38:14.208Z" }, + { url = "https://files.pythonhosted.org/packages/cd/e1/7fadd47f40ce5549dc44493877db40292277db373da5053aff181656e16e/brotli-1.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:350c8348f0e76fff0a0fd6c26755d2653863279d086d3aa2c290a6a7251135dd", size = 1534420, upload-time = "2025-11-05T18:38:15.111Z" }, + { url = "https://files.pythonhosted.org/packages/12/8b/1ed2f64054a5a008a4ccd2f271dbba7a5fb1a3067a99f5ceadedd4c1d5a7/brotli-1.2.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e1ad3fda65ae0d93fec742a128d72e145c9c7a99ee2fcd667785d99eb25a7fe", size = 1632619, upload-time = "2025-11-05T18:38:16.094Z" }, + { url = "https://files.pythonhosted.org/packages/89/5a/7071a621eb2d052d64efd5da2ef55ecdac7c3b0c6e4f9d519e9c66d987ef/brotli-1.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:40d918bce2b427a0c4ba189df7a006ac0c7277c180aee4617d99e9ccaaf59e6a", size = 1426014, upload-time = "2025-11-05T18:38:17.177Z" }, + { url = "https://files.pythonhosted.org/packages/26/6d/0971a8ea435af5156acaaccec1a505f981c9c80227633851f2810abd252a/brotli-1.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2a7f1d03727130fc875448b65b127a9ec5d06d19d0148e7554384229706f9d1b", size = 1489661, upload-time = "2025-11-05T18:38:18.41Z" }, + { url = "https://files.pythonhosted.org/packages/f3/75/c1baca8b4ec6c96a03ef8230fab2a785e35297632f402ebb1e78a1e39116/brotli-1.2.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9c79f57faa25d97900bfb119480806d783fba83cd09ee0b33c17623935b05fa3", size = 1599150, upload-time = "2025-11-05T18:38:19.792Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1a/23fcfee1c324fd48a63d7ebf4bac3a4115bdb1b00e600f80f727d850b1ae/brotli-1.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:844a8ceb8483fefafc412f85c14f2aae2fb69567bf2a0de53cdb88b73e7c43ae", size = 1493505, upload-time = "2025-11-05T18:38:20.913Z" }, + { url = "https://files.pythonhosted.org/packages/36/e5/12904bbd36afeef53d45a84881a4810ae8810ad7e328a971ebbfd760a0b3/brotli-1.2.0-cp311-cp311-win32.whl", hash = "sha256:aa47441fa3026543513139cb8926a92a8e305ee9c71a6209ef7a97d91640ea03", size = 334451, upload-time = "2025-11-05T18:38:21.94Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/ecb5761b989629a4758c394b9301607a5880de61ee2ee5fe104b87149ebc/brotli-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:022426c9e99fd65d9475dce5c195526f04bb8be8907607e27e747893f6ee3e24", size = 369035, upload-time = "2025-11-05T18:38:22.941Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/b0a11ab2315c69bb9b45a2aaed022499c9c24a205c3a49c3513b541a7967/brotli-1.2.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:35d382625778834a7f3061b15423919aa03e4f5da34ac8e02c074e4b75ab4f84", size = 861543, upload-time = "2025-11-05T18:38:24.183Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2f/29c1459513cd35828e25531ebfcbf3e92a5e49f560b1777a9af7203eb46e/brotli-1.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a61c06b334bd99bc5ae84f1eeb36bfe01400264b3c352f968c6e30a10f9d08b", size = 444288, upload-time = "2025-11-05T18:38:25.139Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/feba03130d5fceadfa3a1bb102cb14650798c848b1df2a808356f939bb16/brotli-1.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:acec55bb7c90f1dfc476126f9711a8e81c9af7fb617409a9ee2953115343f08d", size = 1528071, upload-time = "2025-11-05T18:38:26.081Z" }, + { url = "https://files.pythonhosted.org/packages/2b/38/f3abb554eee089bd15471057ba85f47e53a44a462cfce265d9bf7088eb09/brotli-1.2.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:260d3692396e1895c5034f204f0db022c056f9e2ac841593a4cf9426e2a3faca", size = 1626913, upload-time = "2025-11-05T18:38:27.284Z" }, + { url = "https://files.pythonhosted.org/packages/03/a7/03aa61fbc3c5cbf99b44d158665f9b0dd3d8059be16c460208d9e385c837/brotli-1.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:072e7624b1fc4d601036ab3f4f27942ef772887e876beff0301d261210bca97f", size = 1419762, upload-time = "2025-11-05T18:38:28.295Z" }, + { url = "https://files.pythonhosted.org/packages/21/1b/0374a89ee27d152a5069c356c96b93afd1b94eae83f1e004b57eb6ce2f10/brotli-1.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adedc4a67e15327dfdd04884873c6d5a01d3e3b6f61406f99b1ed4865a2f6d28", size = 1484494, upload-time = "2025-11-05T18:38:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/cf/57/69d4fe84a67aef4f524dcd075c6eee868d7850e85bf01d778a857d8dbe0a/brotli-1.2.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7a47ce5c2288702e09dc22a44d0ee6152f2c7eda97b3c8482d826a1f3cfc7da7", size = 1593302, upload-time = "2025-11-05T18:38:30.639Z" }, + { url = "https://files.pythonhosted.org/packages/d5/3b/39e13ce78a8e9a621c5df3aeb5fd181fcc8caba8c48a194cd629771f6828/brotli-1.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:af43b8711a8264bb4e7d6d9a6d004c3a2019c04c01127a868709ec29962b6036", size = 1487913, upload-time = "2025-11-05T18:38:31.618Z" }, + { url = "https://files.pythonhosted.org/packages/62/28/4d00cb9bd76a6357a66fcd54b4b6d70288385584063f4b07884c1e7286ac/brotli-1.2.0-cp312-cp312-win32.whl", hash = "sha256:e99befa0b48f3cd293dafeacdd0d191804d105d279e0b387a32054c1180f3161", size = 334362, upload-time = "2025-11-05T18:38:32.939Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4e/bc1dcac9498859d5e353c9b153627a3752868a9d5f05ce8dedd81a2354ab/brotli-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:b35c13ce241abdd44cb8ca70683f20c0c079728a36a996297adb5334adfc1c44", size = 369115, upload-time = "2025-11-05T18:38:33.765Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" }, + { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" }, + { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" }, + { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" }, + { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" }, + { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" }, + { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" }, + { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" }, +] + +[[package]] +name = "brotlicffi" +version = "1.2.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/97/7845739a36828ffe751a1c6b240692f552fd7ecf65026c51326c0a4aa369/brotlicffi-1.2.0.2.tar.gz", hash = "sha256:5e0fbd13644cf1f6015e75fa5e0ad8fdce1048d9c9ff90b0ce826174b249ee35", size = 478755, upload-time = "2026-08-21T17:29:18.415Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/a2/edda4f3fc7143434402eacad1e91433fe68ae648c22738eeddb6138638ba/brotlicffi-1.2.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad05ca993234cf947f0ad71b1c8bc0af3d74e0410b1e2c32bb99de0cef6a994b", size = 438789, upload-time = "2026-08-21T17:28:55.708Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/506dc8edabb3cf9339c89f1ecc80a218aa166bb83b9f2e9cc1da67314072/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0636cb5a85f31c36e08953d09a226cb788be900b976f81302895e3cf35d5e707", size = 1541246, upload-time = "2026-08-21T17:28:57.669Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d6/74cee9f9fbea8c42030a81056c64e092030a95bd2756ea83da1d1e8f5f29/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97bae40d45ebc2a6ac7b1c9b30825496a257192194b672ef5869e2df93467f69", size = 1542129, upload-time = "2026-08-21T17:28:59.502Z" }, + { url = "https://files.pythonhosted.org/packages/24/cc/c32630b042ec2a13e8342e6ecb6b9d3531b1be4647b733d6fd365976041c/brotlicffi-1.2.0.2-cp314-cp314t-win32.whl", hash = "sha256:8f3f9bd61293dc48359763e693951393f39656086315067cf97e23e23e8911ab", size = 346840, upload-time = "2026-08-21T17:29:01.085Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0b/83cac3075721fe4c253ea1cc5310cb687c2f7d987e0fd60eb3ed769c24c0/brotlicffi-1.2.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:908add8a9c0eea00f5de799dc6de9f6d205d9ee11afabc7c03d6812c481200e2", size = 386079, upload-time = "2026-08-21T17:29:02.667Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/c27f24b8334f65f2492601c7764338f156cb904d2ffe0061e6004a76d9cc/brotlicffi-1.2.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5a8ffa154f16660ab818d78045b55fa6f9970f1ca4c38998766e99c672071cb", size = 438885, upload-time = "2026-08-21T17:29:04.113Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/d8fd1a4d09b7ab563b89380395e09151d2ef1344be31594df6a6987d4028/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6b1af7b7a8ce788354f2c603651ada0fba166ec31ab879e2eec462a3e6dbf4", size = 1534365, upload-time = "2026-08-21T17:29:05.878Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/076419ed6c2c6aa3eaac6fd6b076502b4be89d50625fcdc513cd4aeca718/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22916101de0e7ff535f2edf54b52a85591853b8ae9a98737643defdd3c063a3a", size = 1536851, upload-time = "2026-08-21T17:29:07.599Z" }, + { url = "https://files.pythonhosted.org/packages/35/dd/31ae9945cbd605339fb51c9a609f7dbb182cd361adeabc1d470142357206/brotlicffi-1.2.0.2-cp39-abi3-win32.whl", hash = "sha256:df1d34c4ad9adbf7f63a6b42f7d0e4dfd259c88141b85145b57abecc1abc3b24", size = 342379, upload-time = "2026-08-21T17:29:09.05Z" }, + { url = "https://files.pythonhosted.org/packages/95/ae/afd54e744df93b51cc29f6a19beccf9998b25743d7177697390de10479d1/brotlicffi-1.2.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:489ca4da3ee65926d72bf01584b61088a9da6bdd1bb01b2040901e1beaffa8f0", size = 379761, upload-time = "2026-08-21T17:29:10.687Z" }, + { url = "https://files.pythonhosted.org/packages/37/da/a5b65a86725d772504a348193cf1fab5ad6410794b422bf81faa17a96a66/brotlicffi-1.2.0.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:cf500bb9e02e1474ced1ecf22f74c568de2816b3627af6352ec51ac5e09e60ee", size = 407459, upload-time = "2026-08-21T17:29:12.385Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c7/a253288e66ee340f2f6320eda7022daa723f2918438d586a59e9c998aa27/brotlicffi-1.2.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbb81489562dd5363bf86d9a8edb0ec8c97049b0819ba4936fc023e8847248bc", size = 406825, upload-time = "2026-08-21T17:29:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/ea8e3d34e1d64c5e5a920bb0c89bf9e92badf973937a60922820395e622d/brotlicffi-1.2.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc7647657e4f3d73eab591910dbecb57d1ecaea7aa3dd04e6d704a2756fe0c59", size = 402903, upload-time = "2026-08-21T17:29:15.524Z" }, + { url = "https://files.pythonhosted.org/packages/4e/17/17c22d48819001ca08cadab63b09b00e0c56a7579478aa7c2623f4280de6/brotlicffi-1.2.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5eb5563173afb92c9111b180349ff17d7c83c79febabadca5de983b552565c3c", size = 378395, upload-time = "2026-08-21T17:29:16.857Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642, upload-time = "2026-09-30T04:35:06.91Z" }, + { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280, upload-time = "2026-09-30T04:35:08.448Z" }, + { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335, upload-time = "2026-09-30T04:35:10.104Z" }, + { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707, upload-time = "2026-09-30T04:35:11.575Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747, upload-time = "2026-09-30T04:35:13.022Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972, upload-time = "2026-09-30T04:35:14.606Z" }, + { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339, upload-time = "2026-09-30T04:35:16.087Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683, upload-time = "2026-09-30T04:35:17.488Z" }, + { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143, upload-time = "2026-09-30T04:35:18.849Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358, upload-time = "2026-09-30T04:35:20.326Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766, upload-time = "2026-09-30T04:35:21.72Z" }, + { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432, upload-time = "2026-09-30T04:35:23.273Z" }, + { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470, upload-time = "2026-09-30T04:35:24.655Z" }, + { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688, upload-time = "2026-09-30T04:35:26.225Z" }, + { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932, upload-time = "2026-09-30T04:35:27.796Z" }, + { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828, upload-time = "2026-09-30T04:35:29.259Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946, upload-time = "2026-09-30T04:35:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619, upload-time = "2026-09-30T04:35:56.863Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701, upload-time = "2026-09-30T04:35:58.639Z" }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506, upload-time = "2026-09-30T04:36:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626, upload-time = "2026-09-30T04:36:01.77Z" }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624, upload-time = "2026-09-30T04:36:03.389Z" }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731, upload-time = "2026-09-30T04:36:04.987Z" }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352, upload-time = "2026-09-30T04:36:06.412Z" }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393, upload-time = "2026-09-30T04:36:07.865Z" }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660, upload-time = "2026-09-30T04:36:09.248Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996, upload-time = "2026-09-30T04:36:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028, upload-time = "2026-09-30T04:36:12.294Z" }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562, upload-time = "2026-09-30T04:36:13.887Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699, upload-time = "2026-09-30T04:36:15.41Z" }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030, upload-time = "2026-09-30T04:36:16.839Z" }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565, upload-time = "2026-09-30T04:36:18.468Z" }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627, upload-time = "2026-09-30T04:36:19.938Z" }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407, upload-time = "2026-09-30T04:36:21.376Z" }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207, upload-time = "2026-09-30T04:36:22.776Z" }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489, upload-time = "2026-09-30T04:36:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501, upload-time = "2026-09-30T04:36:25.846Z" }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010, upload-time = "2026-09-30T04:36:27.669Z" }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266, upload-time = "2026-09-30T04:36:29.434Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783, upload-time = "2026-09-30T04:36:30.872Z" }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449, upload-time = "2026-09-30T04:36:32.625Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815, upload-time = "2026-09-30T04:36:34.197Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095, upload-time = "2026-09-30T04:36:35.803Z" }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942, upload-time = "2026-09-30T04:36:37.407Z" }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762, upload-time = "2026-09-30T04:36:38.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514, upload-time = "2026-09-30T04:36:40.469Z" }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056, upload-time = "2026-09-30T04:36:42.02Z" }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311, upload-time = "2026-09-30T04:36:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190, upload-time = "2026-09-30T04:36:45.185Z" }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098, upload-time = "2026-09-30T04:36:46.689Z" }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228, upload-time = "2026-09-30T04:36:48.31Z" }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401, upload-time = "2026-09-30T04:36:50.091Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846, upload-time = "2026-09-30T04:36:51.719Z" }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151, upload-time = "2026-09-30T04:36:53.234Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570, upload-time = "2026-09-30T04:36:54.816Z" }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412, upload-time = "2026-09-30T04:36:56.643Z" }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965, upload-time = "2026-09-30T04:36:58.205Z" }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406, upload-time = "2026-09-30T04:36:59.764Z" }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871, upload-time = "2026-09-30T04:37:01.543Z" }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711, upload-time = "2026-09-30T04:37:03.111Z" }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146, upload-time = "2026-09-30T04:37:04.721Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552, upload-time = "2026-09-30T04:37:06.295Z" }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810, upload-time = "2026-09-30T04:37:07.905Z" }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845, upload-time = "2026-09-30T04:37:09.554Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825, upload-time = "2026-09-30T04:37:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659, upload-time = "2026-09-30T04:37:12.941Z" }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819, upload-time = "2026-09-30T04:37:14.659Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973, upload-time = "2026-09-30T04:37:16.346Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692, upload-time = "2026-09-30T04:37:18.212Z" }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757, upload-time = "2026-09-30T04:37:19.877Z" }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220, upload-time = "2026-09-30T04:37:21.583Z" }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797, upload-time = "2026-09-30T04:37:23.235Z" }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765, upload-time = "2026-09-30T04:37:24.891Z" }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985, upload-time = "2026-09-30T04:37:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676, upload-time = "2026-09-30T04:37:28.329Z" }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414, upload-time = "2026-09-30T04:37:30.167Z" }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775, upload-time = "2026-09-30T04:37:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877, upload-time = "2026-09-30T04:37:33.661Z" }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455, upload-time = "2026-09-30T04:37:35.573Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336, upload-time = "2026-09-30T04:37:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584, upload-time = "2026-09-30T04:37:39.522Z" }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854, upload-time = "2026-09-30T04:37:41.23Z" }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767, upload-time = "2026-09-30T04:37:42.883Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479, upload-time = "2026-09-30T04:37:44.658Z" }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310, upload-time = "2026-09-30T04:37:46.529Z" }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100, upload-time = "2026-09-30T04:37:48.399Z" }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936, upload-time = "2026-09-30T04:37:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301, upload-time = "2026-09-30T04:37:51.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858, upload-time = "2026-09-30T04:37:53.427Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309, upload-time = "2026-09-30T04:37:55.015Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343, upload-time = "2026-09-30T04:37:56.743Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114, upload-time = "2026-09-30T04:37:58.607Z" }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997, upload-time = "2026-09-30T04:38:00.511Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313, upload-time = "2026-09-30T04:38:02.509Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986, upload-time = "2026-09-30T04:38:04.435Z" }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202, upload-time = "2026-09-30T04:38:06.549Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083, upload-time = "2026-09-30T04:38:08.311Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081, upload-time = "2026-09-30T04:38:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712, upload-time = "2026-09-30T04:38:12.317Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176, upload-time = "2026-09-30T04:38:14.138Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413, upload-time = "2026-09-30T04:38:15.841Z" }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434, upload-time = "2026-09-30T04:38:17.626Z" }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716, upload-time = "2026-09-30T04:38:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159, upload-time = "2026-09-30T04:38:21.037Z" }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322, upload-time = "2026-09-30T04:38:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192, upload-time = "2026-09-30T04:38:24.648Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cssselect2" +version = "0.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tinycss2" }, + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/00/2456b6b664c7a770989cbe3c352aac4eb962c938486f03a2e1255ae963c6/cssselect2-0.10.1.tar.gz", hash = "sha256:83b0d820ef589dabaf693289b647c2f5b410f76d285f56deba911ffa75a7b9d1", size = 35653, upload-time = "2026-08-31T21:57:42.59Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/59/6b1daa3b94de8970e2a2787ba73616c2d0675d2f948ef4cad8bef7f21bc6/cssselect2-0.10.1-py3-none-any.whl", hash = "sha256:25cc4494d55985d6a6da359be48da6ce98c28dcbafa2314c383ace3fc32ec868", size = 15489, upload-time = "2026-08-31T21:57:41.162Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "fonttools" +version = "4.66.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/87/b6/126c659ab7e0e03e01a5f5d223abf7b2c0691ae92718085a212a3924a2a3/fonttools-4.66.1.tar.gz", hash = "sha256:64967c6ddb0d4c610dfd8cb1485981b2d27972ddfb7d4bbbd9e199d2a089c450", size = 3694174, upload-time = "2026-09-29T16:11:53.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/2e/2c6d3daaa5152bbb2fc2b44037399366af7eb5fb2fbf7d9a236813753f77/fonttools-4.66.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d4f76868aea9cc4ce47fdbeaa904c02ee7d85dd0ad095071ae77f0bda6e62cf5", size = 3089406, upload-time = "2026-09-29T16:09:50.871Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1c/500fbc0fd5b6d9cb701c1107a6f38ec4d3319057681e520014c1b9beb009/fonttools-4.66.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:34378db9a398b59de18cc79d942f0a907c6fc6301945e065ec888202f607aa3f", size = 2586728, upload-time = "2026-09-29T16:09:53.654Z" }, + { url = "https://files.pythonhosted.org/packages/38/f2/f3ac6374058bc93bd8a685c4849815b598bbd5e3ec5f706f3f4944a060e1/fonttools-4.66.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05c0fff6b4a5d872ed89cab2c4f81060b86ace263903eb4e8d0edcac47a60dfa", size = 5484428, upload-time = "2026-09-29T16:09:55.987Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e0/ed45f50fe7a7320656ac7dde60f26afa3a92a21e14749135b4c03f3385b7/fonttools-4.66.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72299346b96b9244dabcc051b24e4653da4edfda6105544cfb10ce856a1afaac", size = 5447695, upload-time = "2026-09-29T16:09:58.02Z" }, + { url = "https://files.pythonhosted.org/packages/3c/c0/919293f7b38ff81a6014a7fce45fbcc113aaf473f22180a7f7897c702a67/fonttools-4.66.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c724e56213494c6695335577822b2d1628d102e71614de8b7eb8e30886d6a314", size = 5450236, upload-time = "2026-09-29T16:10:00.105Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2a/00864b96e013a05df55b347b3eb9b1726803267d52345676cd86e928ddf1/fonttools-4.66.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b913b8e9f7ca9bec44d1eb919f591c596c61041aa357c96be55ff93169859e91", size = 5588056, upload-time = "2026-09-29T16:10:02.225Z" }, + { url = "https://files.pythonhosted.org/packages/8c/88/7b0259de6d874686532a781059fd85796dcd3e07a12146361092141169f3/fonttools-4.66.1-cp311-cp311-win32.whl", hash = "sha256:e7ea7a08547a453fa000db96ed5714a3dc7e2b4255b9243f897921f8c10c169a", size = 1577702, upload-time = "2026-09-29T16:10:04.082Z" }, + { url = "https://files.pythonhosted.org/packages/39/ff/ccaddfb8ac343e90f40f86fa460f832cd45460c65ee9288b6da923c72728/fonttools-4.66.1-cp311-cp311-win_amd64.whl", hash = "sha256:36bb24d4b98faacaff04af1d5e0a4285feba6ed1da6728cd34b6b6deb6bbb934", size = 1635298, upload-time = "2026-09-29T16:10:06.026Z" }, + { url = "https://files.pythonhosted.org/packages/06/1b/fcb22638f2f5918c855abfbab203701e4b03739953d26a59f3e6e59b8f30/fonttools-4.66.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8526b2b7ec4db6b81efb83438be52b1264eda9a4994d867163cfe8c65581ce8d", size = 3097433, upload-time = "2026-09-29T16:10:07.869Z" }, + { url = "https://files.pythonhosted.org/packages/7f/0d/f51141407f9a64efc9fb39b94b0194c4a99c1ffff50834e7ca7a3f1cdd53/fonttools-4.66.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6946fe7bfb28590a1fd4061a17609c9a843952deb65dcf30d1fe725070c3e7a4", size = 2587566, upload-time = "2026-09-29T16:10:10.099Z" }, + { url = "https://files.pythonhosted.org/packages/75/c0/5810d73f9102eb1a08f26b8f7a6c22498622b43e05e684cd5ec602be8ffd/fonttools-4.66.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09ae73bd219e1245debd8376077a0fa6e03175e255c4f51bae5f6a271bfe384a", size = 5416460, upload-time = "2026-09-29T16:10:12.182Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6e/babde908b879a3b51ffc230919d06a804912559dd4cf8c94f3fa2af69fec/fonttools-4.66.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b8ff9e0edbcee2fbf7dff0c41b9041c1901c26acf64e23adb67495012df11de", size = 5397326, upload-time = "2026-09-29T16:10:14.133Z" }, + { url = "https://files.pythonhosted.org/packages/e6/98/8522cc7a5e5ad64a2b2b6e9598489809ed4956c532b887c65b131b8500b7/fonttools-4.66.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:38ce8f5fbd5c17dd2153d47d7c8d4108f3deda3f2b4a79b60ddc470a58faded3", size = 5353857, upload-time = "2026-09-29T16:10:16.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/f9/ab87d67c23178886e57f24397b3113b4ac35297f086467c2c4d4231671a8/fonttools-4.66.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8aed2bbcd6216253ef1b015763593365ee8084f621dfa53bb957c19d5e05f7cd", size = 5517604, upload-time = "2026-09-29T16:10:18.791Z" }, + { url = "https://files.pythonhosted.org/packages/d4/20/e126062610aea31919310b0b3de4d0bbe73ccbdcb3d0409cc52027db93ee/fonttools-4.66.1-cp312-cp312-win32.whl", hash = "sha256:9ea6c93091cbf83161a544388746a0911550bd98cb911faca3591cf5ead166ac", size = 2435084, upload-time = "2026-09-29T16:10:21.145Z" }, + { url = "https://files.pythonhosted.org/packages/f0/af/5c245a0587e5b7b3dd209f640b9f70d8e63404ad8dc93819ba578d685985/fonttools-4.66.1-cp312-cp312-win_amd64.whl", hash = "sha256:261d8dc95845e751f975fe8d6075600593ee253470d46d1b84801688051b09f6", size = 2486987, upload-time = "2026-09-29T16:10:22.924Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f4/e410b8c913da5b3fdbb4d16db0f2d2a0952f59c4db8d52dcf2d421d82044/fonttools-4.66.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:53e5854ea8003efec34adc0863c18ce91da923018354d27366f7fee7db928d7a", size = 3095249, upload-time = "2026-09-29T16:10:25.261Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6a/275108baf41d9f2f4d1d77cf5f1e22200fe47efd5099dafabc3eba0b6197/fonttools-4.66.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:60f5ea17aed4262630afa43f26997ceabd6417fa05dcedf54c665f5a29193e18", size = 2587288, upload-time = "2026-09-29T16:10:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/db/e7/11e5e6beb7e336d80f0ca870ae080033a91ebfe34fd5390dbcf78f8df56f/fonttools-4.66.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1801fdad5600118327171e0e8aa79f7cc48831dd55ab36998c9de03bd5ffe6cd", size = 5389836, upload-time = "2026-09-29T16:10:28.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/1c/6ec22372362b03350fe3da7bf33491a07cc9a553a36dd2383b76ec1741eb/fonttools-4.66.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:83572afe48733bad7a4a9c11721d3a726c2e976d82b063fc9bdd049d76955abd", size = 5372059, upload-time = "2026-09-29T16:10:31.011Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4a/cb7971f1c0f40f891028ee8c46dadc6897ef61e44aa925a23fba2ef06e2a/fonttools-4.66.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:08d8956e3ec990c75230d92f1630b215e8f3738c83a003421c22b31ebfd0ce15", size = 5332824, upload-time = "2026-09-29T16:10:33.563Z" }, + { url = "https://files.pythonhosted.org/packages/e0/86/563e671f1d43fa8ffb2518d7fe16630fb16c7faf0420cc39f8e80181f486/fonttools-4.66.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fdf4afd75c643e60ef4a96fe64fc8a9def27d2a542112332371a9e5066885f9a", size = 5490659, upload-time = "2026-09-29T16:10:35.788Z" }, + { url = "https://files.pythonhosted.org/packages/79/f7/2573ddfd256be6503458f8523e2893443e66257fc17f6055d7e0f0e721b7/fonttools-4.66.1-cp313-cp313-win32.whl", hash = "sha256:dbb7b950f8c02deaffb6968994691e8589d671b7ef8396bc9d5b5c0dfbb7292f", size = 2433450, upload-time = "2026-09-29T16:10:37.738Z" }, + { url = "https://files.pythonhosted.org/packages/d1/86/68bc2be04b83535607fbb70ebb2ba02380bf4286d79597c4515b7d247187/fonttools-4.66.1-cp313-cp313-win_amd64.whl", hash = "sha256:43d1284c1964666ee833f2badd3017dc138f53d4889043ffca66c5ce4188f188", size = 2485146, upload-time = "2026-09-29T16:10:39.772Z" }, + { url = "https://files.pythonhosted.org/packages/12/83/c745b210ec49379ebfe627e166b527f44671a1f6ec5e1e219d91caa8964d/fonttools-4.66.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b18803cbdef248e7ee1be59cb277fbbe1da1faaa6f726fa5d3557904e6a3d967", size = 3099264, upload-time = "2026-09-29T16:10:41.998Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/dd698f10bf0f743873077259e8a6fce075861dde3bb01eb22b2c4f7aefe8/fonttools-4.66.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f08ab7f8461c37ecfdd29ad97fb0c0780b50501bd664bb0f46b6e83ed2b9d2a7", size = 2588769, upload-time = "2026-09-29T16:10:43.933Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/10b5caa2aa779e62411b67949bda9741d4d7532ba0b6dea647b715131260/fonttools-4.66.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7cf4f996f9b1cb549bff9ea4c50813988a26ec922c95cfa85c7e4f1270447e06", size = 5374278, upload-time = "2026-09-29T16:10:45.727Z" }, + { url = "https://files.pythonhosted.org/packages/6a/db/9ac5c6773feec1b40e57eac106d869886f66a1e44082d343ac1e1e1fb773/fonttools-4.66.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9261ef507f2dd74203443a472b65b5a26429eb378f975016dec7dc7305b24898", size = 5317826, upload-time = "2026-09-29T16:10:48.056Z" }, + { url = "https://files.pythonhosted.org/packages/04/0a/69beb11f6b714ac90ee73ad4600ac91d7dd4e1ce361d087c8425bb8472de/fonttools-4.66.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e1cde50b3ec84ca6fe63ca815de183dbecb88e8adf8ada82d8ea130ef12b2b43", size = 5316239, upload-time = "2026-09-29T16:10:50.201Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/7a77359e469d3a638df91d3e225cef4a3c1184c20e98381238042f7835fa/fonttools-4.66.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d8f0a8f16c4f3a5a87ca971de2631792d8cb4d570951f2000acf712f157d40db", size = 5449423, upload-time = "2026-09-29T16:10:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/93/cf/ea0b2f1ef90431b1879d6e6c680a7fde497129cf511ab995ade0ff8e19a7/fonttools-4.66.1-cp314-cp314-win32.whl", hash = "sha256:b878c78b2af11b879bd4f26bb0d8bda2a4c64543fdd3f28efe2c80f97f043885", size = 2437673, upload-time = "2026-09-29T16:10:54.281Z" }, + { url = "https://files.pythonhosted.org/packages/b2/53/629dbb4a40c4a7b3de61442c6b4430d36ab6e0e8cf941c547f4fd66f3337/fonttools-4.66.1-cp314-cp314-win_amd64.whl", hash = "sha256:05aeb146451f37289f782c3c861f3d0f4b86c2dd2e4620b46683544c7406640e", size = 2489763, upload-time = "2026-09-29T16:10:56.262Z" }, + { url = "https://files.pythonhosted.org/packages/0e/59/342e5fce9438f88882524128d1feb0311d4014cb6f8bdeb4607fcc00713f/fonttools-4.66.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:66fad3b7874062c2a2692f0ae6dea56d24f01b778c7f191950ca3ff997e25a88", size = 3172931, upload-time = "2026-09-29T16:10:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/50/92/96196ebfd02676f28fa9b3776d85e18281bca0c8450d7e214c40e346bf92/fonttools-4.66.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eef76d5796e604f9d6753fa6d323c4eb9f4e0e43f1dcca553f3e6914f1667b64", size = 2621937, upload-time = "2026-09-29T16:11:00.845Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c3/3f4b761037ebc2e5597c52c218a9e95dbc4a2cab572828654f6004f422f5/fonttools-4.66.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c47299bca4b5acaaeb32100f77b944feea151de9ef1773365a410dc3d49b945b", size = 5546932, upload-time = "2026-09-29T16:11:03.126Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d1/3f506cc79608becbc287785db8c44eb3f93079b49752266eb9f57700ecc4/fonttools-4.66.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dfba62cc93199ba62c376f90f2a9147d92730d301e44f88e013e50ff5edf6193", size = 5353546, upload-time = "2026-09-29T16:11:05.394Z" }, + { url = "https://files.pythonhosted.org/packages/6d/27/6534d84430ba1641185f8a0c9e2c7ecd395b15ff98f96967e3fb3c728b09/fonttools-4.66.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2c7340497cf53490293e0c2b61011e0191633022ede0a0a964a68157a98b0fb4", size = 5414313, upload-time = "2026-09-29T16:11:07.618Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/831ceca06d78855b11dc203b0e3ba5e6fd8a63a71ee0343ea8bd367fda55/fonttools-4.66.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c666fefdd5613a0e99aa4516e6ff4ef87aa86cf1c7ba12a73550f4770e46b750", size = 5449661, upload-time = "2026-09-29T16:11:09.997Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e4/21dc18bcbc8d0354814f6ea58af3d76d3bcd9b0d7246df454cb9e00c1740/fonttools-4.66.1-cp314-cp314t-win32.whl", hash = "sha256:2ce4c93160535761f22c80b2afbc96cabc09855363a5d1a5554265b8a4c85901", size = 2471431, upload-time = "2026-09-29T16:11:12.237Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f4/eb0489e7d58ac0d3387584afc7f3e505f60f60fe4b4f5a0274f013d444a2/fonttools-4.66.1-cp314-cp314t-win_amd64.whl", hash = "sha256:b13c8c541ce0b794add3211b3641cc0e113d707f73e06235e6fe9731bd7c45a9", size = 2521288, upload-time = "2026-09-29T16:11:14.52Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/235679d5fe4265c251418cd02321de069281a700415389e14c4cce442e3d/fonttools-4.66.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:2d637468dac23aac0e223bd52e66f8faa3b0dfcef57435460fa2107e830226cd", size = 3093646, upload-time = "2026-09-29T16:11:16.809Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/7bcd4046b3b5644c563059cce6421b488fe57f65c59171ef01ed11b66d3a/fonttools-4.66.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:90de3477394c73481d27d2b86091c1c736053ee13ff52c42f0e151948e8578c6", size = 2587335, upload-time = "2026-09-29T16:11:18.721Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b6/05a093ec04fa2ad449ecc67638aad0f8d60df380df2471b68b549fe2a4b2/fonttools-4.66.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d84ac0bf776b68396185bd919dd29e633d94300660335efc40b55b294b886903", size = 5371509, upload-time = "2026-09-29T16:11:20.742Z" }, + { url = "https://files.pythonhosted.org/packages/65/a9/55effa83e64b9ff4f379d9186236d50d03f6d4770d8346805c1b6620c370/fonttools-4.66.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0dc6fd99cb8c30941036308b148da9432640442a6f26f36d71dad9be24cbd0e9", size = 5334853, upload-time = "2026-09-29T16:11:22.928Z" }, + { url = "https://files.pythonhosted.org/packages/af/a8/44bb4021c585b76f8e480116e1f3fca62eb7d88fe5794e2ec84c10d2da76/fonttools-4.66.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d3b5403e82d0c7659ff1d9f956e29a3a68d094f043e9f5bc0442796fc3a4fb58", size = 5311704, upload-time = "2026-09-29T16:11:25.393Z" }, + { url = "https://files.pythonhosted.org/packages/63/dd/dd482902fb7fd8b71d3b6508431a57938b5e41b29bf6fb252ed3cfce065f/fonttools-4.66.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b8b71db96d605784e2c5ebf0788a406018ea8fdd80338491f4c83613d5cd1fec", size = 5459477, upload-time = "2026-09-29T16:11:27.536Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a5/07611ba4d4b298b90908cb15005a6d730c334e25548f5175a09907b2eea6/fonttools-4.66.1-cp315-cp315-win32.whl", hash = "sha256:668f092bc0de8902167df6a0d5c5aedc3b4f9e43cf88eea92e9b46a2bd3968f5", size = 2436532, upload-time = "2026-09-29T16:11:29.653Z" }, + { url = "https://files.pythonhosted.org/packages/42/a5/5c39a05bf7c518743c6072cd75b63cd27285c58a70b1086e923fc071fb84/fonttools-4.66.1-cp315-cp315-win_amd64.whl", hash = "sha256:7f49f2834f5d006fe0f3bb10fec73b261806c50941f0cfbc08294074ffc32210", size = 2488769, upload-time = "2026-09-29T16:11:31.967Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a6/1205f7a7dd746581498457e55bfbcdfbea87105a454a7b3465259816bb79/fonttools-4.66.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:71c7ca1b5f46f5dd549f56b47d47c0b709217675c23d3a7bc6aa1a69b6d9bbae", size = 3164459, upload-time = "2026-09-29T16:11:33.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/42/915ff8f3c5d3bc9877007e708774e52f7ec431f9e59f607a86e50fe1864c/fonttools-4.66.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2d320483928c7831f0139ecb361954a26b2e2a8995681200155835dd8cd4a7d5", size = 2618183, upload-time = "2026-09-29T16:11:36.067Z" }, + { url = "https://files.pythonhosted.org/packages/0b/c6/cae2f6ebe38f8927a8d0978a349b202047268016344991a14ae978c2aee3/fonttools-4.66.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2aeb745f2664eb811026997c95628071137a777ea2ad296deec9cb393f0b23cf", size = 5524861, upload-time = "2026-09-29T16:11:38.099Z" }, + { url = "https://files.pythonhosted.org/packages/f2/14/1941629956b526d6fb46ee764cf0942221f0238581adb94de0ac229fe67f/fonttools-4.66.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3087a430722aba8de429c2539fd2a58a9cf05238cdfefd8626460001052ca878", size = 5346561, upload-time = "2026-09-29T16:11:40.366Z" }, + { url = "https://files.pythonhosted.org/packages/62/1f/b7e7f4757dcae74285f4ecd8453d870d63c7ba38a3d46bd9175a124c350b/fonttools-4.66.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:058cd823b80bac59e64dfad9e3b6fcd677852f9a3804971bbf6b48cc611e785c", size = 5392826, upload-time = "2026-09-29T16:11:42.653Z" }, + { url = "https://files.pythonhosted.org/packages/d9/71/76db3cbdcfac0e9b3ba26e1e6e8740040cfe5f7b5199dfb9b854bc8da2c3/fonttools-4.66.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:56d41d650cb8fc6cfe1d85ed7c62a0a56cbeed07bc65ca795475b914d401312a", size = 5439233, upload-time = "2026-09-29T16:11:45.088Z" }, + { url = "https://files.pythonhosted.org/packages/10/37/cdc6b213c9fbabdf36e9169f845e8596b419c7e0cceba48e5594b952d2cf/fonttools-4.66.1-cp315-cp315t-win32.whl", hash = "sha256:c258eba62260beb33c110b03a6912cefa3635239c4ab5615b7225fb6f7b85238", size = 2468614, upload-time = "2026-09-29T16:11:47.363Z" }, + { url = "https://files.pythonhosted.org/packages/fb/35/e2247e7e29e8da213e02691a6ada7a30592c7bc0d1db8d2786ebb9bea138/fonttools-4.66.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5de5d80fbc0e50ff794c244e8fb7afd3eadfe0fa232ba8b162b8c551df22fcb4", size = 2516977, upload-time = "2026-09-29T16:11:49.425Z" }, + { url = "https://files.pythonhosted.org/packages/f6/10/d45b74135d5d642cb3a4fb0a957c1613ef93de4c8548671dfc3a5bf38299/fonttools-4.66.1-py3-none-any.whl", hash = "sha256:7234ae9e28db64273fbbfa72caebd0a97e3bdba6b05064114741b9539ef339d0", size = 1202222, upload-time = "2026-09-29T16:11:51.678Z" }, +] + +[package.optional-dependencies] +woff = [ + { name = "brotli", marker = "platform_python_implementation == 'CPython'" }, + { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'" }, + { name = "zopfli" }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "hatchling" +version = "1.30.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/63/4c/8717ccb844b4fa5a5ba6352e97d743ed24e9a22cf90b7c109c17030a46a1/hatchling-1.30.1.tar.gz", hash = "sha256:eee4fd45357f72ebb3d7a42e5d72cfb5e29ed426d79e8836288926c4258d5f2e", size = 56929, upload-time = "2026-06-02T00:09:41.487Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/49/2797ec0ef88008a653a8867bb8d1e5c223cd2df8e40390dd5c6a0279cbc5/hatchling-1.30.1-py3-none-any.whl", hash = "sha256:161eacafb3c6f91526e92116d21426369f2c36e98c36a864f11a96345ad4ee31", size = 77489, upload-time = "2026-06-02T00:09:40.139Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +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/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "linkify-it-py" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/45/98/7a1a5f31fd5c7ba93e963b168e244b8e3dd705b3d2a718e3c3307583bf57/linkify_it_py-2.2.0.tar.gz", hash = "sha256:907acd2d17ac1fbb9ddb62c8957ccbd6158cac602231a15c3b0cd1e215f03cee", size = 32939, upload-time = "2026-08-29T07:07:08.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/d4/1152d1c7ab42d8b908be64fd200ddc870dc9d4925e951198702084aa1a7d/linkify_it_py-2.2.0-py3-none-any.whl", hash = "sha256:3adc40eb5af300b2605fcfdb968c24e1d780a90f1f2221af7c15e5111e94d443", size = 21971, upload-time = "2026-08-29T07:07:07.164Z" }, +] + +[[package]] +name = "markdown" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/4f/700155c8c20d9e655dd0732b5fc3c7614f291b9148da271d7388e50bf774/markdown-3.11.tar.gz", hash = "sha256:180224db6aed87ba9ce1f2781ebcd5826253de8ff637112090e24b84502bbf9f", size = 485623, upload-time = "2026-09-25T13:46:23.473Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl", hash = "sha256:cd6c89e7eb308c8b332ed673215a52d208a43f8bacc030b1419376129408719e", size = 111296, upload-time = "2026-09-25T13:46:22.163Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[package.optional-dependencies] +linkify = [ + { name = "linkify-it-py" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/55/18dbb4778b30ada5ce071608503cc3edc9e14e13d868c17a6d178fc30f7a/markupsafe-3.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9e25feb9e330b63edb0278a0acdf85e50d0cb0fbf49c3084abbe4e24ae195346", size = 11520, upload-time = "2026-10-02T23:04:36.299Z" }, + { url = "https://files.pythonhosted.org/packages/6c/14/0b05f79b4733e264a18d08fe08fa1df7347630ff32a6cb82180d9dccec55/markupsafe-3.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7d3391b2188d18737cb2fa147028b1096236eaa7e156446c650a489fa2cadc91", size = 12063, upload-time = "2026-10-02T23:04:37.291Z" }, + { url = "https://files.pythonhosted.org/packages/ca/3a/63ba10b6c1463216b3e4df669a9f0e5a3b0c3071557d2e8229e3968c79fb/markupsafe-3.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849dd2bb0e5e4ab2b71c7191726a4a8d5aa8a610daa584728cbee0b710ddc4ef", size = 24321, upload-time = "2026-10-02T23:04:38.262Z" }, + { url = "https://files.pythonhosted.org/packages/1a/2e/5f015261b76ad633d187ef6f388b413aedd64a8773c4df59e530a0be5525/markupsafe-3.0.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:befb4158af32106b9a93db8d6d1d1cbbd418c0d5aca0cabb7b1780abf0c89169", size = 28845, upload-time = "2026-10-02T23:04:39.295Z" }, + { url = "https://files.pythonhosted.org/packages/33/cf/26e594b26be40c2f1fec63ccf8a8b99d0335a5b2cbe84835c7d82a994375/markupsafe-3.0.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:71f88e749ea29f67f21f3b36433c1dc54c7729ed2a6d9e2da2e0d9e0d7b224eb", size = 24701, upload-time = "2026-10-02T23:04:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/81/a5/a513b76c139a3915b43404324e55c0b7979ae4f0d39eb6f075b0282e90a8/markupsafe-3.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6da83a088f8ef93b2d483a8232a4dbf4d69d3d8496b568a03c56becac43e1808", size = 22961, upload-time = "2026-10-02T23:04:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/46/cf/4c66192c100b4542bcbe392ae06696b670f66927be3ac38a213234778ff9/markupsafe-3.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0fac8b13d14bb06c68195f849371924ae53dd7b1c00fed24650f704383b692", size = 21920, upload-time = "2026-10-02T23:04:42.429Z" }, + { url = "https://files.pythonhosted.org/packages/cb/17/ac3662678bfbad649893117ada2ba44dc30bf56884e84f13154a792b10f1/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4a7cdc2a420ca01058182da4253329764d4bfa055564d1eced90e6ba1e8b1d3d", size = 23726, upload-time = "2026-10-02T23:04:43.531Z" }, + { url = "https://files.pythonhosted.org/packages/f7/af/fe47cee339180a69ebca3c57fb3483d0f5cbd1e8337d1871fb1d9c1aebee/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:83b3944fea42a8400edf92fd1770fb8d0d4f7de651353bd2d8525a92dba69a21", size = 22539, upload-time = "2026-10-02T23:04:44.618Z" }, + { url = "https://files.pythonhosted.org/packages/12/32/d55440ba140442800e02d799c9cb5ab597bf6ebdb1177b5ea39a11f797bd/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:8138eb83940ec7299024d92d4dee45f601b9e6c5ffde9d25f4e35e326203c707", size = 23963, upload-time = "2026-10-02T23:04:45.656Z" }, + { url = "https://files.pythonhosted.org/packages/50/9d/9c86042cb364c2ad4c971e6d1247929effd25f714cd7ee11b05e6316445b/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:811d02d5122171c1941357efd8f9bf4ffe907b7f0a1a4e729a880e4be3f46e3e", size = 21506, upload-time = "2026-10-02T23:04:46.671Z" }, + { url = "https://files.pythonhosted.org/packages/75/ef/5b824f03ba40c3b3652b6272d083d2fc4fcdd440de519a3a39ba2c3e7262/markupsafe-3.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:50b5bedc9ed8a94fc8857a42ef4f84a81ea88f8d4f05dc8705fb23ee6d8dcca7", size = 22955, upload-time = "2026-10-02T23:04:47.698Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094, upload-time = "2026-10-02T23:04:48.788Z" }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328, upload-time = "2026-10-02T23:04:49.801Z" }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188, upload-time = "2026-10-02T23:04:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" }, + { url = "https://files.pythonhosted.org/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" }, + { url = "https://files.pythonhosted.org/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" }, + { url = "https://files.pythonhosted.org/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" }, + { url = "https://files.pythonhosted.org/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" }, + { url = "https://files.pythonhosted.org/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" }, + { url = "https://files.pythonhosted.org/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" }, + { url = "https://files.pythonhosted.org/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" }, + { url = "https://files.pythonhosted.org/packages/6d/18/4bc5ba32499e87bb2b0ef5b3a9bb9c00a131fa961ddf0be548cb550f548b/markupsafe-3.0.4-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:de8b364c423ef0a4bad9069657d617f9a5d2b2062457a89b1fa16ee199c399c1", size = 12786, upload-time = "2026-10-02T23:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6f/17f0c099bf25f3e31e63cc19244d9f6af861a9a4ab778c203997903cfdd0/markupsafe-3.0.4-cp313-cp313-android_24_x86_64.whl", hash = "sha256:34bdde374c5932765d7dc685c4a1d191a3207852d67e8e0a9eb6ea85156181f1", size = 12510, upload-time = "2026-10-02T23:05:09.93Z" }, + { url = "https://files.pythonhosted.org/packages/11/af/1a141081b905036ee904ec4bd945e1f70b4e1b32d33c4e59e8cf1d58b247/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:6bd9e1788e15bfcf6a9082de42e30387e7b85d211ab21e57a939bb8cfaaf8d96", size = 11583, upload-time = "2026-10-02T23:05:10.884Z" }, + { url = "https://files.pythonhosted.org/packages/e7/0a/a89385ae590232622a03e091805cff12f24fabe6c11e0e8bae096cece81c/markupsafe-3.0.4-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5066b244f576f91afc8ee3ba029a89f99d39c79b1853fe9d39bea9f0afbec148", size = 11801, upload-time = "2026-10-02T23:05:11.913Z" }, + { url = "https://files.pythonhosted.org/packages/ed/85/ea548dc013962eb73653124bc595635fbf9e0fa41d1f181a967ccb784dfb/markupsafe-3.0.4-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7a83aa6e4805df46fed18e989d3d16f86ef60cb50bbc8d9ce3a6be89165fbf6e", size = 11443, upload-time = "2026-10-02T23:05:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/cc/72/15f2e5ec9cf2eb00d5cdfe968d94e4156a7bd7303832c3f3b2c403a36839/markupsafe-3.0.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2d1b7d9308288661f56672b1b157d75fc536714d3638487bbea17b6318a78248", size = 11665, upload-time = "2026-10-02T23:05:13.829Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e0/4030bea613677e333c8a2c901fd405055f657f9d06acba5b7357984b6ef7/markupsafe-3.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73e77980c7207854f00fc4e71fb1626868d5740ab4012623d55c7a99ad122a72", size = 12061, upload-time = "2026-10-02T23:05:14.807Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a5/28b76a7449eb702966b88bef599e2360b411fbb3afeee8fe560939be06ec/markupsafe-3.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7018d4af1cd272e847aa5917983ab5e83e4f6579f9dbfecd4a79c0ca80b144c2", size = 24395, upload-time = "2026-10-02T23:05:15.909Z" }, + { url = "https://files.pythonhosted.org/packages/07/6c/21232811afc3a063b5e934b1ae2efda52f46154ec382f585149c020e61fe/markupsafe-3.0.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c90d5b3d4e944e065a301d741b3c1d784f6bd1f503aa68b4967e32b2ba313d85", size = 27183, upload-time = "2026-10-02T23:05:16.976Z" }, + { url = "https://files.pythonhosted.org/packages/14/38/6ccdfa5b59049cb36fb80cbc80aee9cf1fc9bb77d1335ad435f2070b08cf/markupsafe-3.0.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18a801868a884f216e784d7d14db2a4077143ce7610440aee2ce8f734e7cfcde", size = 24772, upload-time = "2026-10-02T23:05:18.209Z" }, + { url = "https://files.pythonhosted.org/packages/63/e0/cec6865dfe88cb48fedd4b20aed6af5158e41092adcbf3e028bcc6ec2108/markupsafe-3.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:434139499bb20b502ed3baa1f169e618f924a97e7a777fea1a49446d80106cf6", size = 23017, upload-time = "2026-10-02T23:05:19.286Z" }, + { url = "https://files.pythonhosted.org/packages/ee/76/6ed4940bb7648a9aac457c14f870cfdd5105f139a0fb1f29cd61fafa47d1/markupsafe-3.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9e227f3dbe6bde7491cf0a9965d00b88c6b1a4a95d11480ddf88bb96d397c19f", size = 22036, upload-time = "2026-10-02T23:05:20.352Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4f/ed476226d4fe46a09090a36025bf319296810028df55eb12f1253b540f3a/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b8cd1f918b26fd7b1832ece557cc18f2d8747309ff8b3f0ef9d4250c5ad67a39", size = 23786, upload-time = "2026-10-02T23:05:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/9a/35/66ff30450e35ef5fba9ebc930c9411747e537fd9447b65e44f5007e2b84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a5fcffb37e602b0b3c1638a97746b9b96125caa9bcf6fa41d337a9261de231ee", size = 22672, upload-time = "2026-10-02T23:05:22.922Z" }, + { url = "https://files.pythonhosted.org/packages/32/0b/72f45ce4b4efcbca4b80cf1b06703eff0be8d37e82abb78f66c85a7ead1e/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5989cb26b2e1efc6a42216a9f6b5ee495ce5ace2e5b352a9af489976b32d1ee2", size = 24107, upload-time = "2026-10-02T23:05:24.175Z" }, + { url = "https://files.pythonhosted.org/packages/d2/03/71776e5fdcba04614b384cc102e8a4198208579d896fd1394cb7cb9aa900/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:add96447a86d205ab616665d53b2950ee81083757f56e6ea833c8b2917646b46", size = 21615, upload-time = "2026-10-02T23:05:25.215Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/801ce02a02e7aee0f784b1ec7843026178f6adeb9c93ac67eb1992a9a84d/markupsafe-3.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2628d3a8cb648ecebb3c5d6b0a1052d400e4d8b7ac0fb786be8d285b50040d17", size = 23076, upload-time = "2026-10-02T23:05:26.423Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118, upload-time = "2026-10-02T23:05:27.716Z" }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313, upload-time = "2026-10-02T23:05:28.749Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189, upload-time = "2026-10-02T23:05:29.917Z" }, + { url = "https://files.pythonhosted.org/packages/5c/c3/a944f3b0df22bd129e96915b9f4e98d2eeca6516687d7618304a966c3c74/markupsafe-3.0.4-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:4ed644d75aa94a2baf7ec3a96eaa160ea58c742eb9d27c6506053c5c40fc84ed", size = 12787, upload-time = "2026-10-02T23:05:30.971Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d6/a44863f69d88b6c7e27889108f70d47aed259edf89d5df3c5fca1eac87d6/markupsafe-3.0.4-cp314-cp314-android_24_x86_64.whl", hash = "sha256:6d2a9efe686f9de00d0d1ea32a4a5a86d558a2277501bd78d964214eab625e59", size = 12525, upload-time = "2026-10-02T23:05:32.263Z" }, + { url = "https://files.pythonhosted.org/packages/17/8f/168ba80e532dd6a93f96f8f706f1ad41d7990b6e1aeedc1cc0d211a33497/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8781a792a070cf2bd1b86d3aa943894115faaba6e88122a7bf32d62072742453", size = 11589, upload-time = "2026-10-02T23:05:33.251Z" }, + { url = "https://files.pythonhosted.org/packages/32/b3/aa2c95a574d3af39403a469b295886eb9b6d448da568cbebb5a2cbfdc2e5/markupsafe-3.0.4-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:971a3bbb75d97ae4e2e8f7d4834236f86f85f0c85e04ab2e191db1123b04f80b", size = 11801, upload-time = "2026-10-02T23:05:34.315Z" }, + { url = "https://files.pythonhosted.org/packages/60/d0/34b810107d83840e768bf485de795893ebbae35b26ab061b487adfa0a692/markupsafe-3.0.4-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:8909c2f1c6dd65e054ac4b573a91c8384d1492281e55d82d159d653f7a13adf6", size = 11447, upload-time = "2026-10-02T23:05:35.302Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ab/2f8488f0f817a39fca068d2b17daf446bf5cdb3eae28c3720af534d873b4/markupsafe-3.0.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4cf3468d5ec187ffffcaca8e61929a37448f215dafc1386a12c750a72fe53634", size = 11722, upload-time = "2026-10-02T23:05:36.363Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/e2d117b048d47282ade906fbfd92814cbee5647afc13fda88a3406039372/markupsafe-3.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:52704c5d36eb6dda8866493decd61111fff86244c9b1ad225ca01b9e91e5970f", size = 12061, upload-time = "2026-10-02T23:05:37.397Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a8/73a81135e85ba66217f5af7facb03bbb386807e1a729ab64532e4c802652/markupsafe-3.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1caa2fa5a6184fb233153b35f654e6687bd555476f6170f29d8ee9be1a8b0af9", size = 24434, upload-time = "2026-10-02T23:05:38.407Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ca/fa9216dd01efee2dfdacafe7df32b4d0170fbac694b0c258a193d6e53999/markupsafe-3.0.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:387d8cd30e69b3f0a72877b9ae717033396404e19095b17fe89753a981fda44f", size = 27183, upload-time = "2026-10-02T23:05:39.581Z" }, + { url = "https://files.pythonhosted.org/packages/fa/4e/a469509e538d37af51103b17b073126973f2b1cbf197ff32c7ddf025cfe5/markupsafe-3.0.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:051417f74bcaaefa316276e0ff723f541616ca51043d070da00249d9bddd3e3c", size = 24813, upload-time = "2026-10-02T23:05:40.671Z" }, + { url = "https://files.pythonhosted.org/packages/8f/db/d7282caf7ab03af44d5d6fdbaa019b35c7d7f1c90588b839c07cba640d6a/markupsafe-3.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8e9f292fcda89b324f2f5c91d13f1424a153e40fc2756f38ee23b15835ff300", size = 23049, upload-time = "2026-10-02T23:05:41.864Z" }, + { url = "https://files.pythonhosted.org/packages/30/f3/b6a425206e6964efda6acee544d0eb01d1501784d0b8e2dcc74986f33b17/markupsafe-3.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:df1ae86ff54725a01fa1a0510b914ca53a161b7050be74f6204e24aded5971d0", size = 22088, upload-time = "2026-10-02T23:05:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8a/84d3582fc1f0d5bd466cdf2eebf175e172158a6e70701aacec1de1b35430/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8965520ac587c94a4ac48b729be3d8b8de00af39699b17585dfb599babe77977", size = 23846, upload-time = "2026-10-02T23:05:44.098Z" }, + { url = "https://files.pythonhosted.org/packages/1c/65/db101cce51b7ba4864ac491a9859d297dd1adf0e55b103fee9db9c47c527/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:340cbb1957ba99929cbf19a75626d36ba1ae21d1730b287d1cf7f824a20c4fc7", size = 22674, upload-time = "2026-10-02T23:05:45.23Z" }, + { url = "https://files.pythonhosted.org/packages/e0/49/ddee9813d71db0c7a5c9d97c832125e6758a0c844777f1cf076569bb0e22/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3a93d9616ddecfb393727a0041a562cf0b15a244e20f2bd25efc7949be4c4f17", size = 24104, upload-time = "2026-10-02T23:05:46.398Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0e/7d8518d726726870a2399d69fd30d0fa36c5e57a2132c336b58d7c491073/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2e56fd3b00222722abfb3f5f0759ddbae4b90811b5ad4343c64030ad1bde70c", size = 21637, upload-time = "2026-10-02T23:05:47.48Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b0/b505e8a361ba557dbf3b3aa7331ea39b00d2022a26e925ff8463b9714bb3/markupsafe-3.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0d9c47709875fdb321452056622e930c52afbc07a7d780762fbb8b4d91ce6fa4", size = 23081, upload-time = "2026-10-02T23:05:48.611Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272, upload-time = "2026-10-02T23:05:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501, upload-time = "2026-10-02T23:05:50.788Z" }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364, upload-time = "2026-10-02T23:05:51.857Z" }, + { url = "https://files.pythonhosted.org/packages/d9/31/87ce42159aae2163cf3bbbd0c44bc87780510eecab1ea3859099aed95dcb/markupsafe-3.0.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:805c8b84534fa10891890f0e4be39f3a99e94615d93e8836bf9fa1fdca2feeb2", size = 11759, upload-time = "2026-10-02T23:05:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/5f/53/b047207eeb7752e960aca3eb1df5fb7eefa7dd4c62ac49bb156456c8a702/markupsafe-3.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fa95848c929b6a75f6848d3c9793e59db365ee436776e57db835cdbfa79ba977", size = 12096, upload-time = "2026-10-02T23:05:54.066Z" }, + { url = "https://files.pythonhosted.org/packages/ee/51/4326c88a13c7b755657d44b4bb986f8c3d9843ecba7e22d98661d87f9a57/markupsafe-3.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e916035e3e9930cbdfdd10abf48861340221857f45509565898e012263f7b289", size = 25699, upload-time = "2026-10-02T23:05:55.15Z" }, + { url = "https://files.pythonhosted.org/packages/f2/bb/990581b7474bfcf2cf34bed6ba5ea23bd87adb9d671213d68e88620e7a6b/markupsafe-3.0.4-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4d12837e0203bbace818ff4a7461afdcd78bcd782351cea148139180d7bcffe", size = 27804, upload-time = "2026-10-02T23:05:56.29Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/89491878c28e8291f5aa2fffe2c2d57230d10ae366d55dd810b840513d78/markupsafe-3.0.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5086f9975abb1ab531ee6afca1761e4b59a19b446f3f6522ed776963228cfe5a", size = 25912, upload-time = "2026-10-02T23:05:57.416Z" }, + { url = "https://files.pythonhosted.org/packages/30/77/680998b54efdea06fc114565cd739b6d059f826a0279219b218dfa750d29/markupsafe-3.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b4a635a0487774f841cb1fb62e907e7195cc95bc761e053184b8acc3ceb20733", size = 23623, upload-time = "2026-10-02T23:05:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/ae/75/2709f5ac5de9467b40b10e2bb8f89cc63dfb74582e09aa734b1124a217de/markupsafe-3.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb96e6e088d6cf71c1ea977510948320234824cf226e32f6f6e044f7a9c82b34", size = 23301, upload-time = "2026-10-02T23:05:59.94Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c8/39eadc6c5b14c9c7679bfb98f4d4c6a97863b5beb91839aca4d2d6e16e55/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8b5d563170ff8ba3181caa967c99a3c804d1dedb702c7cb93a6a7c32247da978", size = 24950, upload-time = "2026-10-02T23:06:01.289Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5e/01037f8a43e8ccb0bffb4fbdc5212db05bf080fdd7286cd392332d58128a/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:396ec4e65cc889f69786b3b89478b471cee5a3bcf468b9d9bb03e1a30fb291fc", size = 23330, upload-time = "2026-10-02T23:06:02.441Z" }, + { url = "https://files.pythonhosted.org/packages/d4/f4/23e83ce0596bb0cbe670502d31df8f757bbd01a392aa486fa3b40d1ed399/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:15ba9e28640feef770374b116a6f019c21f52404aeabe516aa7f800587b98cfc", size = 25239, upload-time = "2026-10-02T23:06:03.579Z" }, + { url = "https://files.pythonhosted.org/packages/88/5b/3708897368073cc683d524750474f41a77d2986152c380dcc55b20fdf340/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d920abdfa61279ba1a2ef9484aab07bf03331f8c08a10120fa332353d06e6932", size = 22767, upload-time = "2026-10-02T23:06:04.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/61/ebda1307864b409e6b3115757a3d4a09cca46cfb6cc65191b5de226b424b/markupsafe-3.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a9f54054101545a9a9cccefddf54316aa6e4491611fcbef9e91b3b6bebec04f6", size = 23668, upload-time = "2026-10-02T23:06:05.9Z" }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301, upload-time = "2026-10-02T23:06:07.109Z" }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526, upload-time = "2026-10-02T23:06:08.276Z" }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421, upload-time = "2026-10-02T23:06:09.402Z" }, + { url = "https://files.pythonhosted.org/packages/a9/30/54d11c8ca027114898cab97421fb39e4ffd9ddf47cdbc44df2ec76722da9/markupsafe-3.0.4-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:737c9c3981998eba27f11786f84fddcbabc74068b72a4a1f454ea02094b57b65", size = 12791, upload-time = "2026-10-02T23:06:10.485Z" }, + { url = "https://files.pythonhosted.org/packages/10/6d/97c913e253a14bd3cd0e15a5c56d13203b823fa7ee32498342896a072dc4/markupsafe-3.0.4-cp315-cp315-android_24_x86_64.whl", hash = "sha256:489505b03f692c3f376394e49194fa7a7f9e8558d6e293a7056a0032b0c38163", size = 12527, upload-time = "2026-10-02T23:06:11.834Z" }, + { url = "https://files.pythonhosted.org/packages/26/f9/b86d032042a4d597d9e1997f0e5f63a3eedaf11258e0a05760b0a0a826ea/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:077293e425f28ec737dbcad442a71752e28f8ae27cde3d68acd1fb212091cd92", size = 11602, upload-time = "2026-10-02T23:06:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/f2/dc/73c14c1eedf0ac5fa3292ba43435e6c49d2c2050f33cebde541f8f4807f1/markupsafe-3.0.4-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9348cbb300d224fe3b89793262cb093504d4ae927004468463f745188a193e4a", size = 11817, upload-time = "2026-10-02T23:06:14.227Z" }, + { url = "https://files.pythonhosted.org/packages/8f/69/2c2fcaa5fcee22d72c7819c0d536fd181c74a688e6143845419579cd2863/markupsafe-3.0.4-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:b807e598953730f82e4eae3bd30f6a122cf6b31c398c6b504c0e04c13c170429", size = 11446, upload-time = "2026-10-02T23:06:15.574Z" }, + { url = "https://files.pythonhosted.org/packages/88/54/9e5ec76c62e6e2834d5a93623018c943e8b3bb41d663e3fd4c03303b9b85/markupsafe-3.0.4-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:799c39bdf5e2f1292fedd3009f7b3c9e760f10b2420cb9638d56920840ff6db8", size = 11719, upload-time = "2026-10-02T23:06:16.701Z" }, + { url = "https://files.pythonhosted.org/packages/96/24/3ec292b44064c16229e064d770b2625bd8ea941aa61f44905a9fa44942c0/markupsafe-3.0.4-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:ae9dcb8fbe244cb82f8a6458b455b927a03685e383d9bacf1ea5ce180b96dc97", size = 12075, upload-time = "2026-10-02T23:06:17.855Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/b64fdb1f304848518742136983c24e96d967bfb59a0ea160e92736901ab0/markupsafe-3.0.4-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bced6e2a6dba6a28f7dd3c6ce14df1b2dd495923f16ea484cad03decd463b2b", size = 24656, upload-time = "2026-10-02T23:06:18.963Z" }, + { url = "https://files.pythonhosted.org/packages/9c/18/23997d4c65b355da6390d61cd56e0ab3befd6ba8dda25cb40c602bd0fa6b/markupsafe-3.0.4-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3882fb412298575bae3b9c46868251f15cc69307359f87bb1b382e53d6e5a2c9", size = 27737, upload-time = "2026-10-02T23:06:20.117Z" }, + { url = "https://files.pythonhosted.org/packages/d4/36/35998dead3c6af88c38265a56e58100211f036234ab88eb2283fd4cbce44/markupsafe-3.0.4-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04e7902ba80ee4bac1d50a549606527a1dcf0476cd81403db41099d3b60ec653", size = 25003, upload-time = "2026-10-02T23:06:21.284Z" }, + { url = "https://files.pythonhosted.org/packages/82/96/ef49135ce260db4ca4a12b119ed468449cd248db6b1468e2112b546d7a2e/markupsafe-3.0.4-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:925f929d6b59a8b3f8b8c6ac363cd0af7eecc81efb3071770b3c6717c450a369", size = 23111, upload-time = "2026-10-02T23:06:22.524Z" }, + { url = "https://files.pythonhosted.org/packages/50/7d/83126e338bd88c17a220668235368ad719fd4638e426739858cbb8508f77/markupsafe-3.0.4-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f68edfc67aabac33708941f26f22a7b8e9f81429bc0cf249fcf7d66b23af8d19", size = 22403, upload-time = "2026-10-02T23:06:23.785Z" }, + { url = "https://files.pythonhosted.org/packages/83/dd/daf7e420de23c8206c365204e7b85e1251d8e19d34196a56336f316e5ed2/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:e5c802729725bd07e2bc3ab7b76dc7e0bbfc53129d8f1eb1c002c24cf774717e", size = 24083, upload-time = "2026-10-02T23:06:25.037Z" }, + { url = "https://files.pythonhosted.org/packages/19/3c/11eecdc06bc44ad5570350085b572ebf049e8f9a38d1ece6d76640b739cd/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:55ffd6ce583d97dc71dc92e930324c8c0d25aea7e3ade6ae54ef77cedb096811", size = 22931, upload-time = "2026-10-02T23:06:26.328Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9e/ac0fd77f2a726e56ecc3ca0235d095feace1358d1b822406c2a2ef26a4dc/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:2cb3dd71fc6be918ad4264346a8ed69485f9b7ed7bf35495d8e22807cd6b8bea", size = 24299, upload-time = "2026-10-02T23:06:27.742Z" }, + { url = "https://files.pythonhosted.org/packages/d7/09/c6bd842ad58ff5b3bc76eeed7e9a42a6f11adc5d090ec697b72c9672731e/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:94f5407f7bc64fa6463906b896f9904beeeb7dd8dc116ee8e9056c8714ff9916", size = 21948, upload-time = "2026-10-02T23:06:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/a3/46/82f586711fed61e86faa1ee1bc317d68cd45a10c8bdbe3f7d1fdf9026ad8/markupsafe-3.0.4-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2dad610540cb2e6272855c178f08ae9a1c7ac258a7fb71660553a5f104b42741", size = 23150, upload-time = "2026-10-02T23:06:30.583Z" }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269, upload-time = "2026-10-02T23:06:31.949Z" }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496, upload-time = "2026-10-02T23:06:33.258Z" }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361, upload-time = "2026-10-02T23:06:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/90/df/cb8c3dc98d313a951df2f8968f44e4cb5643df6d3cab749a530ce2f7d972/markupsafe-3.0.4-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c61750fadcd119d0825bcb7d7d675dd264dcc89cc05292aab5be68ebdbb374ad", size = 11760, upload-time = "2026-10-02T23:06:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/d6/bb/4af9b3ca0753d654ac75f9531d5bd741bb77ca6e696f36807c475ffc099a/markupsafe-3.0.4-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:1c0df495a977d10460a94941799c72d5b5ab03d3858d949b55b5a66c8f371c99", size = 12108, upload-time = "2026-10-02T23:06:37.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d4/b56429313aee5fd59b079c3df5615299959e25e7113eb6d8caadbdd7d38a/markupsafe-3.0.4-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02fa4acbc6a3fc5c693c34d4dd8c1130b7fe99cc915181b0ddd6f72aeb296002", size = 25762, upload-time = "2026-10-02T23:06:38.419Z" }, + { url = "https://files.pythonhosted.org/packages/65/f5/34c181e891aa4f7d59c918584672e0c5eb7fffe76c1387d1246008bf4081/markupsafe-3.0.4-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:05295589e619b9bed252a86b532b8e27350abc372d18ba89b59375325e91ec1e", size = 28400, upload-time = "2026-10-02T23:06:39.819Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b5/ad14694fd0ac9a5ce30bc6498f2999378f418583dd1679cca5a1b512957e/markupsafe-3.0.4-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:be6cb0c799abb0e2ba3e618e6d28ddddf7e485f6c2ce938dfa237daf3905072c", size = 25946, upload-time = "2026-10-02T23:06:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a8/26b606445387d0ceb1eb1f21840094b84e4e3c3c3983d80d10b89823b490/markupsafe-3.0.4-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:26e9867520db70d37f7fb421a7f0d8adb40171011fb84ce869afa1a83370dfa8", size = 23634, upload-time = "2026-10-02T23:06:42.748Z" }, + { url = "https://files.pythonhosted.org/packages/39/a2/b8814de672f1f0094d498bf646f2fec9d6356b503d28ef500b71c5095377/markupsafe-3.0.4-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f03460ff076f70ab595bb45a0205ccea1971443575b6920c52e755dec2b3fbfe", size = 23488, upload-time = "2026-10-02T23:06:44.176Z" }, + { url = "https://files.pythonhosted.org/packages/db/c7/287223376fb73335a3cc5d6eb22c6ab01358cf33945a9c39c06b9dac3f4b/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:436e3ffc6310d3c41878c601db29098102fe5d8a467c49da4a4125254e0980f2", size = 25037, upload-time = "2026-10-02T23:06:45.646Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/4df8355e313426d19e62ba33e0253c009ca12a0894ee77d67fa67255361c/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:4e2c4809c14559aa7ef426f27fb35afbb38104c349a903bf8f3600456764bb38", size = 23543, upload-time = "2026-10-02T23:06:47.264Z" }, + { url = "https://files.pythonhosted.org/packages/71/e5/8377731e8495668dcc768f645e717df18318c841edaf023a99395f6da9b4/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:da2af0d7aebfc2074080d72efa6ab8317c62481ef1f896f65d9999c1c01f4494", size = 25282, upload-time = "2026-10-02T23:06:48.795Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5f/373456e37ceb1478d657d6fe769cbe0a39f0a8dfc1548eeb19c471eefdd9/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:aa2c838cc024642cc04c6854232f32b43e5e22833dd11119c1766c7873b8370d", size = 22993, upload-time = "2026-10-02T23:06:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/d7/93/2cbd5628435afb6f541bbaced4bce0c2edac4b09a142e6e928b8b0da9858/markupsafe-3.0.4-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b91cc9d336957239ff200f30097e6fea2dc6d6fb3c81e853eaa09eac904fd894", size = 23712, upload-time = "2026-10-02T23:06:51.759Z" }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300, upload-time = "2026-10-02T23:06:53.241Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522, upload-time = "2026-10-02T23:06:54.729Z" }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417, upload-time = "2026-10-02T23:06:56.246Z" }, +] + +[[package]] +name = "mdit-py-plugins" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/fc/f8d0863f8862f25602c0404d75568e89fb6b4109804645e5cdfb1be5cf56/mdit_py_plugins-0.6.1.tar.gz", hash = "sha256:a2bca0f039f39dbd35fb74ae1b5f998608c437463371f0ff7f49a19a17a114d0", size = 56114, upload-time = "2026-05-13T09:03:38.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/69/6da5581c6a7fede7dc261bf4e67d6adca4196f176b43288b55b3db395b6e/mdit_py_plugins-0.6.1-py3-none-any.whl", hash = "sha256:214c82fb2ac524472ab6a5bcab1de80f73b50443e187f401bfd77efbc7c6481d", size = 66663, upload-time = "2026-05-13T09:03:37.76Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047, upload-time = "2026-03-10T02:46:33.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555, upload-time = "2026-03-10T02:46:32.256Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923, upload-time = "2026-07-17T16:21:33.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438, upload-time = "2026-07-17T16:21:30.017Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "narwhals" +version = "2.26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/68/5351e34623d253423240ea7de3f8fc74fa8ab14b1ab3c0ec4ac8997413c9/narwhals-2.26.0.tar.gz", hash = "sha256:6b9cadca82f375c7e4cf584fdc86ca25da54827307a9c58f94547ee6104b82dd", size = 686970, upload-time = "2026-09-08T13:32:08.964Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/b5/1b84b2c784db76d69442334bc8b8748c840f13ca53be086f4f250ad4a0bc/narwhals-2.26.0-py3-none-any.whl", hash = "sha256:29326d74f107c347fd1009bd58e38d9f7c7c5b51e6de97bc93dbc325d9038b54", size = 474034, upload-time = "2026-09-08T13:32:07.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "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 = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/13/01/11703282db468b85f6f7b8c7f22d058de5970d5c7e60a3a8aaa313c3de36/numpy-2.5.3.tar.gz", hash = "sha256:df2d5874ff183595a4ba404edd04f6bd9b5505c1d7708573f6a6c17489a67563", size = 20791231, upload-time = "2026-09-06T16:27:47.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/50/8fdbb16af64895706a45f06a4068e29db732ec180f3c1375f14123359138/numpy-2.5.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb189f09db39283b26bfd061ec16189e14f71c6755207f72a0f7540867afe5b9", size = 16994982, upload-time = "2026-09-06T16:24:29.244Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/789131c1188c078dcb3a1692e72e1e050c68b88ffe72c9ccaac9bcd7a9cd/numpy-2.5.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f59a878c33d6b88122d80d239bb3b845d58708750b0cb06a09aebb9b18ec696c", size = 12009327, upload-time = "2026-09-06T16:24:32.491Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/a312e95696e5f601914dd8b6dd844692ba61670807417e24b68e337b5c70/numpy-2.5.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a72f874bc9e10e4b8f80426fb49716d5141f64442a0c8418065093ec8017fbb0", size = 5445405, upload-time = "2026-09-06T16:24:35.071Z" }, + { url = "https://files.pythonhosted.org/packages/30/d0/5623a1707ed4fe16e3909fe3cf5ee3da004ae677ad23d83bbf3adf1a6faf/numpy-2.5.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:fc36dc566135b5eceec4cf89758fcb719266a019ef07dae1754ae7c9f617ef3e", size = 6783213, upload-time = "2026-09-06T16:24:37.253Z" }, + { url = "https://files.pythonhosted.org/packages/f1/32/84146fc020ad3c25f805f70ab60da46fe3c540a21369754a7e4369754b6f/numpy-2.5.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76c2c1e6bfa5c84adc6434dfbf013aa92096a7985221762c8f11fedfd20fff58", size = 15687872, upload-time = "2026-09-06T16:24:39.751Z" }, + { url = "https://files.pythonhosted.org/packages/65/af/aa78d1a88805456e212b65461354cd943197fb9acecc4c90fd12295123a3/numpy-2.5.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7e18c623bb5c95acb3b3328861272816ba199fb531921c5d6d0b675f1fde9e3", size = 16717410, upload-time = "2026-09-06T16:24:42.745Z" }, + { url = "https://files.pythonhosted.org/packages/3b/24/faa79d865e69a97ba17473b23a1b74094b2259c03e820c70297293b9ea49/numpy-2.5.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4f8929ee6c96bfbd7b4ed2032e0c03af86fe1826740ab61ddabf9072d06e57ff", size = 17040975, upload-time = "2026-09-06T16:24:45.961Z" }, + { url = "https://files.pythonhosted.org/packages/62/4a/8877e629445a7176297dffcaf9c485faa96a95d81728a62521ad55bd4c0f/numpy-2.5.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b5d93cf48f687479941d12b69c873ad2cc76bbd487f0091c2200636497f34034", size = 18476479, upload-time = "2026-09-06T16:24:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/c8/db/35e1c2d38b04cbd5b731f9d71495e055e813197669d22b612f11748d2ff9/numpy-2.5.3-cp312-cp312-win32.whl", hash = "sha256:bf63afbe037eb5d2fe87fbcc7778e61da53ebaf21d938a4515aa73b62532a5d4", size = 6133378, upload-time = "2026-09-06T16:24:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/accf6d4f0c80c5d9ba9735d6b1550e444180599f34dec69ca01360f717ad/numpy-2.5.3-cp312-cp312-win_amd64.whl", hash = "sha256:0a59a421a32580a009e8a1751345bf829631b990dc1794b80514ab722b435def", size = 12567828, upload-time = "2026-09-06T16:24:54.255Z" }, + { url = "https://files.pythonhosted.org/packages/22/43/1764aff32e4652526ae2f71fa8b3efd8d25c8a3d6926914454e47138ed1e/numpy-2.5.3-cp312-cp312-win_arm64.whl", hash = "sha256:ccb32e0525d29e8b0572eb84c9a57af0e7a4e615726927506f55063c62414034", size = 10485432, upload-time = "2026-09-06T16:24:57.278Z" }, + { url = "https://files.pythonhosted.org/packages/79/e5/8fb89cd46d14e35699d13bf943a5f5f441ecee8667120a1f6105ab89e349/numpy-2.5.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:66a78fe4556c60aceda5916f9eacd638b18e9e681016ec302dcb4682d6d4d034", size = 16991061, upload-time = "2026-09-06T16:25:00.411Z" }, + { url = "https://files.pythonhosted.org/packages/2f/06/9dc9e48b5e5e941c8b10350c5ff2d721da42a20517d911d15544246775ff/numpy-2.5.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92f30e89b8ee0ecf363033576c422b2f58fed6a80bed0aa48dff6d14c654663e", size = 12003676, upload-time = "2026-09-06T16:25:03.475Z" }, + { url = "https://files.pythonhosted.org/packages/ab/2a/98282aa5b8f58b1157d440bb6282eed47e3632a5de53a714fbab17e659fe/numpy-2.5.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f9a2353b37a1a9e78fd82b27ad7e2a32a2d036604d18f02b05e3136c62ca3b09", size = 5439695, upload-time = "2026-09-06T16:25:05.978Z" }, + { url = "https://files.pythonhosted.org/packages/a1/f9/b6533d777be9d6ffd29dc1be0867e563e6e8cc9a220ff1b716adc317f060/numpy-2.5.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:ccbc4665079665c3cf3bab4db9f6b095370cd6437d66be549b6c2a1fd19e1958", size = 6779395, upload-time = "2026-09-06T16:25:08.599Z" }, + { url = "https://files.pythonhosted.org/packages/73/85/735720d04ec197c5dcfacdfc9922667c7f1f5f496a279b7ba4d7c74c4cc7/numpy-2.5.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c76d5dde9f445058f83d0c02af00557a4db91de9a9a57c0df87d1535001d654b", size = 15681750, upload-time = "2026-09-06T16:25:11.173Z" }, + { url = "https://files.pythonhosted.org/packages/3a/1b/3b16a9bc514a440a7a0883684111dcb1ef1aee960af2ca95da8fc775f124/numpy-2.5.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5fa86b80fd24bcd1aff83ad23be44ea323de3f787be8f8b15d4a65621e25321", size = 16708577, upload-time = "2026-09-06T16:25:14.171Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/386f397831b07328b639c96c5b62719346cf4baf07c68d927239752b1534/numpy-2.5.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd4cb9ad3c7889b9b3fe0a9a9fb5d2ed26f9879bff2608d9f01aed147a20d231", size = 17042047, upload-time = "2026-09-06T16:25:17.582Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/a700ecbf36e85ae8328fd3b0e12eeddc22ed6358a64cb2bd913e0d195d65/numpy-2.5.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1302b90c0e52281681b2975adfe8a860cb7b12216a27b4b0b4207c44bf7bccf0", size = 18465724, upload-time = "2026-09-06T16:25:20.949Z" }, + { url = "https://files.pythonhosted.org/packages/41/ee/38e785e88a4045f6ad1d1f2808dcdfafdca48c760260c0587bf171e29fc9/numpy-2.5.3-cp313-cp313-win32.whl", hash = "sha256:1c80eabb4035ecf4ca9cd49cde8a9fdd69a729e63e6474887d1523ade7aa277f", size = 6129003, upload-time = "2026-09-06T16:25:23.664Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ec/100f2b1794ede74a9b3d7ec6b9736927f56713414c1dfe19ab6c383494bf/numpy-2.5.3-cp313-cp313-win_amd64.whl", hash = "sha256:71cad2b2a7451ab79d8f5e71b453485b6775963d5cf794179144a7463fe6e8ec", size = 12560965, upload-time = "2026-09-06T16:25:26.602Z" }, + { url = "https://files.pythonhosted.org/packages/80/b1/7dc825ca94c12acebbce4c37caa5e198695eb31424bc579679f32b1bb49d/numpy-2.5.3-cp313-cp313-win_arm64.whl", hash = "sha256:8e4dd766076855b5ff7ea52fa5f07ce26286726e0f8bff446b7739d02e6ea204", size = 10482343, upload-time = "2026-09-06T16:25:29.772Z" }, + { url = "https://files.pythonhosted.org/packages/70/78/cf416f15dc29375a229d9dfebf8db6e313f291580b39fa1a568b6052bb07/numpy-2.5.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:350ba9783ce969cf9f7ce6e6a9a58e1a6e2a19ca025b7ee448c4db727706212a", size = 16998686, upload-time = "2026-09-06T16:25:33.171Z" }, + { url = "https://files.pythonhosted.org/packages/9e/59/abcc2d8def4fd60eec7d87f92d27c13448ffd9ab14339bcc63a0d7a2fdea/numpy-2.5.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:012e66aca395d795496446e52aeeb5866312a5d4d3f27da270e5a0b43f70dc5c", size = 12013862, upload-time = "2026-09-06T16:25:36.748Z" }, + { url = "https://files.pythonhosted.org/packages/94/75/4640d2d6e4b64a049e48425a82728a41ef4adb61332d2cba68055774878b/numpy-2.5.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:adc1ada2662f8a5f960b8a10d9986897e7499ef07e06d4cfe7197f8cce923c07", size = 5449793, upload-time = "2026-09-06T16:25:39.476Z" }, + { url = "https://files.pythonhosted.org/packages/96/cd/625b57ae33d4ca560f32cc0b47b4a5922146d9beb998ddf773900d440a73/numpy-2.5.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:54a115e5a73b8fc44f0cebef486365a1894b5c9760685d4558b72b7c3eb846e0", size = 6785176, upload-time = "2026-09-06T16:25:42.069Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/12918652e7912ef9751e8694c88820fcd1908e0618cb23f5f3caa6004b7b/numpy-2.5.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be5a8381859b6da607c84f4f7d6847725f1cf1853ef8a2c9e115b7d58bef47dc", size = 15703377, upload-time = "2026-09-06T16:25:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/45/8f/9beacf79ca7c650688ad0baa80931adb988fe6e6e5d5903c23cc3dbd70eb/numpy-2.5.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b0521d0f4aebb6e06189451025fa17a913287b13c03d5fe05c017333b654ea5b", size = 16711928, upload-time = "2026-09-06T16:25:48.461Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/41d0a56e1ac4c87495c897a211b1368691b7237aadabec8b3b8f3a74d48f/numpy-2.5.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9deb49575e5b0b94ed72c8a64ec4d033381adc27e9060ae842971f697ba96104", size = 17059507, upload-time = "2026-09-06T16:25:51.873Z" }, + { url = "https://files.pythonhosted.org/packages/08/1e/0dfbc5cc251d54e2af790f254d24ec38637fa97ec7d5d11de7ffed787098/numpy-2.5.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b00eefbcf0f292945c4b4dec2ae845389ef5bcdcd596e6e4328051db5b5ba694", size = 18471002, upload-time = "2026-09-06T16:25:55.233Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2c/dfa40f6991f8185c8c30ffd023dfcbb11888e823cfab9557b920f3bb7bed/numpy-2.5.3-cp314-cp314-win32.whl", hash = "sha256:c2381f82999704f818e2c987a865050e285ec3621262c66d40f5a96c8f899f8e", size = 6180485, upload-time = "2026-09-06T16:25:58.157Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/d2c08231e4fde7e415501fd02c715d96e98599b2d8384445933944152984/numpy-2.5.3-cp314-cp314-win_amd64.whl", hash = "sha256:2c25dfa72943e4336ddb6b0ee4277b47a0c85bede0807530ec68103bf58e2c10", size = 12698179, upload-time = "2026-09-06T16:26:00.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e9/dcdcc9b95cf5f49815055573aee1b11cfbf5299f38a180e437ded050810f/numpy-2.5.3-cp314-cp314-win_arm64.whl", hash = "sha256:15aa985ac73a8db02db7663381aa109510449d3819d37206caed27b33a65a8a6", size = 10769383, upload-time = "2026-09-06T16:26:04.011Z" }, + { url = "https://files.pythonhosted.org/packages/49/c4/af8bc08a7ef4e1529a7c0cf24969accce316b783999802089a581ec99272/numpy-2.5.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ac7bb1c52d445bd4f8f7f97fefe6abc3a084dc4d63df50d79b17fa2b78e89297", size = 12132668, upload-time = "2026-09-06T16:26:07.138Z" }, + { url = "https://files.pythonhosted.org/packages/c5/ae/0f15eb56d4ec5e13c1f7ff04ff407f997d1acbadb45d3e1f2e2645a8f43c/numpy-2.5.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e6ab667ba76450084eb64013762c438ea76d9d29cc676dcd6c2e9892ba37f841", size = 5568580, upload-time = "2026-09-06T16:26:09.828Z" }, + { url = "https://files.pythonhosted.org/packages/23/fb/c72a8f25d4b6e96c354e7ab45ace3b27dc11e5d6a13b6c7d0cd6b08bf112/numpy-2.5.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:f7fabeb6cea87d65f3b926de33d03fb016cfdc29314c90974383b5582ae72891", size = 6882634, upload-time = "2026-09-06T16:26:12.524Z" }, + { url = "https://files.pythonhosted.org/packages/07/a9/968c90ed2ab15060c338e8137f1215b5a60756ae07328e0a60d1c6734df4/numpy-2.5.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1fb6f8fb9ff0b3a69f52c66ce397b0246583e9f28616231b0e32ca49259a5fa6", size = 15748923, upload-time = "2026-09-06T16:26:15.092Z" }, + { url = "https://files.pythonhosted.org/packages/59/08/9df04103947b95e3b6b1f2ed1a70521f325647a31b82da6a2aae3a485508/numpy-2.5.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93e1f5447e2b1e479d7bd74701e84746b86450cff1fc368b132d195e2b8f8211", size = 16746748, upload-time = "2026-09-06T16:26:18.43Z" }, + { url = "https://files.pythonhosted.org/packages/41/a0/14c8d5fe5b53a334aabb653deb391c0fef49558f491880ea300ed6785224/numpy-2.5.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c00abe94c1a69d75d827dcf1c025b25c8a45d230b3bcd77a9020883a1b047653", size = 17111561, upload-time = "2026-09-06T16:26:22.113Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a6/d7e96e42f01522e154c32489640f16dfc4f6181d165d05fc3bec8c2c4999/numpy-2.5.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:536f963710a4e63934d80ac0dc4f478804a83e9a84b6828018f25d09953ada33", size = 18513945, upload-time = "2026-09-06T16:26:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/25/39/3453afb7119d0449ef11c886874120ff180e2c337760e0e2d88f70f1a945/numpy-2.5.3-cp314-cp314t-win32.whl", hash = "sha256:4c8a6d2ebce6305fd82fbefca827775437147052a976ee7c94b36a0c1b52ac6c", size = 6335421, upload-time = "2026-09-06T16:26:28.175Z" }, + { url = "https://files.pythonhosted.org/packages/99/01/22815d2b19a1a746b1d45205cffebb3fe511a18acb75fba6c88491fc9894/numpy-2.5.3-cp314-cp314t-win_amd64.whl", hash = "sha256:9a37475425b431b4d060f23b4f52cd2f3aef6bc7c654bd760adf0040eec9d435", size = 12896420, upload-time = "2026-09-06T16:26:31.265Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ee/a7cbba67eeaff038dc29ca8b98a88396c8b0cc9c89d4924f4a27a5c9150b/numpy-2.5.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2d8240cb4c16fd831074aa2b2cf9fc54664d826341d61c372245b96a74a49a9a", size = 10857177, upload-time = "2026-09-06T16:26:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/78194492883ff5eec90423fe56a3a44b154da047d88a6307f629713c584f/numpy-2.5.3-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a6391fafaba97500887132cd582abc6e19452b1ac775a47caa7b24490e152058", size = 16996531, upload-time = "2026-09-06T16:26:37.287Z" }, + { url = "https://files.pythonhosted.org/packages/11/39/dd55c0af90bbab564b09ae3b0aa60ec5c02b900fa4f1ba23440525c8b32d/numpy-2.5.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:09d5a423c71ad5feb5625844ad58050e35df43871004b52ac9c0ad44a56775be", size = 12012569, upload-time = "2026-09-06T16:26:40.707Z" }, + { url = "https://files.pythonhosted.org/packages/b6/51/04f67d32e4862b281b1cb84ceeaed3421189a84fb6fb51a391cd6d5009f7/numpy-2.5.3-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:f9579f383d1bf9df80081e72760e84960a7fd4f88cf0c9e535a8597c9bb646f5", size = 5448498, upload-time = "2026-09-06T16:26:43.435Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c9/25b4dc0dd1344ec26c7319e84fd4e9809d2b5628f4e12decd618036e5178/numpy-2.5.3-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:86bff898a431c0fb71f7610b75726e75a54d47b37edc9d537f48de63bb3c0b90", size = 6783026, upload-time = "2026-09-06T16:26:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c7/29285be1e5232a6e7ee3268a33c85843f5a8ee93350c6465cddd66ebbf76/numpy-2.5.3-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f3ed25271581281f2fccb1adcedfcde4c07362eec69189b50baf6f90e3ae159", size = 15697322, upload-time = "2026-09-06T16:26:49.415Z" }, + { url = "https://files.pythonhosted.org/packages/55/49/bbad5335fb4996a16881f853ff3e0ba582f01720e55c89b1c06b8fc42a90/numpy-2.5.3-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ffdc76bfcae6b255dff75202c5e7feaf95b40246bc0a17944facc1fecf9f79ab", size = 16708995, upload-time = "2026-09-06T16:26:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e9/1df35483760b04a65ea44669f89dc64f30e5aca098b48ceb8b1310b0e0fe/numpy-2.5.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:116f96cadd935c6122e9228d676fe7ede19e741f5c8bb1c3cddbe0c51ccebea2", size = 17052508, upload-time = "2026-09-06T16:26:56.464Z" }, + { url = "https://files.pythonhosted.org/packages/b8/99/66e54da8265cc8be8a7382bf96edce17aaa2837d6f484432025932a3caa5/numpy-2.5.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:09ffa5d903faeaa5c4dd05009cf81c8bab9f2cb37c548b8d39b65b4cfa7c97f7", size = 18468224, upload-time = "2026-09-06T16:26:59.966Z" }, + { url = "https://files.pythonhosted.org/packages/01/bc/b5e90a91c115168d793dfd2ad9c69c438c2fe7a13a437e770bc5b078e732/numpy-2.5.3-cp315-cp315-win32.whl", hash = "sha256:e01c918ac3d48e18a927cf7b14a26a3e29ff2bdf2eacb976da0aecd6a43ed034", size = 6179919, upload-time = "2026-09-06T16:27:03.166Z" }, + { url = "https://files.pythonhosted.org/packages/37/ea/780748fd3985109075514ef8fc64cd25f943e40dde13a6d59141eb268fc8/numpy-2.5.3-cp315-cp315-win_amd64.whl", hash = "sha256:e931e4f499e0dc7ef29d269a8e5b35dd722e5d14be07df6240166ea7c6532fae", size = 12697656, upload-time = "2026-09-06T16:27:06.153Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/407be69a2a87c8cab64d95975a8977a426a29e138f07e276ec258f0fe4e5/numpy-2.5.3-cp315-cp315-win_arm64.whl", hash = "sha256:26e15e4aecd8617dfbaecb37d223e365d7b39411fba20454be2670a96aa74cb5", size = 10767601, upload-time = "2026-09-06T16:27:09.297Z" }, + { url = "https://files.pythonhosted.org/packages/44/bf/a97ffb01e41d50a32a9177aef942a4d0e389a3daf451d04e5f38ef6afb87/numpy-2.5.3-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:6cef4bb1706dfec49243c05d921eefb4e190d41e2528b30d8035ea1f36b4c24a", size = 17090092, upload-time = "2026-09-06T16:27:12.907Z" }, + { url = "https://files.pythonhosted.org/packages/d1/24/136c02f2c2af9a067a84d0c3aa10c99012c0476fa5066732fa4a4202557d/numpy-2.5.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:d1c89973648c85069c5046ad460f7b8a00218b29a2e42359ac8cc63e9ab94832", size = 12129429, upload-time = "2026-09-06T16:27:16.089Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6c/b47582d6597789bf946d5efbeb6b9e56fd8bcbd5efc6fbf51dbe1ea31eb3/numpy-2.5.3-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:214045a5bf00113a146ab9ee9730c44501af6723cdf1f6830932f7b5ef2e7af0", size = 5565452, upload-time = "2026-09-06T16:27:19.868Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/ef3cc6da73774202d4deae16bb321fd8298a4e0561e3539f8c4be237d916/numpy-2.5.3-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:8617bbfae4486cf99c9f899966699428d19da931d06ca94ad3da986c76e15997", size = 6876736, upload-time = "2026-09-06T16:27:22.232Z" }, + { url = "https://files.pythonhosted.org/packages/9e/24/e3813329498596cb842703dcacac1741612ed9fb9c4e6a3e0c7e2ebbc597/numpy-2.5.3-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:595d020938c84e320bcf40ad71089e108eac0d377cd018e14a8c094f39e98d85", size = 15745777, upload-time = "2026-09-06T16:27:25.181Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9e/4e7a07fd0776dc2210cdacf2010be8665194d094defc10c419d7dea794cc/numpy-2.5.3-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f24021b9f22bc6301c37b196974a92c1c18dccedb6fef3dd252e95f2d6adbe4", size = 16746949, upload-time = "2026-09-06T16:27:28.576Z" }, + { url = "https://files.pythonhosted.org/packages/91/db/01674c0e20335057813a00c2ebd546ed25bff9ed7914f9bced00f8c55d94/numpy-2.5.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:71b39d9f935b6ec0f8753e3e2afb51e3efba6f2e05b68b32a40754d24bcd4a3c", size = 17108994, upload-time = "2026-09-06T16:27:31.946Z" }, + { url = "https://files.pythonhosted.org/packages/45/7a/584c5e71f8d378e57cac0b033891ed65c683ef90573ba4854e8c28203db0/numpy-2.5.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:6b05c171afb3aa07adbd20abc00aea86fe375beb0fdb9ef780ec5b7f63bab1c0", size = 18512266, upload-time = "2026-09-06T16:27:35.196Z" }, + { url = "https://files.pythonhosted.org/packages/a1/d2/4e1014173aa3c55e6a756e0e567290743a6ab33a288460374d7ef6bcd239/numpy-2.5.3-cp315-cp315t-win32.whl", hash = "sha256:f54660b0eb6b0b9f36e7fe1cdfdff472028dd0d14acd9b9b65098efbad059469", size = 6330292, upload-time = "2026-09-06T16:27:38.149Z" }, + { url = "https://files.pythonhosted.org/packages/6c/b0/ff5658a58199b7bcaad87bf260eef6713d9d42cca4e028f935b4fc5fbac6/numpy-2.5.3-cp315-cp315t-win_amd64.whl", hash = "sha256:1aad64d99730d013cfc6debafed22783b4fc5a7f4b8bc744d2d8cf7dcc880551", size = 12884918, upload-time = "2026-09-06T16:27:40.965Z" }, + { url = "https://files.pythonhosted.org/packages/fb/0b/b12a2df5d1b774bd9007a6fdff9381145b6223d37f11afc9c37ab0efd9a1/numpy-2.5.3-cp315-cp315t-win_arm64.whl", hash = "sha256:befa1ae5bd6030b3f512b43ff3fa5290bbed6b84411a44244b14adf835f5b89d", size = 10850807, upload-time = "2026-09-06T16:27:43.868Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.12.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/90/a1/d5f9002a70298c64a789779077d8dd90c10aa1f47fe40c86802df874f2a6/platformdirs-4.12.4.tar.gz", hash = "sha256:63743c02414e755de4e31b8f68125c1407495b86c5a006e203c01ff8b9924250", size = 60010, upload-time = "2026-10-07T23:30:32.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/ba/223e00b885e960edd5d4b4d178c88acce019bd5b83786093681b8c393492/platformdirs-4.12.4-py3-none-any.whl", hash = "sha256:78bfb9db2a8471ed7eebe3c3c932da413911042994e699b384fbb4493fa872d7", size = 32596, upload-time = "2026-10-07T23:30:30.825Z" }, +] + +[[package]] +name = "plotly" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/c3/72b369f5ed7701b04ab0ea3dcf83e9bbce71c0b3bc6f07f87568550d09ea/plotly-7.1.0.tar.gz", hash = "sha256:f860166a4a3d78c69cb1f4a15f28a5c8283eade98a282a698f3bb853a449ace5", size = 6689315, upload-time = "2026-09-15T19:21:21.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7d/905a3a3d51087515719058c94cfbda2ff0fc14417c20d557ae3e82d8b250/plotly-7.1.0-py3-none-any.whl", hash = "sha256:dbb7fa18afce40d0a8e80d1bf162eceb3faa0ce5a77fe741ad09a74cf78f53f3", size = 9692368, upload-time = "2026-09-15T19:21:18.331Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "portalocker" +version = "4.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/cd/d2a23fc80c26f539ac77e7d61bd5e4af5d0b3a09a78ac4c716eead345129/portalocker-4.4.0.tar.gz", hash = "sha256:90c0df939d4ffba121f8e925bbf98ecea8b9381718666ab871a226938d2b63b2", size = 304906, upload-time = "2026-09-19T16:11:09.114Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/6c/07fa2775e87b6e241c2aca5cef79f65e450bc115de55f03cda6251f81690/portalocker-4.4.0-py3-none-any.whl", hash = "sha256:a8e99ea29bfb61766ee0b4008cbaf0651e8e050f7a2485ebf54316480e99226e", size = 129647, upload-time = "2026-09-19T16:11:07.377Z" }, +] + +[package.optional-dependencies] +win32 = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] + +[[package]] +name = "pycparser" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/a8/c5fdbeee588bb8ada9458774f43adf1bdd30bd59157055142183e769a024/pycparser-3.11.tar.gz", hash = "sha256:d875f09c3507d00e1aba0eecc6dcadc1352f30fff09dc6bff2f1c2935e97c2bc", size = 113796, upload-time = "2026-10-09T12:56:59.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/11/0e6f11117525ff0eec40ebac3d313376f102df93ca44ad9e893ee85e4f89/pycparser-3.11-py3-none-any.whl", hash = "sha256:51d5a8ba2be0bbe440b99d2112604c95bbbc3c2748a64260186c541e1729cd80", size = 51178, upload-time = "2026-10-09T12:56:58.131Z" }, +] + +[[package]] +name = "pydyf" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/ee/fb410c5c854b6a081a49077912a9765aeffd8e07cbb0663cfda310b01fb4/pydyf-0.12.1.tar.gz", hash = "sha256:fbd7e759541ac725c29c506612003de393249b94310ea78ae44cb1d04b220095", size = 17716, upload-time = "2025-12-02T14:52:14.244Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/11/47efe2f66ba848a107adfd490b508f5c0cedc82127950553dca44d29e6c4/pydyf-0.12.1-py3-none-any.whl", hash = "sha256:ea25b4e1fe7911195cb57067560daaa266639184e8335365cc3ee5214e7eaadc", size = 8028, upload-time = "2025-12-02T14:52:12.938Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/94/858e0163cb4d83d6d8234018a01792c749a56daee41794cac7d6c46661e8/pymdown_extensions-12.1.tar.gz", hash = "sha256:fdb8f47f5d7fd069d10ef2a7d8908e613d7865ff0419d544a0ffd3984e884f11", size = 868245, upload-time = "2026-09-23T00:08:42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d1/98313da89960a604402266a115311b510254900ff1295ea426403f9423cc/pymdown_extensions-12.1-py3-none-any.whl", hash = "sha256:4a254b771acfcddc6c110a7f4590d818f1f849e2d5aacf69b4b07c8dd2a9700d", size = 277069, upload-time = "2026-09-23T00:08:40.069Z" }, +] + +[[package]] +name = "pyphen" +version = "0.18.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/47/8430452269cd28863d73b903d07d329d058cf762527ff211b3864ba61fc7/pyphen-0.18.1.tar.gz", hash = "sha256:dbae6fbbe4f01cb206108b43573d857c67107be9d0e38eb1b08d6fa2210634a7", size = 2116411, upload-time = "2026-08-14T11:30:12.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1d/23801cf008f71575f0a4800463f349afa5f04b27fe783178a45cdc4d5edf/pyphen-0.18.1-py3-none-any.whl", hash = "sha256:0aa9051e15928cecadd4c632cea0258ba57215b2a197a39baa46abcdb0f47e84", size = 2116143, upload-time = "2026-08-14T11:30:10.428Z" }, +] + +[[package]] +name = "pyserial" +version = "3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/7d/ae3f0a63f41e4d2f6cb66a5b57197850f919f59e558159a4dd3a818f5082/pyserial-3.5.tar.gz", hash = "sha256:3c77e014170dfffbd816e6ffc205e9842efb10be9f58ec16d3e8675b4925cddb", size = 159125, upload-time = "2020-11-23T03:59:15.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/bc/587a445451b253b285629263eb51c2d8e9bcea4fc97826266d186f96f558/pyserial-3.5-py2.py3-none-any.whl", hash = "sha256:c4451db6ba391ca6ca299fb3ec7bae67a5c55dde170964c7a14ceefec02f2cf0", size = 90585, upload-time = "2020-11-23T03:59:13.41Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyvisa" +version = "1.16.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/72/a50adff945e66f2161a44b243970f809de19152a2e7da0473ae8ff96d642/pyvisa-1.16.2.tar.gz", hash = "sha256:75beb93eeafe20a50be5726fa4e3a645948c93d86819f9c1f8c542efa4085a38", size = 238729, upload-time = "2026-02-27T12:14:08.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/a9/776da4f397003cca093659524c3526590522f81b642936838428c11274e6/pyvisa-1.16.2-py3-none-any.whl", hash = "sha256:54f034adafd3e8d1858d57cdafec64e920444f4b84b31c9fd17487fbad0a197a", size = 181413, upload-time = "2026-02-27T12:14:07.301Z" }, +] + +[[package]] +name = "pyvisa-py" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyvisa" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/8d/0f1af2b39feca8868d0692a2c5d0c47f4ad559a051fb678c441fe1e99536/pyvisa_py-0.8.1.tar.gz", hash = "sha256:db53d3219d971d16b8f7c764a93e08475875063635b9ad8ea3c988439833ccdd", size = 102061, upload-time = "2025-09-04T20:48:21.639Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/0b/06ccf917ce30d78d75e452d0afc89236b1d384b776c5c33d75c8cc55fbe7/pyvisa_py-0.8.1-py3-none-any.whl", hash = "sha256:31208a2933c1793b4e829ba5f07d265b83f280668df440ee7ee6ac505dea4ee9", size = 82206, upload-time = "2025-09-04T20:48:20.13Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rsinstrument" +version = "1.131.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, + { name = "pyvisa" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c8/f5/be9aa2aa87818c7d2dee3a01d728c03b050d044e0d7c1800b1abf333c13f/rsinstrument-1.131.0.tar.gz", hash = "sha256:08f7fbd2e88d52f05fd250ce3464c9a2ae5383818592e48dae12d2b7cac20fbc", size = 161579, upload-time = "2026-08-17T16:38:18.575Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/7c/27c424382150f862b67ccfade4b5b8fd6b23c7741ea2d23933e2363a0fb9/rsinstrument-1.131.0-py3-none-any.whl", hash = "sha256:c9021bdc4ade9f39735438cd062bf86eecfc2a3623f9d2ac5fcd7f608f56e347", size = 180630, upload-time = "2026-08-17T16:38:21.164Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.13'", + "python_full_version == '3.12.*'", +] +dependencies = [ + { name = "numpy", version = "2.5.3", source = { registry = "https://pypi.org/simple" } }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" }, + { url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" }, + { url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" }, + { url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" }, + { url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" }, + { url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/4540ee0f9c42a9ad7109d0d1a8cc70de54c3572b01c6693a2b1c70e90ceb/scipy-1.18.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:3ab3523da44749156e1f68b464dc56af11ae4cbc5c739a49d05f32b982eca9f3", size = 31089958, upload-time = "2026-08-21T23:24:35.8Z" }, + { url = "https://files.pythonhosted.org/packages/2a/f5/769f36d14922b8071a43e95d24d18b6bdafad10d7f5cf647867e1ac052bc/scipy-1.18.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:e6fb6a55cc0ba97b59a1f288fb86dc6fce8bdfc0fffcbfd015e3a954bf2a2d93", size = 28715106, upload-time = "2026-08-21T23:24:40.775Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d7/21d890274f75ea37a8209d5519e72da3da90302e3b9fb8397a0918386a62/scipy-1.18.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:ea324d9dd34c38bfb9bec8ca4d1b407db97dbb74029f566b8e322b1b6fe56fe6", size = 20456846, upload-time = "2026-08-21T23:24:45.066Z" }, + { url = "https://files.pythonhosted.org/packages/ec/01/798430ecea2e78ec7c02663d5f71c007bb6abeca931080debd40d7fa55ea/scipy-1.18.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:75b00eb8fb802090aa903f4ea1c7f5a584779f967361e68b7e98e531cc2d7174", size = 23087986, upload-time = "2026-08-21T23:24:49.539Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5f/4634e9d35c68496e4e34cb6946eafab044458e6cedab42b40b6588e475b6/scipy-1.18.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d416b16cccfd70fbf62400e84d0bb2f4e6af519a45557f1692c749b37f14b315", size = 33998146, upload-time = "2026-08-21T23:24:54.714Z" }, + { url = "https://files.pythonhosted.org/packages/41/48/6450ed9243315322bbc19ac57b9b70d66a20bf1d38d124c96bc4bf6af9ea/scipy-1.18.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fdaf5ea890a6183d0565f51a61799d67081bd5b1cf03c5f4b3fd3732108625c9", size = 35312578, upload-time = "2026-08-21T23:25:00.44Z" }, + { url = "https://files.pythonhosted.org/packages/00/bd/bf5a4be6a3525676499f6dff307991739ff6fdcad1481b1aeb6745339f58/scipy-1.18.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c825cef2f49e46753726a7181a8e199804a912b29519ada542c6ebc654951899", size = 35612621, upload-time = "2026-08-21T23:25:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/bd/4e/3c45c33e00a77996c4b1cb707929f833ba7b1d522ee29f882512c330676d/scipy-1.18.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3b417bf8c2c7c16e8f58ad91db17783ec911ac16e7b50eb6eab6e809b4f5b07", size = 37457323, upload-time = "2026-08-21T23:25:12.483Z" }, + { url = "https://files.pythonhosted.org/packages/93/0e/e0348fbc0dbab65c114cf78957e7dfeb49f8e8b556b4d930cc12ff195e18/scipy-1.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:559ed65f60c1af5a03f3912605a1b5114f522c7c32fb23c3376ae8f03219fe28", size = 36622841, upload-time = "2026-08-21T23:25:18.722Z" }, + { url = "https://files.pythonhosted.org/packages/50/a8/6a77f5f267c555108f0a864b6db714363dab567a8266422a79a385f9232b/scipy-1.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:cd479fc04dd9401e3b4f49e76518768ef99c4f517a98c284eb091fd725719adf", size = 24399315, upload-time = "2026-08-21T23:25:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/d8eb4e280ddb56a4ab2c6f02ee49b56b23f6e977cf0802fd6d68dbef14f5/scipy-1.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:83de5453a7799afc9048b4616bd085cef126e36412f0ea2f6370c36a2a3a51e7", size = 31090936, upload-time = "2026-08-21T23:25:28.686Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/59ea385dc3a62ff498ddf3cfff7c2b41b0f9f9d3c4122b3f1dcb6d6327fe/scipy-1.18.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:9554bcc6d715ee87a633a3cc8e7703c6628b100dd29cb8a2efc4c0533c7ff729", size = 28725221, upload-time = "2026-08-21T23:25:33.244Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/6b0c288c50942d78193696c9f15f9a0874f5178aa0ddf40f83d9924b3e8d/scipy-1.18.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:011413b7426b75012840e35649e00fe0a2c3bae89fed433876e3a99251572efc", size = 20466839, upload-time = "2026-08-21T23:25:37.516Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/54fd3793c729e3b936782f181b59cbb1205bf250ab605a16cb1ba61cdd5e/scipy-1.18.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:88f0e784020649f88ea48c9f5ddfa403bf9205820667c0914740b392035afb82", size = 23089121, upload-time = "2026-08-21T23:25:42.019Z" }, + { url = "https://files.pythonhosted.org/packages/0b/56/030af62bea3cf878e0028515dff78c123b01633606a879b63f42d2db99cc/scipy-1.18.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d3ab0e8c69a17dd3559eab8cbb88f258e285c94d572c2719033f90f83290c89", size = 34053851, upload-time = "2026-08-21T23:25:47.998Z" }, + { url = "https://files.pythonhosted.org/packages/6b/89/2a844506d49651e9aa1af6ef95b6bd8031cb1d5a4375edec6155037e04cf/scipy-1.18.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ac0333bdf38309aa3dcbe7e3fa7ea29e7a2c37c6ea306a757b700ded8e4596ad", size = 35329183, upload-time = "2026-08-21T23:25:53.522Z" }, + { url = "https://files.pythonhosted.org/packages/eb/56/c7370c3640e92ac9613cbf26cb3f729f9b12ddf1727b55b94b53b24d6f48/scipy-1.18.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:911de823097db8b63f034299d12662db93344e6ffa0b881cbb57748974b70168", size = 35672551, upload-time = "2026-08-21T23:25:59.387Z" }, + { url = "https://files.pythonhosted.org/packages/24/16/ec8536f351421f8bf60a1120930638f83790f4710b8230446aca3d6159d4/scipy-1.18.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:95298364e251be3e60249facbeeca03631d3bb7584f85879516ec55ac717b81f", size = 37469416, upload-time = "2026-08-21T23:26:05.432Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:78a0d7c918e74a232394117160e7e3db503377572a45bcef8826e4ab8a35feba", size = 37362755, upload-time = "2026-08-21T23:26:11.366Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/e996e4dc74e10e227b1e14db5eaf6608bb6dd33884a64851c38f18dd4249/scipy-1.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:cbf38d043c1aa4ab306e1ada6ab6eddacc3322a20b7af1b30bc93254b366fe09", size = 25036090, upload-time = "2026-08-21T23:26:15.887Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c9/c00213f92309d753b48903e6a451b87eb52ff5b7a16e789d1568bbf221c4/scipy-1.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0fcb3c93519f27bb4f0c4b0f7802cdcaca7fcf93267b75edda2e9f4e8a55cbd7", size = 31485550, upload-time = "2026-08-21T23:26:20.776Z" }, + { url = "https://files.pythonhosted.org/packages/74/b2/e3067c487982d4eeab2938928529410370c06fea84a4d3f4925e7d96647d/scipy-1.18.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ddef79fb382df40104a19bb7151b3b23e57c1778fcf857c71ceecd9bd264513f", size = 29174642, upload-time = "2026-08-21T23:26:25.395Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ab/374c9fe2d1ec014e576c781a4b5d8e1ba340e8f6b4638c16f711d2b194f0/scipy-1.18.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:0e82073ecc7acc6436fac4b31674109c7e1d3e596789767eda01258a8c9e8123", size = 20916357, upload-time = "2026-08-21T23:26:30.112Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/223915c88a17317cafbf8ca2a42b11c265a9fb1e804aa665544132b5fe8a/scipy-1.18.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:8bcf3c1ba5d6456e2effd30fcbd3459b044d683fcdac79a2e6830f0bdf7de487", size = 23482611, upload-time = "2026-08-21T23:26:34.846Z" }, + { url = "https://files.pythonhosted.org/packages/c4/d1/db0948da8ca57a80b36520ef0a768b967d99f3af65f4b6f1bf6362ad4dd4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cfbf154f2ba187f2ed6cce2639efff7d105f1140573642c0161615b6d91d6a87", size = 34143202, upload-time = "2026-08-21T23:26:40.4Z" }, + { url = "https://files.pythonhosted.org/packages/87/53/39d046cc7574ed6acacb6bd5723e220107ece80bff12faaf3efc4ddeede4/scipy-1.18.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1d33a7836f7ddc1993427966a0823468ec41bcbdb1a9f9942d1d7e57f803ba3", size = 35380876, upload-time = "2026-08-21T23:26:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/32e0e799d875a85ca57d9bde6c78148afcc0e38276df683d95854eadc8c3/scipy-1.18.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4b8bc363b6d65ee2152bec57568e3c52639bb34c46057b09857a307ed5e21d", size = 35770885, upload-time = "2026-08-21T23:26:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/88/2e/f97a666d362fee68b18f41c9c30ed502ca5c98b549749bfcb52a8b74d1eb/scipy-1.18.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11c423f1049c5755ad4409af52a9ada1cff96fe9b50795d4af3619f292901239", size = 37525424, upload-time = "2026-08-21T23:26:56.751Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d5/a9e765a84654ebba8479a1fd1b059ced1af72b168a3b2a3a46540ea38d20/scipy-1.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c24acac1e18912761c4700239bbc1fd32f615af690f1584d49b35859be51324d", size = 37416961, upload-time = "2026-08-21T23:27:01.546Z" }, + { url = "https://files.pythonhosted.org/packages/ee/16/e79e0d1c63ef698879d85439d37e9fb434e3b804e506a6991038d086ebd9/scipy-1.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:9f2897bf7737392ad0d5213ea7b6add72a4edf5679b3153106aeb88b6507b3b9", size = 25331848, upload-time = "2026-08-21T23:27:05.884Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/1bd37c883b67163e2ca1f60977a399500e6879c15defecac62831c8d078d/scipy-1.18.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:eb0dfcf4e28a99c12c999744a2ff67c9b06200e20401c7c88186e33552a46331", size = 31091484, upload-time = "2026-08-21T23:27:11.051Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c5/ba929d7feb9b2332f96827c12e0e924b61973b59b4dea383b603372c65ce/scipy-1.18.1-cp315-cp315-macosx_12_0_arm64.whl", hash = "sha256:30f464bee641fa8e282577c7dce027308403213c6ca8270bba73285c91024bc5", size = 28725057, upload-time = "2026-08-21T23:27:15.9Z" }, + { url = "https://files.pythonhosted.org/packages/a4/19/68f1c50f609d955d230e66d25d02bd3e1e167ec540232135354fb9a4b9e3/scipy-1.18.1-cp315-cp315-macosx_14_0_arm64.whl", hash = "sha256:1bca3b943fc2567ea49cd02c99abde49da4d5178ec46f624bd8255cda8755beb", size = 20466734, upload-time = "2026-08-21T23:27:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/ef/6d/319fa29b73d1802fa80b32a6eaf3f5be456ef81526da2716a9493bcb5501/scipy-1.18.1-cp315-cp315-macosx_14_0_x86_64.whl", hash = "sha256:c9d18a33309122074ea483dd92dd444189166b8b2ec429fe9ed5ac73c7a0aa23", size = 23089664, upload-time = "2026-08-21T23:27:24.345Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/30992f9b51a63de671daf3888ffd18378b6cb9ec9f2c972264238ffa7fd6/scipy-1.18.1-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82f201b4c878551d48558337aab270d3c6cca5507b8737c8d8a608d234cccde0", size = 34054035, upload-time = "2026-08-21T23:27:29.409Z" }, + { url = "https://files.pythonhosted.org/packages/91/d4/bf3e735dc0b9d5a8ff45079d2540e17d3aff7a2f0048dd8f552ffd031d2b/scipy-1.18.1-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ac49ea97594532dd44b7136094d35f5440fa06e6d9c6384a74c01764df388c5", size = 35333883, upload-time = "2026-08-21T23:27:34.293Z" }, + { url = "https://files.pythonhosted.org/packages/19/93/12d78ce9f871fe945fca588d32644e6e63f553c2a35c564d73f3b22a3313/scipy-1.18.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:ceb30a00ce7c92d459819443d29ca486d882b83fb6738bdcbb2a1cce94ac5daa", size = 35673124, upload-time = "2026-08-21T23:27:39.059Z" }, + { url = "https://files.pythonhosted.org/packages/70/cd/886219313a1012a48e6ae0ec4f302c837151beb92e1ff0d709ef8fdfc488/scipy-1.18.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f29633129f9fa7e88a3f0fca835de2d030bfc9643f7799e1a0c46cee24d38fc7", size = 37470753, upload-time = "2026-08-21T23:27:44.435Z" }, + { url = "https://files.pythonhosted.org/packages/17/6c/a776888ce618bee54fbde26172f0f46ac1da70d27b63861797fe78e1904b/scipy-1.18.1-cp315-cp315-win_amd64.whl", hash = "sha256:92c14f5bdbfb6216315ce33e78080474082de8b3830122ba97809bfbe65f75c0", size = 37361483, upload-time = "2026-08-21T23:27:49.334Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/97b651691322ebee97999b017ffc18a15a0b815103844c97e8da9d469731/scipy-1.18.1-cp315-cp315-win_arm64.whl", hash = "sha256:e402cf31eb68f453dbb2d36fc6d722b33f24a55d68b2ae1d92fa6305ca71c298", size = 25035883, upload-time = "2026-08-21T23:27:53.596Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/9ec20467bbabd0d44e2a77d0fd3d124f884b4d67df92af82c91d2d6a486f/scipy-1.18.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2a0b02f9fc46f8520330c23d45e6560db7e3a0d927232139427637f98943e11d", size = 31474926, upload-time = "2026-08-21T23:27:57.993Z" }, + { url = "https://files.pythonhosted.org/packages/8a/58/dcb79161e56efbedc50079fcd2f5fe427a0ebb53022eb476aa73c015ad8f/scipy-1.18.1-cp315-cp315t-macosx_12_0_arm64.whl", hash = "sha256:1d73131e358976663dd969e1fb4ed1404b815cd977eaaedc3b3a133ba2d81c35", size = 29164940, upload-time = "2026-08-21T23:28:03.062Z" }, + { url = "https://files.pythonhosted.org/packages/71/d3/1eeea80c817fcb8ef7bd4a05a58824977a0e57a375cfc3d7ea7c911c01ad/scipy-1.18.1-cp315-cp315t-macosx_14_0_arm64.whl", hash = "sha256:bff0b729edd992766136b34e39cc76bc2fad905aa58897ee72a9cd000a6d8443", size = 20906742, upload-time = "2026-08-21T23:28:07.642Z" }, + { url = "https://files.pythonhosted.org/packages/54/46/e59350428b6099301a20128108c995e2eb175a43f383af9a346e38824f9b/scipy-1.18.1-cp315-cp315t-macosx_14_0_x86_64.whl", hash = "sha256:10ac20c69d880f77f375db44c22e3e6a644f9fefa291d4cd2fb9790a89fc99fd", size = 23472183, upload-time = "2026-08-21T23:28:12.109Z" }, + { url = "https://files.pythonhosted.org/packages/89/31/cc91623fa98f0621766a0f0aaaadb2c66de74a7ea7e3837164f6e4354260/scipy-1.18.1-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:33a834464fdabc0f26a45508df31b3cc5d028e04dbf6c5ed398541418e0a12fe", size = 34130796, upload-time = "2026-08-21T23:28:17.906Z" }, + { url = "https://files.pythonhosted.org/packages/fc/3e/8572ef536957ddb8aa81bb4090d9e25f257e3b4e05d97deb54319deb8a3a/scipy-1.18.1-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49023963c193dacee096301452f223ee24d86ec5807f8df93c0f7221d119e305", size = 35374253, upload-time = "2026-08-21T23:28:23.732Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c6/59fdeffb4f1435299f93d9dc8140b43ad2916e6cfc944be6c3041fcec86d/scipy-1.18.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:d84a09d0dad90ba6525d8ac1c2334b33e64bf3ccfe9e841f02feb867a22681e4", size = 35758543, upload-time = "2026-08-21T23:28:29.431Z" }, + { url = "https://files.pythonhosted.org/packages/cf/d9/135be205d9de8783193aff9cc3bf483a03a38e4b29432c954e8cb66ac14e/scipy-1.18.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:179ce34a8d0fe273d8883ba59e17e052247d08973dfcb743ca52bb1cce2d60b0", size = 37521946, upload-time = "2026-08-21T23:28:35.245Z" }, + { url = "https://files.pythonhosted.org/packages/5c/a2/5b7d5270621ab7cfa3f7766067bf95dc360b5efb6394694e8143b4156e2b/scipy-1.18.1-cp315-cp315t-win_amd64.whl", hash = "sha256:5632e3ae3d09197c446310cd5187de63e28448ce22f0f67b2b93d97503c0c230", size = 37408295, upload-time = "2026-08-21T23:28:40.724Z" }, + { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "textual" +version = "8.2.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py", extra = ["linkify"] }, + { name = "mdit-py-plugins" }, + { name = "platformdirs" }, + { name = "pygments" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/21/39a76b01bd5eea82a04baaca7580e105d8c59450df03998345bb2cfb307b/textual-8.2.8.tar.gz", hash = "sha256:3f106a9fbc73e39dd266c9712432087de78a6d644084c7c241d6a25c3169115b", size = 1860502, upload-time = "2026-06-30T06:51:24.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/be/35261223d9416a0751cdff1c7b4a6f881387218a12d439fe22fefebc8c04/textual-8.2.8-py3-none-any.whl", hash = "sha256:267375fd402dc8d981457212efa71f0e3365fd17bba144ba9bb3ed7563cb374a", size = 731418, upload-time = "2026-06-30T06:51:26.364Z" }, +] + +[[package]] +name = "tinycss2" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/ae/2ca4913e5c0f09781d75482874c3a95db9105462a92ddd303c7d285d3df2/tinycss2-1.5.1.tar.gz", hash = "sha256:d339d2b616ba90ccce58da8495a78f46e55d4d25f9fd71dfd526f07e7d53f957", size = 88195, upload-time = "2025-11-23T10:29:10.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/45/c7b5c3168458db837e8ceab06dc77824e18202679d0463f0e8f002143a97/tinycss2-1.5.1-py3-none-any.whl", hash = "sha256:3415ba0f5839c062696996998176c4a3751d18b7edaaeeb658c9ce21ec150661", size = 28404, upload-time = "2025-11-23T10:29:08.676Z" }, +] + +[[package]] +name = "tinyhtml5" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "webencodings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/1f/cfe2f6b30557c92b3f31d41707e09cef5c1efbd87392bc6c0430c46b0e4d/tinyhtml5-2.1.0.tar.gz", hash = "sha256:60a50ec3d938a37e491efa01af895853060943dcebb5627de5b10d188b338a67", size = 179242, upload-time = "2026-03-05T17:06:30.704Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/48/01695a036b695f83fea7aef6955d735db0f517b1c8e25ddb399ac0bdbcbf/tinyhtml5-2.1.0-py3-none-any.whl", hash = "sha256:6e11cfff38515834268daf89d5f85bbde0b6dd02e8d9e212d1385c2289b89f0a", size = 39686, upload-time = "2026-03-05T17:06:28.498Z" }, +] + +[[package]] +name = "trove-classifiers" +version = "2026.9.21.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/93/af436dfaa845cab5d96f0adbc1e4f3730532d37fa249e4eb796fb1d7fc82/trove_classifiers-2026.9.21.13.tar.gz", hash = "sha256:0a9ebc8d4e2f3e8a22848c5258033035bec17a3012ac3fea16dbaa764489eb71", size = 17085, upload-time = "2026-09-21T13:29:07.301Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl", hash = "sha256:8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17", size = 14232, upload-time = "2026-09-21T13:29:06.123Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +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 = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "wavebench" +version = "0.8.28" +source = { editable = "." } +dependencies = [ + { 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 = "packaging" }, + { name = "portalocker", extra = ["win32"] }, + { name = "pyserial" }, + { name = "pyvisa-py" }, + { name = "rsinstrument" }, +] + +[package.optional-dependencies] +analysis = [ + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "scipy", version = "1.18.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +dev = [ + { name = "hatchling" }, + { name = "pytest" }, + { name = "ruff" }, +] +docs = [ + { name = "mkdocs-material" }, +] +pdf = [ + { name = "weasyprint" }, +] +report3d = [ + { name = "plotly" }, +] +tui = [ + { name = "textual" }, +] + +[package.metadata] +requires-dist = [ + { name = "hatchling", marker = "extra == 'dev'", specifier = "==1.30.1" }, + { name = "mkdocs-material", marker = "extra == 'docs'", specifier = ">=9.5,<10" }, + { name = "numpy", specifier = ">=2.0.0" }, + { name = "packaging", specifier = ">=24.0" }, + { name = "plotly", marker = "extra == 'report3d'", specifier = ">=6.0" }, + { name = "portalocker", extras = ["win32"], specifier = ">=4.1.0,<5.0.0" }, + { name = "pyserial", specifier = ">=3.5" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pyvisa-py", specifier = ">=0.8.1" }, + { name = "rsinstrument", specifier = ">=1.90.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.15.20" }, + { name = "scipy", marker = "extra == 'analysis'", specifier = ">=1.14.0" }, + { name = "textual", marker = "extra == 'tui'", specifier = ">=0.86.0" }, + { name = "weasyprint", marker = "extra == 'pdf'", specifier = ">=62.0" }, +] +provides-extras = ["analysis", "dev", "docs", "pdf", "report3d", "tui"] + +[[package]] +name = "weasyprint" +version = "70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, + { name = "cssselect2" }, + { name = "fonttools", extra = ["woff"] }, + { name = "pillow" }, + { name = "pydyf" }, + { name = "pyphen" }, + { name = "tinycss2" }, + { name = "tinyhtml5" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8d/0e/461aeb736762862b511034933d5b98d68f812431b7393b026d9ace5f6b42/weasyprint-70.0.tar.gz", hash = "sha256:c263abf0e86c747b12af678b67f85f4abbfb97d18a20503031e7ba94e4b8cf8c", size = 1565482, upload-time = "2026-09-08T12:25:41.484Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/8b/0c53c869f29d3273536b896dd17f092549e34e35ffba4c7a60a6de1cb1c2/weasyprint-70.0-py3-none-any.whl", hash = "sha256:5043e55e38d2a2af2b2b871e869697b1f65dad5f8b4a3677961d04ceacf9c5fe", size = 328889, upload-time = "2026-09-08T12:25:39.796Z" }, +] + +[[package]] +name = "webencodings" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d5/a0/8fd707bcb776a7be556bad06a2ea5fb9bd519df78ef8e26f70ccf0f38bff/webencodings-0.6.1.tar.gz", hash = "sha256:565f9ad031c702dae404e27a099e3e09186a3ab1b9520f06d215502b651fd910", size = 15001, upload-time = "2026-08-15T14:22:57.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c6/040cbc72480d789a5f40d63fb484d3106554c4dfa2d2b70ad5022057750f/webencodings-0.6.1-py3-none-any.whl", hash = "sha256:7fab6269c8bf237c657876b52058ccb182e861518d1c695c1a9aaa8c1c105d5b", size = 8745, upload-time = "2026-08-15T14:22:56.31Z" }, +] + +[[package]] +name = "zopfli" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/21/3b6af43a663b22b00e738bb0642931a2579e15da6852613d56c6aa535d28/zopfli-0.4.3.tar.gz", hash = "sha256:d3a50f91a13cea9bafe025de8fd87a005eb26de02a4f0c193127ddbf23ac8ebe", size = 179156, upload-time = "2026-06-10T09:10:19.96Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/5f/b7d81b670daf990e15a0f7551da96c3c0700f69ae6d96b0245d6a19f51f3/zopfli-0.4.3-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:88f4fbe429aad72bc206275d81fab11a097e0f951a5848d1f51083c37ea73073", size = 291492, upload-time = "2026-06-10T09:10:06.621Z" }, + { url = "https://files.pythonhosted.org/packages/55/c8/d8d8d731e0b192024567b7198fb77b748821d355f3c8bf0109de27191f43/zopfli-0.4.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:769875152d0625c46707bcca57d4b2233fe653482067acd55fbf6ec525cb9bdc", size = 829354, upload-time = "2026-06-10T09:10:07.909Z" }, + { url = "https://files.pythonhosted.org/packages/0e/2b/fbe8ba2ec40f5986b8983a4752f7a32672a80a10ea6e68213324a7055469/zopfli-0.4.3-cp310-abi3-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0c9c1d40a8cb1d58762d7e57290ccb753e0828c4d01be8acb59aae5d0ca206", size = 818436, upload-time = "2026-06-10T09:10:09.063Z" }, + { url = "https://files.pythonhosted.org/packages/de/d9/63568c54c8b68b9135f3456c5add83797a5528d596657f0e4f4910173b08/zopfli-0.4.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7fa3c35193475290e3f007bbcdebdbae64ba2f012d75c632da0d727e1da50d5e", size = 1778931, upload-time = "2026-06-10T09:10:10.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/05/8f3aac10a858e89c2146d3a1f6ce33634c3db757365b4148fef1b85784d2/zopfli-0.4.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:47604eee5c6704bdf0e94d8391fe3b74ddb2abd84128fbcfdc3ee0fc265feaef", size = 1864132, upload-time = "2026-06-10T09:10:11.595Z" }, + { url = "https://files.pythonhosted.org/packages/8d/20/9ca59d14b91f9fbc631793b4b085b309777edadaca496aa518a180817827/zopfli-0.4.3-cp310-abi3-win32.whl", hash = "sha256:628c3e941752880b3491db8d44163d0aedb221944e22a17187ff7fc549b050f6", size = 271715, upload-time = "2026-06-10T09:10:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3a/4ff4fdead77ef30f5832b38a47eb7a1283e98b3c678576b83f8fdfff53eb/zopfli-0.4.3-cp310-abi3-win_amd64.whl", hash = "sha256:921c2c9907f4364963848da5ad194b46d68865e07fdb975d04fd09bc42d47357", size = 288550, upload-time = "2026-06-10T09:10:13.639Z" }, + { url = "https://files.pythonhosted.org/packages/e6/44/6264f929057236fde72dd6d271f54612b4811ce37288e002f5d5339d696a/zopfli-0.4.3-cp310-abi3-win_arm64.whl", hash = "sha256:7e9703ca6e7ef66c8d05e0826b6f558b680c9db8206f84f05a3ee93430a12e42", size = 451343, upload-time = "2026-06-10T09:10:14.72Z" }, + { url = "https://files.pythonhosted.org/packages/6f/bf/403da5a753731d9a4e4d65a494c4a9ae5a0fe62e7afffe5ab49915adc9a3/zopfli-0.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0087c9a6f0c8a052be0f6d1a9bb71b6caffdd3e10201d6d6166e28d482cebe6d", size = 147045, upload-time = "2026-06-10T09:10:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5f/afaa18db62ab44da01a3fc39b6cb110478d26cd2287baa83461c6454ed45/zopfli-0.4.3-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2e0adcf7d36c6fd0dd36cc771ef7f0c5803a05666feafcd90d7170174a4148e", size = 127265, upload-time = "2026-06-10T09:10:16.911Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/76bdfd8b35300391666b090357d059ce4c555b9d9ce9878dd551a9ad63a0/zopfli-0.4.3-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f51dd1ab5312e837e2091284e0d9f1a138188f2e65812f9a5799dc02c45f94", size = 124288, upload-time = "2026-06-10T09:10:17.891Z" }, + { url = "https://files.pythonhosted.org/packages/c5/95/5781bfb29782c39918686070dbf2ad1425c21384c3223f5c6bd911a806f8/zopfli-0.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:62248dbf8dbcbd588ee194b210e5be9fa80bce29641f55599d6d394bd2a9d8a3", size = 304624, upload-time = "2026-06-10T09:10:18.944Z" }, +]