diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6795a57..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: @@ -96,3 +100,57 @@ jobs: 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 b219420f..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" @@ -33,6 +33,10 @@ on: - "src/wavebench/**" - "wavebench.example.toml" workflow_dispatch: + workflow_call: + +permissions: + contents: read jobs: build: @@ -69,3 +73,9 @@ jobs: - name: Report documentation impact 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 a49f8261..bee43bea 100644 --- a/README.md +++ b/README.md @@ -69,18 +69,15 @@ WaveBench 是面向电子设计调试和日常实验的 Python 测量台。它 ## 无硬件快速开始 -以下 uv 流程面向尚未发布的 `Dev` 开发线,版本选择见[安装说明](docs/getting-started/installation.md)。安装 [uv](https://docs.astral.sh/uv/getting-started/installation/) 后,在新的工作副本运行以下命令。它们只安装包、列出模板并打印一个示例 plan,不会连接仪器或打开输出。 +正式发行包从 [GitHub Releases](https://github.com/Scaxlibur/WaveBench/releases) 获取,安装与开发线入口见[安装说明](docs/getting-started/installation.md)。在已安装 WaveBench 的环境中运行以下命令,可生成一个示例 plan,不会连接仪器或打开输出。 ```bash -git clone --branch Dev https://github.com/Scaxlibur/WaveBench.git -cd WaveBench -uv sync --locked -uv run --no-sync wavebench run template --list -uv run --no-sync wavebench run template source-scope-sine --print -uv run --no-sync 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 已通过检查。 ## 支持范围 diff --git a/docs/README_EN.md b/docs/README_EN.md index 6ce2fb9c..d6db0981 100644 --- a/docs/README_EN.md +++ b/docs/README_EN.md @@ -6,16 +6,15 @@ The current complete documentation is maintained in Chinese. This page is an Eng ## Start without instruments -Use a new checkout of the unreleased `Dev` branch, Python 3.11+, and [uv](https://docs.astral.sh/uv/getting-started/installation/) on PATH. The [installation guide](getting-started/installation.md) explains checkout and index configuration and links to released-version instructions. +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 -uv sync --locked -uv run --no-sync wavebench run template --list -uv run --no-sync wavebench run template source-scope-sine --print -uv run --no-sync 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/development/contributing.md b/docs/development/contributing.md index 3b62b6f3..67c7d93c 100644 --- a/docs/development/contributing.md +++ b/docs/development/contributing.md @@ -14,4 +14,4 @@ WaveBench 的代码、文档、schema 和测试在同一仓库中维护。提交 新增或修改用户可见行为时,更新唯一 canonical Reference,并用[文档工作流](documentation.md)进行 scoped review。插件专用流程见[插件开发](plugin-development.md)。 -尚未发布的示波器观察入口及执行验收合同见[开发线示波器观察](scope-observation.md)。 +示波器观察的实现与回归合同见[示波器观察实现与验收](scope-observation.md)。发布步骤见[发布版本](releasing.md)。 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 index 2b7447fc..f1abaafa 100644 --- a/docs/development/scope-observation.md +++ b/docs/development/scope-observation.md @@ -1,17 +1,16 @@ -# 开发线示波器观察 +# 示波器观察实现与验收 -> 状态:`Proposed / Future`,开发线已实现、尚未正式发布。 -> 本页面向实现与测试人员,记录开发线的执行合同;不表示已安装的正式版本提供这些入口。 +本页面向 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` 为准。 +共同实现。CLI 参数以 `cli_parser.py` 和离线 `--help` 为准。 Core 表达观察、访问约束、会话和报告语义;型号通道、端接与传输差异由 descriptor 和 driver 提供,不在观察层维护型号范围表。 -MCP 的 `doctor.config` 同属未发布入口,调用 `doctor_records()` 并返回结构化检查记录。 +MCP 的 `doctor.config` 调用 `doctor_records()` 并返回结构化检查记录。 它不读取波形或执行建议;实际查询合同以 doctor 与 driver 实现为准。 ## 观察与建议 @@ -78,8 +77,5 @@ duty_percent = 50 以及缺证据建议与同步默认值。测试只使用 fake transport 和合成信号,不连接设备;实机 evidence 属于单独授权的插件验证。 -发布时依据实际 tag 和实现将正式可用行为转入 CLI Reference 和 MCP How-to,并保留唯一 -事实来源。本页不提前承诺发布版本、插件支持范围或硬件验收结论。 - 相关合同见[安全模型](../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 5a5133e9..cdaf205e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -1,27 +1,48 @@ # 安装 WaveBench -本页用于安装 `Dev` 开发线,并确认 CLI 已可运行。该开发线的 uv 安装与插件管理后端尚未发布;使用已发布版本时,应查阅[对应 tag 的安装说明](https://github.com/Scaxlibur/WaveBench/blob/v0.8.27/docs/getting-started/installation.md)。完成后会得到一个独立虚拟环境和可验证的 `--help` 输出。 +本页对应 WaveBench `0.8.28`。发行物及发布状态以 [GitHub Releases](https://github.com/Scaxlibur/WaveBench/releases) 为准;旧版本应使用对应 tag 下的文档。 -## 前置条件 +## 安装发行包 -- Python 3.11 或更高版本。 -- PATH 中可用的 [uv](https://docs.astral.sh/uv/getting-started/installation/)。 -- 一个新的 `Dev` 工作副本;已有仪器插件的环境按[管理仪器插件](../how-to/manage-plugins.md)处理。 +需要 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 +.venv-runtime/bin/python -m wavebench --version +.venv-runtime/bin/python -m wavebench --help +``` + +PowerShell 使用: + +```powershell +.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)。 -以下命令适用于 Bash 和 PowerShell: +## 从固定版本源码安装 + +需要仓库中的示例计划或修改代码时,使用固定 tag 的新工作副本: ```bash -git clone --branch Dev https://github.com/Scaxlibur/WaveBench.git +git clone --branch v0.8.28 https://github.com/Scaxlibur/WaveBench.git cd WaveBench uv sync --locked -uv run --no-sync wavebench --help +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 在正式发布准备完成后可用。 + +## 使用开发线 -`uv sync --locked` 按 `uv.lock` 准备 `.venv`,并以 editable 方式安装 Core。`uv run --no-sync` 使用已准备的环境,不在运行命令前调整依赖。 +跟进未发布修改时,将克隆命令中的 `--branch v0.8.28` 换成 `--branch Dev`。开发线的行为以当前源码、版本查询和文档为准,不作为已发布版本的功能保证。修改包版本后重新同步环境,更新 editable 安装的元数据。 uv 不读取 `pip.conf` 或 `PIP_INDEX_URL`。使用镜像或私有软件源时,先按 [uv 软件源配置](https://docs.astral.sh/uv/concepts/indexes/)设置索引,再运行安装命令。 @@ -36,12 +57,18 @@ uv 不读取 `pip.conf` 或 `PIP_INDEX_URL`。使用镜像或私有软件源时 | `dev` | 本地测试和静态检查。 | | `docs` | 文档站点预览与构建。 | -按实际任务安装,例如: +发行包安装时,在 wheel 路径后加所需 extras: + +```bash +uv pip install --python .venv-runtime "/path/to/wavebench-0.8.28-py3-none-any.whl[analysis,tui]" +``` + +源码开发环境按实际任务同步,例如: ```bash uv sync --locked --extra dev --extra analysis --extra tui ``` -每次同步应列出所有需要的 extras;默认精确同步会移除未选择的可选组件和锁文件未声明的包。运行环境与开发环境的区别见[管理仪器插件](../how-to/manage-plugins.md)。 +每次同步应列出所有需要的 extras;默认精确同步会移除未选择的可选组件和锁文件未声明的包。已有仪器插件的环境应采用专用运行方式。 -可选组件不连接仪器。安装后先继续[无硬件快速开始](quickstart.md),不要直接执行硬件写入命令。 +安装后继续[无硬件快速开始](quickstart.md),确认离线命令可用后再配置实验台。 diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index f2356b5f..87d6a1fe 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -1,54 +1,60 @@ # 无硬件快速开始 -本页用于在未连接任何仪器时确认 WaveBench 已安装,并验证一个现有 plan 可以通过离线检查。完成后可以看到模板列表、示例 TOML 和 `run check` 的明确成功结果。 +本页通过模板生成和离线检查确认 WaveBench 已可运行。不需要仪器或仓库示例文件;检查使用单独的演示配置。 -## 前置条件 +## 1. 确认安装 -- Python 3.11 或更高版本,以及 [uv](https://docs.astral.sh/uv/getting-started/installation/)。 -- 一个新的 `Dev` 工作副本;该开发线尚未发布,获取方式与已发布版本入口见[安装说明](installation.md)。 -- 不需要 `wavebench.toml`,也不需要连接仪器。 - -## 1. 创建虚拟环境并安装 - -在新的仓库工作副本中运行,Bash 与 PowerShell 使用相同命令: +先按[安装说明](installation.md)准备发行包环境或源码环境。以下 `python` 指该环境的解释器;源码环境也可使用 `uv run --no-sync python`。 ```bash -uv sync --locked +python -m wavebench --version +python -m wavebench --help ``` -依赖版本来自 `uv.lock`,项目以 editable 方式安装到 `.venv`。 -已有仪器插件的环境不要执行默认同步,因为未声明的包会被移除;实际仪器使用采用 -[专用运行环境](../how-to/manage-plugins.md)。 +版本应与所安装的发行包一致;帮助中应列出 `run`、`scope`、`source`、`power`、`dmm`、`doctor`、`plugin` 和 `capability` 等命令域。 -## 2. 检查命令入口 +## 2. 查看并打印一个模板 ```bash -uv run --no-sync wavebench --help +python -m wavebench run template --list +python -m wavebench run template source-scope-sine --print ``` -输出会列出 `run`、`scope`、`source`、`power`、`dmm`、`doctor`、`plugin` 和 `capability` 等命令域。`--no-sync` 使用已经准备好的环境,不在执行前更改依赖。 +第一条命令应包含 `source-scope-sine`。第二条命令打印包含实验段和 `[[steps]]` 的 TOML,不加载配置或连接仪器。 -## 3. 查看并打印一个模板 +## 3. 保存模板并离线检查 -```bash -uv run --no-sync wavebench run template --list -uv run --no-sync wavebench run template source-scope-sine --print -``` +将以下内容保存为 `offline.toml`。地址只用于离线示例,不能直接用于真实实验: -第一条命令应包含 `source-scope-sine`。第二条命令会把 TOML 打印到标准输出,其中包含实验段和 `[[steps]]`;它不会写文件、加载配置或连接仪器。 +```toml +[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 +``` -## 4. 离线检查现有示例 +在可写目录运行,使用尚不存在的输出文件名: ```bash -uv run --no-sync 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)。 -- 需要查询字段时,运行 `uv run --no-sync 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 f66536aa..cff41d52 100644 --- a/docs/how-to/manage-plugins.md +++ b/docs/how-to/manage-plugins.md @@ -2,7 +2,7 @@ 本页适用于需要检查、安装、升级、降级、卸载或恢复本地 WaveBench 仪器插件的维护者。可执行插件是可信 Python 代码,不是安全沙箱;只处理来源、版本和依赖均已确认的本地源码目录或 wheel。 -以下 uv 后端属于尚未发布的 `Dev` 开发线。验证它时使用该开发线构建的 Core wheel;已发布版本的安装入口见[安装说明](../getting-started/installation.md)。 +以下 uv 后端适用于 Core `0.8.28`。使用更早版本时查阅对应 tag 的文档;发行包与开发线的安装入口见[安装说明](../getting-started/installation.md)。 ## 前置条件 diff --git a/docs/how-to/serve-mcp.md b/docs/how-to/serve-mcp.md index f7f04cdd..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,16 +20,18 @@ python -m wavebench mcp serve \ | `GET /tools` | Bearer token | 列出只读工具。 | | `POST /call`、`POST /mcp` | Bearer token | 调用 MCP 工具或 JSON-RPC 方法。 | -请求体上限为 1 MiB。已发布的工具不连接仪器;安装版本的实际工具列表以 `GET /tools` 返回为准: +请求体上限为 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` | 基于查询结果和显式期望频率生成显示/时基建议,不执行建议。 | -开发线新增的 `doctor.config`、`scope.observe` 和 `scope.advise` 尚未随正式版本发布, -不构成本页的功能承诺。其实施边界见[开发线示波器观察](../development/scope-observation.md)。 +缺少测量值、有效档位或期望频率时,建议保留 `advice_unavailable`;查询失败不能解释为无需调整。MCP 不提供 `scope observe --fetch-waveform` 的波形报告路径。执行建议须另行经过对应 CLI 的访问、能力和安全检查。开发与回归合同见[示波器观察实现与验收](../development/scope-observation.md)。 ## Verification diff --git a/docs/index.md b/docs/index.md index f5d6c00b..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/tree/Dev#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/Dev/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/reference/cli.md b/docs/reference/cli.md index 0f027a55..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,8 +24,22 @@ python -m wavebench --json ... `scope fetch` 读取已有波形,但仍是仪器 I/O;不要把它当作离线命令。每次硬件操作前确认接线、输入阻抗、输出状态和安全限制。WaveBench 不会自动执行 `*RST`,也不会因设置电压、幅度或频率而自动开启输出。 -`scope observe` 尚未随正式版本发布,不属于本页的已发布命令承诺。开发线的参数、输出和 -副作用合同见[开发线示波器观察](../development/scope-observation.md)。 +`--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 输出与退出码 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/mkdocs.yml b/mkdocs.yml index 03c5801b..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,9 +70,11 @@ nav: - 开发: - 贡献: development/contributing.md - 测试: development/testing.md - - 开发线示波器观察: development/scope-observation.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_parser.py b/src/wavebench/cli_parser.py index ab2d0f67..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", 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/plugins/package_inspect.py b/src/wavebench/plugins/package_inspect.py index ebecbe33..0669ece9 100644 --- a/src/wavebench/plugins/package_inspect.py +++ b/src/wavebench/plugins/package_inspect.py @@ -602,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/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_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_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/uv.lock b/uv.lock index a62ebfbb..760455f1 100644 --- a/uv.lock +++ b/uv.lock @@ -1634,7 +1634,7 @@ wheels = [ [[package]] name = "wavebench" -version = "0.8.27" +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'" },