From d82d4b0fefaa039b9c9c3d2c353fc87713a2db45 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 17:23:45 +0100 Subject: [PATCH 01/41] Add release-to-pypi-uv composite action --- .../actions/release-to-pypi-uv/CHANGELOG.md | 8 ++ .github/actions/release-to-pypi-uv/README.md | 52 ++++++++ .github/actions/release-to-pypi-uv/action.yml | 93 ++++++++++++++ .../scripts/check_github_release.py | 91 ++++++++++++++ .../scripts/confirm_release.py | 28 +++++ .../scripts/determine_release.py | 57 +++++++++ .../scripts/publish_release.py | 34 +++++ .../scripts/validate_toml_versions.py | 117 ++++++++++++++++++ .../scripts/write_summary.py | 34 +++++ .../tests/test_determine_release.py | 82 ++++++++++++ .../tests/test_validate_toml_versions.py | 82 ++++++++++++ README.md | 1 + docs/scripting-standards.md | 43 +++---- 13 files changed, 699 insertions(+), 23 deletions(-) create mode 100644 .github/actions/release-to-pypi-uv/CHANGELOG.md create mode 100644 .github/actions/release-to-pypi-uv/README.md create mode 100644 .github/actions/release-to-pypi-uv/action.yml create mode 100644 .github/actions/release-to-pypi-uv/scripts/check_github_release.py create mode 100644 .github/actions/release-to-pypi-uv/scripts/confirm_release.py create mode 100644 .github/actions/release-to-pypi-uv/scripts/determine_release.py create mode 100644 .github/actions/release-to-pypi-uv/scripts/publish_release.py create mode 100644 .github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py create mode 100644 .github/actions/release-to-pypi-uv/scripts/write_summary.py create mode 100644 .github/actions/release-to-pypi-uv/tests/test_determine_release.py create mode 100644 .github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py diff --git a/.github/actions/release-to-pypi-uv/CHANGELOG.md b/.github/actions/release-to-pypi-uv/CHANGELOG.md new file mode 100644 index 00000000..0d73a670 --- /dev/null +++ b/.github/actions/release-to-pypi-uv/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +## v1.0.0 (2025-09-18) + +- Initial release: resolve release tags, ensure GitHub Release readiness, and + publish Python distributions with uv Trusted Publishing support. +- Validate `pyproject.toml` versions against the release tag and optionally + block dynamic version declarations. diff --git a/.github/actions/release-to-pypi-uv/README.md b/.github/actions/release-to-pypi-uv/README.md new file mode 100644 index 00000000..4779ac76 --- /dev/null +++ b/.github/actions/release-to-pypi-uv/README.md @@ -0,0 +1,52 @@ +# Release to PyPI (uv) + +Build and publish Python distributions via [uv](https://github.com/astral-sh/uv) with GitHub's +trusted publishing flow. + +## Inputs + +| Name | Description | Required | Default | +| --- | --- | --- | --- | +| tag | Tag to release (e.g. `v1.2.3`). Required when the workflow is not running on a tag ref. | no | _(empty)_ | +| require-confirmation | Require a manual confirmation string before publishing. | no | `false` | +| confirm | Confirmation string. Must equal `release ` when `require-confirmation` is true. | no | _(empty)_ | +| environment-name | GitHub environment to reference in the release summary. | no | `pypi` | +| uv-index | Optional uv index name to publish to (e.g. `testpypi`). Must exist in `tool.uv.index`. | no | _(empty)_ | +| toml-glob | Glob used to discover `pyproject.toml` files for version validation. | no | `**/pyproject.toml` | +| fail-on-dynamic-version | Fail when a project declares a dynamic PEP 621 version instead of a literal string. | no | `false` | + +## Outputs + +| Name | Description | +| --- | --- | +| tag | Resolved release tag. | +| version | Resolved release version (tag without the leading `v`). | + +## Usage + +```yaml +name: Release +on: + push: + tags: + - "v*" + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Build and publish + uses: ./.github/actions/release-to-pypi-uv@v1 + with: + require-confirmation: true + confirm: release ${{ github.ref_name }} +``` + +Release history is available in [CHANGELOG](CHANGELOG.md). diff --git a/.github/actions/release-to-pypi-uv/action.yml b/.github/actions/release-to-pypi-uv/action.yml new file mode 100644 index 00000000..f9d0de1c --- /dev/null +++ b/.github/actions/release-to-pypi-uv/action.yml @@ -0,0 +1,93 @@ +name: Release to PyPI (uv) +description: Build and publish Python distributions to PyPI using uv. +inputs: + tag: + description: Tag to release (e.g. v1.2.3). Required when the workflow is not running on a tag ref. + required: false + require-confirmation: + description: Require a manual confirmation string before publishing. + required: false + default: 'false' + confirm: + description: Confirmation string. Must equal `release ` when `require-confirmation` is true. + required: false + environment-name: + description: GitHub environment to target (informational only, surfaced in the summary output). + required: false + default: pypi + uv-index: + description: Optional uv index name to publish to (e.g. testpypi). Must exist in pyproject tool.uv.index. + required: false + default: '' + toml-glob: + description: Glob used to discover pyproject.toml files for version validation. + required: false + default: "**/pyproject.toml" + fail-on-dynamic-version: + description: Fail if any project declares a dynamic version instead of a literal string. + required: false + default: 'false' +outputs: + tag: + description: Resolved release tag. + value: ${{ steps.resolve.outputs.tag }} + version: + description: Resolved release version (tag without the leading v). + value: ${{ steps.resolve.outputs.version }} +runs: + using: composite + steps: + - name: Setup uv + # v6.4.3 + uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc + with: + cache-dependency-glob: | + **/pyproject.toml + **/uv.lock + cache-suffix: action-${{ github.action_ref || github.sha }} + - name: Determine tag and version + id: resolve + run: uv run --script "${{ github.action_path }}/scripts/determine_release.py" + shell: bash + env: + INPUT_TAG: ${{ inputs.tag }} + - name: Optional manual confirmation + if: inputs.require-confirmation == 'true' + run: uv run --script "${{ github.action_path }}/scripts/confirm_release.py" + shell: bash + env: + EXPECTED: release ${{ steps.resolve.outputs.tag }} + INPUT_CONFIRM: ${{ inputs.confirm }} + - name: Ensure GitHub Release exists and is published + run: uv run --script "${{ github.action_path }}/scripts/check_github_release.py" + shell: bash + env: + RELEASE_TAG: ${{ steps.resolve.outputs.tag }} + GH_TOKEN: ${{ github.token }} + GITHUB_REPOSITORY: ${{ github.repository }} + - name: Validate TOML files + run: uv run --script "${{ github.action_path }}/scripts/validate_toml_versions.py" + shell: bash + env: + RESOLVED_VERSION: ${{ steps.resolve.outputs.version }} + INPUT_TOML_GLOB: ${{ inputs.toml-glob }} + INPUT_FAIL_ON_DYNAMIC_VERSION: ${{ inputs.fail-on-dynamic-version }} + - name: Install Python 3.13 + run: uv python install 3.13 + shell: bash + - name: Build distributions + run: uv build + shell: bash + - name: Publish to PyPI (Trusted Publishing) + run: uv run --script "${{ github.action_path }}/scripts/publish_release.py" + shell: bash + env: + INPUT_UV_INDEX: ${{ inputs.uv-index }} + - name: Summary + if: always() + run: uv run --script "${{ github.action_path }}/scripts/write_summary.py" + shell: bash + env: + RESOLVED_TAG: ${{ steps.resolve.outputs.tag }} + INPUT_UV_INDEX: ${{ inputs.uv-index }} + INPUT_ENVIRONMENT_NAME: ${{ inputs.environment-name }} diff --git a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py new file mode 100644 index 00000000..d7f76053 --- /dev/null +++ b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py @@ -0,0 +1,91 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.13" +# dependencies = ["typer"] +# /// +"""Verify that the GitHub Release for the provided tag exists and is published.""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request + +import typer + +TAG_OPTION = typer.Option(..., envvar="RELEASE_TAG") +TOKEN_OPTION = typer.Option(..., envvar="GH_TOKEN") +REPO_OPTION = typer.Option(..., envvar="GITHUB_REPOSITORY") + + +class GithubReleaseError(RuntimeError): + """Raised when the GitHub release is not ready for publishing.""" + + +def _fetch_release(repo: str, tag: str, token: str) -> dict[str, object]: + api = f"https://api.github.com/repos/{repo}/releases/tags/{tag}" + request = urllib.request.Request( + api, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "release-to-pypi-action", + }, + ) + + try: + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 + payload = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: # pragma: no cover - network failure path + if exc.code == 404: + raise GithubReleaseError( + f"No GitHub release found for tag {tag}. Create and publish the release first." + ) from exc + detail = exc.read().decode("utf-8", errors="ignore") + raise GithubReleaseError( + f"GitHub API request failed with status {exc.code}: {detail or exc.reason}" + ) from exc + except urllib.error.URLError as exc: # pragma: no cover - network failure path + raise GithubReleaseError(f"Failed to reach GitHub API: {exc.reason}") from exc + + try: + return json.loads(payload) + except json.JSONDecodeError as exc: # pragma: no cover - unexpected payload + raise GithubReleaseError("GitHub API returned invalid JSON") from exc + + +def _validate_release(tag: str, data: dict[str, object]) -> str: + draft = data.get("draft") + prerelease = data.get("prerelease") + name = data.get("name") or tag + + if draft: + raise GithubReleaseError( + f"Release '{name}' for {tag} is still a draft. Publish it before running this action." + ) + if prerelease: + raise GithubReleaseError( + f"Release '{name}' for {tag} is marked as prerelease. Publish a normal release first." + ) + + return str(name) + + +def main( + tag: str = TAG_OPTION, + token: str = TOKEN_OPTION, + repo: str = REPO_OPTION, +) -> None: + try: + data = _fetch_release(repo, tag, token) + name = _validate_release(tag, data) + except GithubReleaseError as exc: + typer.echo(f"::error::{exc}", err=True) + raise typer.Exit(1) from exc + + typer.echo(f"GitHub Release '{name}' is published.") + + +if __name__ == "__main__": + typer.run(main) diff --git a/.github/actions/release-to-pypi-uv/scripts/confirm_release.py b/.github/actions/release-to-pypi-uv/scripts/confirm_release.py new file mode 100644 index 00000000..893e0b44 --- /dev/null +++ b/.github/actions/release-to-pypi-uv/scripts/confirm_release.py @@ -0,0 +1,28 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.13" +# dependencies = ["typer"] +# /// +"""Validate that the caller supplied the expected confirmation string.""" + +from __future__ import annotations + +import typer + +EXPECTED_OPTION = typer.Option(..., envvar="EXPECTED") +CONFIRM_OPTION = typer.Option("", envvar="INPUT_CONFIRM") + + +def main(expected: str = EXPECTED_OPTION, confirm: str = CONFIRM_OPTION) -> None: + if confirm != expected: + typer.echo( + f"::error::Confirmation failed. Set the 'confirm' input to: {expected}", + err=True, + ) + raise typer.Exit(1) + + typer.echo("Manual confirmation OK.") + + +if __name__ == "__main__": + typer.run(main) diff --git a/.github/actions/release-to-pypi-uv/scripts/determine_release.py b/.github/actions/release-to-pypi-uv/scripts/determine_release.py new file mode 100644 index 00000000..6a41e1d7 --- /dev/null +++ b/.github/actions/release-to-pypi-uv/scripts/determine_release.py @@ -0,0 +1,57 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.13" +# dependencies = ["typer"] +# /// +"""Resolve the release tag and semantic version for the current run.""" + +from __future__ import annotations + +import os +import re +from pathlib import Path + +import typer + +TAG_OPTION = typer.Option(None, envvar="INPUT_TAG") +GITHUB_OUTPUT_OPTION = typer.Option(..., envvar="GITHUB_OUTPUT") + + +def _emit_outputs(dest: Path, tag: str, version: str) -> None: + with dest.open("a", encoding="utf-8") as fh: + fh.write(f"tag={tag}\n") + fh.write(f"version={version}\n") + + +def main(tag: str | None = TAG_OPTION, github_output: Path = GITHUB_OUTPUT_OPTION) -> None: + ref_type = os.getenv("GITHUB_REF_TYPE", "") + ref_name = os.getenv("GITHUB_REF_NAME", "") + + resolved_tag: str | None = None + if ref_type == "tag" and ref_name: + resolved_tag = ref_name + elif tag: + resolved_tag = tag + + if not resolved_tag: + typer.echo( + "::error::No tag was provided and this run is not on a tag ref.", + err=True, + ) + raise typer.Exit(1) + + if not re.fullmatch(r"v[0-9].*", resolved_tag): + typer.echo( + f"::error::Tag must start with 'v' (e.g. v1.2.3), got '{resolved_tag}'.", + err=True, + ) + raise typer.Exit(1) + + version = resolved_tag.removeprefix("v") + + _emit_outputs(github_output, resolved_tag, version) + typer.echo(f"Resolved release tag: {resolved_tag} (version: {version})") + + +if __name__ == "__main__": + typer.run(main) diff --git a/.github/actions/release-to-pypi-uv/scripts/publish_release.py b/.github/actions/release-to-pypi-uv/scripts/publish_release.py new file mode 100644 index 00000000..e431997e --- /dev/null +++ b/.github/actions/release-to-pypi-uv/scripts/publish_release.py @@ -0,0 +1,34 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.13" +# dependencies = ["typer"] +# /// +"""Publish the built distributions using uv.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import typer + +REPO_ROOT = Path(__file__).resolve().parents[3] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from cmd_utils import run_cmd # noqa: E402 + +INDEX_OPTION = typer.Option("", envvar="INPUT_UV_INDEX") + + +def main(index: str = INDEX_OPTION) -> None: + if index: + typer.echo(f"Publishing with uv to index '{index}'") + run_cmd(["uv", "publish", "--index", index]) + else: + typer.echo("Publishing with uv to default index (PyPI)") + run_cmd(["uv", "publish"]) + + +if __name__ == "__main__": + typer.run(main) diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py new file mode 100644 index 00000000..33ecafb7 --- /dev/null +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -0,0 +1,117 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.13" +# dependencies = ["typer"] +# /// +"""Validate that project versions in pyproject.toml files match the release version.""" + +from __future__ import annotations + +import glob +import os +from pathlib import Path +from typing import Iterable + +import typer + +VERSION_OPTION = typer.Option(..., envvar="RESOLVED_VERSION") +PATTERN_OPTION = typer.Option("**/pyproject.toml", envvar="INPUT_TOML_GLOB") +FAIL_ON_DYNAMIC_OPTION = typer.Option( + "false", + envvar="INPUT_FAIL_ON_DYNAMIC_VERSION", +) + +SKIP_PARTS = {".git", ".venv", "venv", "node_modules", "dist", "build"} + + +def _iter_files(pattern: str) -> Iterable[Path]: + candidates = [Path(p) for p in glob.glob(pattern, recursive=True)] + for path in candidates: + if not path.is_file(): + continue + parts = set(path.parts) + if parts & SKIP_PARTS: + continue + yield path + + +def _parse_bool(value: str) -> bool: + return value.strip().lower() == "true" + + +def _load_toml(path: Path) -> dict[str, object]: + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise RuntimeError(f"{path}: failed to read: {exc}") from exc + + try: + import tomllib + except ModuleNotFoundError as exc: # pragma: no cover - python < 3.11 + raise RuntimeError("tomllib module is unavailable") from exc + + try: + return tomllib.loads(text) + except tomllib.TOMLDecodeError as exc: # type: ignore[attr-defined] + raise RuntimeError(f"{path}: failed to parse: {exc}") from exc + + +def main( + version: str = VERSION_OPTION, + pattern: str = PATTERN_OPTION, + fail_on_dynamic: str = FAIL_ON_DYNAMIC_OPTION, +) -> None: + files = list(_iter_files(pattern)) + if not files: + typer.echo(f"::warning::No TOML files matched pattern {pattern}") + return + + literal_version_errors: list[str] = [] + dynamic_errors: list[str] = [] + checked = 0 + fail_dynamic = _parse_bool(fail_on_dynamic) + + for path in files: + try: + data = _load_toml(path) + except RuntimeError as exc: + typer.echo(f"::error::{exc}", err=True) + return + + project = data.get("project") + if not isinstance(project, dict): + continue + checked += 1 + + dynamic = project.get("dynamic") + dynamic_set = {str(item) for item in dynamic} if isinstance(dynamic, (list, tuple)) else set() + if "version" in dynamic_set: + message = f"{path}: uses dynamic 'version' (PEP 621)." + if fail_dynamic: + dynamic_errors.append(message + " Set fail-on-dynamic-version=false to allow.") + else: + typer.echo(f"::notice::{message} Skipping version check.") + continue + + toml_version = project.get("version") + if toml_version is None: + literal_version_errors.append( + f"{path}: missing [project].version and not marked dynamic" + ) + continue + + if str(toml_version) != version: + literal_version_errors.append( + f"{path}: [project].version '{toml_version}' != tag version '{version}'" + ) + + if dynamic_errors or literal_version_errors: + for error in (*dynamic_errors, *literal_version_errors): + typer.echo(f"::error::{error}", err=True) + raise typer.Exit(1) + + typer.echo(f"Checked {checked} PEP 621 project file(s); all versions match {version}.") + + +if __name__ == "__main__": + typer.run(main) diff --git a/.github/actions/release-to-pypi-uv/scripts/write_summary.py b/.github/actions/release-to-pypi-uv/scripts/write_summary.py new file mode 100644 index 00000000..f5c9f58f --- /dev/null +++ b/.github/actions/release-to-pypi-uv/scripts/write_summary.py @@ -0,0 +1,34 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.13" +# dependencies = ["typer"] +# /// +"""Append a short release summary for the workflow run.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +TAG_OPTION = typer.Option(..., envvar="RESOLVED_TAG") +INDEX_OPTION = typer.Option("", envvar="INPUT_UV_INDEX") +ENV_OPTION = typer.Option("pypi", envvar="INPUT_ENVIRONMENT_NAME") +SUMMARY_OPTION = typer.Option(..., envvar="GITHUB_STEP_SUMMARY") + + +def main( + tag: str = TAG_OPTION, + index: str = INDEX_OPTION, + environment_name: str = ENV_OPTION, + summary_path: Path = SUMMARY_OPTION, +) -> None: + index_label = index or "pypi (default)" + with summary_path.open("a", encoding="utf-8") as fh: + fh.write(f"Released tag: {tag}\n") + fh.write(f"Publish index: {index_label}\n") + fh.write(f"Environment: {environment_name}\n") + + +if __name__ == "__main__": + typer.run(main) diff --git a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py new file mode 100644 index 00000000..e8d12e4f --- /dev/null +++ b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py @@ -0,0 +1,82 @@ +"""Tests for determine_release.py.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +def run_script(script: Path, *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + cmd = ["uv", "run", "--script", str(script)] + result = subprocess.run( # noqa: S603 + cmd, + capture_output=True, + encoding="utf-8", + errors="replace", + env=env, + check=False, + cwd=env.get("PWD", None), + ) + return result + + +def base_env(tmp_path: Path) -> dict[str, str]: + merged = {**os.environ} + root = str(Path(__file__).resolve().parents[4]) + merged["PYTHONPATH"] = f"{root}{os.pathsep}{merged.get('PYTHONPATH', '')}".rstrip(os.pathsep) + merged["PYTHONIOENCODING"] = "utf-8" + merged["GITHUB_OUTPUT"] = str(tmp_path / "out.txt") + merged["PWD"] = str(tmp_path) + return merged + + +def read_outputs(tmp_path: Path) -> dict[str, str]: + out = {} + output_file = tmp_path / "out.txt" + if not output_file.exists(): + return out + for line in output_file.read_text(encoding="utf-8").splitlines(): + if "=" in line: + key, value = line.split("=", 1) + out[key] = value + return out + + +def test_resolves_tag_from_ref(tmp_path: Path) -> None: + env = base_env(tmp_path) + env["GITHUB_REF_TYPE"] = "tag" + env["GITHUB_REF_NAME"] = "v1.2.3" + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 0, result.stderr + outputs = read_outputs(tmp_path) + assert outputs["tag"] == "v1.2.3" + assert outputs["version"] == "1.2.3" + + +def test_resolves_tag_from_input(tmp_path: Path) -> None: + env = base_env(tmp_path) + env["INPUT_TAG"] = "v2.0.0" + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 0, result.stderr + outputs = read_outputs(tmp_path) + assert outputs["tag"] == "v2.0.0" + assert outputs["version"] == "2.0.0" + + +def test_rejects_invalid_tag(tmp_path: Path) -> None: + env = base_env(tmp_path) + env["GITHUB_REF_TYPE"] = "tag" + env["GITHUB_REF_NAME"] = "release-1.0.0" + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 1 + assert "Tag must start with 'v'" in result.stderr diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py new file mode 100644 index 00000000..041e747a --- /dev/null +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -0,0 +1,82 @@ +"""Tests for validate_toml_versions.py.""" + +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "validate_toml_versions.py" + + +def _run(tmp_path: Path, *, version: str, fail_dynamic: str = "false") -> subprocess.CompletedProcess[str]: + cmd = ["uv", "run", "--script", str(SCRIPT_PATH)] + env = {**os.environ} + root = str(Path(__file__).resolve().parents[4]) + env["PYTHONPATH"] = f"{root}{os.pathsep}{env.get('PYTHONPATH', '')}".rstrip(os.pathsep) + env["PYTHONIOENCODING"] = "utf-8" + env["RESOLVED_VERSION"] = version + env["INPUT_TOML_GLOB"] = "**/pyproject.toml" + env["INPUT_FAIL_ON_DYNAMIC_VERSION"] = fail_dynamic + + return subprocess.run( # noqa: S603 + cmd, + capture_output=True, + encoding="utf-8", + errors="replace", + cwd=tmp_path, + env=env, + check=False, + ) + + +def test_passes_when_versions_match(tmp_path: Path) -> None: + project = tmp_path / "pkg" + project.mkdir() + (project / "pyproject.toml").write_text( + """ +[project] +name = "demo" +version = "1.0.0" +""".strip() + ) + + result = _run(tmp_path, version="1.0.0") + + assert result.returncode == 0, result.stderr + assert "all versions match 1.0.0" in result.stdout + + +def test_fails_on_mismatch(tmp_path: Path) -> None: + project = tmp_path / "pkg" + project.mkdir() + (project / "pyproject.toml").write_text( + """ +[project] +name = "demo" +version = "1.0.1" +""".strip() + ) + + result = _run(tmp_path, version="1.0.0") + + assert result.returncode == 1 + assert "version '1.0.1' != tag version '1.0.0'" in result.stderr + + +def test_dynamic_version_failure(tmp_path: Path) -> None: + project = tmp_path / "pkg" + project.mkdir() + (project / "pyproject.toml").write_text( + """ +[project] +name = "demo" +dynamic = ["version"] +""".strip() + ) + + result = _run(tmp_path, version="1.0.0", fail_dynamic="true") + + assert result.returncode == 1 + assert "dynamic 'version'" in result.stderr diff --git a/README.md b/README.md index c38a9120..bfae89ed 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ GitHub Actions | Export Postgres URL | `.github/actions/export-postgres-url` | v1 | | Generate coverage | `.github/actions/generate-coverage` | v1 | | Setup Rust | `.github/actions/setup-rust` | v1 | +| Release to PyPI (uv) | `.github/actions/release-to-pypi-uv` | v1 | | Upload CodeScene Coverage | `.github/actions/upload-codescene-coverage` | v1 | | Ratchet coverage | `.github/actions/ratchet-coverage` | v1 | | Rust build release | `.github/actions/rust-build-release` | v1 | diff --git a/docs/scripting-standards.md b/docs/scripting-standards.md index 17d177ea..9c4885d9 100644 --- a/docs/scripting-standards.md +++ b/docs/scripting-standards.md @@ -11,32 +11,31 @@ as a default. ## Rationale for adopting Cyclopts -- **Environment‑first configuration without glue.** Cyclopts reads environment +- Environment‑first configuration without glue. Cyclopts reads environment variables with a defined prefix (for example, `INPUT_`) and maps them to parameters directly. Bash argument assembly and bespoke parsing can be removed. -- **Typed lists and paths from env.** Parameters annotated as `list[str]` or +- Typed lists and paths from env. Parameters annotated as `list[str]` or `list[pathlib.Path]` are populated from whitespace‑ or delimiter‑separated environment values. Custom split/trim helpers are unnecessary. -- **Clear precedence model.** CLI flags override environment variables, which +- Clear precedence model. CLI flags override environment variables, which override code defaults. Behaviour is predictable in both CI and local runs. -- **Small API surface.** The API is explicit and integrates cleanly with type +- Small API surface. The API is explicit and integrates cleanly with type hints, aiding readability and testing. -- **Backwards‑compatible migration.** Option aliases and per‑parameter +- Backwards‑compatible migration. Option aliases and per‑parameter environment variable names permit preservation of existing interfaces while removing shell glue. ## Language and runtime -- Target Python **3.13** for all new scripts. Older versions may be used only - when integration constraints require them; any exception must be documented +- Target Python 3.13 for all new scripts. Older versions may only be used when + integration constraints require them, and any exception must be documented inline. -- Each script starts with a `uv` script block so runtime and dependency - expectations travel with the file. Prefer the shebang - `#!/usr/bin/env -S uv run python` followed by the metadata block shown below. -- External processes are invoked via - [`plumbum`](https://plumbum.readthedocs.io) to provide structured command - execution rather than ad‑hoc shell strings. +- Each script starts with an `uv` script block so runtime and dependency + expectations travel with the file. Prefer the shebang `#!/usr/bin/env -S uv + run python` followed by the metadata block shown in the example below. +- External processes are invoked via [`plumbum`](https://plumbum.readthedocs.io) + to provide structured command execution rather than ad‑hoc shell strings. - File‑system interactions use `pathlib.Path`. Higher‑level operations (for example, copying or removing trees) go through the `shutil` standard library module. @@ -140,7 +139,7 @@ if __name__ == "__main__": app() ``` -**Guidance:** +Guidance: - Parameter names should be descriptive and stable. Where a legacy flag name must remain available, add an alias: @@ -371,7 +370,7 @@ def test_patch_python_dependency(mocker): # Example: patch a helper function used by the script from scripts import helpers - mocker.patch.object(helpers, "compute_checksum", return_value="deadbeef") + mocker.patch_object(helpers, "compute_checksum", return_value="deadbeef") assert helpers.compute_checksum(b"abc") == "deadbeef" ``` @@ -452,17 +451,17 @@ def test_spy_and_record(cmd_mox, monkeypatch, tmp_path): ## Migration guidance (Typer → Cyclopts) -1. **Dependencies:** replace Typer with Cyclopts in the script’s `uv` block. -2. **Entry point:** replace `app = typer.Typer(...)` with `app = App(...)` and +1. Dependencies: replace Typer with Cyclopts in the script’s `uv` block. +2. Entry point: replace `app = typer.Typer(...)` with `app = App(...)` and configure `Env("INPUT_", command=False)` where environment variables are authoritative in CI. -3. **Parameters:** replace `typer.Option(...)` with annotations and +3. Parameters: replace `typer.Option(...)` with annotations and `Parameter(...)`. Mark required options with `required=True`. Map any non‑matching environment names via `env_var=...`. -4. **Lists:** remove custom split/trim code. Use list‑typed parameters; add +4. Lists: remove custom split/trim code. Use list‑typed parameters; add `env_var_split=","` where a non‑whitespace delimiter is required. -5. **Compatibility:** retain legacy flag names using `aliases=["--old-name"]`. -6. **Bash glue:** delete argument arrays and conditional appends in GitHub +5. Compatibility: retain legacy flag names using `aliases=["--old-name"]`. +6. Bash glue: delete argument arrays and conditional appends in GitHub Actions. Export `INPUT_*` environment variables and call `uv run` on the script. @@ -493,7 +492,5 @@ def test_spy_and_record(cmd_mox, monkeypatch, tmp_path): - On Windows, newline‑separated lists are recommended for `list[Path]` to sidestep `;`/`:` semantics. -______________________________________________________________________ - This document should be referenced when introducing or updating automation scripts to maintain a consistent developer experience across the repository. From e5893bd65a2da333be8619b8076b528bf5a32953 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 17:47:05 +0100 Subject: [PATCH 02/41] Document trusted publishing permissions --- .github/actions/release-to-pypi-uv/CHANGELOG.md | 5 +++++ .github/actions/release-to-pypi-uv/README.md | 6 ++++-- .github/actions/release-to-pypi-uv/action.yml | 3 +++ 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/CHANGELOG.md b/.github/actions/release-to-pypi-uv/CHANGELOG.md index 0d73a670..1a45230d 100644 --- a/.github/actions/release-to-pypi-uv/CHANGELOG.md +++ b/.github/actions/release-to-pypi-uv/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v1.0.1 (2025-09-18) + +- Document required workflow permissions for trusted publishing in the action + metadata and README usage example. + ## v1.0.0 (2025-09-18) - Initial release: resolve release tags, ensure GitHub Release readiness, and diff --git a/.github/actions/release-to-pypi-uv/README.md b/.github/actions/release-to-pypi-uv/README.md index 4779ac76..f0bed59f 100644 --- a/.github/actions/release-to-pypi-uv/README.md +++ b/.github/actions/release-to-pypi-uv/README.md @@ -22,6 +22,8 @@ trusted publishing flow. | tag | Resolved release tag. | | version | Resolved release version (tag without the leading `v`). | +> **Required permissions**: set the job to `permissions: contents: read` and `permissions: id-token: write` so uv Trusted Publishing can exchange an OIDC token with PyPI. + ## Usage ```yaml @@ -35,8 +37,8 @@ jobs: publish: runs-on: ubuntu-latest permissions: - contents: read - id-token: write + contents: read # required for trusted publishing + id-token: write # required for trusted publishing steps: - uses: actions/checkout@v4 with: diff --git a/.github/actions/release-to-pypi-uv/action.yml b/.github/actions/release-to-pypi-uv/action.yml index f9d0de1c..1cbcf377 100644 --- a/.github/actions/release-to-pypi-uv/action.yml +++ b/.github/actions/release-to-pypi-uv/action.yml @@ -1,5 +1,8 @@ name: Release to PyPI (uv) description: Build and publish Python distributions to PyPI using uv. +# Required job permissions for OIDC trusted publishing: +# contents: read +# id-token: write inputs: tag: description: Tag to release (e.g. v1.2.3). Required when the workflow is not running on a tag ref. From f78dee31ca997ebb07790a08ffec092004bdba56 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:02:11 +0100 Subject: [PATCH 03/41] Clarify GH_TOKEN handling in README --- .github/actions/release-to-pypi-uv/CHANGELOG.md | 4 ++-- .github/actions/release-to-pypi-uv/README.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/CHANGELOG.md b/.github/actions/release-to-pypi-uv/CHANGELOG.md index 1a45230d..a7f9d19d 100644 --- a/.github/actions/release-to-pypi-uv/CHANGELOG.md +++ b/.github/actions/release-to-pypi-uv/CHANGELOG.md @@ -2,8 +2,8 @@ ## v1.0.1 (2025-09-18) -- Document required workflow permissions for trusted publishing in the action - metadata and README usage example. +- Document required workflow permissions for trusted publishing and clarify + that the action forwards `GITHUB_TOKEN` automatically. ## v1.0.0 (2025-09-18) diff --git a/.github/actions/release-to-pypi-uv/README.md b/.github/actions/release-to-pypi-uv/README.md index f0bed59f..1c0c0fd7 100644 --- a/.github/actions/release-to-pypi-uv/README.md +++ b/.github/actions/release-to-pypi-uv/README.md @@ -23,6 +23,7 @@ trusted publishing flow. | version | Resolved release version (tag without the leading `v`). | > **Required permissions**: set the job to `permissions: contents: read` and `permissions: id-token: write` so uv Trusted Publishing can exchange an OIDC token with PyPI. +> The composite action forwards the workflow's `GITHUB_TOKEN` to its scripts as `GH_TOKEN`, so you do not need to add an extra `env` block. ## Usage From 61985177391771bc8dfc9a3f201e6205aeb471c5 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:05:40 +0100 Subject: [PATCH 04/41] Fix local usage example --- .github/actions/release-to-pypi-uv/CHANGELOG.md | 5 +++-- .github/actions/release-to-pypi-uv/README.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/CHANGELOG.md b/.github/actions/release-to-pypi-uv/CHANGELOG.md index a7f9d19d..ff17df5d 100644 --- a/.github/actions/release-to-pypi-uv/CHANGELOG.md +++ b/.github/actions/release-to-pypi-uv/CHANGELOG.md @@ -2,8 +2,9 @@ ## v1.0.1 (2025-09-18) -- Document required workflow permissions for trusted publishing and clarify - that the action forwards `GITHUB_TOKEN` automatically. +- Document required workflow permissions for trusted publishing, clarify that + the action forwards `GITHUB_TOKEN` automatically, and fix the README usage + example to reference the local path without a version suffix. ## v1.0.0 (2025-09-18) diff --git a/.github/actions/release-to-pypi-uv/README.md b/.github/actions/release-to-pypi-uv/README.md index 1c0c0fd7..5e8f4bc4 100644 --- a/.github/actions/release-to-pypi-uv/README.md +++ b/.github/actions/release-to-pypi-uv/README.md @@ -46,7 +46,7 @@ jobs: fetch-depth: 0 - name: Build and publish - uses: ./.github/actions/release-to-pypi-uv@v1 + uses: ./.github/actions/release-to-pypi-uv with: require-confirmation: true confirm: release ${{ github.ref_name }} From c4f141120c0e3c986b309d401c2e47cf92a64179 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:07:53 +0100 Subject: [PATCH 05/41] Improve GitHub release error handling --- .../release-to-pypi-uv/scripts/check_github_release.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py index d7f76053..b0ecb03f 100644 --- a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py @@ -38,11 +38,18 @@ def _fetch_release(repo: str, tag: str, token: str) -> dict[str, object]: with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 payload = response.read().decode("utf-8") except urllib.error.HTTPError as exc: # pragma: no cover - network failure path + detail = exc.read().decode("utf-8", errors="ignore") if exc.code == 404: raise GithubReleaseError( f"No GitHub release found for tag {tag}. Create and publish the release first." ) from exc - detail = exc.read().decode("utf-8", errors="ignore") + if exc.code == 403: + msg = ( + "GitHub token lacks permission to read releases or has expired. " + "Ensure the workflow is using GITHUB_TOKEN with contents:read scope." + ) + context = detail or exc.reason + raise GithubReleaseError(f"{msg} ({context})") from exc raise GithubReleaseError( f"GitHub API request failed with status {exc.code}: {detail or exc.reason}" ) from exc From 2f412c96526aee6d9f96655b7243a803adf4d219 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:11:11 +0100 Subject: [PATCH 06/41] Fix cmd_utils discovery in publish script --- .../scripts/publish_release.py | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/publish_release.py b/.github/actions/release-to-pypi-uv/scripts/publish_release.py index e431997e..ffa717a0 100644 --- a/.github/actions/release-to-pypi-uv/scripts/publish_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/publish_release.py @@ -7,14 +7,43 @@ from __future__ import annotations +import os import sys from pathlib import Path import typer -REPO_ROOT = Path(__file__).resolve().parents[3] -if str(REPO_ROOT) not in sys.path: - sys.path.insert(0, str(REPO_ROOT)) + +def _extend_sys_path() -> None: + candidates: list[Path] = [] + action_path_env = os.getenv("GITHUB_ACTION_PATH") + if action_path_env: + action_path = Path(action_path_env).resolve() + candidates.append(action_path / "scripts") + try: + candidates.append(action_path.parents[2]) + except IndexError: + pass + else: + script_path = Path(__file__).resolve() + scripts_dir = script_path.parent + candidates.append(scripts_dir) + try: + candidates.append(scripts_dir.parents[3]) + except IndexError: + pass + + for candidate in candidates: + if not candidate: + continue + if not candidate.exists(): + continue + path_str = str(candidate) + if path_str not in sys.path: + sys.path.insert(0, path_str) + + +_extend_sys_path() from cmd_utils import run_cmd # noqa: E402 From 1fe7eebf96ded627b3cc002db1b998af36047553 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:12:42 +0100 Subject: [PATCH 07/41] Skip more build caches --- .../scripts/validate_toml_versions.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index 33ecafb7..4ca7a272 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -21,7 +21,16 @@ envvar="INPUT_FAIL_ON_DYNAMIC_VERSION", ) -SKIP_PARTS = {".git", ".venv", "venv", "node_modules", "dist", "build"} +SKIP_PARTS = { + ".git", + ".venv", + "venv", + "node_modules", + "dist", + "build", + ".direnv", + ".mypy_cache", +} def _iter_files(pattern: str) -> Iterable[Path]: From 6fa4f97cfe44f5db7a60d1d2fae858047ff30c25 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:14:58 +0100 Subject: [PATCH 08/41] Fail fast on TOML parse errors --- .../release-to-pypi-uv/scripts/validate_toml_versions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index 4ca7a272..06e0a90f 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -85,7 +85,7 @@ def main( data = _load_toml(path) except RuntimeError as exc: typer.echo(f"::error::{exc}", err=True) - return + raise typer.Exit(1) from exc project = data.get("project") if not isinstance(project, dict): From 1bd1e2aaeb095ee672826a1aee0537081c99d312 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:21:35 +0100 Subject: [PATCH 09/41] Format release summary output --- .../release-to-pypi-uv/scripts/write_summary.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/write_summary.py b/.github/actions/release-to-pypi-uv/scripts/write_summary.py index f5c9f58f..d9a8f801 100644 --- a/.github/actions/release-to-pypi-uv/scripts/write_summary.py +++ b/.github/actions/release-to-pypi-uv/scripts/write_summary.py @@ -24,10 +24,18 @@ def main( summary_path: Path = SUMMARY_OPTION, ) -> None: index_label = index or "pypi (default)" + heading = "## Release summary\n" + lines = [ + f"- Released tag: {tag}\n", + f"- Publish index: {index_label}\n", + f"- Environment: {environment_name}\n", + ] + + prefix = "\n" if summary_path.exists() and summary_path.stat().st_size > 0 else "" with summary_path.open("a", encoding="utf-8") as fh: - fh.write(f"Released tag: {tag}\n") - fh.write(f"Publish index: {index_label}\n") - fh.write(f"Environment: {environment_name}\n") + fh.write(prefix + heading) + for line in lines: + fh.write(line) if __name__ == "__main__": From 423757a66a0f26a514f5b20ad892899ea89b998b Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:25:13 +0100 Subject: [PATCH 10/41] Add no-tag error test --- .../tests/test_determine_release.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py index e8d12e4f..99243d3d 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py @@ -80,3 +80,16 @@ def test_rejects_invalid_tag(tmp_path: Path) -> None: assert result.returncode == 1 assert "Tag must start with 'v'" in result.stderr + + +def test_errors_when_no_tag_and_not_on_tag_ref(tmp_path: Path) -> None: + env = base_env(tmp_path) + env.pop("GITHUB_REF_TYPE", None) + env.pop("GITHUB_REF_NAME", None) + env.pop("INPUT_TAG", None) + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 1 + assert "No tag was provided" in result.stderr From b837bb7f71a4987bf933c1c596776805ab50a4a7 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:27:15 +0100 Subject: [PATCH 11/41] Test TOML parse failures --- .../tests/test_validate_toml_versions.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index 041e747a..2d4997cc 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -80,3 +80,14 @@ def test_dynamic_version_failure(tmp_path: Path) -> None: assert result.returncode == 1 assert "dynamic 'version'" in result.stderr + + +def test_fails_on_parse_error(tmp_path: Path) -> None: + project = tmp_path / "pkg" + project.mkdir() + (project / "pyproject.toml").write_text("this is not TOML") + + result = _run(tmp_path, version="1.0.0") + + assert result.returncode == 1 + assert "failed to parse" in result.stderr From 7c0ee91c4d0d921a15ac5c93a422d4a1f78f5f1b Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:31:00 +0100 Subject: [PATCH 12/41] Make uv python version configurable --- .../actions/release-to-pypi-uv/CHANGELOG.md | 5 +++++ .github/actions/release-to-pypi-uv/README.md | 1 + .github/actions/release-to-pypi-uv/action.yml | 22 +++++++++++-------- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/CHANGELOG.md b/.github/actions/release-to-pypi-uv/CHANGELOG.md index ff17df5d..9f684b28 100644 --- a/.github/actions/release-to-pypi-uv/CHANGELOG.md +++ b/.github/actions/release-to-pypi-uv/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## v1.0.2 (2025-09-18) + +- Add a configurable `python-version` input and ensure all uv commands honour + it, letting workflows pin their interpreter version. + ## v1.0.1 (2025-09-18) - Document required workflow permissions for trusted publishing, clarify that diff --git a/.github/actions/release-to-pypi-uv/README.md b/.github/actions/release-to-pypi-uv/README.md index 5e8f4bc4..394ed7e1 100644 --- a/.github/actions/release-to-pypi-uv/README.md +++ b/.github/actions/release-to-pypi-uv/README.md @@ -14,6 +14,7 @@ trusted publishing flow. | uv-index | Optional uv index name to publish to (e.g. `testpypi`). Must exist in `tool.uv.index`. | no | _(empty)_ | | toml-glob | Glob used to discover `pyproject.toml` files for version validation. | no | `**/pyproject.toml` | | fail-on-dynamic-version | Fail when a project declares a dynamic PEP 621 version instead of a literal string. | no | `false` | +| python-version | Python version to install and use for all uv commands. | no | `3.13` | ## Outputs diff --git a/.github/actions/release-to-pypi-uv/action.yml b/.github/actions/release-to-pypi-uv/action.yml index 1cbcf377..8f137eb0 100644 --- a/.github/actions/release-to-pypi-uv/action.yml +++ b/.github/actions/release-to-pypi-uv/action.yml @@ -30,6 +30,10 @@ inputs: description: Fail if any project declares a dynamic version instead of a literal string. required: false default: 'false' + python-version: + description: Python version to install and use with uv commands. + required: false + default: '3.13' outputs: tag: description: Resolved release tag. @@ -50,45 +54,45 @@ runs: cache-suffix: action-${{ github.action_ref || github.sha }} - name: Determine tag and version id: resolve - run: uv run --script "${{ github.action_path }}/scripts/determine_release.py" + run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/determine_release.py" shell: bash env: INPUT_TAG: ${{ inputs.tag }} - name: Optional manual confirmation if: inputs.require-confirmation == 'true' - run: uv run --script "${{ github.action_path }}/scripts/confirm_release.py" + run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/confirm_release.py" shell: bash env: EXPECTED: release ${{ steps.resolve.outputs.tag }} INPUT_CONFIRM: ${{ inputs.confirm }} - name: Ensure GitHub Release exists and is published - run: uv run --script "${{ github.action_path }}/scripts/check_github_release.py" + run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/check_github_release.py" shell: bash env: RELEASE_TAG: ${{ steps.resolve.outputs.tag }} GH_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} - name: Validate TOML files - run: uv run --script "${{ github.action_path }}/scripts/validate_toml_versions.py" + run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/validate_toml_versions.py" shell: bash env: RESOLVED_VERSION: ${{ steps.resolve.outputs.version }} INPUT_TOML_GLOB: ${{ inputs.toml-glob }} INPUT_FAIL_ON_DYNAMIC_VERSION: ${{ inputs.fail-on-dynamic-version }} - - name: Install Python 3.13 - run: uv python install 3.13 + - name: Install Python + run: uv python install "${{ inputs.python-version }}" shell: bash - name: Build distributions - run: uv build + run: uv build --python "${{ inputs.python-version }}" shell: bash - name: Publish to PyPI (Trusted Publishing) - run: uv run --script "${{ github.action_path }}/scripts/publish_release.py" + run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/publish_release.py" shell: bash env: INPUT_UV_INDEX: ${{ inputs.uv-index }} - name: Summary if: always() - run: uv run --script "${{ github.action_path }}/scripts/write_summary.py" + run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/write_summary.py" shell: bash env: RESOLVED_TAG: ${{ steps.resolve.outputs.tag }} From 32078b580160599e76f1e5295c13056b5b186e83 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:32:05 +0100 Subject: [PATCH 13/41] Document concurrency guard in usage --- .github/actions/release-to-pypi-uv/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/actions/release-to-pypi-uv/README.md b/.github/actions/release-to-pypi-uv/README.md index 394ed7e1..74d44aa1 100644 --- a/.github/actions/release-to-pypi-uv/README.md +++ b/.github/actions/release-to-pypi-uv/README.md @@ -37,6 +37,9 @@ on: jobs: publish: + concurrency: + group: release-pypi-${{ github.repository }}-${{ github.ref_name }} + cancel-in-progress: true runs-on: ubuntu-latest permissions: contents: read # required for trusted publishing From 9f3443fc8bdc5ad70ad9ee97fb3f76323484dc6f Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Thu, 18 Sep 2025 18:52:06 +0100 Subject: [PATCH 14/41] Address review feedback with retries and tests --- .../actions/release-to-pypi-uv/CHANGELOG.md | 3 + .github/actions/release-to-pypi-uv/README.md | 4 +- .../scripts/check_github_release.py | 61 +++++---- .../scripts/determine_release.py | 4 +- .../scripts/validate_toml_versions.py | 2 +- .../release-to-pypi-uv/tests/__init__.py | 0 .../release-to-pypi-uv/tests/_helpers.py | 24 ++++ .../tests/test_check_github_release.py | 129 ++++++++++++++++++ .../tests/test_confirm_release.py | 39 ++++++ .../tests/test_determine_release.py | 55 +++++++- .../tests/test_publish_release.py | 58 ++++++++ .../tests/test_validate_toml_versions.py | 60 ++++++++ .../tests/test_write_summary.py | 59 ++++++++ 13 files changed, 466 insertions(+), 32 deletions(-) create mode 100644 .github/actions/release-to-pypi-uv/tests/__init__.py create mode 100644 .github/actions/release-to-pypi-uv/tests/_helpers.py create mode 100644 .github/actions/release-to-pypi-uv/tests/test_check_github_release.py create mode 100644 .github/actions/release-to-pypi-uv/tests/test_confirm_release.py create mode 100644 .github/actions/release-to-pypi-uv/tests/test_publish_release.py create mode 100644 .github/actions/release-to-pypi-uv/tests/test_write_summary.py diff --git a/.github/actions/release-to-pypi-uv/CHANGELOG.md b/.github/actions/release-to-pypi-uv/CHANGELOG.md index 9f684b28..77a865db 100644 --- a/.github/actions/release-to-pypi-uv/CHANGELOG.md +++ b/.github/actions/release-to-pypi-uv/CHANGELOG.md @@ -4,6 +4,9 @@ - Add a configurable `python-version` input and ensure all uv commands honour it, letting workflows pin their interpreter version. +- Harden release validation: retry GitHub API lookups with exponential + backoff, tighten semantic version detection, and expand TOML validation + coverage along with unit tests for the helper scripts. ## v1.0.1 (2025-09-18) diff --git a/.github/actions/release-to-pypi-uv/README.md b/.github/actions/release-to-pypi-uv/README.md index 74d44aa1..691ade0e 100644 --- a/.github/actions/release-to-pypi-uv/README.md +++ b/.github/actions/release-to-pypi-uv/README.md @@ -1,7 +1,7 @@ # Release to PyPI (uv) -Build and publish Python distributions via [uv](https://github.com/astral-sh/uv) with GitHub's -trusted publishing flow. +Build and publish Python distributions via +[uv](https://github.com/astral-sh/uv) with GitHub's trusted publishing flow. ## Inputs diff --git a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py index b0ecb03f..5f3cd963 100644 --- a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py @@ -8,6 +8,7 @@ from __future__ import annotations import json +import time import urllib.error import urllib.request @@ -33,31 +34,45 @@ def _fetch_release(repo: str, tag: str, token: str) -> dict[str, object]: "User-Agent": "release-to-pypi-action", }, ) + max_attempts = 5 + backoff_factor = 1.5 + delay = 1.0 + payload: str | None = None + + for attempt in range(1, max_attempts + 1): + try: + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 + payload = response.read().decode("utf-8") + break + except urllib.error.HTTPError as exc: # pragma: no cover - network failure path + detail = exc.read().decode("utf-8", errors="ignore") if hasattr(exc, "read") else "" + if exc.code == 404: + raise GithubReleaseError( + f"No GitHub release found for tag {tag}. Create and publish the release first." + ) from exc + if exc.code == 403: + msg = ( + "GitHub token lacks permission to read releases or has expired. " + "Ensure the workflow is using GITHUB_TOKEN with contents:read scope." + ) + context = detail or exc.reason + raise GithubReleaseError(f"{msg} ({context})") from exc + if attempt == max_attempts: + raise GithubReleaseError( + f"GitHub API request failed with status {exc.code}: {detail or exc.reason}" + ) from exc + time.sleep(delay) + delay *= backoff_factor + except urllib.error.URLError as exc: # pragma: no cover - network failure path + if attempt == max_attempts: + raise GithubReleaseError(f"Failed to reach GitHub API: {exc.reason}") from exc + time.sleep(delay) + delay *= backoff_factor + else: # pragma: no cover - loop exhausted without break + raise GithubReleaseError("GitHub API request failed after retries.") try: - with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 - payload = response.read().decode("utf-8") - except urllib.error.HTTPError as exc: # pragma: no cover - network failure path - detail = exc.read().decode("utf-8", errors="ignore") - if exc.code == 404: - raise GithubReleaseError( - f"No GitHub release found for tag {tag}. Create and publish the release first." - ) from exc - if exc.code == 403: - msg = ( - "GitHub token lacks permission to read releases or has expired. " - "Ensure the workflow is using GITHUB_TOKEN with contents:read scope." - ) - context = detail or exc.reason - raise GithubReleaseError(f"{msg} ({context})") from exc - raise GithubReleaseError( - f"GitHub API request failed with status {exc.code}: {detail or exc.reason}" - ) from exc - except urllib.error.URLError as exc: # pragma: no cover - network failure path - raise GithubReleaseError(f"Failed to reach GitHub API: {exc.reason}") from exc - - try: - return json.loads(payload) + return json.loads(payload or "") except json.JSONDecodeError as exc: # pragma: no cover - unexpected payload raise GithubReleaseError("GitHub API returned invalid JSON") from exc diff --git a/.github/actions/release-to-pypi-uv/scripts/determine_release.py b/.github/actions/release-to-pypi-uv/scripts/determine_release.py index 6a41e1d7..5e6aff1f 100644 --- a/.github/actions/release-to-pypi-uv/scripts/determine_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/determine_release.py @@ -40,9 +40,9 @@ def main(tag: str | None = TAG_OPTION, github_output: Path = GITHUB_OUTPUT_OPTIO ) raise typer.Exit(1) - if not re.fullmatch(r"v[0-9].*", resolved_tag): + if not re.fullmatch(r"v\d+\.\d+\.\d+", resolved_tag): typer.echo( - f"::error::Tag must start with 'v' (e.g. v1.2.3), got '{resolved_tag}'.", + f"::error::Tag must be a valid semantic version (e.g. v1.2.3), got '{resolved_tag}'.", err=True, ) raise typer.Exit(1) diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index 06e0a90f..e836773d 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -97,7 +97,7 @@ def main( if "version" in dynamic_set: message = f"{path}: uses dynamic 'version' (PEP 621)." if fail_dynamic: - dynamic_errors.append(message + " Set fail-on-dynamic-version=false to allow.") + dynamic_errors.append(f"{message} Set fail-on-dynamic-version=false to allow.") else: typer.echo(f"::notice::{message} Skipping version check.") continue diff --git a/.github/actions/release-to-pypi-uv/tests/__init__.py b/.github/actions/release-to-pypi-uv/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/.github/actions/release-to-pypi-uv/tests/_helpers.py b/.github/actions/release-to-pypi-uv/tests/_helpers.py new file mode 100644 index 00000000..190319ae --- /dev/null +++ b/.github/actions/release-to-pypi-uv/tests/_helpers.py @@ -0,0 +1,24 @@ +"""Test helpers for release-to-pypi-uv action scripts.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path +from typing import Any + +SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" +REPO_ROOT = Path(__file__).resolve().parents[3] + + +def load_script_module(name: str) -> Any: + """Load a script module by *name* from the action's scripts directory.""" + script_path = SCRIPTS_DIR / f"{name}.py" + spec = importlib.util.spec_from_file_location(f"release_to_pypi_uv_{name}", script_path) + if spec is None or spec.loader is None: # pragma: no cover - import failure + raise RuntimeError(f"Unable to load script module {name} from {script_path}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +__all__ = ["load_script_module", "REPO_ROOT", "SCRIPTS_DIR"] diff --git a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py new file mode 100644 index 00000000..fc217b30 --- /dev/null +++ b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py @@ -0,0 +1,129 @@ +"""Tests for check_github_release.py.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +from typing import Any + +import pytest + +from ._helpers import load_script_module + + +class DummyResponse: + def __init__(self, payload: dict[str, Any]): + self._payload = json.dumps(payload).encode("utf-8") + + def __enter__(self) -> "DummyResponse": + return self + + def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, traceback: object | None) -> None: + return None + + def read(self) -> bytes: + return self._payload + + +@pytest.fixture(name="module") +def fixture_module() -> Any: + return load_script_module("check_github_release") + + +def test_success(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], module: Any) -> None: + def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 + return DummyResponse({"draft": False, "prerelease": False, "name": "1.2.3"}) + + monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) + + module.main(tag="v1.2.3", token="token", repo="owner/repo") + + captured = capsys.readouterr() + assert "GitHub Release '1.2.3' is published." in captured.out + + +def test_draft_release(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 + return DummyResponse({"draft": True, "prerelease": False, "name": "draft"}) + + monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) + + with pytest.raises(module.typer.Exit): + module.main(tag="v1.0.0", token="token", repo="owner/repo") + + captured = capsys.readouterr() + assert "still a draft" in captured.err + + +def test_prerelease(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 + return DummyResponse({"draft": False, "prerelease": True, "name": "pre"}) + + monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) + + with pytest.raises(module.typer.Exit): + module.main(tag="v1.0.0", token="token", repo="owner/repo") + + captured = capsys.readouterr() + assert "prerelease" in captured.err + + +def test_missing_release(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + def fake_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401 + raise module.urllib.error.HTTPError( + url=str(request.full_url), + code=404, + msg="Not Found", + hdrs=None, + fp=io.BytesIO(b""), + ) + + monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) + + with pytest.raises(module.typer.Exit): + module.main(tag="v1.0.0", token="token", repo="owner/repo") + + captured = capsys.readouterr() + assert "No GitHub release found" in captured.err + + +def test_permission_denied(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + detail = b"forbidden" + error = module.urllib.error.HTTPError( + url="https://api.github.com", + code=403, + msg="Forbidden", + hdrs=None, + fp=io.BytesIO(detail), + ) + + def raising_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401 + raise error + + monkeypatch.setattr(module.urllib.request, "urlopen", raising_urlopen) + + with pytest.raises(module.typer.Exit): + module.main(tag="v1.0.0", token="token", repo="owner/repo") + + captured = capsys.readouterr() + assert "GitHub token lacks permission" in captured.err + + +def test_retries_then_success(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + attempts: list[int] = [] + + def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 + attempts.append(1) + if len(attempts) < 3: + raise module.urllib.error.URLError("temporary") + return DummyResponse({"draft": False, "prerelease": False, "name": "ok"}) + + monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) + monkeypatch.setattr(module.time, "sleep", lambda _: None) + + module.main(tag="v1.0.0", token="token", repo="owner/repo") + + assert len(attempts) == 3 + captured = capsys.readouterr() + assert "GitHub Release 'ok' is published." in captured.out diff --git a/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py b/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py new file mode 100644 index 00000000..7596aa41 --- /dev/null +++ b/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py @@ -0,0 +1,39 @@ +"""Tests for confirm_release.py.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from .test_determine_release import base_env + + +def run_confirm(tmp_path: Path, expected: str, confirm: str) -> subprocess.CompletedProcess[str]: + env = base_env(tmp_path) + env["EXPECTED"] = expected + env["INPUT_CONFIRM"] = confirm + script = Path(__file__).resolve().parents[1] / "scripts" / "confirm_release.py" + cmd = ["uv", "run", "--script", str(script)] + return subprocess.run( # noqa: S603 + cmd, + capture_output=True, + encoding="utf-8", + errors="replace", + env=env, + check=False, + cwd=env.get("PWD"), + ) + + +def test_confirmation_success(tmp_path: Path) -> None: + result = run_confirm(tmp_path, expected="release v1.2.3", confirm="release v1.2.3") + + assert result.returncode == 0, result.stderr + assert "Manual confirmation OK." in result.stdout + + +def test_confirmation_failure(tmp_path: Path) -> None: + result = run_confirm(tmp_path, expected="release v1.2.3", confirm="nope") + + assert result.returncode == 1 + assert "Confirmation failed" in result.stderr diff --git a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py index 99243d3d..17e56b72 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py @@ -9,16 +9,15 @@ def run_script(script: Path, *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: cmd = ["uv", "run", "--script", str(script)] - result = subprocess.run( # noqa: S603 + return subprocess.run( # noqa: S603 cmd, capture_output=True, encoding="utf-8", errors="replace", env=env, check=False, - cwd=env.get("PWD", None), + cwd=env.get("PWD"), ) - return result def base_env(tmp_path: Path) -> dict[str, str]: @@ -79,7 +78,7 @@ def test_rejects_invalid_tag(tmp_path: Path) -> None: result = run_script(script, env=env) assert result.returncode == 1 - assert "Tag must start with 'v'" in result.stderr + assert "Tag must be a valid semantic version" in result.stderr def test_errors_when_no_tag_and_not_on_tag_ref(tmp_path: Path) -> None: @@ -93,3 +92,51 @@ def test_errors_when_no_tag_and_not_on_tag_ref(tmp_path: Path) -> None: assert result.returncode == 1 assert "No tag was provided" in result.stderr + + +def test_errors_when_ref_type_missing(tmp_path: Path) -> None: + env = base_env(tmp_path) + env.pop("GITHUB_REF_TYPE", None) + env["GITHUB_REF_NAME"] = "v1.2.3" + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 1 + assert "No tag was provided" in result.stderr + + +def test_errors_when_ref_name_missing(tmp_path: Path) -> None: + env = base_env(tmp_path) + env["GITHUB_REF_TYPE"] = "tag" + env.pop("GITHUB_REF_NAME", None) + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 1 + assert "No tag was provided" in result.stderr + + +def test_errors_when_ref_name_empty(tmp_path: Path) -> None: + env = base_env(tmp_path) + env["GITHUB_REF_TYPE"] = "tag" + env["GITHUB_REF_NAME"] = "" + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 1 + assert "No tag was provided" in result.stderr + + +def test_errors_on_malformed_version_tag(tmp_path: Path) -> None: + env = base_env(tmp_path) + env["GITHUB_REF_TYPE"] = "tag" + env["GITHUB_REF_NAME"] = "v1.2" + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 1 + assert "Tag must be a valid semantic version" in result.stderr diff --git a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py new file mode 100644 index 00000000..80616673 --- /dev/null +++ b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py @@ -0,0 +1,58 @@ +"""Tests for publish_release.py.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from ._helpers import REPO_ROOT, load_script_module + + +@pytest.fixture(name="publish_module") +def fixture_publish_module() -> Any: + module = load_script_module("publish_release") + # Ensure cmd_utils is importable by mimicking script behaviour + if str(REPO_ROOT) not in module.sys.path: # type: ignore[attr-defined] + module.sys.path.insert(0, str(REPO_ROOT)) # type: ignore[attr-defined] + return module + + +def test_publish_default_index(monkeypatch: pytest.MonkeyPatch, publish_module: Any) -> None: + calls: list[list[str]] = [] + + def fake_run_cmd(args: list[str], **_: object) -> None: + calls.append(args) + + monkeypatch.setattr(publish_module, "run_cmd", fake_run_cmd) + + publish_module.main(index="") + + assert calls == [["uv", "publish"]] + + +def test_publish_custom_index(monkeypatch: pytest.MonkeyPatch, publish_module: Any) -> None: + calls: list[list[str]] = [] + + def fake_run_cmd(args: list[str], **_: object) -> None: + calls.append(args) + + monkeypatch.setattr(publish_module, "run_cmd", fake_run_cmd) + + publish_module.main(index="testpypi") + + assert calls == [["uv", "publish", "--index", "testpypi"]] + + +def test_publish_run_cmd_error(monkeypatch: pytest.MonkeyPatch, publish_module: Any) -> None: + class DummyError(Exception): + pass + + def fake_run_cmd(_: list[str], **__: object) -> None: + raise DummyError("uv publish failed") + + monkeypatch.setattr(publish_module, "run_cmd", fake_run_cmd) + + with pytest.raises(DummyError): + publish_module.main(index="") diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index 2d4997cc..a7333d62 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -91,3 +91,63 @@ def test_fails_on_parse_error(tmp_path: Path) -> None: assert result.returncode == 1 assert "failed to parse" in result.stderr + + +def test_dynamic_version_allowed_when_flag_false(tmp_path: Path) -> None: + project = tmp_path / "pkg" + project.mkdir() + (project / "pyproject.toml").write_text( + """ +[project] +name = "demo" +dynamic = ["version"] +""".strip() + ) + + result = _run(tmp_path, version="1.0.0", fail_dynamic="false") + + assert result.returncode == 0, result.stderr + assert "uses dynamic 'version'" in result.stdout + + +def test_missing_project_section_is_ignored(tmp_path: Path) -> None: + project = tmp_path / "pkg" + project.mkdir() + (project / "pyproject.toml").write_text( + """ +[tool.poetry] +name = "demo" +version = "1.0.0" +""".strip() + ) + + result = _run(tmp_path, version="1.0.0") + + assert result.returncode == 0, result.stderr + + +def test_multiple_toml_files_mixed_validity(tmp_path: Path) -> None: + valid_pkg = tmp_path / "pkg_valid" + valid_pkg.mkdir() + (valid_pkg / "pyproject.toml").write_text( + """ +[project] +name = "demo" +version = "1.0.0" +""".strip() + ) + + invalid_pkg = tmp_path / "pkg_invalid" + invalid_pkg.mkdir() + (invalid_pkg / "pyproject.toml").write_text( + """ +[project] +name = "demo" +version = "2.0.0" +""".strip() + ) + + result = _run(tmp_path, version="1.0.0") + + assert result.returncode == 1 + assert "!= tag version" in result.stderr diff --git a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py new file mode 100644 index 00000000..04918f9d --- /dev/null +++ b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py @@ -0,0 +1,59 @@ +"""Tests for write_summary.py.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest + +from ._helpers import load_script_module + + +@pytest.fixture(name="write_module") +def fixture_write_module() -> Any: + return load_script_module("write_summary") + + +def test_write_summary_appends_markdown(tmp_path: Path, write_module: Any) -> None: + summary_path = tmp_path / "summary.md" + + write_module.main( + tag="v1.2.3", + index="", + environment_name="pypi", + summary_path=summary_path, + ) + + content = summary_path.read_text(encoding="utf-8") + assert "## Release summary" in content + assert "- Released tag: v1.2.3" in content + assert "- Publish index: pypi (default)" in content + + +def test_write_summary_handles_existing_content(tmp_path: Path, write_module: Any) -> None: + summary_path = tmp_path / "summary.md" + summary_path.write_text("Existing\n", encoding="utf-8") + + write_module.main( + tag="v1.2.3", + index="custom", + environment_name="prod", + summary_path=summary_path, + ) + + content = summary_path.read_text(encoding="utf-8") + assert content.endswith("- Environment: prod\n") + assert content.count("## Release summary") == 1 + + +def test_write_summary_raises_on_io_error(write_module: Any) -> None: + summary_path = Path("/nonexistent/path/summary.md") + + with pytest.raises(OSError): + write_module.main( + tag="v1.0.0", + index="", + environment_name="pypi", + summary_path=summary_path, + ) From bf50fd3a57272011b049f3c45c75a8a02dfeea51 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 19 Sep 2025 12:02:49 +0100 Subject: [PATCH 15/41] Improve TOML version validation and test strategy Extends boolean parsing to support more truthy values when validating dynamic version flags in TOML files. Refactors tests to run in-process with better coverage of various flag values and scenarios, replacing subprocess-based tests for efficiency and detail. Updates action to simplify Python version handling with uv and improves path detection for testing. These changes make validation logic more robust and enhance test reliability. --- .github/actions/release-to-pypi-uv/README.md | 4 + .github/actions/release-to-pypi-uv/action.yml | 15 +- .../scripts/validate_toml_versions.py | 11 +- .../release-to-pypi-uv/tests/_helpers.py | 12 +- .../tests/test_determine_release.py | 3 +- .../tests/test_validate_toml_versions.py | 244 ++++++++++++------ 6 files changed, 197 insertions(+), 92 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/README.md b/.github/actions/release-to-pypi-uv/README.md index 691ade0e..ba0b1db4 100644 --- a/.github/actions/release-to-pypi-uv/README.md +++ b/.github/actions/release-to-pypi-uv/README.md @@ -49,6 +49,10 @@ jobs: with: fetch-depth: 0 + - uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc + with: + python-version: ${{ inputs.python-version }} + - name: Build and publish uses: ./.github/actions/release-to-pypi-uv with: diff --git a/.github/actions/release-to-pypi-uv/action.yml b/.github/actions/release-to-pypi-uv/action.yml index 8f137eb0..1c543365 100644 --- a/.github/actions/release-to-pypi-uv/action.yml +++ b/.github/actions/release-to-pypi-uv/action.yml @@ -54,26 +54,27 @@ runs: cache-suffix: action-${{ github.action_ref || github.sha }} - name: Determine tag and version id: resolve - run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/determine_release.py" + run: uv run --script "${{ github.action_path }}/scripts/determine_release.py" shell: bash env: INPUT_TAG: ${{ inputs.tag }} - name: Optional manual confirmation if: inputs.require-confirmation == 'true' - run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/confirm_release.py" + run: uv run --script "${{ github.action_path }}/scripts/confirm_release.py" shell: bash env: EXPECTED: release ${{ steps.resolve.outputs.tag }} INPUT_CONFIRM: ${{ inputs.confirm }} - name: Ensure GitHub Release exists and is published - run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/check_github_release.py" + run: uv run --script "${{ github.action_path }}/scripts/check_github_release.py" shell: bash env: RELEASE_TAG: ${{ steps.resolve.outputs.tag }} GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} GITHUB_REPOSITORY: ${{ github.repository }} - name: Validate TOML files - run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/validate_toml_versions.py" + run: uv run --script "${{ github.action_path }}/scripts/validate_toml_versions.py" shell: bash env: RESOLVED_VERSION: ${{ steps.resolve.outputs.version }} @@ -83,16 +84,16 @@ runs: run: uv python install "${{ inputs.python-version }}" shell: bash - name: Build distributions - run: uv build --python "${{ inputs.python-version }}" + run: uv build shell: bash - name: Publish to PyPI (Trusted Publishing) - run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/publish_release.py" + run: uv run --script "${{ github.action_path }}/scripts/publish_release.py" shell: bash env: INPUT_UV_INDEX: ${{ inputs.uv-index }} - name: Summary if: always() - run: uv run --python "${{ inputs.python-version }}" --script "${{ github.action_path }}/scripts/write_summary.py" + run: uv run --script "${{ github.action_path }}/scripts/write_summary.py" shell: bash env: RESOLVED_TAG: ${{ steps.resolve.outputs.tag }} diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index e836773d..2eea2ada 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -32,6 +32,8 @@ ".mypy_cache", } +TRUTHY_STRINGS = {"true", "1", "yes", "y", "on"} + def _iter_files(pattern: str) -> Iterable[Path]: candidates = [Path(p) for p in glob.glob(pattern, recursive=True)] @@ -44,8 +46,13 @@ def _iter_files(pattern: str) -> Iterable[Path]: yield path -def _parse_bool(value: str) -> bool: - return value.strip().lower() == "true" +def _parse_bool(value: str | None) -> bool: + if value is None: + return False + normalized = value.strip().lower() + if not normalized: + return False + return normalized in TRUTHY_STRINGS def _load_toml(path: Path) -> dict[str, object]: diff --git a/.github/actions/release-to-pypi-uv/tests/_helpers.py b/.github/actions/release-to-pypi-uv/tests/_helpers.py index 190319ae..9045a0f8 100644 --- a/.github/actions/release-to-pypi-uv/tests/_helpers.py +++ b/.github/actions/release-to-pypi-uv/tests/_helpers.py @@ -3,11 +3,19 @@ from __future__ import annotations import importlib.util +import os from pathlib import Path from typing import Any -SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" -REPO_ROOT = Path(__file__).resolve().parents[3] +_ACTION_PATH = os.environ.get("GITHUB_ACTION_PATH") + +if _ACTION_PATH: + _action_root = Path(_ACTION_PATH).resolve() + SCRIPTS_DIR = _action_root / "scripts" + REPO_ROOT = _action_root.parents[2] +else: + SCRIPTS_DIR = Path(__file__).resolve().parents[1] / "scripts" + REPO_ROOT = SCRIPTS_DIR.parents[3] def load_script_module(name: str) -> Any: diff --git a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py index 17e56b72..5d83d460 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py @@ -23,7 +23,8 @@ def run_script(script: Path, *, env: dict[str, str]) -> subprocess.CompletedProc def base_env(tmp_path: Path) -> dict[str, str]: merged = {**os.environ} root = str(Path(__file__).resolve().parents[4]) - merged["PYTHONPATH"] = f"{root}{os.pathsep}{merged.get('PYTHONPATH', '')}".rstrip(os.pathsep) + prev = os.environ.get("PYTHONPATH", "") + merged["PYTHONPATH"] = root + (os.pathsep + prev if prev else "") merged["PYTHONIOENCODING"] = "utf-8" merged["GITHUB_OUTPUT"] = str(tmp_path / "out.txt") merged["PWD"] = str(tmp_path) diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index a7333d62..0cb64e72 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -2,152 +2,236 @@ from __future__ import annotations -import os -import subprocess from pathlib import Path +from typing import Any +import pytest -SCRIPT_PATH = Path(__file__).resolve().parents[1] / "scripts" / "validate_toml_versions.py" +from ._helpers import load_script_module +@pytest.fixture(name="module") +def fixture_module() -> Any: + return load_script_module("validate_toml_versions") -def _run(tmp_path: Path, *, version: str, fail_dynamic: str = "false") -> subprocess.CompletedProcess[str]: - cmd = ["uv", "run", "--script", str(SCRIPT_PATH)] - env = {**os.environ} - root = str(Path(__file__).resolve().parents[4]) - env["PYTHONPATH"] = f"{root}{os.pathsep}{env.get('PYTHONPATH', '')}".rstrip(os.pathsep) - env["PYTHONIOENCODING"] = "utf-8" - env["RESOLVED_VERSION"] = version - env["INPUT_TOML_GLOB"] = "**/pyproject.toml" - env["INPUT_FAIL_ON_DYNAMIC_VERSION"] = fail_dynamic - return subprocess.run( # noqa: S603 - cmd, - capture_output=True, - encoding="utf-8", - errors="replace", - cwd=tmp_path, - env=env, - check=False, - ) +@pytest.fixture() +def project_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.chdir(tmp_path) + return tmp_path + + +def _write_pyproject(base: Path, content: str) -> None: + base.mkdir() + (base / "pyproject.toml").write_text(content.strip()) + + +def _invoke_main(module: Any, **kwargs: Any) -> None: + kwargs.setdefault("pattern", "**/pyproject.toml") + kwargs.setdefault("fail_on_dynamic", "false") + module.main(**kwargs) -def test_passes_when_versions_match(tmp_path: Path) -> None: - project = tmp_path / "pkg" - project.mkdir() - (project / "pyproject.toml").write_text( +def test_passes_when_versions_match(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + _write_pyproject( + project_root / "pkg", """ [project] name = "demo" version = "1.0.0" -""".strip() +""", ) - result = _run(tmp_path, version="1.0.0") + _invoke_main(module, version="1.0.0") - assert result.returncode == 0, result.stderr - assert "all versions match 1.0.0" in result.stdout + captured = capsys.readouterr() + assert "all versions match 1.0.0" in captured.out -def test_fails_on_mismatch(tmp_path: Path) -> None: - project = tmp_path / "pkg" - project.mkdir() - (project / "pyproject.toml").write_text( +def test_fails_on_mismatch(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + _write_pyproject( + project_root / "pkg", """ [project] name = "demo" version = "1.0.1" -""".strip() +""", ) - result = _run(tmp_path, version="1.0.0") + with pytest.raises(module.typer.Exit): + _invoke_main(module, version="1.0.0") - assert result.returncode == 1 - assert "version '1.0.1' != tag version '1.0.0'" in result.stderr + captured = capsys.readouterr() + assert "version '1.0.1' != tag version '1.0.0'" in captured.err -def test_dynamic_version_failure(tmp_path: Path) -> None: - project = tmp_path / "pkg" - project.mkdir() - (project / "pyproject.toml").write_text( +def test_dynamic_version_failure(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + _write_pyproject( + project_root / "pkg", """ [project] name = "demo" dynamic = ["version"] -""".strip() +""", ) - result = _run(tmp_path, version="1.0.0", fail_dynamic="true") + with pytest.raises(module.typer.Exit): + _invoke_main(module, version="1.0.0", fail_on_dynamic="true") - assert result.returncode == 1 - assert "dynamic 'version'" in result.stderr + captured = capsys.readouterr() + assert "dynamic 'version'" in captured.err -def test_fails_on_parse_error(tmp_path: Path) -> None: - project = tmp_path / "pkg" - project.mkdir() - (project / "pyproject.toml").write_text("this is not TOML") +@pytest.mark.parametrize("truthy", ["true", "TRUE", "Yes", " y ", "1", "On"]) +def test_dynamic_version_failure_for_truthy_variants( + project_root: Path, + module: Any, + capsys: pytest.CaptureFixture[str], + truthy: str, +) -> None: + _write_pyproject( + project_root / "pkg", + """ +[project] +name = "demo" +dynamic = ["version"] +""", + ) - result = _run(tmp_path, version="1.0.0") + with pytest.raises(module.typer.Exit): + _invoke_main(module, version="1.0.0", fail_on_dynamic=truthy) - assert result.returncode == 1 - assert "failed to parse" in result.stderr + captured = capsys.readouterr() + assert "dynamic 'version'" in captured.err -def test_dynamic_version_allowed_when_flag_false(tmp_path: Path) -> None: - project = tmp_path / "pkg" - project.mkdir() - (project / "pyproject.toml").write_text( +def test_fails_on_parse_error(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + target = project_root / "pkg" + target.mkdir() + (target / "pyproject.toml").write_text("this is not TOML") + + with pytest.raises(module.typer.Exit): + _invoke_main(module, version="1.0.0") + + captured = capsys.readouterr() + assert "failed to parse" in captured.err + + +def test_dynamic_version_allowed_when_flag_false( + project_root: Path, + module: Any, + capsys: pytest.CaptureFixture[str], +) -> None: + _write_pyproject( + project_root / "pkg", """ [project] name = "demo" dynamic = ["version"] -""".strip() +""", ) - result = _run(tmp_path, version="1.0.0", fail_dynamic="false") + _invoke_main(module, version="1.0.0", fail_on_dynamic="false") - assert result.returncode == 0, result.stderr - assert "uses dynamic 'version'" in result.stdout + captured = capsys.readouterr() + assert "uses dynamic 'version'" in captured.out -def test_missing_project_section_is_ignored(tmp_path: Path) -> None: - project = tmp_path / "pkg" - project.mkdir() - (project / "pyproject.toml").write_text( +@pytest.mark.parametrize("falsey", ["false", "", "no", "0", "off", "n", "False"]) +def test_dynamic_version_allowed_for_falsey_variants( + project_root: Path, + module: Any, + capsys: pytest.CaptureFixture[str], + falsey: str, +) -> None: + _write_pyproject( + project_root / "pkg", + """ +[project] +name = "demo" +dynamic = ["version"] +""", + ) + + _invoke_main(module, version="1.0.0", fail_on_dynamic=falsey) + + captured = capsys.readouterr() + assert "uses dynamic 'version'" in captured.out + + +def test_dynamic_version_allowed_when_flag_unset( + project_root: Path, + module: Any, + capsys: pytest.CaptureFixture[str], +) -> None: + _write_pyproject( + project_root / "pkg", + """ +[project] +name = "demo" +dynamic = ["version"] +""", + ) + + _invoke_main(module, version="1.0.0", fail_on_dynamic="") + + captured = capsys.readouterr() + assert "uses dynamic 'version'" in captured.out + + +def test_missing_project_section_is_ignored( + project_root: Path, + module: Any, + capsys: pytest.CaptureFixture[str], +) -> None: + _write_pyproject( + project_root / "pkg", """ [tool.poetry] name = "demo" version = "1.0.0" -""".strip() +""", ) - result = _run(tmp_path, version="1.0.0") + _invoke_main(module, version="1.0.0") - assert result.returncode == 0, result.stderr + captured = capsys.readouterr() + assert captured.err == "" -def test_multiple_toml_files_mixed_validity(tmp_path: Path) -> None: - valid_pkg = tmp_path / "pkg_valid" - valid_pkg.mkdir() - (valid_pkg / "pyproject.toml").write_text( +def test_multiple_toml_files_mixed_validity( + project_root: Path, + module: Any, + capsys: pytest.CaptureFixture[str], +) -> None: + _write_pyproject( + project_root / "pkg_valid", """ [project] name = "demo" version = "1.0.0" -""".strip() +""", ) - - invalid_pkg = tmp_path / "pkg_invalid" - invalid_pkg.mkdir() - (invalid_pkg / "pyproject.toml").write_text( + _write_pyproject( + project_root / "pkg_invalid", """ [project] name = "demo" version = "2.0.0" -""".strip() +""", ) - result = _run(tmp_path, version="1.0.0") + with pytest.raises(module.typer.Exit): + _invoke_main(module, version="1.0.0") + + captured = capsys.readouterr() + assert "!= tag version" in captured.err + + +@pytest.mark.parametrize("value", ["true", "TRUE", "Yes", "1", "on"]) +def test_parse_bool_truthy_values(module: Any, value: str) -> None: + assert module._parse_bool(value) is True + - assert result.returncode == 1 - assert "!= tag version" in result.stderr +@pytest.mark.parametrize("value", [None, "", "false", "no", "0", "off", "n"]) +def test_parse_bool_falsey_values(module: Any, value: str | None) -> None: + assert module._parse_bool(value) is False From acc4d3e9f644c2399d9c7663a018a1d609d340c6 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Fri, 19 Sep 2025 23:45:58 +0100 Subject: [PATCH 16/41] Add cmd-mox users guide --- docs/cmd-mox-users-guide.md | 234 ++++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 docs/cmd-mox-users-guide.md diff --git a/docs/cmd-mox-users-guide.md b/docs/cmd-mox-users-guide.md new file mode 100644 index 00000000..8269762c --- /dev/null +++ b/docs/cmd-mox-users-guide.md @@ -0,0 +1,234 @@ +# CmdMox Usage Guide + +CmdMox provides a fluent API for mocking, stubbing and spying on external +commands in your tests. This guide shows common patterns for everyday use. + +## Getting started + +Install the package and enable the pytest plugin: + +```bash +pip install cmd-mox +``` + +In your `conftest.py`: + +```python +pytest_plugins = ("cmd_mox.pytest_plugin",) +``` + +Each test receives a `cmd_mox` fixture that provides access to the controller +object. + +## Basic workflow + +CmdMox follows a strict record → replay → verify lifecycle. First declare +expectations, then run your code with the shims active, finally verify that +interactions matched what was recorded. + +The three phases are defined in the design document: + +1. **Record** – describe each expected command call, including its arguments + and behaviour. +2. **Replay** – run the code under test while CmdMox intercepts command + executions. +3. **Verify** – ensure every expectation was met and nothing unexpected + happened. + +These phases form a strict sequence for reliable command-line tests. + +A typical test brings the three phases together: + +```python +cmd_mox.mock("git").with_args("clone", "repo").returns(exit_code=0) + +cmd_mox.replay() +my_tool.clone_repo("repo") +cmd_mox.verify() +``` + +## Stubs, mocks and spies + +Use the controller to register doubles: + +```python +cmd_mox.stub("ls") +cmd_mox.mock("git") +cmd_mox.spy("curl") +``` + +- **Stubs** provide canned responses without strict checking. +- **Mocks** enforce exact usage during verification. +- **Spies** record every call for later inspection and can behave like stubs. + +Each call returns a `CommandDouble` that offers a fluent DSL to configure +behaviour. + +## Defining expectations + +Combine methods to describe how a command should be invoked: + +```python +cmd_mox.mock("git") \ + .with_args("clone", "https://example.com/repo.git") \ + .returns(exit_code=0) +``` + +You can match arguments more flexibly using comparators: + +```python +from cmd_mox import Regex, Contains + +cmd_mox.mock("curl") \ + .with_matching_args(Regex(r"--header=User-Agent:.*"), Contains("example")) +``` + +The design document lists the available comparators: + +- `Any` +- `IsA` +- `Regex` +- `Contains` +- `StartsWith` +- `Predicate` + +Each comparator is a callable that returns `True` on match. +`with_matching_args` expects one comparator per argv element (excluding the program name, i.e., `argv[1:]`), +and `with_stdin` accepts either an exact string or a predicate `Callable[[str], bool]` +for flexible input checks. + +## Running tests + +Typical pytest usage looks like this: + +```python +def test_clone(cmd_mox): + cmd_mox.mock("git").with_args("clone", "repo").returns(exit_code=0) + + cmd_mox.replay() + my_tool.clone_repo("repo") + cmd_mox.verify() +``` + +The context manager interface is available when pytest fixtures are not in play: + +```python +with CmdMox() as mox: + mox.stub("ls").returns(stdout="") + mox.replay() + subprocess.run(["ls"], check=True) +``` + +## Spies and passthrough mode + +Spies expose `invocations` (a list of `Invocation` objects) and `call_count` +during and after replay, making it easy to inspect what actually ran: + +```python +def test_spy(cmd_mox): + spy = cmd_mox.spy("curl").returns(stdout="ok") + cmd_mox.replay() + run_download() + cmd_mox.verify() + assert spy.call_count == 1 +``` + +A spy expectation can also use `times_called(count)`—an alias of +`times(count)`—to require a specific call count during verification. + +A spy can also forward to the real command while recording everything: + +```python +mox.spy("aws").passthrough() +``` + +This "record mode" is helpful for capturing real interactions and later turning +them into mocks. + +After verification, spies provide assertion helpers inspired by +`unittest.mock`: + +```python +spy.assert_called() +spy.assert_called_with("--silent", stdin="payload") +# or, to ensure the spy never executed: +spy.assert_not_called() +``` + +These methods raise `AssertionError` when expectations are not met and are +restricted to spy doubles. + +## Controller configuration and journals + +`CmdMox` offers configuration hooks that surface through both the fixture and +the context-manager API: + +- `verify_on_exit` (default `True`) automatically calls `verify()` when a replay + phase ends inside a `with CmdMox()` block. Disable it when you need to manage + verification manually. Verification still runs if the body raises; when both + verification and the body fail, the verification error is suppressed so the + original exception surfaces. +- `max_journal_entries` bounds the number of stored invocations (oldest entries + are evicted FIFO when the bound is reached). The journal is exposed via + `cmd_mox.journal`, a `collections.deque[Invocation]` recorded during replay. + +The journal is especially handy when debugging: + +```python +cmd_mox.replay() +exercise_system() +cmd_mox.verify() +assert [call.command for call in cmd_mox.journal] == ["git", "curl"] +``` + +When you want to intercept a command without configuring a double—for example to +ensure it is treated as unexpected—register it explicitly: + +```python +cmd_mox.register_command("name") +``` + +CmdMox will create the shim so the command is routed through the IPC server even +without a stub, mock, or spy. + +## Fluent API reference + +The DSL methods closely mirror those described in the design specification. A +few common ones are: + +- `with_args(*args)` – require exact arguments. +- `with_matching_args(*matchers)` – match arguments using comparators. +- `with_stdin(data_or_matcher)` – expect specific standard input (`str`) or + validate it with a predicate `Callable[[str], bool]`. +- `with_env(mapping)` – set additional environment variables for the invocation + and apply them when custom handlers run. +- `returns(stdout="", stderr="", exit_code=0)` – static response using text + values; CmdMox operates in text mode—pass `str` (bytes are not supported). + Note: For binary payloads, prefer `passthrough()` or encode/decode at the + boundary (e.g., base64) so handlers exchange `str`. +- `runs(handler)` – call a function to produce dynamic output. The handler + receives an `Invocation` and should return either a `(stdout, stderr, + exit_code)` tuple or a `Response` instance. +- `times(count)` – expect the command exactly `count` times. +- `times_called(count)` – alias for `times` that emphasises spy call counts. +- `in_order()` – enforce strict ordering with other expectations. +- `any_order()` – allow the expectation to be satisfied in any position. +- `passthrough()` – for spies, run the real command while recording it. +- `assert_called()`, `assert_not_called()`, `assert_called_with(*args, + stdin=None, env=None)` – spy-only helpers for post-verification assertions. + +Refer to the [design document](./python-native-command-mocking-design.md) for +the full table of methods and examples. + +## Environment variables + +CmdMox exposes two environment variables to coordinate shims with the IPC +server. + +- `CMOX_IPC_SOCKET` – path to the Unix domain socket used by shims. The + `CmdMox` fixture sets this automatically when the server starts. Shims exit + with an error if the variable is missing. +- `CMOX_IPC_TIMEOUT` – communication timeout in seconds. Override this to tune + connection waits. When unset, the default is `5.0` seconds. + +Most tests should rely on the fixture to manage these variables. From 9a3db7ad6c7d36dd7183945247b8c616872e6f98 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 20 Sep 2025 11:48:34 +0100 Subject: [PATCH 17/41] Use cmd-mox to stub rust build command tests (#92) * Use cmd-mox for rust build command tests * Guard cmd-mox integration on Windows * Refactor cmd-mox helpers into shared conftest * Apply cmd-mox fake which simplification --- .../rust-build-release/tests/conftest.py | 7 - .../tests/test_cross_install.py | 215 ++++++++---------- .../tests/test_target_install.py | 116 +++------- .../rust-build-release/tests/test_utils.py | 24 +- conftest.py | 64 ++++++ docs/cmd-mox-users-guide.md | 12 +- pyproject.toml | 1 + uv.lock | 7 + 8 files changed, 228 insertions(+), 218 deletions(-) create mode 100644 conftest.py diff --git a/.github/actions/rust-build-release/tests/conftest.py b/.github/actions/rust-build-release/tests/conftest.py index 88bacbc5..b8cd80fd 100644 --- a/.github/actions/rust-build-release/tests/conftest.py +++ b/.github/actions/rust-build-release/tests/conftest.py @@ -135,13 +135,6 @@ def patch_shutil_which(self, func: cabc.Callable[[str], str | None]) -> None: """Patch ``shutil.which`` for the wrapped module.""" self.monkeypatch.setattr(self.module.shutil, "which", func) - def patch_subprocess_run(self, func: cabc.Callable[..., object]) -> None: - """Patch ``subprocess.run`` for the wrapped module.""" - if hasattr(self.module, "run_validated"): - self.monkeypatch.setattr(self.module, "run_validated", func) - if hasattr(self.module, "subprocess"): - self.monkeypatch.setattr(self.module.subprocess, "run", func) - def patch_platform(self, platform: str) -> None: """Force ``sys.platform`` to ``platform`` within the module.""" self.monkeypatch.setattr(self.module.sys, "platform", platform) diff --git a/.github/actions/rust-build-release/tests/test_cross_install.py b/.github/actions/rust-build-release/tests/test_cross_install.py index 077a8dc3..08b17eda 100644 --- a/.github/actions/rust-build-release/tests/test_cross_install.py +++ b/.github/actions/rust-build-release/tests/test_cross_install.py @@ -10,49 +10,41 @@ import pytest +from shared_actions_conftest import ( + CMD_MOX_UNSUPPORTED, + _register_cross_version_stub, + _register_docker_info_stub, + _register_rustup_toolchain_stub, +) + if typ.TYPE_CHECKING: from pathlib import Path from types import ModuleType from .conftest import HarnessFactory - - -def _constant_run(stdout: str) -> typ.Callable[..., subprocess.CompletedProcess[str]]: - """Return a ``run_validated`` stub emitting *stdout*.""" - - def fake_run( - executable: str, - args: list[str], - *, - allowed_names: tuple[str, ...], - capture_output: bool = False, - check: bool = False, - text: bool = False, - **_: object, - ) -> subprocess.CompletedProcess[str]: - _ = allowed_names - cmd = [executable, *args] - return subprocess.CompletedProcess(cmd, 0, stdout=stdout) - - return fake_run - - +@CMD_MOX_UNSUPPORTED def test_installs_cross_when_missing( - cross_module: ModuleType, module_harness: HarnessFactory + cross_module: ModuleType, + module_harness: HarnessFactory, + cmd_mox, ) -> None: """Installs cross when it is missing.""" harness = module_harness(cross_module) - cross_checks = [None, "/usr/bin/cross"] + cross_path = _register_cross_version_stub(cmd_mox) + cross_checks = [None, cross_path] def fake_which(name: str) -> str | None: - return cross_checks.pop(0) if name == "cross" else None + if name == "cross": + return cross_checks.pop(0) if cross_checks else cross_path + return None harness.patch_shutil_which(fake_which) - harness.patch_subprocess_run(_constant_run("cross 0.2.5\n")) + cmd_mox.replay() path, ver = cross_module.ensure_cross("0.2.5") + cmd_mox.verify() - assert path == "/usr/bin/cross" + assert path == cross_path assert ver == "0.2.5" install = next( cmd for cmd in harness.calls if cmd[:3] == ["cargo", "install", "cross"] @@ -85,36 +77,29 @@ def fail_install(cmd: list[str]) -> None: assert exc_info.value.output == "install failed" +@CMD_MOX_UNSUPPORTED def test_upgrades_outdated_cross( - cross_module: ModuleType, module_harness: HarnessFactory + cross_module: ModuleType, + module_harness: HarnessFactory, + cmd_mox, ) -> None: """Upgrades cross when an older version is installed.""" harness = module_harness(cross_module) - versions = ["cross 0.2.4\n", "cross 0.2.5\n"] + cross_path = _register_cross_version_stub( + cmd_mox, ["cross 0.2.4\n", "cross 0.2.5\n"] + ) - def fake_run( - executable: str, - args: list[str], - *, - allowed_names: tuple[str, ...], - capture_output: bool = False, - check: bool = False, - text: bool = False, - **_: object, - ) -> subprocess.CompletedProcess[str]: - _ = allowed_names - cmd = [executable, *args] - return subprocess.CompletedProcess(cmd, 0, stdout=versions.pop(0)) + def fake_which(name: str) -> str | None: + return cross_path if name == "cross" else None - harness.patch_shutil_which( - lambda name: "/usr/bin/cross" if name == "cross" else None - ) - harness.patch_subprocess_run(fake_run) + harness.patch_shutil_which(fake_which) + cmd_mox.replay() path, ver = cross_module.ensure_cross("0.2.5") + cmd_mox.verify() - assert path == "/usr/bin/cross" + assert path == cross_path assert ver == "0.2.5" install = next( cmd for cmd in harness.calls if cmd[:3] == ["cargo", "install", "cross"] @@ -124,35 +109,47 @@ def fake_run( assert install[idx + 1] == "0.2.5" +@CMD_MOX_UNSUPPORTED def test_uses_cached_cross( - cross_module: ModuleType, module_harness: HarnessFactory + cross_module: ModuleType, + module_harness: HarnessFactory, + cmd_mox, ) -> None: """Uses cached cross when version is sufficient.""" harness = module_harness(cross_module) - harness.patch_shutil_which( - lambda name: "/usr/bin/cross" if name == "cross" else None - ) - harness.patch_subprocess_run(_constant_run("cross 0.2.5\n")) + cross_path = _register_cross_version_stub(cmd_mox) + + def fake_which(name: str) -> str | None: + return cross_path if name == "cross" else None + + harness.patch_shutil_which(fake_which) + cmd_mox.replay() path, ver = cross_module.ensure_cross("0.2.5") + cmd_mox.verify() - assert path == "/usr/bin/cross" + assert path == cross_path assert ver == "0.2.5" assert not harness.calls +@CMD_MOX_UNSUPPORTED def test_installs_prebuilt_cross_on_windows( - cross_module: ModuleType, module_harness: HarnessFactory + cross_module: ModuleType, + module_harness: HarnessFactory, + cmd_mox, ) -> None: """Uses the prebuilt cross binary on Windows hosts.""" harness = module_harness(cross_module) - cross_checks = [None, "C:/cross.exe"] + cross_path = _register_cross_version_stub(cmd_mox) + cross_checks = [None, cross_path] def fake_which(name: str) -> str | None: - return cross_checks.pop(0) if name == "cross" else None + if name == "cross": + return cross_checks.pop(0) if cross_checks else cross_path + return None harness.patch_shutil_which(fake_which) - harness.patch_subprocess_run(_constant_run("cross 0.2.5\n")) harness.patch_platform("win32") release_called = {"value": False} @@ -163,10 +160,12 @@ def fake_release(version: str) -> bool: harness.patch_attr("install_cross_release", fake_release) + cmd_mox.replay() path, ver = cross_module.ensure_cross("0.2.5") + cmd_mox.verify() assert release_called["value"] is True - assert path == "C:/cross.exe" + assert path == cross_path assert ver == "0.2.5" assert all(cmd[:2] != ["cargo", "install"] for cmd in harness.calls) @@ -348,48 +347,36 @@ def fake_urlopen(url: str) -> FakeBinaryResponse | FakeTextResponse: assert cross_module.install_cross_release("0.2.5") is False +@CMD_MOX_UNSUPPORTED def test_installs_cross_without_container_runtime( main_module: ModuleType, cross_module: ModuleType, module_harness: HarnessFactory, + cmd_mox, ) -> None: """Installs cross even when no container runtime is available.""" cross_env = module_harness(cross_module) app_env = module_harness(main_module) - cross_checks = [None, "/usr/bin/cross"] + default_toolchain = main_module.DEFAULT_TOOLCHAIN + rustup_stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" + cross_path = _register_cross_version_stub(cmd_mox) + rustup_path = _register_rustup_toolchain_stub(cmd_mox, rustup_stdout) + cross_checks = [None, cross_path] def fake_which(name: str) -> str | None: if name == "cross": - return cross_checks.pop(0) - return None if name in {"docker", "podman"} else "/usr/bin/rustup" + return cross_checks.pop(0) if cross_checks else cross_path + if name in {"docker", "podman"}: + return None + return rustup_path if name == "rustup" else None cross_env.patch_shutil_which(fake_which) app_env.patch_shutil_which(fake_which) - default_toolchain = main_module.DEFAULT_TOOLCHAIN - - def fake_run( - executable: str, - args: list[str], - *, - allowed_names: tuple[str, ...], - capture_output: bool = False, - check: bool = False, - text: bool = False, - **_: object, - ) -> subprocess.CompletedProcess[str]: - _ = allowed_names - cmd = [executable, *args] - if len(cmd) > 1 and cmd[1] == "toolchain": - output = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" - return subprocess.CompletedProcess(cmd, 0, stdout=output) - return subprocess.CompletedProcess(cmd, 0, stdout="cross 0.2.5\n") - - cross_env.patch_subprocess_run(fake_run) - app_env.patch_subprocess_run(fake_run) - + cmd_mox.replay() main_module.main("x86_64-unknown-linux-gnu", default_toolchain) + cmd_mox.verify() install = next( cmd for cmd in cross_env.calls if cmd[:3] == ["cargo", "install", "cross"] @@ -402,76 +389,72 @@ def fake_run( assert build_cmd[1] == f"+{default_toolchain}-x86_64-unknown-linux-gnu" +@CMD_MOX_UNSUPPORTED def test_falls_back_to_git_when_crates_io_unavailable( - cross_module: ModuleType, module_harness: HarnessFactory + cross_module: ModuleType, + module_harness: HarnessFactory, + cmd_mox, ) -> None: """Falls back to git install when crates.io is unavailable.""" harness = module_harness(cross_module) - cross_checks = [None, "/usr/bin/cross"] + cross_path = _register_cross_version_stub(cmd_mox) + cross_checks = [None, cross_path] def run_cmd_side_effect(cmd: list[str]) -> None: if len(harness.calls) == 1: raise subprocess.CalledProcessError(1, cmd) return + def fake_which(name: str) -> str | None: + if name == "cross": + return cross_checks.pop(0) if cross_checks else cross_path + return None + harness.patch_run_cmd(run_cmd_side_effect) - harness.patch_shutil_which( - lambda name: cross_checks.pop(0) if name == "cross" else None - ) - harness.patch_subprocess_run(_constant_run("cross 0.2.5\n")) + harness.patch_shutil_which(fake_which) + cmd_mox.replay() path, ver = cross_module.ensure_cross("0.2.5") + cmd_mox.verify() assert len(harness.calls) == 2 assert "--git" in harness.calls[1] assert "--tag" in harness.calls[1] assert "v0.2.5" in harness.calls[1] - assert path == "/usr/bin/cross" + assert path == cross_path assert ver == "0.2.5" +@CMD_MOX_UNSUPPORTED def test_falls_back_to_cargo_when_runtime_unusable( main_module: ModuleType, cross_module: ModuleType, module_harness: HarnessFactory, + cmd_mox, ) -> None: """Falls back to cargo when docker exists but is unusable.""" cross_env = module_harness(cross_module) app_env = module_harness(main_module) + default_toolchain = main_module.DEFAULT_TOOLCHAIN + rustup_stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" + cross_path = _register_cross_version_stub(cmd_mox) + rustup_path = _register_rustup_toolchain_stub(cmd_mox, rustup_stdout) + docker_path = _register_docker_info_stub(cmd_mox, exit_code=1) + def fake_which(name: str) -> str | None: if name == "docker": - return "/usr/bin/docker" - return "/usr/bin/cross" if name == "cross" else "/usr/bin/rustup" + return docker_path + if name == "cross": + return cross_path + return rustup_path if name == "rustup" else None cross_env.patch_shutil_which(fake_which) app_env.patch_shutil_which(fake_which) - default_toolchain = main_module.DEFAULT_TOOLCHAIN - - def fake_run( - executable: str, - args: list[str], - *, - allowed_names: tuple[str, ...], - capture_output: bool = False, - check: bool = False, - text: bool = False, - **_: object, - ) -> subprocess.CompletedProcess[str]: - _ = allowed_names - cmd = [executable, *args] - if executable == "/usr/bin/docker": - return subprocess.CompletedProcess(cmd, 1, stdout="") - if len(cmd) > 1 and cmd[1] == "toolchain": - output = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" - return subprocess.CompletedProcess(cmd, 0, stdout=output) - return subprocess.CompletedProcess(cmd, 0, stdout="cross 0.2.5\n") - - cross_env.patch_subprocess_run(fake_run) - app_env.patch_subprocess_run(fake_run) - + cmd_mox.replay() main_module.main("x86_64-unknown-linux-gnu", default_toolchain) + cmd_mox.verify() assert any(cmd[0] == "cargo" for cmd in app_env.calls) assert all(cmd[0] != "cross" for cmd in app_env.calls) diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index b9b2aa09..3cc0401f 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -1,5 +1,3 @@ -"""Tests target installation fallback behavior.""" - from __future__ import annotations import os @@ -8,13 +6,19 @@ import pytest +from shared_actions_conftest import ( + CMD_MOX_UNSUPPORTED, + _register_cross_version_stub, + _register_docker_info_stub, + _register_rustup_toolchain_stub, +) + if typ.TYPE_CHECKING: from pathlib import Path from types import ModuleType from .conftest import HarnessFactory - - +@CMD_MOX_UNSUPPORTED def test_skips_target_install_when_cross_available( main_module: ModuleType, cross_module: ModuleType, @@ -30,47 +34,33 @@ def run_cmd_side_effect(cmd: list[str]) -> None: app_env.patch_run_cmd(run_cmd_side_effect) + default_toolchain = main_module.DEFAULT_TOOLCHAIN + rustup_stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" + rustup_path = _register_rustup_toolchain_stub(cmd_mox, rustup_stdout) + cross_path = _register_cross_version_stub(cmd_mox) + docker_path = _register_docker_info_stub(cmd_mox) + def fake_which(name: str) -> str | None: mapping = { - "cross": "/usr/bin/cross", - "docker": "/usr/bin/docker", - "rustup": "/usr/bin/rustup", + "cross": cross_path, + "docker": docker_path, + "rustup": rustup_path, } return mapping.get(name) cross_env.patch_shutil_which(fake_which) app_env.patch_shutil_which(fake_which) - default_toolchain = main_module.DEFAULT_TOOLCHAIN - - def fake_run( - executable: str, - args: list[str], - *, - allowed_names: tuple[str, ...], - capture_output: bool = False, - check: bool = False, - text: bool = False, - **_: object, - ) -> subprocess.CompletedProcess[str]: - _ = allowed_names - cmd = [executable, *args] - if executable == "/usr/bin/docker": - return subprocess.CompletedProcess(cmd, 0, stdout="") - if len(cmd) > 1 and cmd[1] == "toolchain": - stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" - return subprocess.CompletedProcess(cmd, 0, stdout=stdout) - return subprocess.CompletedProcess(cmd, 0, stdout="cross 0.2.5\n") - - cross_env.patch_subprocess_run(fake_run) - app_env.patch_subprocess_run(fake_run) + cmd_mox.replay() main_module.main("aarch64-pc-windows-gnu", default_toolchain) + cmd_mox.verify() build_cmd = app_env.calls[-1] assert build_cmd[0] == "cross" assert build_cmd[1] == f"+{default_toolchain}" +@CMD_MOX_UNSUPPORTED def test_errors_when_target_unsupported_without_cross( main_module: ModuleType, cross_module: ModuleType, @@ -81,34 +71,16 @@ def test_errors_when_target_unsupported_without_cross( cross_env = module_harness(cross_module) app_env = module_harness(main_module) + default_toolchain = main_module.DEFAULT_TOOLCHAIN + rustup_stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" + rustup_path = _register_rustup_toolchain_stub(cmd_mox, rustup_stdout) + def fake_which(name: str) -> str | None: - return "/usr/bin/rustup" if name == "rustup" else None + return rustup_path if name == "rustup" else None cross_env.patch_shutil_which(fake_which) app_env.patch_shutil_which(fake_which) - default_toolchain = main_module.DEFAULT_TOOLCHAIN - - def fake_run( - executable: str, - args: list[str], - *, - allowed_names: tuple[str, ...], - capture_output: bool = False, - check: bool = False, - text: bool = False, - **_: object, - ) -> subprocess.CompletedProcess[str]: - _ = allowed_names - cmd = [executable, *args] - if len(cmd) > 1 and cmd[1] == "toolchain": - stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" - return subprocess.CompletedProcess(cmd, 0, stdout=stdout) - return subprocess.CompletedProcess(cmd, 0, stdout="") - - cross_env.patch_subprocess_run(fake_run) - app_env.patch_subprocess_run(fake_run) - def run_cmd_side_effect(cmd: list[str]) -> None: if cmd[:3] == ["rustup", "target", "add"]: raise subprocess.CalledProcessError(1, cmd) @@ -117,13 +89,16 @@ def run_cmd_side_effect(cmd: list[str]) -> None: app_env.patch_attr("ensure_cross", lambda *_: (None, None)) app_env.patch_attr("runtime_available", lambda name: False) + cmd_mox.replay() with pytest.raises(main_module.typer.Exit): main_module.main("thumbv7em-none-eabihf", default_toolchain) + cmd_mox.verify() err = capsys.readouterr().err assert "does not support target 'thumbv7em-none-eabihf'" in err +@CMD_MOX_UNSUPPORTED def test_falls_back_to_cargo_when_cross_container_fails( main_module: ModuleType, cross_module: ModuleType, @@ -139,40 +114,23 @@ def run_cmd_side_effect(cmd: list[str]) -> None: app_env.patch_run_cmd(run_cmd_side_effect) + default_toolchain = main_module.DEFAULT_TOOLCHAIN + rustup_stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" + rustup_path = _register_rustup_toolchain_stub(cmd_mox, rustup_stdout) + cross_path = _register_cross_version_stub(cmd_mox) + def fake_which(name: str) -> str | None: - mapping = { - "rustup": "/usr/bin/rustup", - } - return mapping.get(name) + return rustup_path if name == "rustup" else None cross_env.patch_shutil_which(fake_which) app_env.patch_shutil_which(fake_which) - default_toolchain = main_module.DEFAULT_TOOLCHAIN - - def fake_run( - executable: str, - args: list[str], - *, - allowed_names: tuple[str, ...], - capture_output: bool = False, - check: bool = False, - text: bool = False, - **_: object, - ) -> subprocess.CompletedProcess[str]: - _ = allowed_names - cmd = [executable, *args] - if len(cmd) > 1 and cmd[1] == "toolchain": - stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" - return subprocess.CompletedProcess(cmd, 0, stdout=stdout) - return subprocess.CompletedProcess(cmd, 0, stdout="") - - cross_env.patch_subprocess_run(fake_run) - app_env.patch_subprocess_run(fake_run) - app_env.patch_attr("ensure_cross", lambda required: ("/usr/bin/cross", required)) + app_env.patch_attr("ensure_cross", lambda required: (cross_path, required)) app_env.patch_attr("runtime_available", lambda name: True) + cmd_mox.replay() main_module.main("x86_64-unknown-linux-gnu", default_toolchain) + cmd_mox.verify() build_cmd = app_env.calls[-1] assert build_cmd[0] == "cargo" assert build_cmd[1] == f"+{default_toolchain}-x86_64-unknown-linux-gnu" diff --git a/.github/actions/rust-build-release/tests/test_utils.py b/.github/actions/rust-build-release/tests/test_utils.py index f49a8076..b46ceb69 100644 --- a/.github/actions/rust-build-release/tests/test_utils.py +++ b/.github/actions/rust-build-release/tests/test_utils.py @@ -8,6 +8,8 @@ import pytest +from shared_actions_conftest import CMD_MOX_UNSUPPORTED + if typ.TYPE_CHECKING: from types import ModuleType @@ -34,21 +36,16 @@ def test_ensure_allowed_executable_rejects_unknown( utils_module.ensure_allowed_executable(exe_path, ("rustup", "rustup.exe")) +@CMD_MOX_UNSUPPORTED def test_run_validated_invokes_subprocess_with_validated_path( - utils_module: ModuleType, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + utils_module: ModuleType, + cmd_mox, ) -> None: """run_validated executes subprocess.run with the validated executable.""" - exe_path = tmp_path / "docker.exe" - exe_path.write_text("", encoding="utf-8") - - recorded: dict[str, list[str]] = {} - - def fake_run(cmd: list[str], **_: object) -> subprocess.CompletedProcess[str]: - recorded["cmd"] = cmd - return subprocess.CompletedProcess(cmd, 0, stdout="ok") - - monkeypatch.setattr(utils_module.subprocess, "run", fake_run) + exe_path = cmd_mox.environment.shim_dir / "docker.exe" + spy = cmd_mox.spy("docker.exe").with_args("info").returns(stdout="ok") + cmd_mox.replay() result = utils_module.run_validated( exe_path, ["info"], @@ -57,11 +54,12 @@ def fake_run(cmd: list[str], **_: object) -> subprocess.CompletedProcess[str]: capture_output=True, text=True, ) + cmd_mox.verify() - assert recorded["cmd"][0] == str(exe_path) - assert recorded["cmd"][1:] == ["info"] assert isinstance(result, subprocess.CompletedProcess) + assert result.args[0] == str(exe_path) assert result.stdout == "ok" + assert spy.call_count == 1 def test_run_validated_raises_for_unexpected_executable( diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..18865fc7 --- /dev/null +++ b/conftest.py @@ -0,0 +1,64 @@ +"""Pytest configuration for shared actions tests.""" + +from __future__ import annotations + +import collections +import collections.abc as cabc +import sys + +import pytest + + +CMD_MOX_UNSUPPORTED = pytest.mark.skipif( + sys.platform == "win32", reason="cmd-mox does not support Windows" +) + +sys.modules.setdefault("shared_actions_conftest", sys.modules[__name__]) + + +def _register_cross_version_stub( + cmd_mox, stdout: str | cabc.Iterable[str] = "cross 0.2.5\n" +) -> str: + """Register a stub for ``cross --version`` and return the shim path.""" + + if isinstance(stdout, str): + cmd_mox.stub("cross").with_args("--version").returns(stdout=stdout) + else: + outputs = collections.deque(stdout) + last = outputs[-1] if outputs else "cross 0.2.5\n" + + def _handler(_invocation: object) -> tuple[str, str, int]: + data = outputs.popleft() if outputs else last + return data, "", 0 + + cmd_mox.stub("cross").with_args("--version").runs(_handler) + return str(cmd_mox.environment.shim_dir / "cross") + + +def _register_rustup_toolchain_stub( + cmd_mox, stdout: str +) -> str: # pragma: no cover - helper + """Register a stub for ``rustup toolchain list`` and return the shim path.""" + + cmd_mox.stub("rustup").with_args("toolchain", "list").returns(stdout=stdout) + return str(cmd_mox.environment.shim_dir / "rustup") + + +def _register_docker_info_stub( + cmd_mox, *, exit_code: int = 0 +) -> str: # pragma: no cover - helper + """Register a stub for ``docker info`` and return the shim path.""" + + cmd_mox.stub("docker").with_args("info").returns(exit_code=exit_code) + return str(cmd_mox.environment.shim_dir / "docker") + + +if sys.platform != "win32": # pragma: win32 no cover - Windows lacks cmd-mox support + pytest_plugins = ("cmd_mox.pytest_plugin",) +else: + + @pytest.fixture() + def cmd_mox(): # pragma: win32 no cover - fixture only used on Windows + """Skip tests that rely on cmd-mox on Windows.""" + + pytest.skip("cmd-mox does not support Windows") diff --git a/docs/cmd-mox-users-guide.md b/docs/cmd-mox-users-guide.md index 8269762c..a6a006bd 100644 --- a/docs/cmd-mox-users-guide.md +++ b/docs/cmd-mox-users-guide.md @@ -5,7 +5,8 @@ commands in your tests. This guide shows common patterns for everyday use. ## Getting started -Install the package and enable the pytest plugin: +Install the package and enable the pytest plugin (guarded on Windows where +cmd-mox is not currently supported): ```bash pip install cmd-mox @@ -14,11 +15,16 @@ pip install cmd-mox In your `conftest.py`: ```python -pytest_plugins = ("cmd_mox.pytest_plugin",) +import sys + +if sys.platform != "win32": + pytest_plugins = ("cmd_mox.pytest_plugin",) ``` Each test receives a `cmd_mox` fixture that provides access to the controller -object. +object. Because the IPC transport is Unix-specific, guard any cmd-mox-backed +tests with `pytest.mark.skipif(sys.platform == "win32", ...)` so CI runners on +Windows bypass them gracefully. ## Basic workflow diff --git a/pyproject.toml b/pyproject.toml index a6ecbf81..088896ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ dev = [ "pyyaml>=6.0,<7.0", "ty>=0.0.1a20", "uuid6>=2025.0.1", + "cmd-mox@git+https://github.com/leynos/cmd-mox.git", ] [tool.ruff] diff --git a/uv.lock b/uv.lock index 8cfdbd99..782df030 100644 --- a/uv.lock +++ b/uv.lock @@ -23,6 +23,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, ] +[[package]] +name = "cmd-mox" +version = "0.1.0" +source = { git = "https://github.com/leynos/cmd-mox.git#5bf23d0ae6055397956a3d4440063fa6a77b10d8" } + [[package]] name = "colorama" version = "0.4.6" @@ -283,6 +288,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "cmd-mox" }, { name = "lxml-stubs" }, { name = "pytest" }, { name = "pyyaml" }, @@ -300,6 +306,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "cmd-mox", git = "https://github.com/leynos/cmd-mox.git" }, { name = "lxml-stubs", specifier = ">=0.5.1" }, { name = "pytest", specifier = ">=8.0,<9.0" }, { name = "pyyaml", specifier = ">=6.0,<7.0" }, From effe1f4ec8107937d8ad65ed832370dcf1be4bce Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 20 Sep 2025 11:58:36 +0100 Subject: [PATCH 18/41] Mark Windows smoke tests as xfail (#94) * Mark Windows smoke tests as xfail * Tighten Windows smoke test expectations --- .../actions/rust-build-release/tests/test_smoke.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/actions/rust-build-release/tests/test_smoke.py b/.github/actions/rust-build-release/tests/test_smoke.py index c7639f80..bdd3e2f9 100644 --- a/.github/actions/rust-build-release/tests/test_smoke.py +++ b/.github/actions/rust-build-release/tests/test_smoke.py @@ -29,6 +29,14 @@ WINDOWS_ONLY = pytest.mark.skipif(sys.platform != "win32", reason="requires Windows") LINUX_ONLY = pytest.mark.skipif(sys.platform == "win32", reason="requires Linux") +WINDOWS_KNOWN_FAILURE = pytest.mark.xfail( + sys.platform == "win32", + reason=( + "Known failure on Windows; see " + "https://github.com/leynos/shared-actions/issues/93" + ), + strict=True, +) HOST_TARGET = detect_host_target() @@ -54,8 +62,10 @@ def _param_for_target(target: str) -> object: marks: list[pytest.MarkDecorator] = [] if target != HOST_TARGET and target.endswith("-unknown-linux-gnu"): marks.append(LINUX_ONLY) - if target.endswith("-pc-windows-gnu"): + if target.endswith("-pc-windows-gnu") or target.endswith("-pc-windows-msvc"): marks.append(WINDOWS_ONLY) + if target.endswith("-pc-windows-gnu") or target.endswith("-pc-windows-msvc"): + marks.append(WINDOWS_KNOWN_FAILURE) if marks: return pytest.param(target, marks=tuple(marks)) return pytest.param(target) From 3db6752adee546bc18ff2243eceac79ef194ce52 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 21 Sep 2025 01:07:40 +0100 Subject: [PATCH 19/41] Address reviewer feedback for release-to-pypi-uv action (#98) * Address review comments for release to PyPI action * Add tests and docs for python-version handling --- .github/actions/release-to-pypi-uv/README.md | 9 +- .github/actions/release-to-pypi-uv/action.yml | 1 + .../tests/test_action_python_version.py | 43 +++++++ .../tests/test_confirm_release.py | 5 + .../tests/test_determine_release.py | 5 + conftest.py | 12 ++ docs/cmd-mox-users-guide.md | 15 ++- docs/python-native-command-mocking-design.md | 111 ++++++++++++++++++ 8 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 .github/actions/release-to-pypi-uv/tests/test_action_python_version.py create mode 100644 docs/python-native-command-mocking-design.md diff --git a/.github/actions/release-to-pypi-uv/README.md b/.github/actions/release-to-pypi-uv/README.md index ba0b1db4..db5c075e 100644 --- a/.github/actions/release-to-pypi-uv/README.md +++ b/.github/actions/release-to-pypi-uv/README.md @@ -16,6 +16,10 @@ Build and publish Python distributions via | fail-on-dynamic-version | Fail when a project declares a dynamic PEP 621 version instead of a literal string. | no | `false` | | python-version | Python version to install and use for all uv commands. | no | `3.13` | +The composite action installs the interpreter requested through `python-version` +before invoking any uv commands, ensuring builds run against the expected +runtime. + ## Outputs | Name | Description | @@ -49,13 +53,10 @@ jobs: with: fetch-depth: 0 - - uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc - with: - python-version: ${{ inputs.python-version }} - - name: Build and publish uses: ./.github/actions/release-to-pypi-uv with: + python-version: '3.12' require-confirmation: true confirm: release ${{ github.ref_name }} ``` diff --git a/.github/actions/release-to-pypi-uv/action.yml b/.github/actions/release-to-pypi-uv/action.yml index 1c543365..84d4f3bc 100644 --- a/.github/actions/release-to-pypi-uv/action.yml +++ b/.github/actions/release-to-pypi-uv/action.yml @@ -48,6 +48,7 @@ runs: # v6.4.3 uses: astral-sh/setup-uv@e92bafb6253dcd438e0484186d7669ea7a8ca1cc with: + python-version: ${{ inputs.python-version }} cache-dependency-glob: | **/pyproject.toml **/uv.lock diff --git a/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py b/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py new file mode 100644 index 00000000..46715e91 --- /dev/null +++ b/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py @@ -0,0 +1,43 @@ +"""Tests covering the python-version input in action.yml.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import yaml + + +def _load_action() -> dict[str, Any]: + action_path = Path(__file__).resolve().parents[1] / "action.yml" + return yaml.safe_load(action_path.read_text(encoding="utf-8")) + + +def test_action_exposes_python_version_input() -> None: + """Unit test: ensure metadata defines python-version with the expected default.""" + + data = _load_action() + python_version = data["inputs"]["python-version"] + assert python_version["default"] == "3.13" + assert "Python version" in python_version["description"] + + +def test_setup_step_forwards_python_version_input() -> None: + """Behavioral test: ensure setup-uv installs the requested interpreter.""" + + data = _load_action() + steps = data["runs"]["steps"] + setup_step = next(step for step in steps if step["name"] == "Setup uv") + assert setup_step["with"]["python-version"] == "${{ inputs.python-version }}" + + +def test_install_step_uses_python_version_input() -> None: + """Behavioral test: ensure uv python install receives the requested version.""" + + data = _load_action() + steps = data["runs"]["steps"] + install_step = next(step for step in steps if step["name"] == "Install Python") + assert ( + install_step["run"] + == 'uv python install "${{ inputs.python-version }}"' + ) diff --git a/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py b/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py index 7596aa41..e5109a98 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py @@ -5,9 +5,14 @@ import subprocess from pathlib import Path +from shared_actions_conftest import REQUIRES_UV + from .test_determine_release import base_env +pytestmark = REQUIRES_UV + + def run_confirm(tmp_path: Path, expected: str, confirm: str) -> subprocess.CompletedProcess[str]: env = base_env(tmp_path) env["EXPECTED"] = expected diff --git a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py index 5d83d460..ea006ca7 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py @@ -6,6 +6,11 @@ import subprocess from pathlib import Path +from shared_actions_conftest import REQUIRES_UV + + +pytestmark = REQUIRES_UV + def run_script(script: Path, *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: cmd = ["uv", "run", "--script", str(script)] diff --git a/conftest.py b/conftest.py index 18865fc7..371bcae4 100644 --- a/conftest.py +++ b/conftest.py @@ -4,6 +4,7 @@ import collections import collections.abc as cabc +import shutil import sys import pytest @@ -12,10 +13,21 @@ CMD_MOX_UNSUPPORTED = pytest.mark.skipif( sys.platform == "win32", reason="cmd-mox does not support Windows" ) +HAS_UV = shutil.which("uv") is not None + +REQUIRES_UV = pytest.mark.usefixtures("require_uv") sys.modules.setdefault("shared_actions_conftest", sys.modules[__name__]) +@pytest.fixture() +def require_uv() -> None: + """Skip tests that exercise uv when the CLI is unavailable.""" + + if not HAS_UV: + pytest.skip("uv CLI not installed") + + def _register_cross_version_stub( cmd_mox, stdout: str | cabc.Iterable[str] = "cross 0.2.5\n" ) -> str: diff --git a/docs/cmd-mox-users-guide.md b/docs/cmd-mox-users-guide.md index a6a006bd..5ca2b1bc 100644 --- a/docs/cmd-mox-users-guide.md +++ b/docs/cmd-mox-users-guide.md @@ -3,6 +3,11 @@ CmdMox provides a fluent API for mocking, stubbing and spying on external commands in your tests. This guide shows common patterns for everyday use. +## Related documents + +- [Python Native Command Mocking Design](./python-native-command-mocking-design.md) + – Architectural decisions, lifecycle sequencing and IPC design details. + ## Getting started Install the package and enable the pytest plugin (guarded on Windows where @@ -35,7 +40,7 @@ interactions matched what was recorded. The three phases are defined in the design document: 1. **Record** – describe each expected command call, including its arguments - and behaviour. + and behavior. 2. **Replay** – run the code under test while CmdMox intercepts command executions. 3. **Verify** – ensure every expectation was met and nothing unexpected @@ -68,7 +73,7 @@ cmd_mox.spy("curl") - **Spies** record every call for later inspection and can behave like stubs. Each call returns a `CommandDouble` that offers a fluent DSL to configure -behaviour. +behavior. ## Defining expectations @@ -187,8 +192,8 @@ cmd_mox.verify() assert [call.command for call in cmd_mox.journal] == ["git", "curl"] ``` -When you want to intercept a command without configuring a double—for example to -ensure it is treated as unexpected—register it explicitly: +To intercept a command without configuring a double—for example, to ensure it is +treated as unexpected—register it explicitly: ```python cmd_mox.register_command("name") @@ -216,7 +221,7 @@ few common ones are: receives an `Invocation` and should return either a `(stdout, stderr, exit_code)` tuple or a `Response` instance. - `times(count)` – expect the command exactly `count` times. -- `times_called(count)` – alias for `times` that emphasises spy call counts. +- `times_called(count)` – alias for `times` that emphasizes spy call counts. - `in_order()` – enforce strict ordering with other expectations. - `any_order()` – allow the expectation to be satisfied in any position. - `passthrough()` – for spies, run the real command while recording it. diff --git a/docs/python-native-command-mocking-design.md b/docs/python-native-command-mocking-design.md new file mode 100644 index 00000000..3976ae43 --- /dev/null +++ b/docs/python-native-command-mocking-design.md @@ -0,0 +1,111 @@ +# Python Native Command Mocking Design + +CmdMox underpins the Python-based command doubling strategy. The library offers +an ergonomic façade for writing tests while keeping the execution model explicit +and deterministic. This document captures the architectural decisions and the +contracts relied upon by the higher-level usage guide. + +## Objectives + +- Provide a transport-agnostic façade that lets tests intercept subprocess + invocations without patching the Python standard library. +- Support mocks, stubs and spies with a consistent fluent DSL that emphasizes + readability. +- Capture interactions for later inspection through a replay journal so tests + remain debuggable. +- Remain portable across Unix platforms while documenting the Windows + limitations of the IPC transport. + +## Architecture Overview + +CmdMox consists of three cooperating subsystems: + +1. **Controller** – The public entry point used by tests. It configures + expectations, manages lifecycle transitions and coordinates verification. +2. **Environment** – Provisions temporary shim binaries (or scripts) and binds + them to the controller via Unix domain sockets. Environment configuration is + exposed through attributes such as `environment.shim_dir`. +3. **IPC Server** – Handles requests from shims, dispatching them to the + recorded doubles. The server enforces strict sequencing to maintain + deterministic behavior. + +The pytest plugin creates a controller per test function. When used as a context +manager (`with CmdMox() as mox:`) the same controller lifecycle is available for +non-pytest clients. + +## Lifecycle: Record → Replay → Verify + +CmdMox enforces a three-stage lifecycle: + +1. **Record** – Tests describe expectations using the fluent API. Each + expectation registers a command double with information about argument + matching, environment and the response strategy. +2. **Replay** – The controller activates the IPC server and replaces the target + commands with shims. During this phase, invocations flow through the doubles. +3. **Verify** – Finally, the controller checks that every expectation was + satisfied, including call counts and ordering rules. + +Exiting a `with CmdMox()` block triggers `verify()` automatically when +`verify_on_exit=True` (the default). Failing verification suppresses the error +if an exception already bubbled out of the context, keeping the original +exception visible to the test runner. + +## Command Doubles and Responses + +`CommandDouble` instances configure behavior with a fluent DSL: + +- `with_args(*args)` asserts exact argument sequences. +- `with_matching_args(*matchers)` allows per-position comparator functions such + as `Regex`, `Contains`, `StartsWith`, `Any`, `IsA` or custom predicates. +- `with_stdin(...)` and `with_env({...})` match stdin content and environment + fragments. +- `returns(stdout="", stderr="", exit_code=0)` provides deterministic + responses; the API operates exclusively on `str` payloads. +- `runs(handler)` executes dynamic hooks that receive an `Invocation` object. +- `times(count)` and `times_called(count)` enforce call counts, with the latter + acting as a spy-specific alias. +- `passthrough()` forwards execution to the real command while continuing to + record invocations. +- `assert_called*` helpers are available on spies after verification to ease + assertions in tests. + +## Journal and Diagnostics + +Every invocation processed during replay is appended to `cmd_mox.journal`, a +bounded `collections.deque`. The capacity is controlled by +`max_journal_entries`; exceeding the limit evicts the oldest entries. The +journal is the primary diagnostic surface for understanding unexpected +interactions and is frequently asserted against in tests. + +## Environment Variables + +Two environment variables tie the controller and shims together: + +- `CMOX_IPC_SOCKET` – Path to the Unix domain socket exposed by the server. Shims + exit early if this variable is missing. +- `CMOX_IPC_TIMEOUT` – Seconds to wait for IPC operations before raising a + timeout error. The default is `5.0` seconds and can be tuned per test via the + controller API. + +These variables are injected automatically when the pytest fixture or context +manager initialises the controller. + +## Platform Notes + +The IPC transport relies on Unix domain sockets, so the pytest plugin guards +against activation on Windows (`sys.platform == "win32"`). Tests should guard +Windows-specific code paths accordingly. Future work may explore TCP loopback or +named-pipe transports for full parity. + +## Error Handling and Validation + +- The controller refuses to enter replay without recorded expectations when + strict verification is required, ensuring unexpected commands fail fast. +- Each shim invocation is validated against its matching strategy; mismatches are + surfaced immediately with descriptive error messages. +- Journal eviction and verification are both deterministic so repeated runs yield + identical behavior given the same expectations and inputs. + +CmdMox is designed to remain implementation-agnostic at the call site, allowing +maintainers to evolve the underlying IPC layer or shim mechanism without +breaking tests that depend on the documented contracts above. From 75a83c179c641631b742b382d762b58595512a6d Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 21 Sep 2025 12:19:35 +0100 Subject: [PATCH 20/41] Fix cmd_mox fixture usage and expose dev extra (#101) * Fix cmd_mox fixture usage and expose dev extras * Remove pyproject optional dev extra * Remove Pyright configuration --- .../tests/test_target_install.py | 4 ++ docs/cmd-mox-users-guide.md | 12 ++--- pyproject.toml | 48 +++++++++---------- 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index 3cc0401f..8379978e 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -18,11 +18,13 @@ from types import ModuleType from .conftest import HarnessFactory + @CMD_MOX_UNSUPPORTED def test_skips_target_install_when_cross_available( main_module: ModuleType, cross_module: ModuleType, module_harness: HarnessFactory, + cmd_mox, ) -> None: """Continues when target addition fails but cross is available.""" cross_env = module_harness(cross_module) @@ -65,6 +67,7 @@ def test_errors_when_target_unsupported_without_cross( main_module: ModuleType, cross_module: ModuleType, module_harness: HarnessFactory, + cmd_mox, capsys: pytest.CaptureFixture[str], ) -> None: """Emits an error when the toolchain lacks the requested target.""" @@ -103,6 +106,7 @@ def test_falls_back_to_cargo_when_cross_container_fails( main_module: ModuleType, cross_module: ModuleType, module_harness: HarnessFactory, + cmd_mox, ) -> None: """Falls back to cargo when cross exits with a container error.""" cross_env = module_harness(cross_module) diff --git a/docs/cmd-mox-users-guide.md b/docs/cmd-mox-users-guide.md index 5ca2b1bc..88d1ad55 100644 --- a/docs/cmd-mox-users-guide.md +++ b/docs/cmd-mox-users-guide.md @@ -1,7 +1,7 @@ # CmdMox Usage Guide CmdMox provides a fluent API for mocking, stubbing and spying on external -commands in your tests. This guide shows common patterns for everyday use. +commands in tests. This guide shows common patterns for everyday use. ## Related documents @@ -17,7 +17,7 @@ cmd-mox is not currently supported): pip install cmd-mox ``` -In your `conftest.py`: +In a project's `conftest.py`: ```python import sys @@ -34,7 +34,7 @@ Windows bypass them gracefully. ## Basic workflow CmdMox follows a strict record → replay → verify lifecycle. First declare -expectations, then run your code with the shims active, finally verify that +expectations, then run the code with the shims active, finally verify that interactions matched what was recorded. The three phases are defined in the design document: @@ -85,7 +85,7 @@ cmd_mox.mock("git") \ .returns(exit_code=0) ``` -You can match arguments more flexibly using comparators: +Arguments can be matched more flexibly using comparators: ```python from cmd_mox import Regex, Contains @@ -175,8 +175,8 @@ restricted to spy doubles. the context-manager API: - `verify_on_exit` (default `True`) automatically calls `verify()` when a replay - phase ends inside a `with CmdMox()` block. Disable it when you need to manage - verification manually. Verification still runs if the body raises; when both + phase ends inside a `with CmdMox()` block. Disable it when manual verification + management is required. Verification still runs if the body raises; when both verification and the body fail, the verification error is suppressed so the original exception surfaces. - `max_journal_entries` bounds the number of stored invocations (oldest entries diff --git a/pyproject.toml b/pyproject.toml index 088896ab..114e57a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,8 +23,6 @@ classifiers = [ "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", ] - - [dependency-groups] dev = [ "lxml-stubs>=0.5.1", @@ -40,27 +38,27 @@ line-length = 88 [tool.ruff.lint] select = [ - "F", # Pyflakes rules - "W", # PyCodeStyle warnings - "E", # PyCodeStyle errors - "I", # Sort imports properly - "UP", # Warn if certain things can changed due to newer Python versions - "C4", # Catch incorrect use of comprehensions, dict, list, etc - "FA", # Enforce from __future__ import annotations - "ISC", # Good use of string concatenation - "ICN", # Use common import conventions - "RET", # Good return practices - "SIM", # Common simplification rules - "TID", # Some good import practices - "TC", # Enforce importing certain types in a TYPE_CHECKING block - "PTH", # Use pathlib instead of os.path - "TD", # Be diligent with TODO comments - "A", # detect shadowed builtins - "BLE", # disallow catch-all exceptions - "S", # disallow things like "exec"; also restricts "assert" but I just NOQA it when I really need it - "DTZ", # require strict timezone manipulation with datetime - "FBT", # detect boolean traps - "N", # enforce naming conventions, e.g. ClassName vs function_name + "F", + "W", + "E", + "I", + "UP", + "C4", + "FA", + "ISC", + "ICN", + "RET", + "SIM", + "TID", + "TC", + "PTH", + "TD", + "A", + "BLE", + "S", + "DTZ", + "FBT", + "N", "FURB", "B", "RUF", @@ -126,7 +124,7 @@ typeCheckingMode = "strict" reportUnknownVariableType = "error" reportUnknownParameterType = "error" reportUnknownMemberType = "error" -reportMissingTypeStubs = "error" +reportMissingTypeStubs = "warning" pythonVersion = "3.12" venvPath = "." -venv = ".venv" +venv = ".venv" \ No newline at end of file From aabef78a81ef9bdae2ba85a9f981af8d7fdceb54 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 21 Sep 2025 13:46:56 +0100 Subject: [PATCH 21/41] Add missing docstrings to release-to-pypi-uv modules (#103) --- .../scripts/check_github_release.py | 16 ++ .../scripts/confirm_release.py | 14 ++ .../scripts/determine_release.py | 16 ++ .../scripts/publish_release.py | 7 + .../scripts/validate_toml_versions.py | 17 ++ .../scripts/write_summary.py | 13 ++ .../tests/test_check_github_release.py | 73 +++++++++ .../tests/test_confirm_release.py | 30 ++++ .../tests/test_determine_release.py | 94 +++++++++++ .../tests/test_publish_release.py | 34 ++++ .../tests/test_validate_toml_versions.py | 153 ++++++++++++++++++ .../tests/test_write_summary.py | 32 ++++ 12 files changed, 499 insertions(+) diff --git a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py index 5f3cd963..cb02bf25 100644 --- a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py @@ -99,6 +99,22 @@ def main( token: str = TOKEN_OPTION, repo: str = REPO_OPTION, ) -> None: + """Check that the GitHub release for ``tag`` is published. + + Parameters + ---------- + tag : str + Release tag to validate. + token : str + Token used to authenticate the GitHub API request. + repo : str + Repository slug in ``owner/name`` form where the release should exist. + + Raises + ------ + typer.Exit + Raised when the release is missing or not ready for publication. + """ try: data = _fetch_release(repo, tag, token) name = _validate_release(tag, data) diff --git a/.github/actions/release-to-pypi-uv/scripts/confirm_release.py b/.github/actions/release-to-pypi-uv/scripts/confirm_release.py index 893e0b44..e1450a86 100644 --- a/.github/actions/release-to-pypi-uv/scripts/confirm_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/confirm_release.py @@ -14,6 +14,20 @@ def main(expected: str = EXPECTED_OPTION, confirm: str = CONFIRM_OPTION) -> None: + """Validate that the provided confirmation string matches ``expected``. + + Parameters + ---------- + expected : str + Confirmation phrase that must be entered to proceed. + confirm : str + User-supplied confirmation string collected from workflow input. + + Raises + ------ + typer.Exit + Raised when the supplied confirmation does not match ``expected``. + """ if confirm != expected: typer.echo( f"::error::Confirmation failed. Set the 'confirm' input to: {expected}", diff --git a/.github/actions/release-to-pypi-uv/scripts/determine_release.py b/.github/actions/release-to-pypi-uv/scripts/determine_release.py index 5e6aff1f..39b7e5a9 100644 --- a/.github/actions/release-to-pypi-uv/scripts/determine_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/determine_release.py @@ -24,6 +24,22 @@ def _emit_outputs(dest: Path, tag: str, version: str) -> None: def main(tag: str | None = TAG_OPTION, github_output: Path = GITHUB_OUTPUT_OPTION) -> None: + """Resolve the release tag and write outputs for downstream steps. + + Parameters + ---------- + tag : str | None + Tag supplied via workflow input when the workflow is not running on a + tag reference. + github_output : Path + Path to the ``GITHUB_OUTPUT`` file that receives the resolved values. + + Raises + ------ + typer.Exit + Raised when no tag can be determined or the tag is not SemVer + compliant. + """ ref_type = os.getenv("GITHUB_REF_TYPE", "") ref_name = os.getenv("GITHUB_REF_NAME", "") diff --git a/.github/actions/release-to-pypi-uv/scripts/publish_release.py b/.github/actions/release-to-pypi-uv/scripts/publish_release.py index ffa717a0..799b9e57 100644 --- a/.github/actions/release-to-pypi-uv/scripts/publish_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/publish_release.py @@ -51,6 +51,13 @@ def _extend_sys_path() -> None: def main(index: str = INDEX_OPTION) -> None: + """Publish the built distributions with uv. + + Parameters + ---------- + index : str + Optional package index name or URL to pass to ``uv publish``. + """ if index: typer.echo(f"Publishing with uv to index '{index}'") run_cmd(["uv", "publish", "--index", index]) diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index 2eea2ada..0f83bd65 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -77,6 +77,23 @@ def main( pattern: str = PATTERN_OPTION, fail_on_dynamic: str = FAIL_ON_DYNAMIC_OPTION, ) -> None: + """Confirm that project versions in TOML files match the release version. + + Parameters + ---------- + version : str + Semantic version resolved for the release tag. + pattern : str + Glob pattern used to discover ``pyproject.toml`` files to inspect. + fail_on_dynamic : str + String flag that controls whether dynamic versions should raise an + error. + + Raises + ------ + typer.Exit + Raised when TOML files cannot be read or contain mismatched versions. + """ files = list(_iter_files(pattern)) if not files: typer.echo(f"::warning::No TOML files matched pattern {pattern}") diff --git a/.github/actions/release-to-pypi-uv/scripts/write_summary.py b/.github/actions/release-to-pypi-uv/scripts/write_summary.py index d9a8f801..1840c76c 100644 --- a/.github/actions/release-to-pypi-uv/scripts/write_summary.py +++ b/.github/actions/release-to-pypi-uv/scripts/write_summary.py @@ -23,6 +23,19 @@ def main( environment_name: str = ENV_OPTION, summary_path: Path = SUMMARY_OPTION, ) -> None: + """Append release details to the GitHub step summary file. + + Parameters + ---------- + tag : str + Resolved release tag to report. + index : str + Optional package index identifier provided to the publish step. + environment_name : str + Name of the deployment environment associated with the release. + summary_path : Path + File path to ``GITHUB_STEP_SUMMARY`` that should receive the content. + """ index_label = index or "pypi (default)" heading = "## Release summary\n" lines = [ diff --git a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py index fc217b30..4b4b7b3d 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py @@ -28,10 +28,28 @@ def read(self) -> bytes: @pytest.fixture(name="module") def fixture_module() -> Any: + """Load the ``check_github_release`` script for testing. + + Returns + ------- + Any + Imported module object exposing the ``main`` entrypoint. + """ return load_script_module("check_github_release") def test_success(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], module: Any) -> None: + """Confirm that a published release prints a success message. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Fixture used to replace ``urllib.request.urlopen``. + capsys : pytest.CaptureFixture[str] + Captures standard output and error from the command execution. + module : Any + Script module under test. + """ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 return DummyResponse({"draft": False, "prerelease": False, "name": "1.2.3"}) @@ -44,6 +62,17 @@ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: A def test_draft_release(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + """Fail when the release is still marked as a draft. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Fixture used to replace ``urllib.request.urlopen``. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures emitted error output. + """ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 return DummyResponse({"draft": True, "prerelease": False, "name": "draft"}) @@ -57,6 +86,17 @@ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: A def test_prerelease(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + """Fail when the release is published as a prerelease. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Fixture used to replace ``urllib.request.urlopen``. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures emitted error output. + """ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 return DummyResponse({"draft": False, "prerelease": True, "name": "pre"}) @@ -70,6 +110,17 @@ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: A def test_missing_release(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + """Raise an error when the requested release does not exist. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Fixture used to replace ``urllib.request.urlopen``. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures emitted error output. + """ def fake_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401 raise module.urllib.error.HTTPError( url=str(request.full_url), @@ -89,6 +140,17 @@ def fake_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401 def test_permission_denied(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + """Surface permission errors from the GitHub API. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Fixture used to replace ``urllib.request.urlopen``. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures emitted error output. + """ detail = b"forbidden" error = module.urllib.error.HTTPError( url="https://api.github.com", @@ -111,6 +173,17 @@ def raising_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401 def test_retries_then_success(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + """Retry transient failures before succeeding. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Fixture used to replace network calls and sleep behaviour. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures command output for assertions. + """ attempts: list[int] = [] def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 diff --git a/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py b/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py index e5109a98..a5f49f30 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py @@ -14,6 +14,22 @@ def run_confirm(tmp_path: Path, expected: str, confirm: str) -> subprocess.CompletedProcess[str]: + """Execute the confirmation script with the provided values. + + Parameters + ---------- + tmp_path : Path + Temporary directory used as the working directory for the script. + expected : str + Expected confirmation string supplied via environment variable. + confirm : str + Confirmation value provided to the workflow input. + + Returns + ------- + subprocess.CompletedProcess[str] + Result from invoking the script with ``uv run``. + """ env = base_env(tmp_path) env["EXPECTED"] = expected env["INPUT_CONFIRM"] = confirm @@ -31,6 +47,13 @@ def run_confirm(tmp_path: Path, expected: str, confirm: str) -> subprocess.Compl def test_confirmation_success(tmp_path: Path) -> None: + """Accept when the confirmation matches the expected phrase. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + """ result = run_confirm(tmp_path, expected="release v1.2.3", confirm="release v1.2.3") assert result.returncode == 0, result.stderr @@ -38,6 +61,13 @@ def test_confirmation_success(tmp_path: Path) -> None: def test_confirmation_failure(tmp_path: Path) -> None: + """Reject confirmation attempts with mismatched input. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + """ result = run_confirm(tmp_path, expected="release v1.2.3", confirm="nope") assert result.returncode == 1 diff --git a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py index ea006ca7..1bc36668 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py @@ -13,6 +13,20 @@ def run_script(script: Path, *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: + """Execute the determine-release script with the provided environment. + + Parameters + ---------- + script : Path + Path to the script to execute with ``uv run``. + env : dict[str, str] + Environment variables to use when invoking the script. + + Returns + ------- + subprocess.CompletedProcess[str] + Result object capturing stdout, stderr, and the return code. + """ cmd = ["uv", "run", "--script", str(script)] return subprocess.run( # noqa: S603 cmd, @@ -26,6 +40,18 @@ def run_script(script: Path, *, env: dict[str, str]) -> subprocess.CompletedProc def base_env(tmp_path: Path) -> dict[str, str]: + """Construct the base environment used by the release script tests. + + Parameters + ---------- + tmp_path : Path + Temporary working directory provided by pytest. + + Returns + ------- + dict[str, str] + Environment mapping that mimics the workflow runtime configuration. + """ merged = {**os.environ} root = str(Path(__file__).resolve().parents[4]) prev = os.environ.get("PYTHONPATH", "") @@ -37,6 +63,18 @@ def base_env(tmp_path: Path) -> dict[str, str]: def read_outputs(tmp_path: Path) -> dict[str, str]: + """Read ``GITHUB_OUTPUT`` key/value pairs written by the script. + + Parameters + ---------- + tmp_path : Path + Temporary working directory containing the output file. + + Returns + ------- + dict[str, str] + Parsed mapping of output names to their recorded values. + """ out = {} output_file = tmp_path / "out.txt" if not output_file.exists(): @@ -49,6 +87,13 @@ def read_outputs(tmp_path: Path) -> dict[str, str]: def test_resolves_tag_from_ref(tmp_path: Path) -> None: + """Resolve the release tag from the Git reference metadata. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + """ env = base_env(tmp_path) env["GITHUB_REF_TYPE"] = "tag" env["GITHUB_REF_NAME"] = "v1.2.3" @@ -63,6 +108,13 @@ def test_resolves_tag_from_ref(tmp_path: Path) -> None: def test_resolves_tag_from_input(tmp_path: Path) -> None: + """Resolve the release tag from the workflow input when provided. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + """ env = base_env(tmp_path) env["INPUT_TAG"] = "v2.0.0" @@ -76,6 +128,13 @@ def test_resolves_tag_from_input(tmp_path: Path) -> None: def test_rejects_invalid_tag(tmp_path: Path) -> None: + """Reject release tags that do not follow the expected SemVer format. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + """ env = base_env(tmp_path) env["GITHUB_REF_TYPE"] = "tag" env["GITHUB_REF_NAME"] = "release-1.0.0" @@ -88,6 +147,13 @@ def test_rejects_invalid_tag(tmp_path: Path) -> None: def test_errors_when_no_tag_and_not_on_tag_ref(tmp_path: Path) -> None: + """Exit with an error when no release tag can be resolved. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + """ env = base_env(tmp_path) env.pop("GITHUB_REF_TYPE", None) env.pop("GITHUB_REF_NAME", None) @@ -101,6 +167,13 @@ def test_errors_when_no_tag_and_not_on_tag_ref(tmp_path: Path) -> None: def test_errors_when_ref_type_missing(tmp_path: Path) -> None: + """Exit with an error when ``GITHUB_REF_TYPE`` is missing. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + """ env = base_env(tmp_path) env.pop("GITHUB_REF_TYPE", None) env["GITHUB_REF_NAME"] = "v1.2.3" @@ -113,6 +186,13 @@ def test_errors_when_ref_type_missing(tmp_path: Path) -> None: def test_errors_when_ref_name_missing(tmp_path: Path) -> None: + """Exit with an error when ``GITHUB_REF_NAME`` is missing. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + """ env = base_env(tmp_path) env["GITHUB_REF_TYPE"] = "tag" env.pop("GITHUB_REF_NAME", None) @@ -125,6 +205,13 @@ def test_errors_when_ref_name_missing(tmp_path: Path) -> None: def test_errors_when_ref_name_empty(tmp_path: Path) -> None: + """Exit with an error when ``GITHUB_REF_NAME`` is empty. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + """ env = base_env(tmp_path) env["GITHUB_REF_TYPE"] = "tag" env["GITHUB_REF_NAME"] = "" @@ -137,6 +224,13 @@ def test_errors_when_ref_name_empty(tmp_path: Path) -> None: def test_errors_on_malformed_version_tag(tmp_path: Path) -> None: + """Exit with an error when the tag omits components of the version. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + """ env = base_env(tmp_path) env["GITHUB_REF_TYPE"] = "tag" env["GITHUB_REF_NAME"] = "v1.2" diff --git a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py index 80616673..5eb0e1d7 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py @@ -12,6 +12,13 @@ @pytest.fixture(name="publish_module") def fixture_publish_module() -> Any: + """Load the ``publish_release`` script and adjust its import path. + + Returns + ------- + Any + Imported module with ``run_cmd`` exposed for monkeypatching. + """ module = load_script_module("publish_release") # Ensure cmd_utils is importable by mimicking script behaviour if str(REPO_ROOT) not in module.sys.path: # type: ignore[attr-defined] @@ -20,6 +27,15 @@ def fixture_publish_module() -> Any: def test_publish_default_index(monkeypatch: pytest.MonkeyPatch, publish_module: Any) -> None: + """Use the default PyPI index when no custom index is provided. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Fixture used to replace ``run_cmd`` during the test. + publish_module : Any + Script module under test. + """ calls: list[list[str]] = [] def fake_run_cmd(args: list[str], **_: object) -> None: @@ -33,6 +49,15 @@ def fake_run_cmd(args: list[str], **_: object) -> None: def test_publish_custom_index(monkeypatch: pytest.MonkeyPatch, publish_module: Any) -> None: + """Invoke ``uv publish`` with the provided custom index. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Fixture used to replace ``run_cmd`` during the test. + publish_module : Any + Script module under test. + """ calls: list[list[str]] = [] def fake_run_cmd(args: list[str], **_: object) -> None: @@ -46,6 +71,15 @@ def fake_run_cmd(args: list[str], **_: object) -> None: def test_publish_run_cmd_error(monkeypatch: pytest.MonkeyPatch, publish_module: Any) -> None: + """Propagate exceptions raised by ``run_cmd``. + + Parameters + ---------- + monkeypatch : pytest.MonkeyPatch + Fixture used to replace ``run_cmd`` during the test. + publish_module : Any + Script module under test. + """ class DummyError(Exception): pass diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index 0cb64e72..5a2c4c0d 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -11,11 +11,32 @@ @pytest.fixture(name="module") def fixture_module() -> Any: + """Load the ``validate_toml_versions`` script for tests. + + Returns + ------- + Any + Imported module object exposing helper functions and the CLI entrypoint. + """ return load_script_module("validate_toml_versions") @pytest.fixture() def project_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Use a temporary directory as the working tree for each test. + + Parameters + ---------- + tmp_path : Path + Temporary directory provided by pytest. + monkeypatch : pytest.MonkeyPatch + Fixture used to update the working directory during the test. + + Returns + ------- + Path + Path to the temporary working tree for the current test. + """ monkeypatch.chdir(tmp_path) return tmp_path @@ -32,6 +53,17 @@ def _invoke_main(module: Any, **kwargs: Any) -> None: def test_passes_when_versions_match(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + """Pass when project versions match the resolved release version. + + Parameters + ---------- + project_root : Path + Temporary project directory for the test run. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures output from the command execution. + """ _write_pyproject( project_root / "pkg", """ @@ -48,6 +80,17 @@ def test_passes_when_versions_match(project_root: Path, module: Any, capsys: pyt def test_fails_on_mismatch(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + """Fail when a TOML file contains a mismatched version string. + + Parameters + ---------- + project_root : Path + Temporary project directory for the test run. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures output from the command execution. + """ _write_pyproject( project_root / "pkg", """ @@ -65,6 +108,17 @@ def test_fails_on_mismatch(project_root: Path, module: Any, capsys: pytest.Captu def test_dynamic_version_failure(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + """Fail when dynamic versions are disallowed and the project uses them. + + Parameters + ---------- + project_root : Path + Temporary project directory for the test run. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures output from the command execution. + """ _write_pyproject( project_root / "pkg", """ @@ -88,6 +142,19 @@ def test_dynamic_version_failure_for_truthy_variants( capsys: pytest.CaptureFixture[str], truthy: str, ) -> None: + """Fail whenever dynamic versions are disallowed with truthy inputs. + + Parameters + ---------- + project_root : Path + Temporary project directory for the test run. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures output from the command execution. + truthy : str + Variant of the ``fail_on_dynamic`` flag expected to trigger a failure. + """ _write_pyproject( project_root / "pkg", """ @@ -105,6 +172,17 @@ def test_dynamic_version_failure_for_truthy_variants( def test_fails_on_parse_error(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: + """Surface parse failures encountered when reading TOML files. + + Parameters + ---------- + project_root : Path + Temporary project directory for the test run. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures output from the command execution. + """ target = project_root / "pkg" target.mkdir() (target / "pyproject.toml").write_text("this is not TOML") @@ -121,6 +199,17 @@ def test_dynamic_version_allowed_when_flag_false( module: Any, capsys: pytest.CaptureFixture[str], ) -> None: + """Allow dynamic versions when the flag explicitly disables failures. + + Parameters + ---------- + project_root : Path + Temporary project directory for the test run. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures output from the command execution. + """ _write_pyproject( project_root / "pkg", """ @@ -143,6 +232,19 @@ def test_dynamic_version_allowed_for_falsey_variants( capsys: pytest.CaptureFixture[str], falsey: str, ) -> None: + """Allow dynamic versions for all supported falsey flag values. + + Parameters + ---------- + project_root : Path + Temporary project directory for the test run. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures output from the command execution. + falsey : str + Representation of ``fail_on_dynamic`` that should be treated as false. + """ _write_pyproject( project_root / "pkg", """ @@ -163,6 +265,17 @@ def test_dynamic_version_allowed_when_flag_unset( module: Any, capsys: pytest.CaptureFixture[str], ) -> None: + """Allow dynamic versions when the flag is omitted entirely. + + Parameters + ---------- + project_root : Path + Temporary project directory for the test run. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures output from the command execution. + """ _write_pyproject( project_root / "pkg", """ @@ -183,6 +296,17 @@ def test_missing_project_section_is_ignored( module: Any, capsys: pytest.CaptureFixture[str], ) -> None: + """Ignore files lacking a ``[project]`` table when validating versions. + + Parameters + ---------- + project_root : Path + Temporary project directory for the test run. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures output from the command execution. + """ _write_pyproject( project_root / "pkg", """ @@ -203,6 +327,17 @@ def test_multiple_toml_files_mixed_validity( module: Any, capsys: pytest.CaptureFixture[str], ) -> None: + """Fail when any discovered TOML file contains a mismatched version. + + Parameters + ---------- + project_root : Path + Temporary project directory for the test run. + module : Any + Script module under test. + capsys : pytest.CaptureFixture[str] + Captures output from the command execution. + """ _write_pyproject( project_root / "pkg_valid", """ @@ -229,9 +364,27 @@ def test_multiple_toml_files_mixed_validity( @pytest.mark.parametrize("value", ["true", "TRUE", "Yes", "1", "on"]) def test_parse_bool_truthy_values(module: Any, value: str) -> None: + """Interpret various truthy strings as ``True`` when parsing flags. + + Parameters + ---------- + module : Any + Script module under test. + value : str + Truthy string representation to parse. + """ assert module._parse_bool(value) is True @pytest.mark.parametrize("value", [None, "", "false", "no", "0", "off", "n"]) def test_parse_bool_falsey_values(module: Any, value: str | None) -> None: + """Interpret falsey strings and ``None`` as ``False`` when parsing flags. + + Parameters + ---------- + module : Any + Script module under test. + value : str | None + Falsey value to parse with ``_parse_bool``. + """ assert module._parse_bool(value) is False diff --git a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py index 04918f9d..985d87fe 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py +++ b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py @@ -12,10 +12,26 @@ @pytest.fixture(name="write_module") def fixture_write_module() -> Any: + """Load the ``write_summary`` script for testing. + + Returns + ------- + Any + Imported module object exposing the ``main`` entrypoint. + """ return load_script_module("write_summary") def test_write_summary_appends_markdown(tmp_path: Path, write_module: Any) -> None: + """Append a new summary when the file is initially empty. + + Parameters + ---------- + tmp_path : Path + Temporary directory containing the summary file. + write_module : Any + Script module under test. + """ summary_path = tmp_path / "summary.md" write_module.main( @@ -32,6 +48,15 @@ def test_write_summary_appends_markdown(tmp_path: Path, write_module: Any) -> No def test_write_summary_handles_existing_content(tmp_path: Path, write_module: Any) -> None: + """Preserve existing summary content while appending new entries. + + Parameters + ---------- + tmp_path : Path + Temporary directory containing the summary file. + write_module : Any + Script module under test. + """ summary_path = tmp_path / "summary.md" summary_path.write_text("Existing\n", encoding="utf-8") @@ -48,6 +73,13 @@ def test_write_summary_handles_existing_content(tmp_path: Path, write_module: An def test_write_summary_raises_on_io_error(write_module: Any) -> None: + """Propagate I/O errors encountered when writing the summary file. + + Parameters + ---------- + write_module : Any + Script module under test. + """ summary_path = Path("/nonexistent/path/summary.md") with pytest.raises(OSError): From eb48dec0518c3215a404b9ff996ceca8a42b10ad Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 21 Sep 2025 15:10:10 +0100 Subject: [PATCH 22/41] Silence type-check import lints for release action (#104) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add toolchain-specific cargo cache key and clean shellstub imports (#88) * Address review feedback for shellstub imports * Fix MSYS2 install comments breaking pacman (#89) * Fix MSYS2 install comments breaking pacman * Fix Windows llvm-mingw metadata export * Handle runtime detection timeouts * Fix rust host detection timeout and Windows bin path (#91) * Fix rust host detection timeout and Windows bin path * Add timeout coverage for rust host detection * Unify runtime probe timeout configuration * test: capture rustc probe kwargs in timeout test * Remove inline comments from Windows GNU MSYS2 package list (#99) * Remove inline MSYS2 install comments * Skip nfpm steps on Windows builds * Handle nfpm packaging only for Linux * Fix Windows invocation of rust-build-release (#102) * Silence type-check import lints for release action * Adds ignore rule for Crush agent artifacts Ignores files generated by the Crush AI agent to prevent them from being tracked in version control. Adds a symlink to AGENTS.md for discovery by Crush * Format code after rebase validation to satisfy repo style checks. 💘 Generated with Crush Co-Authored-By: Crush * fmt: extend fmt target to run ruff check --select D202,I001 --fix; apply resulting fixes 💘 Generated with Crush Co-Authored-By: Crush --------- Co-authored-by: Payton McIntosh Co-authored-by: Crush --- .../scripts/check_github_release.py | 10 +- .../scripts/determine_release.py | 22 +-- .../scripts/validate_toml_versions.py | 18 ++- .../scripts/write_summary.py | 2 +- .../release-to-pypi-uv/tests/_helpers.py | 8 +- .../tests/test_action_python_version.py | 12 +- .../tests/test_check_github_release.py | 137 ++++++------------ .../tests/test_confirm_release.py | 21 +-- .../tests/test_determine_release.py | 19 +-- .../tests/test_publish_release.py | 51 ++----- .../tests/test_validate_toml_versions.py | 112 ++++---------- .../tests/test_write_summary.py | 44 +----- .../tests/test_cross_install.py | 3 +- .../tests/test_target_install.py | 2 +- .../rust-build-release/tests/test_utils.py | 1 - .gitignore | 3 + Makefile | 1 + conftest.py | 6 - 18 files changed, 138 insertions(+), 334 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py index cb02bf25..5617c951 100644 --- a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py @@ -45,7 +45,11 @@ def _fetch_release(repo: str, tag: str, token: str) -> dict[str, object]: payload = response.read().decode("utf-8") break except urllib.error.HTTPError as exc: # pragma: no cover - network failure path - detail = exc.read().decode("utf-8", errors="ignore") if hasattr(exc, "read") else "" + detail = ( + exc.read().decode("utf-8", errors="ignore") + if hasattr(exc, "read") + else "" + ) if exc.code == 404: raise GithubReleaseError( f"No GitHub release found for tag {tag}. Create and publish the release first." @@ -65,7 +69,9 @@ def _fetch_release(repo: str, tag: str, token: str) -> dict[str, object]: delay *= backoff_factor except urllib.error.URLError as exc: # pragma: no cover - network failure path if attempt == max_attempts: - raise GithubReleaseError(f"Failed to reach GitHub API: {exc.reason}") from exc + raise GithubReleaseError( + f"Failed to reach GitHub API: {exc.reason}" + ) from exc time.sleep(delay) delay *= backoff_factor else: # pragma: no cover - loop exhausted without break diff --git a/.github/actions/release-to-pypi-uv/scripts/determine_release.py b/.github/actions/release-to-pypi-uv/scripts/determine_release.py index 39b7e5a9..d432272b 100644 --- a/.github/actions/release-to-pypi-uv/scripts/determine_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/determine_release.py @@ -9,7 +9,7 @@ import os import re -from pathlib import Path +from pathlib import Path # noqa: TC003 # used at runtime for Typer CLI types import typer @@ -23,23 +23,9 @@ def _emit_outputs(dest: Path, tag: str, version: str) -> None: fh.write(f"version={version}\n") -def main(tag: str | None = TAG_OPTION, github_output: Path = GITHUB_OUTPUT_OPTION) -> None: - """Resolve the release tag and write outputs for downstream steps. - - Parameters - ---------- - tag : str | None - Tag supplied via workflow input when the workflow is not running on a - tag reference. - github_output : Path - Path to the ``GITHUB_OUTPUT`` file that receives the resolved values. - - Raises - ------ - typer.Exit - Raised when no tag can be determined or the tag is not SemVer - compliant. - """ +def main( + tag: str | None = TAG_OPTION, github_output: Path = GITHUB_OUTPUT_OPTION +) -> None: ref_type = os.getenv("GITHUB_REF_TYPE", "") ref_name = os.getenv("GITHUB_REF_NAME", "") diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index 0f83bd65..12c8bfe5 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -9,8 +9,8 @@ import glob import os +import typing as typ from pathlib import Path -from typing import Iterable import typer @@ -35,7 +35,7 @@ TRUTHY_STRINGS = {"true", "1", "yes", "y", "on"} -def _iter_files(pattern: str) -> Iterable[Path]: +def _iter_files(pattern: str) -> typ.Iterable[Path]: candidates = [Path(p) for p in glob.glob(pattern, recursive=True)] for path in candidates: if not path.is_file(): @@ -117,11 +117,17 @@ def main( checked += 1 dynamic = project.get("dynamic") - dynamic_set = {str(item) for item in dynamic} if isinstance(dynamic, (list, tuple)) else set() + dynamic_set = ( + {str(item) for item in dynamic} + if isinstance(dynamic, (list, tuple)) + else set() + ) if "version" in dynamic_set: message = f"{path}: uses dynamic 'version' (PEP 621)." if fail_dynamic: - dynamic_errors.append(f"{message} Set fail-on-dynamic-version=false to allow.") + dynamic_errors.append( + f"{message} Set fail-on-dynamic-version=false to allow." + ) else: typer.echo(f"::notice::{message} Skipping version check.") continue @@ -143,7 +149,9 @@ def main( typer.echo(f"::error::{error}", err=True) raise typer.Exit(1) - typer.echo(f"Checked {checked} PEP 621 project file(s); all versions match {version}.") + typer.echo( + f"Checked {checked} PEP 621 project file(s); all versions match {version}." + ) if __name__ == "__main__": diff --git a/.github/actions/release-to-pypi-uv/scripts/write_summary.py b/.github/actions/release-to-pypi-uv/scripts/write_summary.py index 1840c76c..8e385557 100644 --- a/.github/actions/release-to-pypi-uv/scripts/write_summary.py +++ b/.github/actions/release-to-pypi-uv/scripts/write_summary.py @@ -7,7 +7,7 @@ from __future__ import annotations -from pathlib import Path +from pathlib import Path # noqa: TC003 # used at runtime for Typer CLI types import typer diff --git a/.github/actions/release-to-pypi-uv/tests/_helpers.py b/.github/actions/release-to-pypi-uv/tests/_helpers.py index 9045a0f8..bda186a1 100644 --- a/.github/actions/release-to-pypi-uv/tests/_helpers.py +++ b/.github/actions/release-to-pypi-uv/tests/_helpers.py @@ -4,8 +4,8 @@ import importlib.util import os +import typing as typ from pathlib import Path -from typing import Any _ACTION_PATH = os.environ.get("GITHUB_ACTION_PATH") @@ -18,10 +18,12 @@ REPO_ROOT = SCRIPTS_DIR.parents[3] -def load_script_module(name: str) -> Any: +def load_script_module(name: str) -> typ.Any: """Load a script module by *name* from the action's scripts directory.""" script_path = SCRIPTS_DIR / f"{name}.py" - spec = importlib.util.spec_from_file_location(f"release_to_pypi_uv_{name}", script_path) + spec = importlib.util.spec_from_file_location( + f"release_to_pypi_uv_{name}", script_path + ) if spec is None or spec.loader is None: # pragma: no cover - import failure raise RuntimeError(f"Unable to load script module {name} from {script_path}") module = importlib.util.module_from_spec(spec) diff --git a/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py b/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py index 46715e91..af6cfc2d 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py +++ b/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py @@ -2,20 +2,19 @@ from __future__ import annotations +import typing as typ from pathlib import Path -from typing import Any import yaml -def _load_action() -> dict[str, Any]: +def _load_action() -> dict[str, typ.Any]: action_path = Path(__file__).resolve().parents[1] / "action.yml" return yaml.safe_load(action_path.read_text(encoding="utf-8")) def test_action_exposes_python_version_input() -> None: """Unit test: ensure metadata defines python-version with the expected default.""" - data = _load_action() python_version = data["inputs"]["python-version"] assert python_version["default"] == "3.13" @@ -24,7 +23,6 @@ def test_action_exposes_python_version_input() -> None: def test_setup_step_forwards_python_version_input() -> None: """Behavioral test: ensure setup-uv installs the requested interpreter.""" - data = _load_action() steps = data["runs"]["steps"] setup_step = next(step for step in steps if step["name"] == "Setup uv") @@ -33,11 +31,7 @@ def test_setup_step_forwards_python_version_input() -> None: def test_install_step_uses_python_version_input() -> None: """Behavioral test: ensure uv python install receives the requested version.""" - data = _load_action() steps = data["runs"]["steps"] install_step = next(step for step in steps if step["name"] == "Install Python") - assert ( - install_step["run"] - == 'uv python install "${{ inputs.python-version }}"' - ) + assert install_step["run"] == 'uv python install "${{ inputs.python-version }}"' diff --git a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py index 4b4b7b3d..b1209358 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py @@ -4,8 +4,7 @@ import io import json -from pathlib import Path -from typing import Any +import typing as typ import pytest @@ -13,13 +12,18 @@ class DummyResponse: - def __init__(self, payload: dict[str, Any]): + def __init__(self, payload: dict[str, typ.Any]): self._payload = json.dumps(payload).encode("utf-8") - def __enter__(self) -> "DummyResponse": + def __enter__(self) -> DummyResponse: return self - def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, traceback: object | None) -> None: + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback: object | None, + ) -> None: return None def read(self) -> bytes: @@ -27,30 +31,16 @@ def read(self) -> bytes: @pytest.fixture(name="module") -def fixture_module() -> Any: - """Load the ``check_github_release`` script for testing. - - Returns - ------- - Any - Imported module object exposing the ``main`` entrypoint. - """ +def fixture_module() -> typ.Any: return load_script_module("check_github_release") -def test_success(monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], module: Any) -> None: - """Confirm that a published release prints a success message. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Fixture used to replace ``urllib.request.urlopen``. - capsys : pytest.CaptureFixture[str] - Captures standard output and error from the command execution. - module : Any - Script module under test. - """ - def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 +def test_success( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + module: typ.Any, +) -> None: + def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 return DummyResponse({"draft": False, "prerelease": False, "name": "1.2.3"}) monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) @@ -61,19 +51,12 @@ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: A assert "GitHub Release '1.2.3' is published." in captured.out -def test_draft_release(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: - """Fail when the release is still marked as a draft. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Fixture used to replace ``urllib.request.urlopen``. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures emitted error output. - """ - def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 +def test_draft_release( + monkeypatch: pytest.MonkeyPatch, + module: typ.Any, + capsys: pytest.CaptureFixture[str], +) -> None: + def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 return DummyResponse({"draft": True, "prerelease": False, "name": "draft"}) monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) @@ -85,19 +68,12 @@ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: A assert "still a draft" in captured.err -def test_prerelease(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: - """Fail when the release is published as a prerelease. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Fixture used to replace ``urllib.request.urlopen``. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures emitted error output. - """ - def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 +def test_prerelease( + monkeypatch: pytest.MonkeyPatch, + module: typ.Any, + capsys: pytest.CaptureFixture[str], +) -> None: + def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 return DummyResponse({"draft": False, "prerelease": True, "name": "pre"}) monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) @@ -109,19 +85,12 @@ def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: A assert "prerelease" in captured.err -def test_missing_release(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: - """Raise an error when the requested release does not exist. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Fixture used to replace ``urllib.request.urlopen``. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures emitted error output. - """ - def fake_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401 +def test_missing_release( + monkeypatch: pytest.MonkeyPatch, + module: typ.Any, + capsys: pytest.CaptureFixture[str], +) -> None: + def fake_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: ANN401 raise module.urllib.error.HTTPError( url=str(request.full_url), code=404, @@ -139,18 +108,11 @@ def fake_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401 assert "No GitHub release found" in captured.err -def test_permission_denied(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: - """Surface permission errors from the GitHub API. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Fixture used to replace ``urllib.request.urlopen``. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures emitted error output. - """ +def test_permission_denied( + monkeypatch: pytest.MonkeyPatch, + module: typ.Any, + capsys: pytest.CaptureFixture[str], +) -> None: detail = b"forbidden" error = module.urllib.error.HTTPError( url="https://api.github.com", @@ -160,7 +122,7 @@ def test_permission_denied(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: fp=io.BytesIO(detail), ) - def raising_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401 + def raising_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: ANN401 raise error monkeypatch.setattr(module.urllib.request, "urlopen", raising_urlopen) @@ -172,21 +134,14 @@ def raising_urlopen(request: Any, timeout: float = 30) -> Any: # noqa: ANN401 assert "GitHub token lacks permission" in captured.err -def test_retries_then_success(monkeypatch: pytest.MonkeyPatch, module: Any, capsys: pytest.CaptureFixture[str]) -> None: - """Retry transient failures before succeeding. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Fixture used to replace network calls and sleep behaviour. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures command output for assertions. - """ +def test_retries_then_success( + monkeypatch: pytest.MonkeyPatch, + module: typ.Any, + capsys: pytest.CaptureFixture[str], +) -> None: attempts: list[int] = [] - def fake_urlopen(request: Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 + def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 attempts.append(1) if len(attempts) < 3: raise module.urllib.error.URLError("temporary") diff --git a/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py b/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py index a5f49f30..e117a5e2 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py @@ -9,27 +9,12 @@ from .test_determine_release import base_env - pytestmark = REQUIRES_UV -def run_confirm(tmp_path: Path, expected: str, confirm: str) -> subprocess.CompletedProcess[str]: - """Execute the confirmation script with the provided values. - - Parameters - ---------- - tmp_path : Path - Temporary directory used as the working directory for the script. - expected : str - Expected confirmation string supplied via environment variable. - confirm : str - Confirmation value provided to the workflow input. - - Returns - ------- - subprocess.CompletedProcess[str] - Result from invoking the script with ``uv run``. - """ +def run_confirm( + tmp_path: Path, expected: str, confirm: str +) -> subprocess.CompletedProcess[str]: env = base_env(tmp_path) env["EXPECTED"] = expected env["INPUT_CONFIRM"] = confirm diff --git a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py index 1bc36668..acfc9f9a 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py @@ -8,25 +8,12 @@ from shared_actions_conftest import REQUIRES_UV - pytestmark = REQUIRES_UV -def run_script(script: Path, *, env: dict[str, str]) -> subprocess.CompletedProcess[str]: - """Execute the determine-release script with the provided environment. - - Parameters - ---------- - script : Path - Path to the script to execute with ``uv run``. - env : dict[str, str] - Environment variables to use when invoking the script. - - Returns - ------- - subprocess.CompletedProcess[str] - Result object capturing stdout, stderr, and the return code. - """ +def run_script( + script: Path, *, env: dict[str, str] +) -> subprocess.CompletedProcess[str]: cmd = ["uv", "run", "--script", str(script)] return subprocess.run( # noqa: S603 cmd, diff --git a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py index 5eb0e1d7..25d055c7 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py @@ -2,8 +2,8 @@ from __future__ import annotations +import typing as typ from pathlib import Path -from typing import Any import pytest @@ -11,31 +11,16 @@ @pytest.fixture(name="publish_module") -def fixture_publish_module() -> Any: - """Load the ``publish_release`` script and adjust its import path. - - Returns - ------- - Any - Imported module with ``run_cmd`` exposed for monkeypatching. - """ +def fixture_publish_module() -> typ.Any: module = load_script_module("publish_release") - # Ensure cmd_utils is importable by mimicking script behaviour if str(REPO_ROOT) not in module.sys.path: # type: ignore[attr-defined] module.sys.path.insert(0, str(REPO_ROOT)) # type: ignore[attr-defined] return module -def test_publish_default_index(monkeypatch: pytest.MonkeyPatch, publish_module: Any) -> None: - """Use the default PyPI index when no custom index is provided. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Fixture used to replace ``run_cmd`` during the test. - publish_module : Any - Script module under test. - """ +def test_publish_default_index( + monkeypatch: pytest.MonkeyPatch, publish_module: typ.Any +) -> None: calls: list[list[str]] = [] def fake_run_cmd(args: list[str], **_: object) -> None: @@ -48,16 +33,9 @@ def fake_run_cmd(args: list[str], **_: object) -> None: assert calls == [["uv", "publish"]] -def test_publish_custom_index(monkeypatch: pytest.MonkeyPatch, publish_module: Any) -> None: - """Invoke ``uv publish`` with the provided custom index. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Fixture used to replace ``run_cmd`` during the test. - publish_module : Any - Script module under test. - """ +def test_publish_custom_index( + monkeypatch: pytest.MonkeyPatch, publish_module: typ.Any +) -> None: calls: list[list[str]] = [] def fake_run_cmd(args: list[str], **_: object) -> None: @@ -70,16 +48,9 @@ def fake_run_cmd(args: list[str], **_: object) -> None: assert calls == [["uv", "publish", "--index", "testpypi"]] -def test_publish_run_cmd_error(monkeypatch: pytest.MonkeyPatch, publish_module: Any) -> None: - """Propagate exceptions raised by ``run_cmd``. - - Parameters - ---------- - monkeypatch : pytest.MonkeyPatch - Fixture used to replace ``run_cmd`` during the test. - publish_module : Any - Script module under test. - """ +def test_publish_run_cmd_error( + monkeypatch: pytest.MonkeyPatch, publish_module: typ.Any +) -> None: class DummyError(Exception): pass diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index 5a2c4c0d..bd6c9ecc 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -2,22 +2,18 @@ from __future__ import annotations -from pathlib import Path -from typing import Any +import typing as typ + +if typ.TYPE_CHECKING: # pragma: no cover - type hints only + from pathlib import Path import pytest from ._helpers import load_script_module -@pytest.fixture(name="module") -def fixture_module() -> Any: - """Load the ``validate_toml_versions`` script for tests. - Returns - ------- - Any - Imported module object exposing helper functions and the CLI entrypoint. - """ +@pytest.fixture(name="module") +def fixture_module() -> typ.Any: return load_script_module("validate_toml_versions") @@ -46,24 +42,15 @@ def _write_pyproject(base: Path, content: str) -> None: (base / "pyproject.toml").write_text(content.strip()) -def _invoke_main(module: Any, **kwargs: Any) -> None: +def _invoke_main(module: typ.Any, **kwargs: typ.Any) -> None: kwargs.setdefault("pattern", "**/pyproject.toml") kwargs.setdefault("fail_on_dynamic", "false") module.main(**kwargs) -def test_passes_when_versions_match(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: - """Pass when project versions match the resolved release version. - - Parameters - ---------- - project_root : Path - Temporary project directory for the test run. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures output from the command execution. - """ +def test_passes_when_versions_match( + project_root: Path, module: typ.Any, capsys: pytest.CaptureFixture[str] +) -> None: _write_pyproject( project_root / "pkg", """ @@ -79,18 +66,9 @@ def test_passes_when_versions_match(project_root: Path, module: Any, capsys: pyt assert "all versions match 1.0.0" in captured.out -def test_fails_on_mismatch(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: - """Fail when a TOML file contains a mismatched version string. - - Parameters - ---------- - project_root : Path - Temporary project directory for the test run. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures output from the command execution. - """ +def test_fails_on_mismatch( + project_root: Path, module: typ.Any, capsys: pytest.CaptureFixture[str] +) -> None: _write_pyproject( project_root / "pkg", """ @@ -107,18 +85,9 @@ def test_fails_on_mismatch(project_root: Path, module: Any, capsys: pytest.Captu assert "version '1.0.1' != tag version '1.0.0'" in captured.err -def test_dynamic_version_failure(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: - """Fail when dynamic versions are disallowed and the project uses them. - - Parameters - ---------- - project_root : Path - Temporary project directory for the test run. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures output from the command execution. - """ +def test_dynamic_version_failure( + project_root: Path, module: typ.Any, capsys: pytest.CaptureFixture[str] +) -> None: _write_pyproject( project_root / "pkg", """ @@ -138,7 +107,7 @@ def test_dynamic_version_failure(project_root: Path, module: Any, capsys: pytest @pytest.mark.parametrize("truthy", ["true", "TRUE", "Yes", " y ", "1", "On"]) def test_dynamic_version_failure_for_truthy_variants( project_root: Path, - module: Any, + module: typ.Any, capsys: pytest.CaptureFixture[str], truthy: str, ) -> None: @@ -171,18 +140,9 @@ def test_dynamic_version_failure_for_truthy_variants( assert "dynamic 'version'" in captured.err -def test_fails_on_parse_error(project_root: Path, module: Any, capsys: pytest.CaptureFixture[str]) -> None: - """Surface parse failures encountered when reading TOML files. - - Parameters - ---------- - project_root : Path - Temporary project directory for the test run. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures output from the command execution. - """ +def test_fails_on_parse_error( + project_root: Path, module: typ.Any, capsys: pytest.CaptureFixture[str] +) -> None: target = project_root / "pkg" target.mkdir() (target / "pyproject.toml").write_text("this is not TOML") @@ -196,7 +156,7 @@ def test_fails_on_parse_error(project_root: Path, module: Any, capsys: pytest.Ca def test_dynamic_version_allowed_when_flag_false( project_root: Path, - module: Any, + module: typ.Any, capsys: pytest.CaptureFixture[str], ) -> None: """Allow dynamic versions when the flag explicitly disables failures. @@ -228,7 +188,7 @@ def test_dynamic_version_allowed_when_flag_false( @pytest.mark.parametrize("falsey", ["false", "", "no", "0", "off", "n", "False"]) def test_dynamic_version_allowed_for_falsey_variants( project_root: Path, - module: Any, + module: typ.Any, capsys: pytest.CaptureFixture[str], falsey: str, ) -> None: @@ -262,7 +222,7 @@ def test_dynamic_version_allowed_for_falsey_variants( def test_dynamic_version_allowed_when_flag_unset( project_root: Path, - module: Any, + module: typ.Any, capsys: pytest.CaptureFixture[str], ) -> None: """Allow dynamic versions when the flag is omitted entirely. @@ -293,7 +253,7 @@ def test_dynamic_version_allowed_when_flag_unset( def test_missing_project_section_is_ignored( project_root: Path, - module: Any, + module: typ.Any, capsys: pytest.CaptureFixture[str], ) -> None: """Ignore files lacking a ``[project]`` table when validating versions. @@ -324,7 +284,7 @@ def test_missing_project_section_is_ignored( def test_multiple_toml_files_mixed_validity( project_root: Path, - module: Any, + module: typ.Any, capsys: pytest.CaptureFixture[str], ) -> None: """Fail when any discovered TOML file contains a mismatched version. @@ -363,28 +323,10 @@ def test_multiple_toml_files_mixed_validity( @pytest.mark.parametrize("value", ["true", "TRUE", "Yes", "1", "on"]) -def test_parse_bool_truthy_values(module: Any, value: str) -> None: - """Interpret various truthy strings as ``True`` when parsing flags. - - Parameters - ---------- - module : Any - Script module under test. - value : str - Truthy string representation to parse. - """ +def test_parse_bool_truthy_values(module: typ.Any, value: str) -> None: assert module._parse_bool(value) is True @pytest.mark.parametrize("value", [None, "", "false", "no", "0", "off", "n"]) -def test_parse_bool_falsey_values(module: Any, value: str | None) -> None: - """Interpret falsey strings and ``None`` as ``False`` when parsing flags. - - Parameters - ---------- - module : Any - Script module under test. - value : str | None - Falsey value to parse with ``_parse_bool``. - """ +def test_parse_bool_falsey_values(module: typ.Any, value: str | None) -> None: assert module._parse_bool(value) is False diff --git a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py index 985d87fe..ca172b48 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py +++ b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py @@ -2,8 +2,8 @@ from __future__ import annotations +import typing as typ from pathlib import Path -from typing import Any import pytest @@ -11,27 +11,11 @@ @pytest.fixture(name="write_module") -def fixture_write_module() -> Any: - """Load the ``write_summary`` script for testing. - - Returns - ------- - Any - Imported module object exposing the ``main`` entrypoint. - """ +def fixture_write_module() -> typ.Any: return load_script_module("write_summary") -def test_write_summary_appends_markdown(tmp_path: Path, write_module: Any) -> None: - """Append a new summary when the file is initially empty. - - Parameters - ---------- - tmp_path : Path - Temporary directory containing the summary file. - write_module : Any - Script module under test. - """ +def test_write_summary_appends_markdown(tmp_path: Path, write_module: typ.Any) -> None: summary_path = tmp_path / "summary.md" write_module.main( @@ -47,16 +31,9 @@ def test_write_summary_appends_markdown(tmp_path: Path, write_module: Any) -> No assert "- Publish index: pypi (default)" in content -def test_write_summary_handles_existing_content(tmp_path: Path, write_module: Any) -> None: - """Preserve existing summary content while appending new entries. - - Parameters - ---------- - tmp_path : Path - Temporary directory containing the summary file. - write_module : Any - Script module under test. - """ +def test_write_summary_handles_existing_content( + tmp_path: Path, write_module: typ.Any +) -> None: summary_path = tmp_path / "summary.md" summary_path.write_text("Existing\n", encoding="utf-8") @@ -72,14 +49,7 @@ def test_write_summary_handles_existing_content(tmp_path: Path, write_module: An assert content.count("## Release summary") == 1 -def test_write_summary_raises_on_io_error(write_module: Any) -> None: - """Propagate I/O errors encountered when writing the summary file. - - Parameters - ---------- - write_module : Any - Script module under test. - """ +def test_write_summary_raises_on_io_error(write_module: typ.Any) -> None: summary_path = Path("/nonexistent/path/summary.md") with pytest.raises(OSError): diff --git a/.github/actions/rust-build-release/tests/test_cross_install.py b/.github/actions/rust-build-release/tests/test_cross_install.py index 08b17eda..697450f4 100644 --- a/.github/actions/rust-build-release/tests/test_cross_install.py +++ b/.github/actions/rust-build-release/tests/test_cross_install.py @@ -9,7 +9,6 @@ import zipfile import pytest - from shared_actions_conftest import ( CMD_MOX_UNSUPPORTED, _register_cross_version_stub, @@ -22,6 +21,8 @@ from types import ModuleType from .conftest import HarnessFactory + + @CMD_MOX_UNSUPPORTED def test_installs_cross_when_missing( cross_module: ModuleType, diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index 8379978e..40ac70d8 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -5,7 +5,6 @@ import typing as typ import pytest - from shared_actions_conftest import ( CMD_MOX_UNSUPPORTED, _register_cross_version_stub, @@ -19,6 +18,7 @@ from .conftest import HarnessFactory + @CMD_MOX_UNSUPPORTED def test_skips_target_install_when_cross_available( main_module: ModuleType, diff --git a/.github/actions/rust-build-release/tests/test_utils.py b/.github/actions/rust-build-release/tests/test_utils.py index b46ceb69..b7c11c00 100644 --- a/.github/actions/rust-build-release/tests/test_utils.py +++ b/.github/actions/rust-build-release/tests/test_utils.py @@ -7,7 +7,6 @@ from pathlib import Path import pytest - from shared_actions_conftest import CMD_MOX_UNSUPPORTED if typ.TYPE_CHECKING: diff --git a/.gitignore b/.gitignore index eb7b2ad3..9eebcdb9 100644 --- a/.gitignore +++ b/.gitignore @@ -155,3 +155,6 @@ target/ # uv cache and lockfile uv.lock .uv/ + +# Crush AI agent +.crush/ diff --git a/Makefile b/Makefile index e699f688..aafb39ec 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ typecheck: .venv ## Run static type checking with Ty uvx pyright fmt: ## Apply formatting to Python files uvx ruff format + uvx ruff check --select D202,I001 --fix check-fmt: ## Check Python formatting without modifying files uvx ruff format --check diff --git a/conftest.py b/conftest.py index 371bcae4..193719c8 100644 --- a/conftest.py +++ b/conftest.py @@ -9,7 +9,6 @@ import pytest - CMD_MOX_UNSUPPORTED = pytest.mark.skipif( sys.platform == "win32", reason="cmd-mox does not support Windows" ) @@ -23,7 +22,6 @@ @pytest.fixture() def require_uv() -> None: """Skip tests that exercise uv when the CLI is unavailable.""" - if not HAS_UV: pytest.skip("uv CLI not installed") @@ -32,7 +30,6 @@ def _register_cross_version_stub( cmd_mox, stdout: str | cabc.Iterable[str] = "cross 0.2.5\n" ) -> str: """Register a stub for ``cross --version`` and return the shim path.""" - if isinstance(stdout, str): cmd_mox.stub("cross").with_args("--version").returns(stdout=stdout) else: @@ -51,7 +48,6 @@ def _register_rustup_toolchain_stub( cmd_mox, stdout: str ) -> str: # pragma: no cover - helper """Register a stub for ``rustup toolchain list`` and return the shim path.""" - cmd_mox.stub("rustup").with_args("toolchain", "list").returns(stdout=stdout) return str(cmd_mox.environment.shim_dir / "rustup") @@ -60,7 +56,6 @@ def _register_docker_info_stub( cmd_mox, *, exit_code: int = 0 ) -> str: # pragma: no cover - helper """Register a stub for ``docker info`` and return the shim path.""" - cmd_mox.stub("docker").with_args("info").returns(exit_code=exit_code) return str(cmd_mox.environment.shim_dir / "docker") @@ -72,5 +67,4 @@ def _register_docker_info_stub( @pytest.fixture() def cmd_mox(): # pragma: win32 no cover - fixture only used on Windows """Skip tests that rely on cmd-mox on Windows.""" - pytest.skip("cmd-mox does not support Windows") From 551b45376d4e17400e1df575778b42dcf42dfa44 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 21 Sep 2025 15:28:44 +0100 Subject: [PATCH 23/41] Narrow module fixtures to ModuleType (#105) --- .../release-to-pypi-uv/tests/_helpers.py | 3 +- .../tests/test_check_github_release.py | 15 +++++----- .../tests/test_publish_release.py | 9 +++--- .../tests/test_validate_toml_versions.py | 29 ++++++++++--------- .../tests/test_write_summary.py | 9 +++--- 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/tests/_helpers.py b/.github/actions/release-to-pypi-uv/tests/_helpers.py index bda186a1..78728253 100644 --- a/.github/actions/release-to-pypi-uv/tests/_helpers.py +++ b/.github/actions/release-to-pypi-uv/tests/_helpers.py @@ -6,6 +6,7 @@ import os import typing as typ from pathlib import Path +from types import ModuleType _ACTION_PATH = os.environ.get("GITHUB_ACTION_PATH") @@ -18,7 +19,7 @@ REPO_ROOT = SCRIPTS_DIR.parents[3] -def load_script_module(name: str) -> typ.Any: +def load_script_module(name: str) -> ModuleType: """Load a script module by *name* from the action's scripts directory.""" script_path = SCRIPTS_DIR / f"{name}.py" spec = importlib.util.spec_from_file_location( diff --git a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py index b1209358..311f60b6 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py @@ -5,6 +5,7 @@ import io import json import typing as typ +from types import ModuleType import pytest @@ -31,14 +32,14 @@ def read(self) -> bytes: @pytest.fixture(name="module") -def fixture_module() -> typ.Any: +def fixture_module() -> ModuleType: return load_script_module("check_github_release") def test_success( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], - module: typ.Any, + module: ModuleType, ) -> None: def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 return DummyResponse({"draft": False, "prerelease": False, "name": "1.2.3"}) @@ -53,7 +54,7 @@ def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noq def test_draft_release( monkeypatch: pytest.MonkeyPatch, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 @@ -70,7 +71,7 @@ def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noq def test_prerelease( monkeypatch: pytest.MonkeyPatch, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 @@ -87,7 +88,7 @@ def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noq def test_missing_release( monkeypatch: pytest.MonkeyPatch, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: def fake_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: ANN401 @@ -110,7 +111,7 @@ def fake_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: ANN def test_permission_denied( monkeypatch: pytest.MonkeyPatch, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: detail = b"forbidden" @@ -136,7 +137,7 @@ def raising_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: def test_retries_then_success( monkeypatch: pytest.MonkeyPatch, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: attempts: list[int] = [] diff --git a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py index 25d055c7..b9374638 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py @@ -4,6 +4,7 @@ import typing as typ from pathlib import Path +from types import ModuleType import pytest @@ -11,7 +12,7 @@ @pytest.fixture(name="publish_module") -def fixture_publish_module() -> typ.Any: +def fixture_publish_module() -> ModuleType: module = load_script_module("publish_release") if str(REPO_ROOT) not in module.sys.path: # type: ignore[attr-defined] module.sys.path.insert(0, str(REPO_ROOT)) # type: ignore[attr-defined] @@ -19,7 +20,7 @@ def fixture_publish_module() -> typ.Any: def test_publish_default_index( - monkeypatch: pytest.MonkeyPatch, publish_module: typ.Any + monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType ) -> None: calls: list[list[str]] = [] @@ -34,7 +35,7 @@ def fake_run_cmd(args: list[str], **_: object) -> None: def test_publish_custom_index( - monkeypatch: pytest.MonkeyPatch, publish_module: typ.Any + monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType ) -> None: calls: list[list[str]] = [] @@ -49,7 +50,7 @@ def fake_run_cmd(args: list[str], **_: object) -> None: def test_publish_run_cmd_error( - monkeypatch: pytest.MonkeyPatch, publish_module: typ.Any + monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType ) -> None: class DummyError(Exception): pass diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index bd6c9ecc..dc299693 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -3,6 +3,7 @@ from __future__ import annotations import typing as typ +from types import ModuleType if typ.TYPE_CHECKING: # pragma: no cover - type hints only from pathlib import Path @@ -13,7 +14,7 @@ @pytest.fixture(name="module") -def fixture_module() -> typ.Any: +def fixture_module() -> ModuleType: return load_script_module("validate_toml_versions") @@ -42,14 +43,14 @@ def _write_pyproject(base: Path, content: str) -> None: (base / "pyproject.toml").write_text(content.strip()) -def _invoke_main(module: typ.Any, **kwargs: typ.Any) -> None: +def _invoke_main(module: ModuleType, **kwargs: str) -> None: kwargs.setdefault("pattern", "**/pyproject.toml") kwargs.setdefault("fail_on_dynamic", "false") module.main(**kwargs) def test_passes_when_versions_match( - project_root: Path, module: typ.Any, capsys: pytest.CaptureFixture[str] + project_root: Path, module: ModuleType, capsys: pytest.CaptureFixture[str] ) -> None: _write_pyproject( project_root / "pkg", @@ -67,7 +68,7 @@ def test_passes_when_versions_match( def test_fails_on_mismatch( - project_root: Path, module: typ.Any, capsys: pytest.CaptureFixture[str] + project_root: Path, module: ModuleType, capsys: pytest.CaptureFixture[str] ) -> None: _write_pyproject( project_root / "pkg", @@ -86,7 +87,7 @@ def test_fails_on_mismatch( def test_dynamic_version_failure( - project_root: Path, module: typ.Any, capsys: pytest.CaptureFixture[str] + project_root: Path, module: ModuleType, capsys: pytest.CaptureFixture[str] ) -> None: _write_pyproject( project_root / "pkg", @@ -107,7 +108,7 @@ def test_dynamic_version_failure( @pytest.mark.parametrize("truthy", ["true", "TRUE", "Yes", " y ", "1", "On"]) def test_dynamic_version_failure_for_truthy_variants( project_root: Path, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], truthy: str, ) -> None: @@ -141,7 +142,7 @@ def test_dynamic_version_failure_for_truthy_variants( def test_fails_on_parse_error( - project_root: Path, module: typ.Any, capsys: pytest.CaptureFixture[str] + project_root: Path, module: ModuleType, capsys: pytest.CaptureFixture[str] ) -> None: target = project_root / "pkg" target.mkdir() @@ -156,7 +157,7 @@ def test_fails_on_parse_error( def test_dynamic_version_allowed_when_flag_false( project_root: Path, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: """Allow dynamic versions when the flag explicitly disables failures. @@ -188,7 +189,7 @@ def test_dynamic_version_allowed_when_flag_false( @pytest.mark.parametrize("falsey", ["false", "", "no", "0", "off", "n", "False"]) def test_dynamic_version_allowed_for_falsey_variants( project_root: Path, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], falsey: str, ) -> None: @@ -222,7 +223,7 @@ def test_dynamic_version_allowed_for_falsey_variants( def test_dynamic_version_allowed_when_flag_unset( project_root: Path, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: """Allow dynamic versions when the flag is omitted entirely. @@ -253,7 +254,7 @@ def test_dynamic_version_allowed_when_flag_unset( def test_missing_project_section_is_ignored( project_root: Path, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: """Ignore files lacking a ``[project]`` table when validating versions. @@ -284,7 +285,7 @@ def test_missing_project_section_is_ignored( def test_multiple_toml_files_mixed_validity( project_root: Path, - module: typ.Any, + module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: """Fail when any discovered TOML file contains a mismatched version. @@ -323,10 +324,10 @@ def test_multiple_toml_files_mixed_validity( @pytest.mark.parametrize("value", ["true", "TRUE", "Yes", "1", "on"]) -def test_parse_bool_truthy_values(module: typ.Any, value: str) -> None: +def test_parse_bool_truthy_values(module: ModuleType, value: str) -> None: assert module._parse_bool(value) is True @pytest.mark.parametrize("value", [None, "", "false", "no", "0", "off", "n"]) -def test_parse_bool_falsey_values(module: typ.Any, value: str | None) -> None: +def test_parse_bool_falsey_values(module: ModuleType, value: str | None) -> None: assert module._parse_bool(value) is False diff --git a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py index ca172b48..7b48ea92 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py +++ b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py @@ -4,6 +4,7 @@ import typing as typ from pathlib import Path +from types import ModuleType import pytest @@ -11,11 +12,11 @@ @pytest.fixture(name="write_module") -def fixture_write_module() -> typ.Any: +def fixture_write_module() -> ModuleType: return load_script_module("write_summary") -def test_write_summary_appends_markdown(tmp_path: Path, write_module: typ.Any) -> None: +def test_write_summary_appends_markdown(tmp_path: Path, write_module: ModuleType) -> None: summary_path = tmp_path / "summary.md" write_module.main( @@ -32,7 +33,7 @@ def test_write_summary_appends_markdown(tmp_path: Path, write_module: typ.Any) - def test_write_summary_handles_existing_content( - tmp_path: Path, write_module: typ.Any + tmp_path: Path, write_module: ModuleType ) -> None: summary_path = tmp_path / "summary.md" summary_path.write_text("Existing\n", encoding="utf-8") @@ -49,7 +50,7 @@ def test_write_summary_handles_existing_content( assert content.count("## Release summary") == 1 -def test_write_summary_raises_on_io_error(write_module: typ.Any) -> None: +def test_write_summary_raises_on_io_error(write_module: ModuleType) -> None: summary_path = Path("/nonexistent/path/summary.md") with pytest.raises(OSError): From 33bbd80eefaa0d82c7a852d8bf785bab4e895da7 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 21 Sep 2025 17:18:21 +0100 Subject: [PATCH 24/41] Add docstrings for release-to-pypi-uv tests and helpers (#106) * Add missing docstrings for release-to-pypi-uv tests * Simplify cmd-mox typing and streamline docstrings * Apply formatting * chore: remove unused imports across scripts and tests * style(_helpers): alphabetise __all__ tuple * tests: drop superfluous parentheses from @pytest.fixture usage --------- Co-authored-by: Payton McIntosh --- .../scripts/determine_release.py | 15 +++ .../scripts/validate_toml_versions.py | 1 - .../release-to-pypi-uv/tests/__init__.py | 1 + .../release-to-pypi-uv/tests/_helpers.py | 3 +- .../tests/test_check_github_release.py | 19 +++- .../tests/test_confirm_release.py | 17 +-- .../tests/test_determine_release.py | 91 ++-------------- .../tests/test_publish_release.py | 7 +- .../tests/test_validate_toml_versions.py | 102 +++--------------- .../tests/test_write_summary.py | 9 +- .../tests/test_cross_install.py | 16 +-- .../tests/test_target_install.py | 10 +- .../rust-build-release/tests/test_utils.py | 6 +- conftest.py | 64 +++++++++-- 14 files changed, 153 insertions(+), 208 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/determine_release.py b/.github/actions/release-to-pypi-uv/scripts/determine_release.py index d432272b..5068e641 100644 --- a/.github/actions/release-to-pypi-uv/scripts/determine_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/determine_release.py @@ -26,6 +26,21 @@ def _emit_outputs(dest: Path, tag: str, version: str) -> None: def main( tag: str | None = TAG_OPTION, github_output: Path = GITHUB_OUTPUT_OPTION ) -> None: + """Resolve the release tag for the workflow execution. + + Parameters + ---------- + tag : str or None + Optional release tag supplied via the action input or CLI argument. + github_output : Path + Destination file used to communicate outputs to GitHub Actions. + + Raises + ------ + typer.Exit + If a tag cannot be resolved or does not follow the ``vMAJOR.MINOR.PATCH`` + semantic versioning format. + """ ref_type = os.getenv("GITHUB_REF_TYPE", "") ref_name = os.getenv("GITHUB_REF_NAME", "") diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index 12c8bfe5..68c4c838 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -8,7 +8,6 @@ from __future__ import annotations import glob -import os import typing as typ from pathlib import Path diff --git a/.github/actions/release-to-pypi-uv/tests/__init__.py b/.github/actions/release-to-pypi-uv/tests/__init__.py index e69de29b..406af808 100644 --- a/.github/actions/release-to-pypi-uv/tests/__init__.py +++ b/.github/actions/release-to-pypi-uv/tests/__init__.py @@ -0,0 +1 @@ +"""Test helpers for the release-to-pypi-uv action.""" diff --git a/.github/actions/release-to-pypi-uv/tests/_helpers.py b/.github/actions/release-to-pypi-uv/tests/_helpers.py index 78728253..3c8d071c 100644 --- a/.github/actions/release-to-pypi-uv/tests/_helpers.py +++ b/.github/actions/release-to-pypi-uv/tests/_helpers.py @@ -4,7 +4,6 @@ import importlib.util import os -import typing as typ from pathlib import Path from types import ModuleType @@ -32,4 +31,4 @@ def load_script_module(name: str) -> ModuleType: return module -__all__ = ["load_script_module", "REPO_ROOT", "SCRIPTS_DIR"] +__all__ = ["REPO_ROOT", "SCRIPTS_DIR", "load_script_module"] diff --git a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py index 311f60b6..6ae06fc9 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py @@ -13,10 +13,14 @@ class DummyResponse: + """In-memory substitute for an ``urllib`` HTTP response.""" + def __init__(self, payload: dict[str, typ.Any]): + """Store the JSON payload returned by the fake response.""" self._payload = json.dumps(payload).encode("utf-8") def __enter__(self) -> DummyResponse: + """Return the response instance for context manager usage.""" return self def __exit__( @@ -25,14 +29,17 @@ def __exit__( exc: BaseException | None, traceback: object | None, ) -> None: - return None + """Propagate exceptions raised within the context manager.""" + return def read(self) -> bytes: + """Return the cached payload bytes.""" return self._payload @pytest.fixture(name="module") def fixture_module() -> ModuleType: + """Load the ``check_github_release`` script module under test.""" return load_script_module("check_github_release") @@ -41,6 +48,8 @@ def test_success( capsys: pytest.CaptureFixture[str], module: ModuleType, ) -> None: + """Print a success message when GitHub marks the release as published.""" + def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 return DummyResponse({"draft": False, "prerelease": False, "name": "1.2.3"}) @@ -57,6 +66,8 @@ def test_draft_release( module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: + """Exit with an error when GitHub reports the release as a draft.""" + def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 return DummyResponse({"draft": True, "prerelease": False, "name": "draft"}) @@ -74,6 +85,8 @@ def test_prerelease( module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: + """Exit with an error when GitHub flags the release as a prerelease.""" + def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 return DummyResponse({"draft": False, "prerelease": True, "name": "pre"}) @@ -91,6 +104,8 @@ def test_missing_release( module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: + """Raise an error when the GitHub API cannot find the release.""" + def fake_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: ANN401 raise module.urllib.error.HTTPError( url=str(request.full_url), @@ -114,6 +129,7 @@ def test_permission_denied( module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: + """Exit with a helpful error when GitHub responds with 403 Forbidden.""" detail = b"forbidden" error = module.urllib.error.HTTPError( url="https://api.github.com", @@ -140,6 +156,7 @@ def test_retries_then_success( module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: + """Retry transient HTTP failures until GitHub releases the metadata.""" attempts: list[int] = [] def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 diff --git a/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py b/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py index e117a5e2..a7a5d2ba 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_confirm_release.py @@ -15,6 +15,7 @@ def run_confirm( tmp_path: Path, expected: str, confirm: str ) -> subprocess.CompletedProcess[str]: + """Run the ``confirm_release`` script with explicit confirmation inputs.""" env = base_env(tmp_path) env["EXPECTED"] = expected env["INPUT_CONFIRM"] = confirm @@ -32,13 +33,7 @@ def run_confirm( def test_confirmation_success(tmp_path: Path) -> None: - """Accept when the confirmation matches the expected phrase. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - """ + """Accept matching confirmation phrases.""" result = run_confirm(tmp_path, expected="release v1.2.3", confirm="release v1.2.3") assert result.returncode == 0, result.stderr @@ -46,13 +41,7 @@ def test_confirmation_success(tmp_path: Path) -> None: def test_confirmation_failure(tmp_path: Path) -> None: - """Reject confirmation attempts with mismatched input. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - """ + """Reject confirmation attempts with mismatched input.""" result = run_confirm(tmp_path, expected="release v1.2.3", confirm="nope") assert result.returncode == 1 diff --git a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py index acfc9f9a..dd93180a 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py @@ -14,6 +14,7 @@ def run_script( script: Path, *, env: dict[str, str] ) -> subprocess.CompletedProcess[str]: + """Execute ``determine_release`` with a controlled environment.""" cmd = ["uv", "run", "--script", str(script)] return subprocess.run( # noqa: S603 cmd, @@ -27,18 +28,7 @@ def run_script( def base_env(tmp_path: Path) -> dict[str, str]: - """Construct the base environment used by the release script tests. - - Parameters - ---------- - tmp_path : Path - Temporary working directory provided by pytest. - - Returns - ------- - dict[str, str] - Environment mapping that mimics the workflow runtime configuration. - """ + """Construct the base environment shared by the release script tests.""" merged = {**os.environ} root = str(Path(__file__).resolve().parents[4]) prev = os.environ.get("PYTHONPATH", "") @@ -50,18 +40,7 @@ def base_env(tmp_path: Path) -> dict[str, str]: def read_outputs(tmp_path: Path) -> dict[str, str]: - """Read ``GITHUB_OUTPUT`` key/value pairs written by the script. - - Parameters - ---------- - tmp_path : Path - Temporary working directory containing the output file. - - Returns - ------- - dict[str, str] - Parsed mapping of output names to their recorded values. - """ + """Return ``GITHUB_OUTPUT`` key/value pairs emitted by the script.""" out = {} output_file = tmp_path / "out.txt" if not output_file.exists(): @@ -74,13 +53,7 @@ def read_outputs(tmp_path: Path) -> dict[str, str]: def test_resolves_tag_from_ref(tmp_path: Path) -> None: - """Resolve the release tag from the Git reference metadata. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - """ + """Derive the release tag from Git reference metadata.""" env = base_env(tmp_path) env["GITHUB_REF_TYPE"] = "tag" env["GITHUB_REF_NAME"] = "v1.2.3" @@ -95,13 +68,7 @@ def test_resolves_tag_from_ref(tmp_path: Path) -> None: def test_resolves_tag_from_input(tmp_path: Path) -> None: - """Resolve the release tag from the workflow input when provided. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - """ + """Derive the release tag from the workflow input when present.""" env = base_env(tmp_path) env["INPUT_TAG"] = "v2.0.0" @@ -115,13 +82,7 @@ def test_resolves_tag_from_input(tmp_path: Path) -> None: def test_rejects_invalid_tag(tmp_path: Path) -> None: - """Reject release tags that do not follow the expected SemVer format. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - """ + """Reject release tags that do not follow the expected SemVer format.""" env = base_env(tmp_path) env["GITHUB_REF_TYPE"] = "tag" env["GITHUB_REF_NAME"] = "release-1.0.0" @@ -134,13 +95,7 @@ def test_rejects_invalid_tag(tmp_path: Path) -> None: def test_errors_when_no_tag_and_not_on_tag_ref(tmp_path: Path) -> None: - """Exit with an error when no release tag can be resolved. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - """ + """Fail when neither Git metadata nor inputs provide a tag.""" env = base_env(tmp_path) env.pop("GITHUB_REF_TYPE", None) env.pop("GITHUB_REF_NAME", None) @@ -154,13 +109,7 @@ def test_errors_when_no_tag_and_not_on_tag_ref(tmp_path: Path) -> None: def test_errors_when_ref_type_missing(tmp_path: Path) -> None: - """Exit with an error when ``GITHUB_REF_TYPE`` is missing. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - """ + """Fail when ``GITHUB_REF_TYPE`` is absent.""" env = base_env(tmp_path) env.pop("GITHUB_REF_TYPE", None) env["GITHUB_REF_NAME"] = "v1.2.3" @@ -173,13 +122,7 @@ def test_errors_when_ref_type_missing(tmp_path: Path) -> None: def test_errors_when_ref_name_missing(tmp_path: Path) -> None: - """Exit with an error when ``GITHUB_REF_NAME`` is missing. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - """ + """Fail when ``GITHUB_REF_NAME`` is not provided.""" env = base_env(tmp_path) env["GITHUB_REF_TYPE"] = "tag" env.pop("GITHUB_REF_NAME", None) @@ -192,13 +135,7 @@ def test_errors_when_ref_name_missing(tmp_path: Path) -> None: def test_errors_when_ref_name_empty(tmp_path: Path) -> None: - """Exit with an error when ``GITHUB_REF_NAME`` is empty. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - """ + """Fail when ``GITHUB_REF_NAME`` is empty.""" env = base_env(tmp_path) env["GITHUB_REF_TYPE"] = "tag" env["GITHUB_REF_NAME"] = "" @@ -211,13 +148,7 @@ def test_errors_when_ref_name_empty(tmp_path: Path) -> None: def test_errors_on_malformed_version_tag(tmp_path: Path) -> None: - """Exit with an error when the tag omits components of the version. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - """ + """Fail when the release tag omits version components.""" env = base_env(tmp_path) env["GITHUB_REF_TYPE"] = "tag" env["GITHUB_REF_NAME"] = "v1.2" diff --git a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py index b9374638..b1e5228f 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py @@ -2,8 +2,6 @@ from __future__ import annotations -import typing as typ -from pathlib import Path from types import ModuleType import pytest @@ -13,6 +11,7 @@ @pytest.fixture(name="publish_module") def fixture_publish_module() -> ModuleType: + """Load the ``publish_release`` script module with repository paths set.""" module = load_script_module("publish_release") if str(REPO_ROOT) not in module.sys.path: # type: ignore[attr-defined] module.sys.path.insert(0, str(REPO_ROOT)) # type: ignore[attr-defined] @@ -22,6 +21,7 @@ def fixture_publish_module() -> ModuleType: def test_publish_default_index( monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType ) -> None: + """Invoke ``uv publish`` without an index when none is provided.""" calls: list[list[str]] = [] def fake_run_cmd(args: list[str], **_: object) -> None: @@ -37,6 +37,7 @@ def fake_run_cmd(args: list[str], **_: object) -> None: def test_publish_custom_index( monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType ) -> None: + """Add the ``--index`` flag when a custom index value is supplied.""" calls: list[list[str]] = [] def fake_run_cmd(args: list[str], **_: object) -> None: @@ -52,6 +53,8 @@ def fake_run_cmd(args: list[str], **_: object) -> None: def test_publish_run_cmd_error( monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType ) -> None: + """Propagate errors raised by ``run_cmd`` during publishing.""" + class DummyError(Exception): pass diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index dc299693..8d48526c 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -15,35 +15,25 @@ @pytest.fixture(name="module") def fixture_module() -> ModuleType: + """Load the ``validate_toml_versions`` script module under test.""" return load_script_module("validate_toml_versions") -@pytest.fixture() +@pytest.fixture def project_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: - """Use a temporary directory as the working tree for each test. - - Parameters - ---------- - tmp_path : Path - Temporary directory provided by pytest. - monkeypatch : pytest.MonkeyPatch - Fixture used to update the working directory during the test. - - Returns - ------- - Path - Path to the temporary working tree for the current test. - """ + """Use a temporary directory as the working tree for each test.""" monkeypatch.chdir(tmp_path) return tmp_path def _write_pyproject(base: Path, content: str) -> None: + """Create a ``pyproject.toml`` file populated with the provided content.""" base.mkdir() (base / "pyproject.toml").write_text(content.strip()) def _invoke_main(module: ModuleType, **kwargs: str) -> None: + """Invoke ``module.main`` with defaults tailored for the tests.""" kwargs.setdefault("pattern", "**/pyproject.toml") kwargs.setdefault("fail_on_dynamic", "false") module.main(**kwargs) @@ -52,6 +42,7 @@ def _invoke_main(module: ModuleType, **kwargs: str) -> None: def test_passes_when_versions_match( project_root: Path, module: ModuleType, capsys: pytest.CaptureFixture[str] ) -> None: + """Succeed when all discovered packages match the expected version.""" _write_pyproject( project_root / "pkg", """ @@ -70,6 +61,7 @@ def test_passes_when_versions_match( def test_fails_on_mismatch( project_root: Path, module: ModuleType, capsys: pytest.CaptureFixture[str] ) -> None: + """Fail when a package declares a version that differs from the tag.""" _write_pyproject( project_root / "pkg", """ @@ -89,6 +81,7 @@ def test_fails_on_mismatch( def test_dynamic_version_failure( project_root: Path, module: ModuleType, capsys: pytest.CaptureFixture[str] ) -> None: + """Fail when dynamic versions are disallowed but present in metadata.""" _write_pyproject( project_root / "pkg", """ @@ -112,19 +105,7 @@ def test_dynamic_version_failure_for_truthy_variants( capsys: pytest.CaptureFixture[str], truthy: str, ) -> None: - """Fail whenever dynamic versions are disallowed with truthy inputs. - - Parameters - ---------- - project_root : Path - Temporary project directory for the test run. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures output from the command execution. - truthy : str - Variant of the ``fail_on_dynamic`` flag expected to trigger a failure. - """ + """Fail whenever dynamic versions are disallowed with truthy inputs.""" _write_pyproject( project_root / "pkg", """ @@ -144,6 +125,7 @@ def test_dynamic_version_failure_for_truthy_variants( def test_fails_on_parse_error( project_root: Path, module: ModuleType, capsys: pytest.CaptureFixture[str] ) -> None: + """Fail gracefully when the TOML configuration cannot be parsed.""" target = project_root / "pkg" target.mkdir() (target / "pyproject.toml").write_text("this is not TOML") @@ -160,17 +142,7 @@ def test_dynamic_version_allowed_when_flag_false( module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: - """Allow dynamic versions when the flag explicitly disables failures. - - Parameters - ---------- - project_root : Path - Temporary project directory for the test run. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures output from the command execution. - """ + """Allow dynamic versions when the flag explicitly disables failures.""" _write_pyproject( project_root / "pkg", """ @@ -193,19 +165,7 @@ def test_dynamic_version_allowed_for_falsey_variants( capsys: pytest.CaptureFixture[str], falsey: str, ) -> None: - """Allow dynamic versions for all supported falsey flag values. - - Parameters - ---------- - project_root : Path - Temporary project directory for the test run. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures output from the command execution. - falsey : str - Representation of ``fail_on_dynamic`` that should be treated as false. - """ + """Allow dynamic versions for all supported falsey flag values.""" _write_pyproject( project_root / "pkg", """ @@ -226,17 +186,7 @@ def test_dynamic_version_allowed_when_flag_unset( module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: - """Allow dynamic versions when the flag is omitted entirely. - - Parameters - ---------- - project_root : Path - Temporary project directory for the test run. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures output from the command execution. - """ + """Allow dynamic versions when the flag is omitted entirely.""" _write_pyproject( project_root / "pkg", """ @@ -257,17 +207,7 @@ def test_missing_project_section_is_ignored( module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: - """Ignore files lacking a ``[project]`` table when validating versions. - - Parameters - ---------- - project_root : Path - Temporary project directory for the test run. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures output from the command execution. - """ + """Ignore files lacking a ``[project]`` table when validating versions.""" _write_pyproject( project_root / "pkg", """ @@ -288,17 +228,7 @@ def test_multiple_toml_files_mixed_validity( module: ModuleType, capsys: pytest.CaptureFixture[str], ) -> None: - """Fail when any discovered TOML file contains a mismatched version. - - Parameters - ---------- - project_root : Path - Temporary project directory for the test run. - module : Any - Script module under test. - capsys : pytest.CaptureFixture[str] - Captures output from the command execution. - """ + """Fail when any discovered TOML file contains a mismatched version.""" _write_pyproject( project_root / "pkg_valid", """ @@ -325,9 +255,11 @@ def test_multiple_toml_files_mixed_validity( @pytest.mark.parametrize("value", ["true", "TRUE", "Yes", "1", "on"]) def test_parse_bool_truthy_values(module: ModuleType, value: str) -> None: + """Treat recognised truthy values as ``True`` for configuration flags.""" assert module._parse_bool(value) is True @pytest.mark.parametrize("value", [None, "", "false", "no", "0", "off", "n"]) def test_parse_bool_falsey_values(module: ModuleType, value: str | None) -> None: + """Treat recognised falsey values as ``False`` for configuration flags.""" assert module._parse_bool(value) is False diff --git a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py index 7b48ea92..f26e66d3 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py +++ b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py @@ -2,7 +2,6 @@ from __future__ import annotations -import typing as typ from pathlib import Path from types import ModuleType @@ -13,10 +12,14 @@ @pytest.fixture(name="write_module") def fixture_write_module() -> ModuleType: + """Load the ``write_summary`` script module under test.""" return load_script_module("write_summary") -def test_write_summary_appends_markdown(tmp_path: Path, write_module: ModuleType) -> None: +def test_write_summary_appends_markdown( + tmp_path: Path, write_module: ModuleType +) -> None: + """Append a fresh summary block when the summary file is empty.""" summary_path = tmp_path / "summary.md" write_module.main( @@ -35,6 +38,7 @@ def test_write_summary_appends_markdown(tmp_path: Path, write_module: ModuleType def test_write_summary_handles_existing_content( tmp_path: Path, write_module: ModuleType ) -> None: + """Preserve existing content while appending the release summary.""" summary_path = tmp_path / "summary.md" summary_path.write_text("Existing\n", encoding="utf-8") @@ -51,6 +55,7 @@ def test_write_summary_handles_existing_content( def test_write_summary_raises_on_io_error(write_module: ModuleType) -> None: + """Surface file-system errors when the summary path is invalid.""" summary_path = Path("/nonexistent/path/summary.md") with pytest.raises(OSError): diff --git a/.github/actions/rust-build-release/tests/test_cross_install.py b/.github/actions/rust-build-release/tests/test_cross_install.py index 697450f4..3b7fdfed 100644 --- a/.github/actions/rust-build-release/tests/test_cross_install.py +++ b/.github/actions/rust-build-release/tests/test_cross_install.py @@ -20,6 +20,8 @@ from pathlib import Path from types import ModuleType + from shared_actions_conftest import CmdMox + from .conftest import HarnessFactory @@ -27,7 +29,7 @@ def test_installs_cross_when_missing( cross_module: ModuleType, module_harness: HarnessFactory, - cmd_mox, + cmd_mox: CmdMox, ) -> None: """Installs cross when it is missing.""" harness = module_harness(cross_module) @@ -82,7 +84,7 @@ def fail_install(cmd: list[str]) -> None: def test_upgrades_outdated_cross( cross_module: ModuleType, module_harness: HarnessFactory, - cmd_mox, + cmd_mox: CmdMox, ) -> None: """Upgrades cross when an older version is installed.""" harness = module_harness(cross_module) @@ -114,7 +116,7 @@ def fake_which(name: str) -> str | None: def test_uses_cached_cross( cross_module: ModuleType, module_harness: HarnessFactory, - cmd_mox, + cmd_mox: CmdMox, ) -> None: """Uses cached cross when version is sufficient.""" harness = module_harness(cross_module) @@ -138,7 +140,7 @@ def fake_which(name: str) -> str | None: def test_installs_prebuilt_cross_on_windows( cross_module: ModuleType, module_harness: HarnessFactory, - cmd_mox, + cmd_mox: CmdMox, ) -> None: """Uses the prebuilt cross binary on Windows hosts.""" harness = module_harness(cross_module) @@ -353,7 +355,7 @@ def test_installs_cross_without_container_runtime( main_module: ModuleType, cross_module: ModuleType, module_harness: HarnessFactory, - cmd_mox, + cmd_mox: CmdMox, ) -> None: """Installs cross even when no container runtime is available.""" cross_env = module_harness(cross_module) @@ -394,7 +396,7 @@ def fake_which(name: str) -> str | None: def test_falls_back_to_git_when_crates_io_unavailable( cross_module: ModuleType, module_harness: HarnessFactory, - cmd_mox, + cmd_mox: CmdMox, ) -> None: """Falls back to git install when crates.io is unavailable.""" harness = module_harness(cross_module) @@ -431,7 +433,7 @@ def test_falls_back_to_cargo_when_runtime_unusable( main_module: ModuleType, cross_module: ModuleType, module_harness: HarnessFactory, - cmd_mox, + cmd_mox: CmdMox, ) -> None: """Falls back to cargo when docker exists but is unusable.""" cross_env = module_harness(cross_module) diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index 40ac70d8..e0eaf492 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -1,3 +1,5 @@ +"""Tests for the target installation helpers in the rust-build-release action.""" + from __future__ import annotations import os @@ -16,6 +18,8 @@ from pathlib import Path from types import ModuleType + from shared_actions_conftest import CmdMox + from .conftest import HarnessFactory @@ -24,7 +28,7 @@ def test_skips_target_install_when_cross_available( main_module: ModuleType, cross_module: ModuleType, module_harness: HarnessFactory, - cmd_mox, + cmd_mox: CmdMox, ) -> None: """Continues when target addition fails but cross is available.""" cross_env = module_harness(cross_module) @@ -67,7 +71,7 @@ def test_errors_when_target_unsupported_without_cross( main_module: ModuleType, cross_module: ModuleType, module_harness: HarnessFactory, - cmd_mox, + cmd_mox: CmdMox, capsys: pytest.CaptureFixture[str], ) -> None: """Emits an error when the toolchain lacks the requested target.""" @@ -106,7 +110,7 @@ def test_falls_back_to_cargo_when_cross_container_fails( main_module: ModuleType, cross_module: ModuleType, module_harness: HarnessFactory, - cmd_mox, + cmd_mox: CmdMox, ) -> None: """Falls back to cargo when cross exits with a container error.""" cross_env = module_harness(cross_module) diff --git a/.github/actions/rust-build-release/tests/test_utils.py b/.github/actions/rust-build-release/tests/test_utils.py index b7c11c00..f561fcc2 100644 --- a/.github/actions/rust-build-release/tests/test_utils.py +++ b/.github/actions/rust-build-release/tests/test_utils.py @@ -12,6 +12,8 @@ if typ.TYPE_CHECKING: from types import ModuleType + from shared_actions_conftest import CmdMox + def test_ensure_allowed_executable_accepts_valid_name( utils_module: ModuleType, tmp_path: Path @@ -38,10 +40,10 @@ def test_ensure_allowed_executable_rejects_unknown( @CMD_MOX_UNSUPPORTED def test_run_validated_invokes_subprocess_with_validated_path( utils_module: ModuleType, - cmd_mox, + cmd_mox: CmdMox, ) -> None: """run_validated executes subprocess.run with the validated executable.""" - exe_path = cmd_mox.environment.shim_dir / "docker.exe" + exe_path = cmd_mox.shim_dir / "docker.exe" spy = cmd_mox.spy("docker.exe").with_args("info").returns(stdout="ok") cmd_mox.replay() diff --git a/conftest.py b/conftest.py index 193719c8..2c65f109 100644 --- a/conftest.py +++ b/conftest.py @@ -6,6 +6,8 @@ import collections.abc as cabc import shutil import sys +from pathlib import Path +from typing import NoReturn, Protocol import pytest @@ -19,7 +21,47 @@ sys.modules.setdefault("shared_actions_conftest", sys.modules[__name__]) -@pytest.fixture() +class CmdDouble(Protocol): + """Contract for cmd-mox doubles that record expectations and behaviour.""" + + call_count: int + + def with_args(self, *args: str) -> CmdDouble: + """Set the expected argv for the double.""" + + def returns( + self, + *, + stdout: str = "", + stderr: str = "", + exit_code: int = 0, + **_: object, + ) -> CmdDouble: + """Provide canned output for the command invocation.""" + + def runs(self, handler: cabc.Callable[[object], tuple[str, str, int]]) -> CmdDouble: + """Execute a handler when the double is invoked.""" + + +class CmdMox(Protocol): + """Typed façade for the cmd-mox pytest fixture used in tests.""" + + shim_dir: Path + + def stub(self, command: str) -> CmdDouble: + """Register a stubbed command double.""" + + def spy(self, command: str) -> CmdDouble: + """Register a spying command double.""" + + def replay(self) -> None: + """Activate the recorded doubles.""" + + def verify(self) -> None: + """Assert that recorded expectations were satisfied.""" + + +@pytest.fixture def require_uv() -> None: """Skip tests that exercise uv when the CLI is unavailable.""" if not HAS_UV: @@ -27,7 +69,8 @@ def require_uv() -> None: def _register_cross_version_stub( - cmd_mox, stdout: str | cabc.Iterable[str] = "cross 0.2.5\n" + cmd_mox: CmdMox, + stdout: str | cabc.Iterable[str] = "cross 0.2.5\n", ) -> str: """Register a stub for ``cross --version`` and return the shim path.""" if isinstance(stdout, str): @@ -41,30 +84,33 @@ def _handler(_invocation: object) -> tuple[str, str, int]: return data, "", 0 cmd_mox.stub("cross").with_args("--version").runs(_handler) - return str(cmd_mox.environment.shim_dir / "cross") + return str(cmd_mox.shim_dir / "cross") def _register_rustup_toolchain_stub( - cmd_mox, stdout: str + cmd_mox: CmdMox, + stdout: str, ) -> str: # pragma: no cover - helper """Register a stub for ``rustup toolchain list`` and return the shim path.""" cmd_mox.stub("rustup").with_args("toolchain", "list").returns(stdout=stdout) - return str(cmd_mox.environment.shim_dir / "rustup") + return str(cmd_mox.shim_dir / "rustup") def _register_docker_info_stub( - cmd_mox, *, exit_code: int = 0 + cmd_mox: CmdMox, + *, + exit_code: int = 0, ) -> str: # pragma: no cover - helper """Register a stub for ``docker info`` and return the shim path.""" cmd_mox.stub("docker").with_args("info").returns(exit_code=exit_code) - return str(cmd_mox.environment.shim_dir / "docker") + return str(cmd_mox.shim_dir / "docker") if sys.platform != "win32": # pragma: win32 no cover - Windows lacks cmd-mox support pytest_plugins = ("cmd_mox.pytest_plugin",) else: - @pytest.fixture() - def cmd_mox(): # pragma: win32 no cover - fixture only used on Windows + @pytest.fixture + def cmd_mox() -> NoReturn: # pragma: win32 no cover - fixture only used on Windows """Skip tests that rely on cmd-mox on Windows.""" pytest.skip("cmd-mox does not support Windows") From c5ccda9d12892d97eea5c195c0a8ab0271b7c2a0 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 21 Sep 2025 22:02:16 +0100 Subject: [PATCH 25/41] Scope type-checking imports (#111) * Scope type-checking imports * Fix lint warnings across release scripts * Clarify cargo stream capture error * ci: add lint and format checks * Install action-validator in CI lint workflow * Install bun validator packages in CI --- .../generate-coverage/scripts/run_rust.py | 10 +++- .../generate-coverage/tests/test_scripts.py | 12 ++--- .../scripts/check_github_release.py | 54 ++++++++++++------- .../scripts/determine_release.py | 3 +- .../scripts/publish_release.py | 9 ++-- .../scripts/validate_toml_versions.py | 14 ++--- .../release-to-pypi-uv/tests/_helpers.py | 8 ++- .../tests/test_check_github_release.py | 34 ++++++++---- .../tests/test_publish_release.py | 8 ++- .../tests/test_validate_toml_versions.py | 2 +- .../tests/test_write_summary.py | 7 ++- .../rust-build-release/tests/test_utils.py | 4 +- .github/workflows/ci.yml | 16 ++++++ conftest.py | 38 +++++++++---- 14 files changed, 152 insertions(+), 67 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_rust.py b/.github/actions/generate-coverage/scripts/run_rust.py index 59cde881..6cd3ebc5 100644 --- a/.github/actions/generate-coverage/scripts/run_rust.py +++ b/.github/actions/generate-coverage/scripts/run_rust.py @@ -161,8 +161,14 @@ def _run_cargo(args: list[str]) -> str: errors="replace", ) if proc.stdout is None or proc.stderr is None: - msg = "cargo output streams not captured" - raise RuntimeError(msg) + missing_streams = [] + if proc.stdout is None: + missing_streams.append("stdout") + if proc.stderr is None: + missing_streams.append("stderr") + missing = ", ".join(missing_streams) + message = f"cargo output streams not captured: missing {missing}" + raise RuntimeError(message) stdout_lines: list[str] = [] if os.name == "nt": diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 5d8e582f..2efb38dc 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -228,8 +228,8 @@ def test_run_cargo_windows_pump_exception( class BoomIO(io.StringIO): def readline(self) -> str: - msg = "boom in pump" - raise RuntimeError(msg) + message = "boom in pump" + raise RuntimeError(message) fake_cargo = _make_fake_cargo(BoomIO(), io.StringIO(""), track_lifecycle=True) monkeypatch.setattr(mod, "cargo", fake_cargo) @@ -510,8 +510,8 @@ def test_lcov_permission_error( lcov.write_text("LF:1\nLH:1\n") def bad_read_text(*_: object, **__: object) -> str: - msg = "nope" - raise PermissionError(msg) + message = "nope" + raise PermissionError(message) monkeypatch.setattr(Path, "read_text", bad_read_text, raising=False) with pytest.raises(run_rust_module.typer.Exit) as excinfo: @@ -679,8 +679,8 @@ def test_cobertura_permission_error( xml.write_text("") def raise_permission_error(*_: object, **__: object) -> object: - msg = "denied" - raise PermissionError(msg) + message = "denied" + raise PermissionError(message) import coverage_parsers diff --git a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py index 5617c951..7b3f0fbc 100644 --- a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py @@ -10,6 +10,7 @@ import json import time import urllib.error +import urllib.parse import urllib.request import typer @@ -25,7 +26,11 @@ class GithubReleaseError(RuntimeError): def _fetch_release(repo: str, tag: str, token: str) -> dict[str, object]: api = f"https://api.github.com/repos/{repo}/releases/tags/{tag}" - request = urllib.request.Request( + parsed = urllib.parse.urlsplit(api) + if parsed.scheme != "https": # pragma: no cover - defensive guard + message = f"Unsupported URL scheme '{parsed.scheme}' for GitHub API request." + raise GithubReleaseError(message) + request = urllib.request.Request( # noqa: S310 - https scheme enforced above api, headers={ "Authorization": f"Bearer {token}", @@ -51,36 +56,43 @@ def _fetch_release(repo: str, tag: str, token: str) -> dict[str, object]: else "" ) if exc.code == 404: - raise GithubReleaseError( - f"No GitHub release found for tag {tag}. Create and publish the release first." - ) from exc + message = ( + "No GitHub release found for tag " + f"{tag}. Create and publish the release first." + ) + raise GithubReleaseError(message) from exc if exc.code == 403: - msg = ( + permission_message = ( "GitHub token lacks permission to read releases or has expired. " - "Ensure the workflow is using GITHUB_TOKEN with contents:read scope." + "Use a token with contents:read scope." ) context = detail or exc.reason - raise GithubReleaseError(f"{msg} ({context})") from exc + message = f"{permission_message} ({context})" + raise GithubReleaseError(message) from exc if attempt == max_attempts: - raise GithubReleaseError( - f"GitHub API request failed with status {exc.code}: {detail or exc.reason}" - ) from exc + failure_reason = detail or exc.reason + message = ( + "GitHub API request failed with status " + f"{exc.code}: {failure_reason}" + ) + raise GithubReleaseError(message) from exc time.sleep(delay) delay *= backoff_factor except urllib.error.URLError as exc: # pragma: no cover - network failure path if attempt == max_attempts: - raise GithubReleaseError( - f"Failed to reach GitHub API: {exc.reason}" - ) from exc + message = f"Failed to reach GitHub API: {exc.reason}" + raise GithubReleaseError(message) from exc time.sleep(delay) delay *= backoff_factor else: # pragma: no cover - loop exhausted without break - raise GithubReleaseError("GitHub API request failed after retries.") + message = "GitHub API request failed after retries." + raise GithubReleaseError(message) try: return json.loads(payload or "") except json.JSONDecodeError as exc: # pragma: no cover - unexpected payload - raise GithubReleaseError("GitHub API returned invalid JSON") from exc + message = "GitHub API returned invalid JSON" + raise GithubReleaseError(message) from exc def _validate_release(tag: str, data: dict[str, object]) -> str: @@ -89,13 +101,17 @@ def _validate_release(tag: str, data: dict[str, object]) -> str: name = data.get("name") or tag if draft: - raise GithubReleaseError( - f"Release '{name}' for {tag} is still a draft. Publish it before running this action." + message = ( + f"Release '{name}' for {tag} is still a draft. " + "Publish it before running this action." ) + raise GithubReleaseError(message) if prerelease: - raise GithubReleaseError( - f"Release '{name}' for {tag} is marked as prerelease. Publish a normal release first." + message = ( + f"Release '{name}' for {tag} is marked as prerelease. " + "Publish a normal release first." ) + raise GithubReleaseError(message) return str(name) diff --git a/.github/actions/release-to-pypi-uv/scripts/determine_release.py b/.github/actions/release-to-pypi-uv/scripts/determine_release.py index 5068e641..54d9af5c 100644 --- a/.github/actions/release-to-pypi-uv/scripts/determine_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/determine_release.py @@ -59,7 +59,8 @@ def main( if not re.fullmatch(r"v\d+\.\d+\.\d+", resolved_tag): typer.echo( - f"::error::Tag must be a valid semantic version (e.g. v1.2.3), got '{resolved_tag}'.", + "::error::Tag must be a valid semantic version (e.g. v1.2.3), " + f"got '{resolved_tag}'.", err=True, ) raise typer.Exit(1) diff --git a/.github/actions/release-to-pypi-uv/scripts/publish_release.py b/.github/actions/release-to-pypi-uv/scripts/publish_release.py index 799b9e57..f9f7488b 100644 --- a/.github/actions/release-to-pypi-uv/scripts/publish_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/publish_release.py @@ -7,6 +7,7 @@ from __future__ import annotations +import contextlib import os import sys from pathlib import Path @@ -20,18 +21,14 @@ def _extend_sys_path() -> None: if action_path_env: action_path = Path(action_path_env).resolve() candidates.append(action_path / "scripts") - try: + with contextlib.suppress(IndexError): candidates.append(action_path.parents[2]) - except IndexError: - pass else: script_path = Path(__file__).resolve() scripts_dir = script_path.parent candidates.append(scripts_dir) - try: + with contextlib.suppress(IndexError): candidates.append(scripts_dir.parents[3]) - except IndexError: - pass for candidate in candidates: if not candidate: diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index 68c4c838..ddb8aabe 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -7,7 +7,6 @@ from __future__ import annotations -import glob import typing as typ from pathlib import Path @@ -35,8 +34,8 @@ def _iter_files(pattern: str) -> typ.Iterable[Path]: - candidates = [Path(p) for p in glob.glob(pattern, recursive=True)] - for path in candidates: + root = Path() + for path in root.glob(pattern): if not path.is_file(): continue parts = set(path.parts) @@ -58,17 +57,20 @@ def _load_toml(path: Path) -> dict[str, object]: try: text = path.read_text(encoding="utf-8") except OSError as exc: - raise RuntimeError(f"{path}: failed to read: {exc}") from exc + message = f"{path}: failed to read: {exc}" + raise RuntimeError(message) from exc try: import tomllib except ModuleNotFoundError as exc: # pragma: no cover - python < 3.11 - raise RuntimeError("tomllib module is unavailable") from exc + message = "tomllib module is unavailable" + raise RuntimeError(message) from exc try: return tomllib.loads(text) except tomllib.TOMLDecodeError as exc: # type: ignore[attr-defined] - raise RuntimeError(f"{path}: failed to parse: {exc}") from exc + message = f"{path}: failed to parse: {exc}" + raise RuntimeError(message) from exc def main( diff --git a/.github/actions/release-to-pypi-uv/tests/_helpers.py b/.github/actions/release-to-pypi-uv/tests/_helpers.py index 3c8d071c..b0316aa9 100644 --- a/.github/actions/release-to-pypi-uv/tests/_helpers.py +++ b/.github/actions/release-to-pypi-uv/tests/_helpers.py @@ -4,8 +4,11 @@ import importlib.util import os +import typing as typ from pathlib import Path -from types import ModuleType + +if typ.TYPE_CHECKING: # pragma: no cover - imported for annotations only + from types import ModuleType _ACTION_PATH = os.environ.get("GITHUB_ACTION_PATH") @@ -25,7 +28,8 @@ def load_script_module(name: str) -> ModuleType: f"release_to_pypi_uv_{name}", script_path ) if spec is None or spec.loader is None: # pragma: no cover - import failure - raise RuntimeError(f"Unable to load script module {name} from {script_path}") + message = f"Unable to load script module {name} from {script_path}" + raise RuntimeError(message) module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) return module diff --git a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py index 6ae06fc9..1ce150dc 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py @@ -5,17 +5,20 @@ import io import json import typing as typ -from types import ModuleType +import uuid import pytest +if typ.TYPE_CHECKING: # pragma: no cover - imported for annotations only + from types import ModuleType + from ._helpers import load_script_module class DummyResponse: """In-memory substitute for an ``urllib`` HTTP response.""" - def __init__(self, payload: dict[str, typ.Any]): + def __init__(self, payload: dict[str, typ.Any]) -> None: """Store the JSON payload returned by the fake response.""" self._payload = json.dumps(payload).encode("utf-8") @@ -43,10 +46,17 @@ def fixture_module() -> ModuleType: return load_script_module("check_github_release") +@pytest.fixture(name="fake_token") +def fixture_fake_token() -> str: + """Generate a unique but fake token for GitHub API requests.""" + return f"test-token-{uuid.uuid4().hex}" + + def test_success( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], module: ModuleType, + fake_token: str, ) -> None: """Print a success message when GitHub marks the release as published.""" @@ -55,7 +65,7 @@ def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noq monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) - module.main(tag="v1.2.3", token="token", repo="owner/repo") + module.main(tag="v1.2.3", token=fake_token, repo="owner/repo") captured = capsys.readouterr() assert "GitHub Release '1.2.3' is published." in captured.out @@ -65,6 +75,7 @@ def test_draft_release( monkeypatch: pytest.MonkeyPatch, module: ModuleType, capsys: pytest.CaptureFixture[str], + fake_token: str, ) -> None: """Exit with an error when GitHub reports the release as a draft.""" @@ -74,7 +85,7 @@ def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noq monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) with pytest.raises(module.typer.Exit): - module.main(tag="v1.0.0", token="token", repo="owner/repo") + module.main(tag="v1.0.0", token=fake_token, repo="owner/repo") captured = capsys.readouterr() assert "still a draft" in captured.err @@ -84,6 +95,7 @@ def test_prerelease( monkeypatch: pytest.MonkeyPatch, module: ModuleType, capsys: pytest.CaptureFixture[str], + fake_token: str, ) -> None: """Exit with an error when GitHub flags the release as a prerelease.""" @@ -93,7 +105,7 @@ def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noq monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) with pytest.raises(module.typer.Exit): - module.main(tag="v1.0.0", token="token", repo="owner/repo") + module.main(tag="v1.0.0", token=fake_token, repo="owner/repo") captured = capsys.readouterr() assert "prerelease" in captured.err @@ -103,6 +115,7 @@ def test_missing_release( monkeypatch: pytest.MonkeyPatch, module: ModuleType, capsys: pytest.CaptureFixture[str], + fake_token: str, ) -> None: """Raise an error when the GitHub API cannot find the release.""" @@ -118,7 +131,7 @@ def fake_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: ANN monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) with pytest.raises(module.typer.Exit): - module.main(tag="v1.0.0", token="token", repo="owner/repo") + module.main(tag="v1.0.0", token=fake_token, repo="owner/repo") captured = capsys.readouterr() assert "No GitHub release found" in captured.err @@ -128,6 +141,7 @@ def test_permission_denied( monkeypatch: pytest.MonkeyPatch, module: ModuleType, capsys: pytest.CaptureFixture[str], + fake_token: str, ) -> None: """Exit with a helpful error when GitHub responds with 403 Forbidden.""" detail = b"forbidden" @@ -145,7 +159,7 @@ def raising_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: monkeypatch.setattr(module.urllib.request, "urlopen", raising_urlopen) with pytest.raises(module.typer.Exit): - module.main(tag="v1.0.0", token="token", repo="owner/repo") + module.main(tag="v1.0.0", token=fake_token, repo="owner/repo") captured = capsys.readouterr() assert "GitHub token lacks permission" in captured.err @@ -155,6 +169,7 @@ def test_retries_then_success( monkeypatch: pytest.MonkeyPatch, module: ModuleType, capsys: pytest.CaptureFixture[str], + fake_token: str, ) -> None: """Retry transient HTTP failures until GitHub releases the metadata.""" attempts: list[int] = [] @@ -162,13 +177,14 @@ def test_retries_then_success( def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noqa: ANN401 attempts.append(1) if len(attempts) < 3: - raise module.urllib.error.URLError("temporary") + message = "temporary" + raise module.urllib.error.URLError(message) return DummyResponse({"draft": False, "prerelease": False, "name": "ok"}) monkeypatch.setattr(module.urllib.request, "urlopen", fake_urlopen) monkeypatch.setattr(module.time, "sleep", lambda _: None) - module.main(tag="v1.0.0", token="token", repo="owner/repo") + module.main(tag="v1.0.0", token=fake_token, repo="owner/repo") assert len(attempts) == 3 captured = capsys.readouterr() diff --git a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py index b1e5228f..7b705e3c 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py @@ -2,7 +2,10 @@ from __future__ import annotations -from types import ModuleType +import typing as typ + +if typ.TYPE_CHECKING: # pragma: no cover - imported for annotations only + from types import ModuleType import pytest @@ -59,7 +62,8 @@ class DummyError(Exception): pass def fake_run_cmd(_: list[str], **__: object) -> None: - raise DummyError("uv publish failed") + message = "uv publish failed" + raise DummyError(message) monkeypatch.setattr(publish_module, "run_cmd", fake_run_cmd) diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index 8d48526c..ca7ef295 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -3,10 +3,10 @@ from __future__ import annotations import typing as typ -from types import ModuleType if typ.TYPE_CHECKING: # pragma: no cover - type hints only from pathlib import Path + from types import ModuleType import pytest diff --git a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py index f26e66d3..a78f9418 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py +++ b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py @@ -2,8 +2,11 @@ from __future__ import annotations +import typing as typ from pathlib import Path -from types import ModuleType + +if typ.TYPE_CHECKING: # pragma: no cover - imported for annotations only + from types import ModuleType import pytest @@ -58,7 +61,7 @@ def test_write_summary_raises_on_io_error(write_module: ModuleType) -> None: """Surface file-system errors when the summary path is invalid.""" summary_path = Path("/nonexistent/path/summary.md") - with pytest.raises(OSError): + with pytest.raises(FileNotFoundError): write_module.main( tag="v1.0.0", index="", diff --git a/.github/actions/rust-build-release/tests/test_utils.py b/.github/actions/rust-build-release/tests/test_utils.py index f561fcc2..0e96e3c9 100644 --- a/.github/actions/rust-build-release/tests/test_utils.py +++ b/.github/actions/rust-build-release/tests/test_utils.py @@ -43,7 +43,9 @@ def test_run_validated_invokes_subprocess_with_validated_path( cmd_mox: CmdMox, ) -> None: """run_validated executes subprocess.run with the validated executable.""" - exe_path = cmd_mox.shim_dir / "docker.exe" + shim_dir = cmd_mox.environment.shim_dir + assert shim_dir is not None + exe_path = shim_dir / "docker.exe" spy = cmd_mox.spy("docker.exe").with_args("info").returns(stdout="ok") cmd_mox.replay() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca8e7a82..003860e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,22 @@ jobs: - name: Install nfpm if: matrix.os == 'ubuntu-latest' uses: ./.github/actions/install-nfpm + - name: Setup Bun + if: matrix.os == 'ubuntu-latest' + # v2.0.2 + uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 + - name: Install action-validator + if: matrix.os == 'ubuntu-latest' + run: bun install -g @action-validator/core @action-validator/cli + shell: bash + - name: Check formatting + if: matrix.os == 'ubuntu-latest' + run: make check-fmt + shell: bash + - name: Run lint checks + if: matrix.os == 'ubuntu-latest' + run: make lint + shell: bash - name: Run tests run: | uv sync --group dev diff --git a/conftest.py b/conftest.py index 2c65f109..142137f9 100644 --- a/conftest.py +++ b/conftest.py @@ -6,8 +6,7 @@ import collections.abc as cabc import shutil import sys -from pathlib import Path -from typing import NoReturn, Protocol +import typing as typ import pytest @@ -21,7 +20,11 @@ sys.modules.setdefault("shared_actions_conftest", sys.modules[__name__]) -class CmdDouble(Protocol): +if typ.TYPE_CHECKING: # pragma: no cover - imported for annotations only + from pathlib import Path + + +class CmdDouble(typ.Protocol): """Contract for cmd-mox doubles that record expectations and behaviour.""" call_count: int @@ -43,10 +46,16 @@ def runs(self, handler: cabc.Callable[[object], tuple[str, str, int]]) -> CmdDou """Execute a handler when the double is invoked.""" -class CmdMox(Protocol): +class CmdMoxEnvironment(typ.Protocol): + """Subset of :class:`cmd_mox.EnvironmentManager` used in tests.""" + + shim_dir: Path | None + + +class CmdMox(typ.Protocol): """Typed façade for the cmd-mox pytest fixture used in tests.""" - shim_dir: Path + environment: CmdMoxEnvironment def stub(self, command: str) -> CmdDouble: """Register a stubbed command double.""" @@ -61,6 +70,15 @@ def verify(self) -> None: """Assert that recorded expectations were satisfied.""" +def _shim_path(cmd_mox: CmdMox, command: str) -> str: + """Return the shim path for ``command`` ensuring the environment is ready.""" + shim_dir = cmd_mox.environment.shim_dir + if shim_dir is None: # pragma: no cover - defensive guard + msg = "cmd-mox shim directory is unavailable" + raise RuntimeError(msg) + return str(shim_dir / command) + + @pytest.fixture def require_uv() -> None: """Skip tests that exercise uv when the CLI is unavailable.""" @@ -84,7 +102,7 @@ def _handler(_invocation: object) -> tuple[str, str, int]: return data, "", 0 cmd_mox.stub("cross").with_args("--version").runs(_handler) - return str(cmd_mox.shim_dir / "cross") + return _shim_path(cmd_mox, "cross") def _register_rustup_toolchain_stub( @@ -93,7 +111,7 @@ def _register_rustup_toolchain_stub( ) -> str: # pragma: no cover - helper """Register a stub for ``rustup toolchain list`` and return the shim path.""" cmd_mox.stub("rustup").with_args("toolchain", "list").returns(stdout=stdout) - return str(cmd_mox.shim_dir / "rustup") + return _shim_path(cmd_mox, "rustup") def _register_docker_info_stub( @@ -103,14 +121,14 @@ def _register_docker_info_stub( ) -> str: # pragma: no cover - helper """Register a stub for ``docker info`` and return the shim path.""" cmd_mox.stub("docker").with_args("info").returns(exit_code=exit_code) - return str(cmd_mox.shim_dir / "docker") + return _shim_path(cmd_mox, "docker") -if sys.platform != "win32": # pragma: win32 no cover - Windows lacks cmd-mox support +if sys.platform != "win32": # pragma: win32 no cover - windows lacks cmd-mox pytest_plugins = ("cmd_mox.pytest_plugin",) else: @pytest.fixture - def cmd_mox() -> NoReturn: # pragma: win32 no cover - fixture only used on Windows + def cmd_mox() -> typ.NoReturn: # pragma: win32 no cover """Skip tests that rely on cmd-mox on Windows.""" pytest.skip("cmd-mox does not support Windows") From 136849140c8453e51baec0d5e6ad7c5c8fddfe1d Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 22 Sep 2025 00:11:47 +0100 Subject: [PATCH 26/41] Test full success message for matching versions (#113) --- .../release-to-pypi-uv/tests/test_validate_toml_versions.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index ca7ef295..20ea9869 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -55,7 +55,10 @@ def test_passes_when_versions_match( _invoke_main(module, version="1.0.0") captured = capsys.readouterr() - assert "all versions match 1.0.0" in captured.out + assert ( + captured.out.strip() + == "Checked 1 PEP 621 project file(s); all versions match 1.0.0." + ) def test_fails_on_mismatch( From 1d90f22a6da780233f3f22f10504de83defb1b73 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 22 Sep 2025 06:52:30 +0100 Subject: [PATCH 27/41] Address review comments (#116) --- .../scripts/validate_toml_versions.py | 4 +- .../tests/test_action_python_version.py | 2 +- .../tests/test_check_github_release.py | 22 ++++++++++ .../tests/test_validate_toml_versions.py | 41 ++++++++++++++++++ .../tests/test_target_install.py | 43 +++++++++++++++++++ .github/workflows/ci.yml | 7 ++- conftest.py | 10 +++++ 7 files changed, 125 insertions(+), 4 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index ddb8aabe..90b81cdb 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -35,7 +35,9 @@ def _iter_files(pattern: str) -> typ.Iterable[Path]: root = Path() - for path in root.glob(pattern): + for path in sorted( + root.glob(pattern), key=lambda candidate: tuple(candidate.parts) + ): if not path.is_file(): continue parts = set(path.parts) diff --git a/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py b/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py index af6cfc2d..de3d79e4 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py +++ b/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py @@ -34,4 +34,4 @@ def test_install_step_uses_python_version_input() -> None: data = _load_action() steps = data["runs"]["steps"] install_step = next(step for step in steps if step["name"] == "Install Python") - assert install_step["run"] == 'uv python install "${{ inputs.python-version }}"' + assert 'uv python install "${{ inputs.python-version }}"' in install_step["run"] diff --git a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py index 1ce150dc..ead65c14 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py @@ -189,3 +189,25 @@ def fake_urlopen(request: typ.Any, timeout: float = 30) -> DummyResponse: # noq assert len(attempts) == 3 captured = capsys.readouterr() assert "GitHub Release 'ok' is published." in captured.out + + +def test_retries_then_fail( + monkeypatch: pytest.MonkeyPatch, + module: ModuleType, + capsys: pytest.CaptureFixture[str], + fake_token: str, +) -> None: + """Abort after exhausting retries when transient errors persist.""" + + def failing_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: ANN401 + _ = request, timeout + raise module.urllib.error.URLError("temporary") + + monkeypatch.setattr(module.urllib.request, "urlopen", failing_urlopen) + monkeypatch.setattr(module.time, "sleep", lambda _: None) + + with pytest.raises(module.typer.Exit): + module.main(tag="v1.0.0", token=fake_token, repo="owner/repo") + + captured = capsys.readouterr() + assert "temporary" in captured.err or "fetch" in captured.err diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index 20ea9869..f5a781b2 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -256,6 +256,47 @@ def test_multiple_toml_files_mixed_validity( assert "!= tag version" in captured.err +def test_iter_files_discovers_paths_in_deterministic_order( + project_root: Path, + module: ModuleType, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Ensure TOML discovery yields paths in a stable, sorted order.""" + _write_pyproject( + project_root / "pkg_b", + """ +[project] +name = "pkg-b" +version = "1.0.0" +""", + ) + _write_pyproject( + project_root / "pkg_a", + """ +[project] +name = "pkg-a" +version = "1.0.0" +""", + ) + + first = project_root / "pkg_a" / "pyproject.toml" + second = project_root / "pkg_b" / "pyproject.toml" + + def fake_glob( + self: Path, + pattern: str, + ) -> typ.Iterator[Path]: + _ = self + assert pattern == "**/pyproject.toml" + return iter((second, first)) + + monkeypatch.setattr(module.Path, "glob", fake_glob, raising=False) + + discovered = list(module._iter_files("**/pyproject.toml")) + + assert discovered == [first, second] + + @pytest.mark.parametrize("value", ["true", "TRUE", "Yes", "1", "on"]) def test_parse_bool_truthy_values(module: ModuleType, value: str) -> None: """Treat recognised truthy values as ``True`` for configuration flags.""" diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index e0eaf492..010652b1 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -11,6 +11,7 @@ CMD_MOX_UNSUPPORTED, _register_cross_version_stub, _register_docker_info_stub, + _register_podman_info_stub, _register_rustup_toolchain_stub, ) @@ -144,6 +145,48 @@ def fake_which(name: str) -> str | None: assert build_cmd[1] == f"+{default_toolchain}-x86_64-unknown-linux-gnu" +@CMD_MOX_UNSUPPORTED +def test_falls_back_to_cargo_when_podman_unusable( + main_module: ModuleType, + cross_module: ModuleType, + runtime_module: ModuleType, + module_harness: HarnessFactory, + cmd_mox: CmdMox, +) -> None: + """Fallback to cargo when podman runtime detection fails quickly (issue #97).""" + + cross_env = module_harness(cross_module) + runtime_env = module_harness(runtime_module) + app_env = module_harness(main_module) + + default_toolchain = main_module.DEFAULT_TOOLCHAIN + rustup_stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" + cross_path = _register_cross_version_stub(cmd_mox) + rustup_path = _register_rustup_toolchain_stub(cmd_mox, rustup_stdout) + podman_path = _register_podman_info_stub(cmd_mox, exit_code=1) + + def fake_which(name: str) -> str | None: + if name == "podman": + return podman_path + if name == "cross": + return cross_path + return rustup_path if name == "rustup" else None + + cross_env.patch_shutil_which(fake_which) + runtime_env.patch_shutil_which(fake_which) + app_env.patch_shutil_which(fake_which) + + app_env.patch_attr("ensure_cross", lambda required: (cross_path, required)) + app_env.patch_attr("runtime_available", runtime_module.runtime_available) + + cmd_mox.replay() + main_module.main("x86_64-unknown-linux-gnu", default_toolchain) + cmd_mox.verify() + + assert any(cmd[0] == "cargo" for cmd in app_env.calls) + assert all(cmd[0] != "cross" for cmd in app_env.calls) + + @pytest.mark.parametrize( "target", [ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 003860e8..41158883 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,9 +39,12 @@ jobs: if: matrix.os == 'ubuntu-latest' # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 - - name: Install action-validator + - name: Validate GitHub Actions metadata if: matrix.os == 'ubuntu-latest' - run: bun install -g @action-validator/core @action-validator/cli + run: | + set -euxo pipefail + find . -type f \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ + | xargs -0 -n1 bunx -y @action-validator/cli validate shell: bash - name: Check formatting if: matrix.os == 'ubuntu-latest' diff --git a/conftest.py b/conftest.py index 142137f9..3ade95aa 100644 --- a/conftest.py +++ b/conftest.py @@ -124,6 +124,16 @@ def _register_docker_info_stub( return _shim_path(cmd_mox, "docker") +def _register_podman_info_stub( + cmd_mox: CmdMox, + *, + exit_code: int = 0, +) -> str: # pragma: no cover - helper + """Register a stub for ``podman info`` and return the shim path.""" + cmd_mox.stub("podman").with_args("info").returns(exit_code=exit_code) + return _shim_path(cmd_mox, "podman") + + if sys.platform != "win32": # pragma: win32 no cover - windows lacks cmd-mox pytest_plugins = ("cmd_mox.pytest_plugin",) else: From 0d6640880b494656a20785f14750b5f961e55b54 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 22 Sep 2025 12:51:28 +0100 Subject: [PATCH 28/41] Address review feedback for release-to-pypi-uv actions (#112) * Address review feedback * Add CLI coverage for publish and validate scripts * Add deterministic validation tests and bunx validator checks --- .../scripts/check_github_release.py | 2 +- .../scripts/confirm_release.py | 2 +- .../scripts/determine_release.py | 2 +- .../scripts/publish_release.py | 21 ++++-- .../scripts/validate_toml_versions.py | 2 +- .../scripts/write_summary.py | 2 +- .../tests/test_publish_release.py | 73 ++++++++++++++++++- .../tests/test_validate_toml_versions.py | 47 +++++++++++- .../tests/test_cross_install.py | 49 ++++++++++++- Makefile | 2 +- 10 files changed, 181 insertions(+), 21 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py index 7b3f0fbc..f0b29b5c 100644 --- a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.13" -# dependencies = ["typer"] +# dependencies = ["typer>=0.17,<0.18"] # /// """Verify that the GitHub Release for the provided tag exists and is published.""" diff --git a/.github/actions/release-to-pypi-uv/scripts/confirm_release.py b/.github/actions/release-to-pypi-uv/scripts/confirm_release.py index e1450a86..ae1ce08e 100644 --- a/.github/actions/release-to-pypi-uv/scripts/confirm_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/confirm_release.py @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.13" -# dependencies = ["typer"] +# dependencies = ["typer>=0.17,<0.18"] # /// """Validate that the caller supplied the expected confirmation string.""" diff --git a/.github/actions/release-to-pypi-uv/scripts/determine_release.py b/.github/actions/release-to-pypi-uv/scripts/determine_release.py index 54d9af5c..3e1276c9 100644 --- a/.github/actions/release-to-pypi-uv/scripts/determine_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/determine_release.py @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.13" -# dependencies = ["typer"] +# dependencies = ["typer>=0.17,<0.18"] # /// """Resolve the release tag and semantic version for the current run.""" diff --git a/.github/actions/release-to-pypi-uv/scripts/publish_release.py b/.github/actions/release-to-pypi-uv/scripts/publish_release.py index f9f7488b..e5616cba 100644 --- a/.github/actions/release-to-pypi-uv/scripts/publish_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/publish_release.py @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.13" -# dependencies = ["typer"] +# dependencies = ["typer>=0.17,<0.18"] # /// """Publish the built distributions using uv.""" @@ -31,8 +31,6 @@ def _extend_sys_path() -> None: candidates.append(scripts_dir.parents[3]) for candidate in candidates: - if not candidate: - continue if not candidate.exists(): continue path_str = str(candidate) @@ -44,10 +42,14 @@ def _extend_sys_path() -> None: from cmd_utils import run_cmd # noqa: E402 -INDEX_OPTION = typer.Option("", envvar="INPUT_UV_INDEX") +INDEX_OPTION = typer.Option( + "", + envvar="INPUT_UV_INDEX", + help="Optional index name or URL for uv publish.", +) -def main(index: str = INDEX_OPTION) -> None: +def main(index: str = "") -> None: """Publish the built distributions with uv. Parameters @@ -55,7 +57,7 @@ def main(index: str = INDEX_OPTION) -> None: index : str Optional package index name or URL to pass to ``uv publish``. """ - if index: + if index := index.strip(): typer.echo(f"Publishing with uv to index '{index}'") run_cmd(["uv", "publish", "--index", index]) else: @@ -63,5 +65,10 @@ def main(index: str = INDEX_OPTION) -> None: run_cmd(["uv", "publish"]) +def cli(index: str = INDEX_OPTION) -> None: + """CLI entrypoint.""" + main(index=index) + + if __name__ == "__main__": - typer.run(main) + typer.run(cli) diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index 90b81cdb..5dfbaccb 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.13" -# dependencies = ["typer"] +# dependencies = ["typer>=0.17,<0.18"] # /// """Validate that project versions in pyproject.toml files match the release version.""" diff --git a/.github/actions/release-to-pypi-uv/scripts/write_summary.py b/.github/actions/release-to-pypi-uv/scripts/write_summary.py index 8e385557..a938e378 100644 --- a/.github/actions/release-to-pypi-uv/scripts/write_summary.py +++ b/.github/actions/release-to-pypi-uv/scripts/write_summary.py @@ -1,7 +1,7 @@ #!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.13" -# dependencies = ["typer"] +# dependencies = ["typer>=0.17,<0.18"] # /// """Append a short release summary for the workflow run.""" diff --git a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py index 7b705e3c..633b8e3e 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py @@ -8,6 +8,7 @@ from types import ModuleType import pytest +from typer.testing import CliRunner from ._helpers import REPO_ROOT, load_script_module @@ -22,7 +23,9 @@ def fixture_publish_module() -> ModuleType: def test_publish_default_index( - monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + publish_module: ModuleType, ) -> None: """Invoke ``uv publish`` without an index when none is provided.""" calls: list[list[str]] = [] @@ -35,10 +38,14 @@ def fake_run_cmd(args: list[str], **_: object) -> None: publish_module.main(index="") assert calls == [["uv", "publish"]] + captured = capsys.readouterr() + assert "Publishing with uv to default index (PyPI)" in captured.out def test_publish_custom_index( - monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + publish_module: ModuleType, ) -> None: """Add the ``--index`` flag when a custom index value is supplied.""" calls: list[list[str]] = [] @@ -48,9 +55,11 @@ def fake_run_cmd(args: list[str], **_: object) -> None: monkeypatch.setattr(publish_module, "run_cmd", fake_run_cmd) - publish_module.main(index="testpypi") + publish_module.main(index=" testpypi ") assert calls == [["uv", "publish", "--index", "testpypi"]] + captured = capsys.readouterr() + assert "Publishing with uv to index 'testpypi'" in captured.out def test_publish_run_cmd_error( @@ -69,3 +78,61 @@ def fake_run_cmd(_: list[str], **__: object) -> None: with pytest.raises(DummyError): publish_module.main(index="") + + +def test_cli_proxies_to_main( + monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType +) -> None: + """Ensure the CLI entrypoint forwards arguments to ``main``.""" + received: dict[str, str] = {} + + def fake_main(*, index: str) -> None: + received["index"] = index + + monkeypatch.setattr(publish_module, "main", fake_main) + + publish_module.cli(index="mirror") + + assert received == {"index": "mirror"} + + +def test_cli_runner_default_index( + monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType +) -> None: + """Exercise the CLI behaviour when no index is provided.""" + calls: list[list[str]] = [] + + def fake_run_cmd(args: list[str], **_: object) -> None: + calls.append(args) + + monkeypatch.setattr(publish_module, "run_cmd", fake_run_cmd) + + runner = CliRunner() + app = publish_module.typer.Typer() + app.command()(publish_module.cli) + result = runner.invoke(app, []) + + assert result.exit_code == 0 + assert calls == [["uv", "publish"]] + assert "Publishing with uv to default index (PyPI)" in result.output + + +def test_cli_runner_respects_env_index( + monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType +) -> None: + """Accept the index from the GitHub Action input environment variable.""" + calls: list[list[str]] = [] + + def fake_run_cmd(args: list[str], **_: object) -> None: + calls.append(args) + + monkeypatch.setattr(publish_module, "run_cmd", fake_run_cmd) + + runner = CliRunner() + app = publish_module.typer.Typer() + app.command()(publish_module.cli) + result = runner.invoke(app, [], env={"INPUT_UV_INDEX": "testpypi"}) + + assert result.exit_code == 0 + assert calls == [["uv", "publish", "--index", "testpypi"]] + assert "Publishing with uv to index 'testpypi'" in result.output diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index f5a781b2..7c36993f 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -9,6 +9,7 @@ from types import ModuleType import pytest +from typer.testing import CliRunner from ._helpers import load_script_module @@ -32,7 +33,7 @@ def _write_pyproject(base: Path, content: str) -> None: (base / "pyproject.toml").write_text(content.strip()) -def _invoke_main(module: ModuleType, **kwargs: str) -> None: +def _invoke_main(module: ModuleType, **kwargs: object) -> None: """Invoke ``module.main`` with defaults tailored for the tests.""" kwargs.setdefault("pattern", "**/pyproject.toml") kwargs.setdefault("fail_on_dynamic", "false") @@ -61,6 +62,28 @@ def test_passes_when_versions_match( ) +def test_cli_defaults_when_optional_parameters_omitted( + project_root: Path, module: ModuleType +) -> None: + """Use default CLI values when optional flags are not provided.""" + _write_pyproject( + project_root / "pkg", + """ +[project] +name = "demo" +version = "1.0.0" +""", + ) + + runner = CliRunner() + app = module.typer.Typer() + app.command()(module.main) + result = runner.invoke(app, ["--version", "1.0.0"]) + + assert result.exit_code == 0 + assert "all versions match 1.0.0" in result.output + + def test_fails_on_mismatch( project_root: Path, module: ModuleType, capsys: pytest.CaptureFixture[str] ) -> None: @@ -199,7 +222,7 @@ def test_dynamic_version_allowed_when_flag_unset( """, ) - _invoke_main(module, version="1.0.0", fail_on_dynamic="") + _invoke_main(module, version="1.0.0") captured = capsys.readouterr() assert "uses dynamic 'version'" in captured.out @@ -297,6 +320,26 @@ def fake_glob( assert discovered == [first, second] +def test_iter_files_discovers_paths_in_sorted_order( + project_root: Path, + module: ModuleType, +) -> None: + """Ensure discovery order remains deterministic for reproducible output.""" + for name in ("pkg_c", "pkg_a", "pkg_b"): + _write_pyproject( + project_root / name, + """ +[project] +name = "demo" +version = "1.0.0" +""", + ) + + discovered = list(module._iter_files("**/pyproject.toml")) + relative = [path.as_posix() for path in discovered] + assert relative == sorted(relative) + + @pytest.mark.parametrize("value", ["true", "TRUE", "Yes", "1", "on"]) def test_parse_bool_truthy_values(module: ModuleType, value: str) -> None: """Treat recognised truthy values as ``True`` for configuration flags.""" diff --git a/.github/actions/rust-build-release/tests/test_cross_install.py b/.github/actions/rust-build-release/tests/test_cross_install.py index 3b7fdfed..aafbcaa5 100644 --- a/.github/actions/rust-build-release/tests/test_cross_install.py +++ b/.github/actions/rust-build-release/tests/test_cross_install.py @@ -13,6 +13,7 @@ CMD_MOX_UNSUPPORTED, _register_cross_version_stub, _register_docker_info_stub, + _register_podman_info_stub, _register_rustup_toolchain_stub, ) @@ -421,9 +422,14 @@ def fake_which(name: str) -> str | None: cmd_mox.verify() assert len(harness.calls) == 2 - assert "--git" in harness.calls[1] - assert "--tag" in harness.calls[1] - assert "v0.2.5" in harness.calls[1] + first, second = harness.calls + # First attempt was crates.io + assert "--git" not in first + assert "--tag" not in first + # Second attempt is the git fallback with a tag + assert "--git" in second + assert "--tag" in second + assert "v0.2.5" in second assert path == cross_path assert ver == "0.2.5" @@ -463,6 +469,43 @@ def fake_which(name: str) -> str | None: assert all(cmd[0] != "cross" for cmd in app_env.calls) +@CMD_MOX_UNSUPPORTED +def test_falls_back_to_cargo_when_podman_unusable( + main_module: ModuleType, + cross_module: ModuleType, + module_harness: HarnessFactory, + cmd_mox: CmdMox, +) -> None: + """Falls back to cargo when podman exists but is unusable.""" + cross_env = module_harness(cross_module) + app_env = module_harness(main_module) + + default_toolchain = main_module.DEFAULT_TOOLCHAIN + rustup_stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" + cross_path = _register_cross_version_stub(cmd_mox) + rustup_path = _register_rustup_toolchain_stub(cmd_mox, rustup_stdout) + podman_path = _register_podman_info_stub(cmd_mox, exit_code=1) + + def fake_which(name: str) -> str | None: + if name == "podman": + return podman_path + if name == "cross": + return cross_path + if name == "rustup": + return rustup_path + return None + + cross_env.patch_shutil_which(fake_which) + app_env.patch_shutil_which(fake_which) + + cmd_mox.replay() + main_module.main("x86_64-unknown-linux-gnu", default_toolchain) + cmd_mox.verify() + + assert any(cmd[0] == "cargo" for cmd in app_env.calls) + assert all(cmd[0] != "cross" for cmd in app_env.calls) + + def test_returns_none_when_install_fails_on_windows( cross_module: ModuleType, module_harness: HarnessFactory, diff --git a/Makefile b/Makefile index aafb39ec..5e082b35 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ test: .venv ## Run tests lint: ## Check test scripts and actions uvx ruff check find .github/actions -type f \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ - | xargs -r -0 -n1 ${HOME}/.bun/bin/action-validator + | xargs -r -0 -n1 bunx -y @action-validator/cli typecheck: .venv ## Run static type checking with Ty ./.venv/bin/ty check \ From 074ed0dbe087891d9a6ffc2fc39a8b418d7fb633 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 22 Sep 2025 12:54:54 +0100 Subject: [PATCH 29/41] Update ci.yml remove unneeded step --- .github/workflows/ci.yml | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41158883..9dfe351a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,13 +39,6 @@ jobs: if: matrix.os == 'ubuntu-latest' # v2.0.2 uses: oven-sh/setup-bun@735343b667d3e6f658f44d0eca948eb6282f2b76 - - name: Validate GitHub Actions metadata - if: matrix.os == 'ubuntu-latest' - run: | - set -euxo pipefail - find . -type f \( -name 'action.yml' -o -name 'action.yaml' \) -print0 \ - | xargs -0 -n1 bunx -y @action-validator/cli validate - shell: bash - name: Check formatting if: matrix.os == 'ubuntu-latest' run: make check-fmt From e673de75469516752f6a06e4002b754aeb05d394 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Mon, 22 Sep 2025 12:56:48 +0100 Subject: [PATCH 30/41] Fix formatting --- .github/actions/rust-build-release/tests/test_target_install.py | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index 010652b1..39166d64 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -154,7 +154,6 @@ def test_falls_back_to_cargo_when_podman_unusable( cmd_mox: CmdMox, ) -> None: """Fallback to cargo when podman runtime detection fails quickly (issue #97).""" - cross_env = module_harness(cross_module) runtime_env = module_harness(runtime_module) app_env = module_harness(main_module) From da97bc3f376c7e310c2f3fc80cdc67f68d82a5c9 Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 22 Sep 2025 19:23:05 +0100 Subject: [PATCH 31/41] Fix Windows xfail marker removal for pytest 8 (#120) * Fix Windows xfail marker removal for pytest 8 * Tighten Windows xfail marker filtering --- .../rust-build-release/tests/conftest.py | 24 +++++++------------ 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/.github/actions/rust-build-release/tests/conftest.py b/.github/actions/rust-build-release/tests/conftest.py index b8cd80fd..347374ed 100644 --- a/.github/actions/rust-build-release/tests/conftest.py +++ b/.github/actions/rust-build-release/tests/conftest.py @@ -326,22 +326,14 @@ def pytest_collection_modifyitems( nodeid = getattr(item, "nodeid", "") if WINDOWS_SMOKE_TEST not in nodeid or "-pc-windows-" not in nodeid: continue - xfail_marks = [ - mark for mark in item.iter_markers(name="xfail") if mark in item.own_markers - ] - if not xfail_marks: - continue - drop_marks = [ + original_count = len(item.own_markers) + filtered_markers = [ mark - for mark in xfail_marks - if ( - isinstance(reason := mark.kwargs.get("reason"), str) - and reason.strip() == WINDOWS_XFAIL_REASON + for mark in item.own_markers + if not ( + mark.name == "xfail" + and mark.kwargs.get("reason") == WINDOWS_XFAIL_REASON ) ] - if not drop_marks: - continue - keep_marks = [mark for mark in xfail_marks if mark not in drop_marks] - item.remove_marker("xfail") - for mark in keep_marks: - item.add_marker(pytest.mark.xfail(*mark.args, **mark.kwargs)) + if len(filtered_markers) != original_count: + item.own_markers[:] = filtered_markers From 286abde1d14affa3dbdfee7464df95a3da53b07b Mon Sep 17 00:00:00 2001 From: Leynos Date: Mon, 22 Sep 2025 19:38:18 +0100 Subject: [PATCH 32/41] Handle runtime probe timeouts and expand release tests (#119) * Handle runtime probe timeouts and expand release tests * Add _probe_runtime coverage and assert timeout warnings --- .../tests/test_publish_release.py | 7 +- .../tests/test_validate_toml_versions.py | 41 +++++- .../actions/rust-build-release/src/main.py | 19 ++- .../tests/test_target_install.py | 118 ++++++++++++++++++ 4 files changed, 179 insertions(+), 6 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py index 633b8e3e..4b35c916 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py @@ -10,15 +10,16 @@ import pytest from typer.testing import CliRunner -from ._helpers import REPO_ROOT, load_script_module +from ._helpers import SCRIPTS_DIR, load_script_module @pytest.fixture(name="publish_module") def fixture_publish_module() -> ModuleType: """Load the ``publish_release`` script module with repository paths set.""" module = load_script_module("publish_release") - if str(REPO_ROOT) not in module.sys.path: # type: ignore[attr-defined] - module.sys.path.insert(0, str(REPO_ROOT)) # type: ignore[attr-defined] + scripts_dir = str(SCRIPTS_DIR) + if scripts_dir not in module.sys.path: # type: ignore[attr-defined] + module.sys.path.insert(0, scripts_dir) # type: ignore[attr-defined] return module diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index 7c36993f..d0dbb8a7 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -29,7 +29,7 @@ def project_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def _write_pyproject(base: Path, content: str) -> None: """Create a ``pyproject.toml`` file populated with the provided content.""" - base.mkdir() + base.mkdir(parents=True, exist_ok=True) (base / "pyproject.toml").write_text(content.strip()) @@ -207,6 +207,26 @@ def test_dynamic_version_allowed_for_falsey_variants( assert "uses dynamic 'version'" in captured.out +def test_skips_files_in_ignored_directories( + project_root: Path, + module: ModuleType, + capsys: pytest.CaptureFixture[str], +) -> None: + """Warn and exit when only ignored directories match the pattern.""" + ignored = project_root / ".venv" / "pkg" + _write_pyproject( + ignored, + """ +[project] +name = "ignored" +version = "9.9.9" +""", + ) + _invoke_main(module, version="1.0.0") + captured = capsys.readouterr() + assert "::warning::No TOML files matched pattern" in captured.out + + def test_dynamic_version_allowed_when_flag_unset( project_root: Path, module: ModuleType, @@ -249,6 +269,25 @@ def test_missing_project_section_is_ignored( assert captured.err == "" +def test_fails_when_project_version_missing( + project_root: Path, + module: ModuleType, + capsys: pytest.CaptureFixture[str], +) -> None: + """Error when a project lacks a version and is not marked dynamic.""" + _write_pyproject( + project_root / "pkg", + """ +[project] +name = "demo" +""", + ) + with pytest.raises(module.typer.Exit): + _invoke_main(module, version="1.0.0") + captured = capsys.readouterr() + assert "missing [project].version" in captured.err + + def test_multiple_toml_files_mixed_validity( project_root: Path, module: ModuleType, diff --git a/.github/actions/rust-build-release/src/main.py b/.github/actions/rust-build-release/src/main.py index 75940b15..5fb0cbfe 100755 --- a/.github/actions/rust-build-release/src/main.py +++ b/.github/actions/rust-build-release/src/main.py @@ -124,6 +124,21 @@ def _toolchain_channel(toolchain_name: str) -> str: return toolchain_name +def _probe_runtime(name: str) -> bool: + """Return True when *name* runtime is available, tolerating probe timeouts.""" + try: + return runtime_available(name) + except subprocess.TimeoutExpired as exc: + timeout = getattr(exc, "timeout", None) + duration = f" after {timeout}s" if timeout else "" + message = ( + f"::warning::{name} runtime probe timed out{duration}; " + "treating runtime as unavailable" + ) + typer.echo(message, err=True) + return False + + @app.command() def main( target: str = typer.Argument("", help="Target triple to build"), @@ -226,8 +241,8 @@ def main( docker_present = False podman_present = False if should_probe_container(sys.platform, target): - docker_present = runtime_available("docker") - podman_present = runtime_available("podman") + docker_present = _probe_runtime("docker") + podman_present = _probe_runtime("podman") has_container = docker_present or podman_present use_cross = cross_path is not None and has_container diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index 39166d64..5e571ae0 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -319,6 +319,124 @@ def test_should_probe_container_handles_windows_targets( assert main_module.should_probe_container(host_platform, target) is should_probe +def test_probe_runtime_returns_runtime_available( + main_module: ModuleType, + module_harness: HarnessFactory, + capsys: pytest.CaptureFixture[str], +) -> None: + """_probe_runtime returns the underlying runtime availability flag.""" + harness = module_harness(main_module) + harness.patch_attr("runtime_available", lambda name: name == "docker") + + assert main_module._probe_runtime("docker") is True + assert main_module._probe_runtime("podman") is False + + captured = capsys.readouterr() + assert captured.err == "" + + +def test_probe_runtime_warns_on_timeout( + main_module: ModuleType, + module_harness: HarnessFactory, + capsys: pytest.CaptureFixture[str], +) -> None: + """Timeouts are converted into warnings and treated as unavailable.""" + harness = module_harness(main_module) + + def raise_timeout(name: str) -> bool: + raise subprocess.TimeoutExpired(cmd=f"{name} info", timeout=5) + + harness.patch_attr("runtime_available", raise_timeout) + + assert main_module._probe_runtime("podman") is False + + err = capsys.readouterr().err + expected = ( + "::warning::podman runtime probe timed out after 5s; " + "treating runtime as unavailable" + ) + assert expected in err + + +def test_probe_runtime_propagates_unexpected_error( + main_module: ModuleType, + module_harness: HarnessFactory, + capsys: pytest.CaptureFixture[str], +) -> None: + """Errors other than timeouts propagate to the caller.""" + harness = module_harness(main_module) + + class ProbeError(RuntimeError): + """Sentinel error for runtime probe tests.""" + + def raise_error(name: str) -> bool: + raise ProbeError + + harness.patch_attr("runtime_available", raise_error) + + with pytest.raises(ProbeError): + main_module._probe_runtime("docker") + + captured = capsys.readouterr() + assert captured.err == "" + + +def test_runtime_available_handles_timeout( + main_module: ModuleType, + module_harness: HarnessFactory, + capsys: pytest.CaptureFixture[str], +) -> None: + """Treat runtime probe timeouts as unavailable instead of crashing.""" + harness = module_harness(main_module) + default_toolchain = main_module.DEFAULT_TOOLCHAIN + + harness.patch_shutil_which( + lambda name: "/usr/bin/rustup" if name == "rustup" else None + ) + + def fake_run_validated( + executable: str, args: list[str], **_: object + ) -> subprocess.CompletedProcess[str]: + if executable == "/usr/bin/rustup" and args[:2] == ["toolchain", "list"]: + stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" + return subprocess.CompletedProcess([executable, *args], 0, stdout=stdout) + pytest.fail(f"unexpected run_validated call: {executable} {args}") + + harness.patch_attr("run_validated", fake_run_validated) + + def run_cmd_side_effect(cmd: list[str]) -> None: + if cmd[:3] == ["/usr/bin/rustup", "target", "add"]: + raise subprocess.CalledProcessError(1, cmd) + if cmd and cmd[0] == "cargo": + return + pytest.fail(f"unexpected run_cmd call: {cmd}") + + harness.patch_run_cmd(run_cmd_side_effect) + harness.patch_attr("configure_windows_linkers", lambda *_: None) + + def timeout_runtime(_name: str, *, cwd: object | None = None) -> bool: + _ = cwd + raise subprocess.TimeoutExpired(cmd="podman info", timeout=10) + + harness.patch_attr("runtime_available", timeout_runtime) + harness.patch_attr("ensure_cross", lambda *_: (None, None)) + + with pytest.raises(main_module.typer.Exit): + main_module.main("thumbv7em-none-eabihf", default_toolchain) + + err = capsys.readouterr().err + expected_docker = ( + "::warning::docker runtime probe timed out after 10s; " + "treating runtime as unavailable" + ) + expected_podman = ( + "::warning::podman runtime probe timed out after 10s; " + "treating runtime as unavailable" + ) + assert expected_docker in err + assert expected_podman in err + + def test_configure_windows_linkers_prefers_toolchain_gcc( main_module: ModuleType, module_harness: HarnessFactory, From 47c2f88d8d0f1f9664d6a8fa0b8f5d985ae90f7c Mon Sep 17 00:00:00 2001 From: Leynos Date: Tue, 23 Sep 2025 07:55:43 +0100 Subject: [PATCH 33/41] Expand TOML skip directories for release validation (#122) * Expand pyproject skip list handling * Strengthen TOML and runtime timeout tests * Assert cargo fallback logs podman failure * Parameterize skip-directory regression --- .../scripts/validate_toml_versions.py | 3 +++ .../tests/test_validate_toml_versions.py | 16 ++++++++++++---- .../tests/test_target_install.py | 13 +++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index 5dfbaccb..d3838ae4 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -28,6 +28,9 @@ "build", ".direnv", ".mypy_cache", + ".pytest_cache", + ".cache", + "htmlcov", } TRUTHY_STRINGS = {"true", "1", "yes", "y", "on"} diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index d0dbb8a7..5323966f 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -13,6 +13,8 @@ from ._helpers import load_script_module +SKIP_PARTS = tuple(sorted(load_script_module("validate_toml_versions").SKIP_PARTS)) + @pytest.fixture(name="module") def fixture_module() -> ModuleType: @@ -207,21 +209,27 @@ def test_dynamic_version_allowed_for_falsey_variants( assert "uses dynamic 'version'" in captured.out -def test_skips_files_in_ignored_directories( +@pytest.mark.parametrize("skip_part", SKIP_PARTS, ids=lambda part: part) +def test_skips_files_in_ignored_directory( project_root: Path, module: ModuleType, capsys: pytest.CaptureFixture[str], + skip_part: str, ) -> None: - """Warn and exit when only ignored directories match the pattern.""" - ignored = project_root / ".venv" / "pkg" + """Warn and exit when only a single ignored directory matches the pattern.""" + assert skip_part in module.SKIP_PARTS _write_pyproject( - ignored, + project_root / skip_part / "pkg", """ [project] name = "ignored" version = "9.9.9" """, ) + + discovered = list(module._iter_files("**/pyproject.toml")) + assert not discovered + _invoke_main(module, version="1.0.0") captured = capsys.readouterr() assert "::warning::No TOML files matched pattern" in captured.out diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index 5e571ae0..e8af0aaa 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -152,6 +152,7 @@ def test_falls_back_to_cargo_when_podman_unusable( runtime_module: ModuleType, module_harness: HarnessFactory, cmd_mox: CmdMox, + capsys: pytest.CaptureFixture[str], ) -> None: """Fallback to cargo when podman runtime detection fails quickly (issue #97).""" cross_env = module_harness(cross_module) @@ -184,6 +185,12 @@ def fake_which(name: str) -> str | None: assert any(cmd[0] == "cargo" for cmd in app_env.calls) assert all(cmd[0] != "cross" for cmd in app_env.calls) + captured = capsys.readouterr() + assert ( + "cross (0.2.5) requires a container runtime; " + "using cargo (docker=False, podman=False)" in captured.out + ) + assert "TimeoutExpired" not in captured.err @pytest.mark.parametrize( @@ -433,8 +440,14 @@ def timeout_runtime(_name: str, *, cwd: object | None = None) -> bool: "::warning::podman runtime probe timed out after 10s; " "treating runtime as unavailable" ) + expected_toolchain = ( + f"::error:: toolchain '{default_toolchain}-x86_64-unknown-linux-gnu' " + "does not support target 'thumbv7em-none-eabihf'" + ) assert expected_docker in err assert expected_podman in err + assert expected_toolchain in err + assert "TimeoutExpired" not in err def test_configure_windows_linkers_prefers_toolchain_gcc( From d27e6b74a241ea87b430951ab1cab586e2b71cab Mon Sep 17 00:00:00 2001 From: Leynos Date: Tue, 23 Sep 2025 09:44:48 +0100 Subject: [PATCH 34/41] Reinforce regression coverage for release tooling (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Refine skip directory regression parameterization * Document skip directories and DRY timeout assertions * Parametrize publish index test * Refactor runtime timeout tests * Rebase onto origin/python-lib-release-action and resolve conflicts - Resolve merge in validate_toml_versions tests by standardizing module loading - Register dynamically loaded scripts in sys.modules to support reload semantics - Make toolchain triple test robust to host arch by selecting matching target - Run formatting, lint, typecheck, and tests to validate integration 💘 Generated with Crush Co-Authored-By: Crush --------- Co-authored-by: Crush --- .../scripts/validate_toml_versions.py | 4 + .../release-to-pypi-uv/tests/_helpers.py | 3 + .../tests/test_publish_release.py | 44 +++++----- .../tests/test_validate_toml_versions.py | 11 ++- .../rust-build-release/tests/test_runtime.py | 83 +++++++++---------- .../tests/test_target_install.py | 9 +- 6 files changed, 79 insertions(+), 75 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index d3838ae4..ff5543bd 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -19,6 +19,10 @@ envvar="INPUT_FAIL_ON_DYNAMIC_VERSION", ) +# Common transient directories created by tooling (virtualenvs, caches, +# pytest artefacts such as ``.pytest_cache``/``.cache`` and coverage reports +# under ``htmlcov``) that should be ignored when searching for +# ``pyproject.toml`` files to validate. SKIP_PARTS = { ".git", ".venv", diff --git a/.github/actions/release-to-pypi-uv/tests/_helpers.py b/.github/actions/release-to-pypi-uv/tests/_helpers.py index b0316aa9..b7057f7e 100644 --- a/.github/actions/release-to-pypi-uv/tests/_helpers.py +++ b/.github/actions/release-to-pypi-uv/tests/_helpers.py @@ -4,6 +4,7 @@ import importlib.util import os +import sys import typing as typ from pathlib import Path @@ -31,6 +32,8 @@ def load_script_module(name: str) -> ModuleType: message = f"Unable to load script module {name} from {script_path}" raise RuntimeError(message) module = importlib.util.module_from_spec(spec) + # Register module in sys.modules so importlib.reload works in tests + sys.modules[spec.name] = module spec.loader.exec_module(module) return module diff --git a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py index 4b35c916..024504cc 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py @@ -23,12 +23,26 @@ def fixture_publish_module() -> ModuleType: return module -def test_publish_default_index( +@pytest.mark.parametrize( + ("index", "expected_calls", "expected_message"), + [ + ("", [["uv", "publish"]], "Publishing with uv to default index (PyPI)"), + ( + " testpypi ", + [["uv", "publish", "--index", "testpypi"]], + "Publishing with uv to index 'testpypi'", + ), + ], +) +def test_publish_index_behaviour( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], publish_module: ModuleType, + index: str, + expected_calls: list[list[str]], + expected_message: str, ) -> None: - """Invoke ``uv publish`` without an index when none is provided.""" + """Exercise publishing for both default and custom index inputs.""" calls: list[list[str]] = [] def fake_run_cmd(args: list[str], **_: object) -> None: @@ -36,31 +50,11 @@ def fake_run_cmd(args: list[str], **_: object) -> None: monkeypatch.setattr(publish_module, "run_cmd", fake_run_cmd) - publish_module.main(index="") + publish_module.main(index=index) - assert calls == [["uv", "publish"]] - captured = capsys.readouterr() - assert "Publishing with uv to default index (PyPI)" in captured.out - - -def test_publish_custom_index( - monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], - publish_module: ModuleType, -) -> None: - """Add the ``--index`` flag when a custom index value is supplied.""" - calls: list[list[str]] = [] - - def fake_run_cmd(args: list[str], **_: object) -> None: - calls.append(args) - - monkeypatch.setattr(publish_module, "run_cmd", fake_run_cmd) - - publish_module.main(index=" testpypi ") - - assert calls == [["uv", "publish", "--index", "testpypi"]] + assert calls == expected_calls captured = capsys.readouterr() - assert "Publishing with uv to index 'testpypi'" in captured.out + assert expected_message in captured.out def test_publish_run_cmd_error( diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index 5323966f..85156cda 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -13,12 +13,13 @@ from ._helpers import load_script_module -SKIP_PARTS = tuple(sorted(load_script_module("validate_toml_versions").SKIP_PARTS)) +MODULE: ModuleType = load_script_module("validate_toml_versions") +SKIP_PARTS = tuple(sorted(MODULE.SKIP_PARTS)) @pytest.fixture(name="module") def fixture_module() -> ModuleType: - """Load the ``validate_toml_versions`` script module under test.""" + """Reload the ``validate_toml_versions`` script for a clean state.""" return load_script_module("validate_toml_versions") @@ -235,6 +236,12 @@ def test_skips_files_in_ignored_directory( assert "::warning::No TOML files matched pattern" in captured.out +def test_skip_parts_cover_transient_tooling_dirs(module: ModuleType) -> None: + """Ensure tooling artefact directories remain excluded from discovery.""" + expected = {".pytest_cache", ".cache", "htmlcov"} + assert expected <= module.SKIP_PARTS + + def test_dynamic_version_allowed_when_flag_unset( project_root: Path, module: ModuleType, diff --git a/.github/actions/rust-build-release/tests/test_runtime.py b/.github/actions/rust-build-release/tests/test_runtime.py index fd0afc69..82af2826 100644 --- a/.github/actions/rust-build-release/tests/test_runtime.py +++ b/.github/actions/rust-build-release/tests/test_runtime.py @@ -11,7 +11,36 @@ if typ.TYPE_CHECKING: from types import ModuleType - from .conftest import HarnessFactory + from .conftest import HarnessFactory, ModuleHarness + + +def _patch_run_validated_timeout( + runtime_module: ModuleType, + harness: ModuleHarness, + *, + predicate: typ.Callable[[list[str]], bool] | None = None, + success_factory: typ.Callable[[list[str]], subprocess.CompletedProcess[str]] + | None = None, +) -> None: + """Patch ``run_validated`` to raise ``TimeoutExpired`` when *predicate* matches.""" + + def fake_run( + executable: str, + args: list[str], + *, + allowed_names: tuple[str, ...], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + _ = (allowed_names, kwargs) + cmd = [executable, *args] + should_timeout = predicate(args) if predicate is not None else True + if should_timeout: + raise subprocess.TimeoutExpired(cmd, runtime_module.PROBE_TIMEOUT) + if success_factory is not None: + return success_factory(cmd) + return subprocess.CompletedProcess(cmd, 0, stdout="") + + harness.monkeypatch.setattr(runtime_module, "run_validated", fake_run) def test_runtime_available_false_when_missing( @@ -44,19 +73,7 @@ def test_runtime_available_returns_false_on_timeout( harness = module_harness(runtime_module) harness.patch_shutil_which(lambda name: "/usr/bin/docker") harness.patch_attr("ensure_allowed_executable", lambda path, allowed: path) - - def fake_run( - executable: str, - args: list[str], - *, - allowed_names: tuple[str, ...], - **_: object, - ) -> subprocess.CompletedProcess[str]: - _ = allowed_names - cmd = [executable, *args] - raise subprocess.TimeoutExpired(cmd, runtime_module.PROBE_TIMEOUT) - - harness.monkeypatch.setattr(runtime_module, "run_validated", fake_run) + _patch_run_validated_timeout(runtime_module, harness) assert runtime_module.runtime_available("docker") is False @@ -134,24 +151,11 @@ def test_podman_security_timeout_treated_as_unavailable( harness = module_harness(runtime_module) harness.patch_shutil_which(lambda name: "/usr/bin/podman") harness.patch_attr("ensure_allowed_executable", lambda path, allowed: path) - - def fake_run( - executable: str, - args: list[str], - *, - allowed_names: tuple[str, ...], - capture_output: bool = False, - check: bool = False, - text: bool = False, - **_: object, - ) -> subprocess.CompletedProcess[str]: - _ = (allowed_names, capture_output, check, text) - cmd = [executable, *args] - if "--format" in args: - raise subprocess.TimeoutExpired(cmd, runtime_module.PROBE_TIMEOUT) - return subprocess.CompletedProcess(cmd, 0, stdout="") - - harness.monkeypatch.setattr(runtime_module, "run_validated", fake_run) + _patch_run_validated_timeout( + runtime_module, + harness, + predicate=lambda args: "--format" in args, + ) assert runtime_module.runtime_available("podman") is False @@ -202,20 +206,7 @@ def test_detect_host_target_returns_default_on_timeout( lambda name: "/usr/bin/rustc" if name == "rustc" else None ) harness.patch_attr("ensure_allowed_executable", lambda path, allowed: path) - - def fake_run( - executable: str, - args: list[str], - *, - allowed_names: tuple[str, ...], - **_: object, - ) -> subprocess.CompletedProcess[str]: - _ = (executable, args, allowed_names) - raise subprocess.TimeoutExpired( - [executable, *args], runtime_module.PROBE_TIMEOUT - ) - - harness.monkeypatch.setattr(runtime_module, "run_validated", fake_run) + _patch_run_validated_timeout(runtime_module, harness) assert ( runtime_module.detect_host_target(default="fallback-triple") diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index e8af0aaa..95e12f1f 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -24,6 +24,11 @@ from .conftest import HarnessFactory +def _assert_no_timeout_trace(output: str) -> None: + """Ensure TimeoutExpired tracebacks do not leak into CLI output.""" + assert "TimeoutExpired" not in output, output + + @CMD_MOX_UNSUPPORTED def test_skips_target_install_when_cross_available( main_module: ModuleType, @@ -190,7 +195,7 @@ def fake_which(name: str) -> str | None: "cross (0.2.5) requires a container runtime; " "using cargo (docker=False, podman=False)" in captured.out ) - assert "TimeoutExpired" not in captured.err + _assert_no_timeout_trace(captured.err) @pytest.mark.parametrize( @@ -447,7 +452,7 @@ def timeout_runtime(_name: str, *, cwd: object | None = None) -> bool: assert expected_docker in err assert expected_podman in err assert expected_toolchain in err - assert "TimeoutExpired" not in err + _assert_no_timeout_trace(err) def test_configure_windows_linkers_prefers_toolchain_gcc( From 1805b4a164bd87125622304555ec8550733918f6 Mon Sep 17 00:00:00 2001 From: Leynos Date: Tue, 23 Sep 2025 17:08:34 +0100 Subject: [PATCH 35/41] Harden release-to-pypi-uv workflow and regression coverage (#134) * Improve release validation jitter handling * Adjust runtime fallback host triples per platform * Make release validation deterministic and configurable --- .../generate-coverage/scripts/run_rust.py | 7 +- .github/actions/release-to-pypi-uv/README.md | 11 +- .github/actions/release-to-pypi-uv/action.yml | 16 +- .../scripts/check_github_release.py | 39 +- .../scripts/confirm_release.py | 6 +- .../scripts/determine_release.py | 15 +- .../scripts/publish_release.py | 15 + .../scripts/validate_toml_versions.py | 43 +- .../tests/test_action_python_version.py | 22 + .../tests/test_check_github_release.py | 20 +- .../tests/test_determine_release.py | 43 +- .../tests/test_publish_release.py | 20 +- .../tests/test_validate_toml_versions.py | 108 ++++- .../tests/test_write_summary.py | 1 + .../actions/rust-build-release/src/runtime.py | 81 ++-- .../rust-build-release/tests/test_runtime.py | 23 ++ .../rust-build-release/tests/test_smoke.py | 6 +- .../tests/test_target_install.py | 51 ++- .gitignore | 4 +- Makefile | 3 +- docs/cmd-mox-users-guide.md | 4 +- docs/python-native-command-mocking-design.md | 6 +- pyproject.toml | 2 +- uv.lock | 382 ------------------ 24 files changed, 444 insertions(+), 484 deletions(-) delete mode 100644 uv.lock diff --git a/.github/actions/generate-coverage/scripts/run_rust.py b/.github/actions/generate-coverage/scripts/run_rust.py index 6cd3ebc5..6b65ed6b 100644 --- a/.github/actions/generate-coverage/scripts/run_rust.py +++ b/.github/actions/generate-coverage/scripts/run_rust.py @@ -168,7 +168,12 @@ def _run_cargo(args: list[str]) -> str: missing_streams.append("stderr") missing = ", ".join(missing_streams) message = f"cargo output streams not captured: missing {missing}" - raise RuntimeError(message) + with contextlib.suppress(Exception): + proc.kill() + with contextlib.suppress(Exception): + proc.wait(timeout=5) + typer.echo(f"::error::{message}", err=True) + raise typer.Exit(1) stdout_lines: list[str] = [] if os.name == "nt": diff --git a/.github/actions/release-to-pypi-uv/README.md b/.github/actions/release-to-pypi-uv/README.md index db5c075e..767b4fc5 100644 --- a/.github/actions/release-to-pypi-uv/README.md +++ b/.github/actions/release-to-pypi-uv/README.md @@ -13,12 +13,21 @@ Build and publish Python distributions via | environment-name | GitHub environment to reference in the release summary. | no | `pypi` | | uv-index | Optional uv index name to publish to (e.g. `testpypi`). Must exist in `tool.uv.index`. | no | _(empty)_ | | toml-glob | Glob used to discover `pyproject.toml` files for version validation. | no | `**/pyproject.toml` | +| skip-directories | Comma- or newline-separated directory names to skip during discovery. | no | _(empty)_ | | fail-on-dynamic-version | Fail when a project declares a dynamic PEP 621 version instead of a literal string. | no | `false` | +| fail-on-empty | Fail when no `pyproject.toml` files match the discovery glob. | no | `false` | | python-version | Python version to install and use for all uv commands. | no | `3.13` | The composite action installs the interpreter requested through `python-version` before invoking any uv commands, ensuring builds run against the expected -runtime. +runtime. Set `fail-on-empty: true` when your repository must always contain at +least one `pyproject.toml`. This turns the default warning into a failing error +so misconfigured globs surface early. + +Directories named `.venv`, `venv`, `.mypy_cache`, `.pytest_cache`, `.cache`, +`htmlcov`, and `node_modules` are skipped during TOML discovery. Provide a +comma- or newline-separated list via `skip-directories` when your repository +uses additional transient paths that should be excluded. ## Outputs diff --git a/.github/actions/release-to-pypi-uv/action.yml b/.github/actions/release-to-pypi-uv/action.yml index 84d4f3bc..1fbe9afb 100644 --- a/.github/actions/release-to-pypi-uv/action.yml +++ b/.github/actions/release-to-pypi-uv/action.yml @@ -26,10 +26,18 @@ inputs: description: Glob used to discover pyproject.toml files for version validation. required: false default: "**/pyproject.toml" + skip-directories: + description: Comma- or newline-separated directory names to skip during TOML discovery. + required: false + default: '' fail-on-dynamic-version: description: Fail if any project declares a dynamic version instead of a literal string. required: false default: 'false' + fail-on-empty: + description: Fail when no pyproject.toml files match the configured glob. + required: false + default: 'false' python-version: description: Python version to install and use with uv commands. required: false @@ -53,6 +61,9 @@ runs: **/pyproject.toml **/uv.lock cache-suffix: action-${{ github.action_ref || github.sha }} + - name: Install Python + run: uv python install "${{ inputs.python-version }}" + shell: bash - name: Determine tag and version id: resolve run: uv run --script "${{ github.action_path }}/scripts/determine_release.py" @@ -81,9 +92,8 @@ runs: RESOLVED_VERSION: ${{ steps.resolve.outputs.version }} INPUT_TOML_GLOB: ${{ inputs.toml-glob }} INPUT_FAIL_ON_DYNAMIC_VERSION: ${{ inputs.fail-on-dynamic-version }} - - name: Install Python - run: uv python install "${{ inputs.python-version }}" - shell: bash + INPUT_FAIL_ON_EMPTY: ${{ inputs.fail-on-empty }} + INPUT_SKIP_DIRECTORIES: ${{ inputs.skip-directories }} - name: Build distributions run: uv build shell: bash diff --git a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py index f0b29b5c..74d625cf 100644 --- a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py @@ -7,8 +7,11 @@ from __future__ import annotations +import contextlib import json +import random import time +import typing as typ import urllib.error import urllib.parse import urllib.request @@ -20,6 +23,32 @@ REPO_OPTION = typer.Option(..., envvar="GITHUB_REPOSITORY") +class _UniformGenerator(typ.Protocol): + """Protocol describing RNG objects that provide ``uniform``.""" + + def uniform(self, a: float, b: float) -> float: + """Return a random floating point number N such that ``a <= N <= b``.""" + + +SleepFn = typ.Callable[[float], None] + +_JITTER = random.SystemRandom() + + +def _sleep_with_jitter( + delay: float, + *, + jitter: _UniformGenerator | None = None, + sleep: SleepFn | None = None, +) -> None: + """Sleep for ``delay`` seconds with a deterministic jitter hook for tests.""" + sleep_base = max(delay, 0.0) + jitter_source = _JITTER if jitter is None else jitter + sleep_fn = time.sleep if sleep is None else sleep + jitter_amount = sleep_base * jitter_source.uniform(0.0, 0.1) + sleep_fn(sleep_base + jitter_amount) + + class GithubReleaseError(RuntimeError): """Raised when the GitHub release is not ready for publishing.""" @@ -76,13 +105,19 @@ def _fetch_release(repo: str, tag: str, token: str) -> dict[str, object]: f"{exc.code}: {failure_reason}" ) raise GithubReleaseError(message) from exc - time.sleep(delay) + retry_after = None + if hasattr(exc, "headers") and exc.headers is not None: + retry_after = exc.headers.get("Retry-After") + if retry_after: + with contextlib.suppress(Exception): + delay = float(retry_after) + _sleep_with_jitter(delay) delay *= backoff_factor except urllib.error.URLError as exc: # pragma: no cover - network failure path if attempt == max_attempts: message = f"Failed to reach GitHub API: {exc.reason}" raise GithubReleaseError(message) from exc - time.sleep(delay) + _sleep_with_jitter(delay) delay *= backoff_factor else: # pragma: no cover - loop exhausted without break message = "GitHub API request failed after retries." diff --git a/.github/actions/release-to-pypi-uv/scripts/confirm_release.py b/.github/actions/release-to-pypi-uv/scripts/confirm_release.py index ae1ce08e..6c022a57 100644 --- a/.github/actions/release-to-pypi-uv/scripts/confirm_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/confirm_release.py @@ -28,9 +28,13 @@ def main(expected: str = EXPECTED_OPTION, confirm: str = CONFIRM_OPTION) -> None typer.Exit Raised when the supplied confirmation does not match ``expected``. """ + # Normalise whitespace in both inputs before comparison. + expected = expected.strip() + confirm = confirm.strip() if confirm != expected: typer.echo( - f"::error::Confirmation failed. Set the 'confirm' input to: {expected}", + "::error::Confirmation failed. " + "Set the 'confirm' input to the expected phrase.", err=True, ) raise typer.Exit(1) diff --git a/.github/actions/release-to-pypi-uv/scripts/determine_release.py b/.github/actions/release-to-pypi-uv/scripts/determine_release.py index 3e1276c9..2df6a0fb 100644 --- a/.github/actions/release-to-pypi-uv/scripts/determine_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/determine_release.py @@ -19,8 +19,8 @@ def _emit_outputs(dest: Path, tag: str, version: str) -> None: with dest.open("a", encoding="utf-8") as fh: - fh.write(f"tag={tag}\n") - fh.write(f"version={version}\n") + for key, value in (("tag", tag), ("version", version)): + fh.write(f"{key}={value}\n") def main( @@ -45,10 +45,12 @@ def main( ref_name = os.getenv("GITHUB_REF_NAME", "") resolved_tag: str | None = None - if ref_type == "tag" and ref_name: + candidate_tag = (tag or "").strip() + ref_name = ref_name.strip() + if candidate_tag: + resolved_tag = candidate_tag + elif ref_type == "tag" and ref_name: resolved_tag = ref_name - elif tag: - resolved_tag = tag if not resolved_tag: typer.echo( @@ -57,7 +59,8 @@ def main( ) raise typer.Exit(1) - if not re.fullmatch(r"v\d+\.\d+\.\d+", resolved_tag): + semver_pattern = r"v\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?" + if not re.fullmatch(semver_pattern, resolved_tag): typer.echo( "::error::Tag must be a valid semantic version (e.g. v1.2.3), " f"got '{resolved_tag}'.", diff --git a/.github/actions/release-to-pypi-uv/scripts/publish_release.py b/.github/actions/release-to-pypi-uv/scripts/publish_release.py index e5616cba..17ee216d 100644 --- a/.github/actions/release-to-pypi-uv/scripts/publish_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/publish_release.py @@ -9,12 +9,26 @@ import contextlib import os +import shutil import sys from pathlib import Path import typer +def _ensure_python_runtime() -> None: + """Fail fast when Python 3.13+ or uv provisioning is unavailable.""" + if sys.version_info >= (3, 13): + return + if shutil.which("uv") is not None: + return + typer.echo( + "::error::Python >= 3.13 or uv must be available before publishing.", + err=True, + ) + raise typer.Exit(1) + + def _extend_sys_path() -> None: candidates: list[Path] = [] action_path_env = os.getenv("GITHUB_ACTION_PATH") @@ -38,6 +52,7 @@ def _extend_sys_path() -> None: sys.path.insert(0, path_str) +_ensure_python_runtime() _extend_sys_path() from cmd_utils import run_cmd # noqa: E402 diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index ff5543bd..146164db 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -18,12 +18,20 @@ "false", envvar="INPUT_FAIL_ON_DYNAMIC_VERSION", ) +FAIL_ON_EMPTY_OPTION = typer.Option( + "false", + envvar="INPUT_FAIL_ON_EMPTY", +) +SKIP_DIRECTORIES_OPTION = typer.Option( + "", + envvar="INPUT_SKIP_DIRECTORIES", +) # Common transient directories created by tooling (virtualenvs, caches, # pytest artefacts such as ``.pytest_cache``/``.cache`` and coverage reports # under ``htmlcov``) that should be ignored when searching for # ``pyproject.toml`` files to validate. -SKIP_PARTS = { +DEFAULT_SKIP_PARTS = { ".git", ".venv", "venv", @@ -37,22 +45,34 @@ "htmlcov", } +SKIP_PARTS = frozenset(DEFAULT_SKIP_PARTS) + TRUTHY_STRINGS = {"true", "1", "yes", "y", "on"} -def _iter_files(pattern: str) -> typ.Iterable[Path]: +def _iter_files( + pattern: str, *, skip_parts: typ.Collection[str] | None = None +) -> typ.Iterable[Path]: root = Path() + skip = set(SKIP_PARTS if skip_parts is None else skip_parts) for path in sorted( root.glob(pattern), key=lambda candidate: tuple(candidate.parts) ): if not path.is_file(): continue parts = set(path.parts) - if parts & SKIP_PARTS: + if parts & skip: continue yield path +def _parse_skip_directories(raw: str | None) -> set[str]: + if not raw: + return set() + normalized = raw.replace(",", "\n") + return {part.strip() for part in normalized.splitlines() if part.strip()} + + def _parse_bool(value: str | None) -> bool: if value is None: return False @@ -86,6 +106,8 @@ def main( version: str = VERSION_OPTION, pattern: str = PATTERN_OPTION, fail_on_dynamic: str = FAIL_ON_DYNAMIC_OPTION, + fail_on_empty: str = FAIL_ON_EMPTY_OPTION, + skip_directories: str = SKIP_DIRECTORIES_OPTION, ) -> None: """Confirm that project versions in TOML files match the release version. @@ -98,14 +120,27 @@ def main( fail_on_dynamic : str String flag that controls whether dynamic versions should raise an error. + fail_on_empty : str + String flag that controls whether missing matches should raise an + error instead of logging a warning. + skip_directories : str + Comma- or newline-separated list of directory name components to ignore + when matching ``pyproject.toml`` files. Raises ------ typer.Exit Raised when TOML files cannot be read or contain mismatched versions. """ - files = list(_iter_files(pattern)) + skip_parts = set(SKIP_PARTS) | _parse_skip_directories(skip_directories) + files = list(_iter_files(pattern, skip_parts=skip_parts)) if not files: + if _parse_bool(fail_on_empty): + typer.echo( + f"::error::No TOML files matched pattern {pattern}", + err=True, + ) + raise typer.Exit(1) typer.echo(f"::warning::No TOML files matched pattern {pattern}") return diff --git a/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py b/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py index de3d79e4..7461f7f5 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py +++ b/.github/actions/release-to-pypi-uv/tests/test_action_python_version.py @@ -35,3 +35,25 @@ def test_install_step_uses_python_version_input() -> None: steps = data["runs"]["steps"] install_step = next(step for step in steps if step["name"] == "Install Python") assert 'uv python install "${{ inputs.python-version }}"' in install_step["run"] + + +def test_validate_step_passes_fail_on_empty_flag() -> None: + """Ensure the validation step forwards the fail-on-empty input.""" + data = _load_action() + steps = data["runs"]["steps"] + validate_step = next( + step for step in steps if step["name"] == "Validate TOML files" + ) + env = validate_step["env"] + assert env["INPUT_FAIL_ON_EMPTY"] == "${{ inputs.fail-on-empty }}" + + +def test_validate_step_passes_skip_directories_input() -> None: + """Ensure the validation step forwards the skip-directories input.""" + data = _load_action() + steps = data["runs"]["steps"] + validate_step = next( + step for step in steps if step["name"] == "Validate TOML files" + ) + env = validate_step["env"] + assert env["INPUT_SKIP_DIRECTORIES"] == "${{ inputs.skip-directories }}" diff --git a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py index ead65c14..9472211a 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py @@ -52,6 +52,23 @@ def fixture_fake_token() -> str: return f"test-token-{uuid.uuid4().hex}" +def test_sleep_with_jitter_allows_custom_rng(module: ModuleType) -> None: + """Allow tests to provide deterministic jitter and sleep functions.""" + calls: list[float] = [] + + class FixedRandom: + """Stub RNG that always returns a fixed jitter fraction.""" + + def uniform(self, a: float, b: float) -> float: + assert a == 0.0 + assert b == 0.1 + return 0.05 + + module._sleep_with_jitter(4.0, jitter=FixedRandom(), sleep=calls.append) + + assert calls == [4.2] + + def test_success( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -206,8 +223,9 @@ def failing_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: monkeypatch.setattr(module.urllib.request, "urlopen", failing_urlopen) monkeypatch.setattr(module.time, "sleep", lambda _: None) - with pytest.raises(module.typer.Exit): + with pytest.raises(module.typer.Exit) as exc_info: module.main(tag="v1.0.0", token=fake_token, repo="owner/repo") captured = capsys.readouterr() + assert exc_info.value.exit_code == 1 assert "temporary" in captured.err or "fetch" in captured.err diff --git a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py index dd93180a..e51dbc06 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py @@ -45,7 +45,18 @@ def read_outputs(tmp_path: Path) -> dict[str, str]: output_file = tmp_path / "out.txt" if not output_file.exists(): return out - for line in output_file.read_text(encoding="utf-8").splitlines(): + lines = output_file.read_text(encoding="utf-8").splitlines() + iterator = iter(lines) + for line in iterator: + if line.endswith("<<__EOF__"): + key, _ = line.split("<<", 1) + value_lines: list[str] = [] + for value_line in iterator: + if value_line == "__EOF__": + break + value_lines.append(value_line) + out[key] = "\n".join(value_lines) + continue if "=" in line: key, value = line.split("=", 1) out[key] = value @@ -81,6 +92,36 @@ def test_resolves_tag_from_input(tmp_path: Path) -> None: assert outputs["version"] == "2.0.0" +def test_input_tag_overrides_ref(tmp_path: Path) -> None: + """Prefer the workflow input tag when both sources are present.""" + env = base_env(tmp_path) + env["GITHUB_REF_TYPE"] = "tag" + env["GITHUB_REF_NAME"] = "v0.9.9" + env["INPUT_TAG"] = "v2.3.4" + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 0, result.stderr + outputs = read_outputs(tmp_path) + assert outputs["tag"] == "v2.3.4" + assert outputs["version"] == "2.3.4" + + +def test_accepts_prerelease_and_build_tags(tmp_path: Path) -> None: + """Allow SemVer pre-release and build metadata components.""" + env = base_env(tmp_path) + env["INPUT_TAG"] = "v1.2.3-rc.1+build.5" + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 0, result.stderr + outputs = read_outputs(tmp_path) + assert outputs["tag"] == "v1.2.3-rc.1+build.5" + assert outputs["version"] == "1.2.3-rc.1+build.5" + + def test_rejects_invalid_tag(tmp_path: Path) -> None: """Reject release tags that do not follow the expected SemVer format.""" env = base_env(tmp_path) diff --git a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py index 024504cc..9bbe873e 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_publish_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_publish_release.py @@ -2,6 +2,7 @@ from __future__ import annotations +import types import typing as typ if typ.TYPE_CHECKING: # pragma: no cover - imported for annotations only @@ -57,6 +58,23 @@ def fake_run_cmd(args: list[str], **_: object) -> None: assert expected_message in captured.out +def test_ensure_python_runtime_errors_without_uv( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + publish_module: ModuleType, +) -> None: + """Guard the fail-fast check when Python < 3.13 and uv is unavailable.""" + stub_sys = types.SimpleNamespace(version_info=(3, 12, 0)) + monkeypatch.setattr(publish_module, "sys", stub_sys) + monkeypatch.setattr(publish_module.shutil, "which", lambda name: None) + + with pytest.raises(publish_module.typer.Exit): + publish_module._ensure_python_runtime() + + err = capsys.readouterr().err + assert "Python >= 3.13" in err + + def test_publish_run_cmd_error( monkeypatch: pytest.MonkeyPatch, publish_module: ModuleType ) -> None: @@ -71,7 +89,7 @@ def fake_run_cmd(_: list[str], **__: object) -> None: monkeypatch.setattr(publish_module, "run_cmd", fake_run_cmd) - with pytest.raises(DummyError): + with pytest.raises(DummyError, match="uv publish failed"): publish_module.main(index="") diff --git a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py index 85156cda..f57f6b76 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/tests/test_validate_toml_versions.py @@ -33,13 +33,15 @@ def project_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: def _write_pyproject(base: Path, content: str) -> None: """Create a ``pyproject.toml`` file populated with the provided content.""" base.mkdir(parents=True, exist_ok=True) - (base / "pyproject.toml").write_text(content.strip()) + (base / "pyproject.toml").write_text(content.strip(), encoding="utf-8") def _invoke_main(module: ModuleType, **kwargs: object) -> None: """Invoke ``module.main`` with defaults tailored for the tests.""" kwargs.setdefault("pattern", "**/pyproject.toml") kwargs.setdefault("fail_on_dynamic", "false") + kwargs.setdefault("fail_on_empty", "false") + kwargs.setdefault("skip_directories", "") module.main(**kwargs) @@ -56,7 +58,7 @@ def test_passes_when_versions_match( """, ) - _invoke_main(module, version="1.0.0") + _invoke_main(module, version="1.0.0", fail_on_dynamic=None) captured = capsys.readouterr() assert ( @@ -217,7 +219,7 @@ def test_skips_files_in_ignored_directory( capsys: pytest.CaptureFixture[str], skip_part: str, ) -> None: - """Warn and exit when only a single ignored directory matches the pattern.""" + """Warn and exit when matches appear solely under ignored directories.""" assert skip_part in module.SKIP_PARTS _write_pyproject( project_root / skip_part / "pkg", @@ -225,6 +227,14 @@ def test_skips_files_in_ignored_directory( [project] name = "ignored" version = "9.9.9" +""", + ) + _write_pyproject( + project_root / "nested" / skip_part / "pkg", + """ +[project] +name = "nested-ignored" +version = "9.9.9" """, ) @@ -236,9 +246,99 @@ def test_skips_files_in_ignored_directory( assert "::warning::No TOML files matched pattern" in captured.out +def test_iter_files_skips_virtualenv_and_mypy_cache( + project_root: Path, + module: ModuleType, + capsys: pytest.CaptureFixture[str], +) -> None: + """Ignore matches located under virtualenv and mypy cache directories.""" + _write_pyproject( + project_root / ".venv" / "pkg", + """ +[project] +name = "ignored-venv" +version = "0.1.0" +""", + ) + _write_pyproject( + project_root / "src" / ".mypy_cache" / "pkg", + """ +[project] +name = "ignored-mypy" +version = "0.2.0" +""", + ) + + discovered = list(module._iter_files("**/pyproject.toml")) + assert not discovered + + _invoke_main(module, version="1.0.0") + captured = capsys.readouterr() + assert "::warning::No TOML files matched pattern" in captured.out + + +def test_custom_skip_directories_filter_matches( + project_root: Path, + module: ModuleType, + capsys: pytest.CaptureFixture[str], +) -> None: + """Allow repositories to skip additional transient directory names.""" + _write_pyproject( + project_root / "cache_dir" / "pkg", + """ +[project] +name = "ignored-cache" +version = "0.3.0" +""", + ) + _write_pyproject( + project_root / "alt-dir" / "pkg", + """ +[project] +name = "ignored-alt" +version = "0.4.0" +""", + ) + + discovered = list(module._iter_files("**/pyproject.toml")) + assert discovered + assert "cache_dir" not in module.SKIP_PARTS + + _invoke_main( + module, + version="1.0.0", + skip_directories="cache_dir\nalt-dir", + ) + + captured = capsys.readouterr() + assert "::warning::No TOML files matched pattern" in captured.out + assert "cache_dir" not in module.SKIP_PARTS + + +def test_fail_on_empty_errors_when_enabled( + project_root: Path, + module: ModuleType, + capsys: pytest.CaptureFixture[str], +) -> None: + """Raise an error instead of a warning when ``fail_on_empty`` is truthy.""" + with pytest.raises(module.typer.Exit): + _invoke_main(module, version="1.0.0", fail_on_empty="true") + + captured = capsys.readouterr() + assert "::error::No TOML files matched pattern" in captured.err + + def test_skip_parts_cover_transient_tooling_dirs(module: ModuleType) -> None: """Ensure tooling artefact directories remain excluded from discovery.""" - expected = {".pytest_cache", ".cache", "htmlcov"} + expected = { + ".venv", + "venv", + ".direnv", + ".mypy_cache", + ".pytest_cache", + ".cache", + "htmlcov", + } assert expected <= module.SKIP_PARTS diff --git a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py index a78f9418..f0932cc6 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_write_summary.py +++ b/.github/actions/release-to-pypi-uv/tests/test_write_summary.py @@ -36,6 +36,7 @@ def test_write_summary_appends_markdown( assert "## Release summary" in content assert "- Released tag: v1.2.3" in content assert "- Publish index: pypi (default)" in content + assert "- Environment: pypi" in content def test_write_summary_handles_existing_content( diff --git a/.github/actions/rust-build-release/src/runtime.py b/.github/actions/rust-build-release/src/runtime.py index 09f5b2f0..4390da27 100644 --- a/.github/actions/rust-build-release/src/runtime.py +++ b/.github/actions/rust-build-release/src/runtime.py @@ -18,59 +18,36 @@ CROSS_CONTAINER_ERROR_CODES = {125, 126, 127} +_ARCH_TO_WINDOWS_DEFAULT = { + "amd64": "x86_64-pc-windows-msvc", + "x86_64": "x86_64-pc-windows-msvc", + "arm64": "aarch64-pc-windows-msvc", + "aarch64": "aarch64-pc-windows-msvc", +} + +_ARCH_TO_DARWIN_DEFAULT = { + "x86_64": "x86_64-apple-darwin", + "amd64": "x86_64-apple-darwin", + "arm64": "aarch64-apple-darwin", + "aarch64": "aarch64-apple-darwin", +} + + +def _platform_default_host_target() -> str: + """Return a platform-specific fallback host triple.""" + machine = ( + platform.machine().lower() + or os.environ.get("PROCESSOR_ARCHITECTURE", "").lower() + ) + if sys_platform := sys.platform: + if sys_platform == "win32": + return _ARCH_TO_WINDOWS_DEFAULT.get(machine, "x86_64-pc-windows-msvc") + if sys_platform == "darwin": + return _ARCH_TO_DARWIN_DEFAULT.get(machine, "x86_64-apple-darwin") + return "x86_64-unknown-linux-gnu" + -def _normalize_arch(machine: str) -> str: - mapping = { - "amd64": "x86_64", - "x64": "x86_64", - "x86_64": "x86_64", - "i386": "i686", - "i486": "i686", - "i586": "i686", - "i686": "i686", - "x86": "i686", - "arm64": "aarch64", - "aarch64": "aarch64", - "armv8": "aarch64", - "armv8a": "aarch64", - "armv8l": "aarch64", - "armv7": "armv7", - "armv7a": "armv7", - "armv7hl": "armv7", - "armv7l": "armv7", - "armv6": "armv6", - "armv6l": "armv6", - "ppc64": "ppc64", - "ppc64le": "ppc64le", - "powerpc64": "ppc64", - "powerpc64le": "ppc64le", - "s390x": "s390x", - "riscv64": "riscv64", - "loongarch64": "loongarch64", - } - if not machine: - return "x86_64" - machine_lower = machine.lower() - return mapping.get(machine_lower, machine_lower) - - -def _default_host_target_for_current_platform() -> str: - arch = _normalize_arch(platform.machine()) or "x86_64" - system_name = platform.system().lower() - platform_id = sys.platform.lower() - if system_name == "windows": - return f"{arch}-pc-windows-msvc" - if system_name.startswith(("cygwin", "msys")) or platform_id in {"cygwin", "msys"}: - return f"{arch}-pc-windows-gnu" - if system_name == "darwin": - return f"{arch}-apple-darwin" - if system_name.startswith("linux"): - return f"{arch}-unknown-linux-gnu" - identifier = system_name or platform_id or "linux" - return f"{arch}-unknown-{identifier}" - - -DEFAULT_HOST_TARGET = _default_host_target_for_current_platform() +DEFAULT_HOST_TARGET = _platform_default_host_target() PROBE_TIMEOUT = int(os.environ.get("RUNTIME_PROBE_TIMEOUT", "10")) diff --git a/.github/actions/rust-build-release/tests/test_runtime.py b/.github/actions/rust-build-release/tests/test_runtime.py index 82af2826..2c82f142 100644 --- a/.github/actions/rust-build-release/tests/test_runtime.py +++ b/.github/actions/rust-build-release/tests/test_runtime.py @@ -5,6 +5,7 @@ import json import subprocess import typing as typ +from types import SimpleNamespace import pytest @@ -214,6 +215,28 @@ def test_detect_host_target_returns_default_on_timeout( ) +def test_platform_default_host_target_windows( + runtime_module: ModuleType, module_harness: HarnessFactory +) -> None: + """Windows fallbacks prefer the MSVC triple for common architectures.""" + harness = module_harness(runtime_module) + harness.patch_attr("platform", SimpleNamespace(machine=lambda: "AMD64")) + harness.monkeypatch.setattr(runtime_module.sys, "platform", "win32") + + assert runtime_module._platform_default_host_target() == "x86_64-pc-windows-msvc" + + +def test_platform_default_host_target_darwin_arm( + runtime_module: ModuleType, module_harness: HarnessFactory +) -> None: + """Ensure macOS ARM platforms fall back to the aarch64 Apple triple.""" + harness = module_harness(runtime_module) + harness.patch_attr("platform", SimpleNamespace(machine=lambda: "arm64")) + harness.monkeypatch.setattr(runtime_module.sys, "platform", "darwin") + + assert runtime_module._platform_default_host_target() == "aarch64-apple-darwin" + + def test_detect_host_target_passes_timeout_to_run_validated( runtime_module: ModuleType, module_harness: HarnessFactory ) -> None: diff --git a/.github/actions/rust-build-release/tests/test_smoke.py b/.github/actions/rust-build-release/tests/test_smoke.py index bdd3e2f9..a669b5f6 100644 --- a/.github/actions/rust-build-release/tests/test_smoke.py +++ b/.github/actions/rust-build-release/tests/test_smoke.py @@ -62,10 +62,8 @@ def _param_for_target(target: str) -> object: marks: list[pytest.MarkDecorator] = [] if target != HOST_TARGET and target.endswith("-unknown-linux-gnu"): marks.append(LINUX_ONLY) - if target.endswith("-pc-windows-gnu") or target.endswith("-pc-windows-msvc"): - marks.append(WINDOWS_ONLY) - if target.endswith("-pc-windows-gnu") or target.endswith("-pc-windows-msvc"): - marks.append(WINDOWS_KNOWN_FAILURE) + if target.endswith(("-pc-windows-gnu", "-pc-windows-msvc")): + marks.extend((WINDOWS_ONLY, WINDOWS_KNOWN_FAILURE)) if marks: return pytest.param(target, marks=tuple(marks)) return pytest.param(target) diff --git a/.github/actions/rust-build-release/tests/test_target_install.py b/.github/actions/rust-build-release/tests/test_target_install.py index 95e12f1f..d661e7be 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -370,6 +370,29 @@ def raise_timeout(name: str) -> bool: assert expected in err +def test_probe_runtime_warns_on_timeout_without_duration( + main_module: ModuleType, + module_harness: HarnessFactory, + capsys: pytest.CaptureFixture[str], +) -> None: + """Timeout warnings omit duration when the exception lacks a timeout.""" + harness = module_harness(main_module) + + def raise_timeout(name: str) -> bool: + _ = name + raise subprocess.TimeoutExpired(cmd="docker info", timeout=None) + + harness.patch_attr("runtime_available", raise_timeout) + + assert main_module._probe_runtime("docker") is False + + err = capsys.readouterr().err + expected = ( + "::warning::docker runtime probe timed out; treating runtime as unavailable" + ) + assert expected in err + + def test_probe_runtime_propagates_unexpected_error( main_module: ModuleType, module_harness: HarnessFactory, @@ -398,7 +421,7 @@ def test_runtime_available_handles_timeout( module_harness: HarnessFactory, capsys: pytest.CaptureFixture[str], ) -> None: - """Treat runtime probe timeouts as unavailable instead of crashing.""" + """Treat runtime probe timeouts as unavailable while still completing the build.""" harness = module_harness(main_module) default_toolchain = main_module.DEFAULT_TOOLCHAIN @@ -416,14 +439,17 @@ def fake_run_validated( harness.patch_attr("run_validated", fake_run_validated) - def run_cmd_side_effect(cmd: list[str]) -> None: + commands: list[list[str]] = [] + + def record_run_cmd(cmd: list[str]) -> None: + commands.append(cmd) if cmd[:3] == ["/usr/bin/rustup", "target", "add"]: - raise subprocess.CalledProcessError(1, cmd) + return if cmd and cmd[0] == "cargo": return pytest.fail(f"unexpected run_cmd call: {cmd}") - harness.patch_run_cmd(run_cmd_side_effect) + harness.patch_run_cmd(record_run_cmd) harness.patch_attr("configure_windows_linkers", lambda *_: None) def timeout_runtime(_name: str, *, cwd: object | None = None) -> bool: @@ -433,10 +459,9 @@ def timeout_runtime(_name: str, *, cwd: object | None = None) -> bool: harness.patch_attr("runtime_available", timeout_runtime) harness.patch_attr("ensure_cross", lambda *_: (None, None)) - with pytest.raises(main_module.typer.Exit): - main_module.main("thumbv7em-none-eabihf", default_toolchain) + main_module.main("thumbv7em-none-eabihf", default_toolchain) - err = capsys.readouterr().err + out, err = capsys.readouterr() expected_docker = ( "::warning::docker runtime probe timed out after 10s; " "treating runtime as unavailable" @@ -445,15 +470,17 @@ def timeout_runtime(_name: str, *, cwd: object | None = None) -> bool: "::warning::podman runtime probe timed out after 10s; " "treating runtime as unavailable" ) - expected_toolchain = ( - f"::error:: toolchain '{default_toolchain}-x86_64-unknown-linux-gnu' " - "does not support target 'thumbv7em-none-eabihf'" - ) assert expected_docker in err assert expected_podman in err - assert expected_toolchain in err + assert "cross missing; using cargo" in out _assert_no_timeout_trace(err) + assert len(commands) >= 2 + assert commands[0][:3] == ["/usr/bin/rustup", "target", "add"] + assert commands[1][0] == "cargo" + assert commands[1][1].startswith("+") + assert commands[1][-1] == "thumbv7em-none-eabihf" + def test_configure_windows_linkers_prefers_toolchain_gcc( main_module: ModuleType, diff --git a/.gitignore b/.gitignore index 9eebcdb9..646ad25f 100644 --- a/.gitignore +++ b/.gitignore @@ -153,8 +153,8 @@ venv/ target/ # uv cache and lockfile -uv.lock -.uv/ +/uv.lock +/.uv/ # Crush AI agent .crush/ diff --git a/Makefile b/Makefile index 5e082b35..e641ed7a 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ clean: ## Remove transient artefacts BUILD_JOBS ?= MDLINT ?= markdownlint NIXIE ?= nixie +RUFF_FIX_RULES ?= D202,I001 test: .venv ## Run tests uv run --with typer --with packaging --with plumbum --with pyyaml pytest -v @@ -43,7 +44,7 @@ typecheck: .venv ## Run static type checking with Ty uvx pyright fmt: ## Apply formatting to Python files uvx ruff format - uvx ruff check --select D202,I001 --fix + uvx ruff check --select $(RUFF_FIX_RULES) --fix check-fmt: ## Check Python formatting without modifying files uvx ruff format --check diff --git a/docs/cmd-mox-users-guide.md b/docs/cmd-mox-users-guide.md index 88d1ad55..b64297cc 100644 --- a/docs/cmd-mox-users-guide.md +++ b/docs/cmd-mox-users-guide.md @@ -40,7 +40,7 @@ interactions matched what was recorded. The three phases are defined in the design document: 1. **Record** – describe each expected command call, including its arguments - and behavior. + and behaviour. 2. **Replay** – run the code under test while CmdMox intercepts command executions. 3. **Verify** – ensure every expectation was met and nothing unexpected @@ -73,7 +73,7 @@ cmd_mox.spy("curl") - **Spies** record every call for later inspection and can behave like stubs. Each call returns a `CommandDouble` that offers a fluent DSL to configure -behavior. +behaviour. ## Defining expectations diff --git a/docs/python-native-command-mocking-design.md b/docs/python-native-command-mocking-design.md index 3976ae43..f1ed9034 100644 --- a/docs/python-native-command-mocking-design.md +++ b/docs/python-native-command-mocking-design.md @@ -27,7 +27,7 @@ CmdMox consists of three cooperating subsystems: exposed through attributes such as `environment.shim_dir`. 3. **IPC Server** – Handles requests from shims, dispatching them to the recorded doubles. The server enforces strict sequencing to maintain - deterministic behavior. + deterministic behaviour. The pytest plugin creates a controller per test function. When used as a context manager (`with CmdMox() as mox:`) the same controller lifecycle is available for @@ -52,7 +52,7 @@ exception visible to the test runner. ## Command Doubles and Responses -`CommandDouble` instances configure behavior with a fluent DSL: +`CommandDouble` instances configure behaviour with a fluent DSL: - `with_args(*args)` asserts exact argument sequences. - `with_matching_args(*matchers)` allows per-position comparator functions such @@ -104,7 +104,7 @@ named-pipe transports for full parity. - Each shim invocation is validated against its matching strategy; mismatches are surfaced immediately with descriptive error messages. - Journal eviction and verification are both deterministic so repeated runs yield - identical behavior given the same expectations and inputs. + identical behaviour given the same expectations and inputs. CmdMox is designed to remain implementation-agnostic at the call site, allowing maintainers to evolve the underlying IPC layer or shim mechanism without diff --git a/pyproject.toml b/pyproject.toml index 114e57a5..34f457bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,7 +30,7 @@ dev = [ "pyyaml>=6.0,<7.0", "ty>=0.0.1a20", "uuid6>=2025.0.1", - "cmd-mox@git+https://github.com/leynos/cmd-mox.git", + "cmd-mox@git+https://github.com/leynos/cmd-mox.git@baaaf89862837b8a1565fab2c18c34d498e08601", ] [tool.ruff] diff --git a/uv.lock b/uv.lock deleted file mode 100644 index 782df030..00000000 --- a/uv.lock +++ /dev/null @@ -1,382 +0,0 @@ -version = 1 -revision = 2 -requires-python = ">=3.12" - -[[package]] -name = "attrs" -version = "25.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032, upload-time = "2025-03-13T11:10:22.779Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815, upload-time = "2025-03-13T11:10:21.14Z" }, -] - -[[package]] -name = "click" -version = "8.2.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, -] - -[[package]] -name = "cmd-mox" -version = "0.1.0" -source = { git = "https://github.com/leynos/cmd-mox.git#5bf23d0ae6055397956a3d4440063fa6a77b10d8" } - -[[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 = "cyclopts" -version = "2.9.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "attrs" }, - { name = "docstring-parser", marker = "python_full_version < '4'" }, - { name = "rich" }, - { name = "rich-rst" }, - { name = "typing-extensions", marker = "python_full_version < '4'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/61/b6/51022d8b673087262c4bcd1e64c1db3a8ab01510033f7f82a561998e3499/cyclopts-2.9.9.tar.gz", hash = "sha256:11d7bb59be253329ff49a1b9a634676c7ae708605d4975090783b99d081c1a0b", size = 45179, upload-time = "2024-08-27T21:14:47.137Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/08/7c/f1af5b44f581df05009ecc9b15395532fc18c7aa7292b5e98501af25c2db/cyclopts-2.9.9-py3-none-any.whl", hash = "sha256:d0ce956c70f3070e5bc16824ecb5ebba155be45ef4aadbb78ac4753dd99367e3", size = 51062, upload-time = "2024-08-27T21:14:45.977Z" }, -] - -[[package]] -name = "docstring-parser" -version = "0.17.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b2/9d/c3b43da9515bd270df0f80548d9944e389870713cc1fe2b8fb35fe2bcefd/docstring_parser-0.17.0.tar.gz", hash = "sha256:583de4a309722b3315439bb31d64ba3eebada841f2e2cee23b99df001434c912", size = 27442, upload-time = "2025-07-21T07:35:01.868Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, -] - -[[package]] -name = "docutils" -version = "0.22.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4a/c0/89fe6215b443b919cb98a5002e107cb5026854ed1ccb6b5833e0768419d1/docutils-0.22.2.tar.gz", hash = "sha256:9fdb771707c8784c8f2728b67cb2c691305933d68137ef95a75db5f4dfbc213d", size = 2289092, upload-time = "2025-09-20T17:55:47.994Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/66/dd/f95350e853a4468ec37478414fc04ae2d61dad7a947b3015c3dcc51a09b9/docutils-0.22.2-py3-none-any.whl", hash = "sha256:b0e98d679283fc3bb0ead8a5da7f501baa632654e7056e9c5846842213d674d8", size = 632667, upload-time = "2025-09-20T17:55:43.052Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, -] - -[[package]] -name = "lxml" -version = "5.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/76/3d/14e82fc7c8fb1b7761f7e748fd47e2ec8276d137b6acfe5a4bb73853e08f/lxml-5.4.0.tar.gz", hash = "sha256:d12832e1dbea4be280b22fd0ea7c9b87f0d8fc51ba06e92dc62d52f804f78ebd", size = 3679479, upload-time = "2025-04-23T01:50:29.322Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f8/4c/d101ace719ca6a4ec043eb516fcfcb1b396a9fccc4fcd9ef593df34ba0d5/lxml-5.4.0-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:b5aff6f3e818e6bdbbb38e5967520f174b18f539c2b9de867b1e7fde6f8d95a4", size = 8127392, upload-time = "2025-04-23T01:46:04.09Z" }, - { url = "https://files.pythonhosted.org/packages/11/84/beddae0cec4dd9ddf46abf156f0af451c13019a0fa25d7445b655ba5ccb7/lxml-5.4.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:942a5d73f739ad7c452bf739a62a0f83e2578afd6b8e5406308731f4ce78b16d", size = 4415103, upload-time = "2025-04-23T01:46:07.227Z" }, - { url = "https://files.pythonhosted.org/packages/d0/25/d0d93a4e763f0462cccd2b8a665bf1e4343dd788c76dcfefa289d46a38a9/lxml-5.4.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:460508a4b07364d6abf53acaa0a90b6d370fafde5693ef37602566613a9b0779", size = 5024224, upload-time = "2025-04-23T01:46:10.237Z" }, - { url = "https://files.pythonhosted.org/packages/31/ce/1df18fb8f7946e7f3388af378b1f34fcf253b94b9feedb2cec5969da8012/lxml-5.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:529024ab3a505fed78fe3cc5ddc079464e709f6c892733e3f5842007cec8ac6e", size = 4769913, upload-time = "2025-04-23T01:46:12.757Z" }, - { url = "https://files.pythonhosted.org/packages/4e/62/f4a6c60ae7c40d43657f552f3045df05118636be1165b906d3423790447f/lxml-5.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ca56ebc2c474e8f3d5761debfd9283b8b18c76c4fc0967b74aeafba1f5647f9", size = 5290441, upload-time = "2025-04-23T01:46:16.037Z" }, - { url = "https://files.pythonhosted.org/packages/9e/aa/04f00009e1e3a77838c7fc948f161b5d2d5de1136b2b81c712a263829ea4/lxml-5.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a81e1196f0a5b4167a8dafe3a66aa67c4addac1b22dc47947abd5d5c7a3f24b5", size = 4820165, upload-time = "2025-04-23T01:46:19.137Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/e0b2f61fa2404bf0f1fdf1898377e5bd1b74cc9b2cf2c6ba8509b8f27990/lxml-5.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00b8686694423ddae324cf614e1b9659c2edb754de617703c3d29ff568448df5", size = 4932580, upload-time = "2025-04-23T01:46:21.963Z" }, - { url = "https://files.pythonhosted.org/packages/24/a2/8263f351b4ffe0ed3e32ea7b7830f845c795349034f912f490180d88a877/lxml-5.4.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c5681160758d3f6ac5b4fea370495c48aac0989d6a0f01bb9a72ad8ef5ab75c4", size = 4759493, upload-time = "2025-04-23T01:46:24.316Z" }, - { url = "https://files.pythonhosted.org/packages/05/00/41db052f279995c0e35c79d0f0fc9f8122d5b5e9630139c592a0b58c71b4/lxml-5.4.0-cp312-cp312-manylinux_2_28_ppc64le.whl", hash = "sha256:2dc191e60425ad70e75a68c9fd90ab284df64d9cd410ba8d2b641c0c45bc006e", size = 5324679, upload-time = "2025-04-23T01:46:27.097Z" }, - { url = "https://files.pythonhosted.org/packages/1d/be/ee99e6314cdef4587617d3b3b745f9356d9b7dd12a9663c5f3b5734b64ba/lxml-5.4.0-cp312-cp312-manylinux_2_28_s390x.whl", hash = "sha256:67f779374c6b9753ae0a0195a892a1c234ce8416e4448fe1e9f34746482070a7", size = 4890691, upload-time = "2025-04-23T01:46:30.009Z" }, - { url = "https://files.pythonhosted.org/packages/ad/36/239820114bf1d71f38f12208b9c58dec033cbcf80101cde006b9bde5cffd/lxml-5.4.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:79d5bfa9c1b455336f52343130b2067164040604e41f6dc4d8313867ed540079", size = 4955075, upload-time = "2025-04-23T01:46:32.33Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e1/1b795cc0b174efc9e13dbd078a9ff79a58728a033142bc6d70a1ee8fc34d/lxml-5.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3d3c30ba1c9b48c68489dc1829a6eede9873f52edca1dda900066542528d6b20", size = 4838680, upload-time = "2025-04-23T01:46:34.852Z" }, - { url = "https://files.pythonhosted.org/packages/72/48/3c198455ca108cec5ae3662ae8acd7fd99476812fd712bb17f1b39a0b589/lxml-5.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1af80c6316ae68aded77e91cd9d80648f7dd40406cef73df841aa3c36f6907c8", size = 5391253, upload-time = "2025-04-23T01:46:37.608Z" }, - { url = "https://files.pythonhosted.org/packages/d6/10/5bf51858971c51ec96cfc13e800a9951f3fd501686f4c18d7d84fe2d6352/lxml-5.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4d885698f5019abe0de3d352caf9466d5de2baded00a06ef3f1216c1a58ae78f", size = 5261651, upload-time = "2025-04-23T01:46:40.183Z" }, - { url = "https://files.pythonhosted.org/packages/2b/11/06710dd809205377da380546f91d2ac94bad9ff735a72b64ec029f706c85/lxml-5.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:aea53d51859b6c64e7c51d522c03cc2c48b9b5d6172126854cc7f01aa11f52bc", size = 5024315, upload-time = "2025-04-23T01:46:43.333Z" }, - { url = "https://files.pythonhosted.org/packages/f5/b0/15b6217834b5e3a59ebf7f53125e08e318030e8cc0d7310355e6edac98ef/lxml-5.4.0-cp312-cp312-win32.whl", hash = "sha256:d90b729fd2732df28130c064aac9bb8aff14ba20baa4aee7bd0795ff1187545f", size = 3486149, upload-time = "2025-04-23T01:46:45.684Z" }, - { url = "https://files.pythonhosted.org/packages/91/1e/05ddcb57ad2f3069101611bd5f5084157d90861a2ef460bf42f45cced944/lxml-5.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:1dc4ca99e89c335a7ed47d38964abcb36c5910790f9bd106f2a8fa2ee0b909d2", size = 3817095, upload-time = "2025-04-23T01:46:48.521Z" }, - { url = "https://files.pythonhosted.org/packages/87/cb/2ba1e9dd953415f58548506fa5549a7f373ae55e80c61c9041b7fd09a38a/lxml-5.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:773e27b62920199c6197130632c18fb7ead3257fce1ffb7d286912e56ddb79e0", size = 8110086, upload-time = "2025-04-23T01:46:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3e/6602a4dca3ae344e8609914d6ab22e52ce42e3e1638c10967568c5c1450d/lxml-5.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9c671845de9699904b1e9df95acfe8dfc183f2310f163cdaa91a3535af95de", size = 4404613, upload-time = "2025-04-23T01:46:55.281Z" }, - { url = "https://files.pythonhosted.org/packages/4c/72/bf00988477d3bb452bef9436e45aeea82bb40cdfb4684b83c967c53909c7/lxml-5.4.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9454b8d8200ec99a224df8854786262b1bd6461f4280064c807303c642c05e76", size = 5012008, upload-time = "2025-04-23T01:46:57.817Z" }, - { url = "https://files.pythonhosted.org/packages/92/1f/93e42d93e9e7a44b2d3354c462cd784dbaaf350f7976b5d7c3f85d68d1b1/lxml-5.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cccd007d5c95279e529c146d095f1d39ac05139de26c098166c4beb9374b0f4d", size = 4760915, upload-time = "2025-04-23T01:47:00.745Z" }, - { url = "https://files.pythonhosted.org/packages/45/0b/363009390d0b461cf9976a499e83b68f792e4c32ecef092f3f9ef9c4ba54/lxml-5.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fce1294a0497edb034cb416ad3e77ecc89b313cff7adbee5334e4dc0d11f422", size = 5283890, upload-time = "2025-04-23T01:47:04.702Z" }, - { url = "https://files.pythonhosted.org/packages/19/dc/6056c332f9378ab476c88e301e6549a0454dbee8f0ae16847414f0eccb74/lxml-5.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24974f774f3a78ac12b95e3a20ef0931795ff04dbb16db81a90c37f589819551", size = 4812644, upload-time = "2025-04-23T01:47:07.833Z" }, - { url = "https://files.pythonhosted.org/packages/ee/8a/f8c66bbb23ecb9048a46a5ef9b495fd23f7543df642dabeebcb2eeb66592/lxml-5.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:497cab4d8254c2a90bf988f162ace2ddbfdd806fce3bda3f581b9d24c852e03c", size = 4921817, upload-time = "2025-04-23T01:47:10.317Z" }, - { url = "https://files.pythonhosted.org/packages/04/57/2e537083c3f381f83d05d9b176f0d838a9e8961f7ed8ddce3f0217179ce3/lxml-5.4.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:e794f698ae4c5084414efea0f5cc9f4ac562ec02d66e1484ff822ef97c2cadff", size = 4753916, upload-time = "2025-04-23T01:47:12.823Z" }, - { url = "https://files.pythonhosted.org/packages/d8/80/ea8c4072109a350848f1157ce83ccd9439601274035cd045ac31f47f3417/lxml-5.4.0-cp313-cp313-manylinux_2_28_ppc64le.whl", hash = "sha256:2c62891b1ea3094bb12097822b3d44b93fc6c325f2043c4d2736a8ff09e65f60", size = 5289274, upload-time = "2025-04-23T01:47:15.916Z" }, - { url = "https://files.pythonhosted.org/packages/b3/47/c4be287c48cdc304483457878a3f22999098b9a95f455e3c4bda7ec7fc72/lxml-5.4.0-cp313-cp313-manylinux_2_28_s390x.whl", hash = "sha256:142accb3e4d1edae4b392bd165a9abdee8a3c432a2cca193df995bc3886249c8", size = 4874757, upload-time = "2025-04-23T01:47:19.793Z" }, - { url = "https://files.pythonhosted.org/packages/2f/04/6ef935dc74e729932e39478e44d8cfe6a83550552eaa072b7c05f6f22488/lxml-5.4.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:1a42b3a19346e5601d1b8296ff6ef3d76038058f311902edd574461e9c036982", size = 4947028, upload-time = "2025-04-23T01:47:22.401Z" }, - { url = "https://files.pythonhosted.org/packages/cb/f9/c33fc8daa373ef8a7daddb53175289024512b6619bc9de36d77dca3df44b/lxml-5.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4291d3c409a17febf817259cb37bc62cb7eb398bcc95c1356947e2871911ae61", size = 4834487, upload-time = "2025-04-23T01:47:25.513Z" }, - { url = "https://files.pythonhosted.org/packages/8d/30/fc92bb595bcb878311e01b418b57d13900f84c2b94f6eca9e5073ea756e6/lxml-5.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4f5322cf38fe0e21c2d73901abf68e6329dc02a4994e483adbcf92b568a09a54", size = 5381688, upload-time = "2025-04-23T01:47:28.454Z" }, - { url = "https://files.pythonhosted.org/packages/43/d1/3ba7bd978ce28bba8e3da2c2e9d5ae3f8f521ad3f0ca6ea4788d086ba00d/lxml-5.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:0be91891bdb06ebe65122aa6bf3fc94489960cf7e03033c6f83a90863b23c58b", size = 5242043, upload-time = "2025-04-23T01:47:31.208Z" }, - { url = "https://files.pythonhosted.org/packages/ee/cd/95fa2201041a610c4d08ddaf31d43b98ecc4b1d74b1e7245b1abdab443cb/lxml-5.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:15a665ad90054a3d4f397bc40f73948d48e36e4c09f9bcffc7d90c87410e478a", size = 5021569, upload-time = "2025-04-23T01:47:33.805Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a6/31da006fead660b9512d08d23d31e93ad3477dd47cc42e3285f143443176/lxml-5.4.0-cp313-cp313-win32.whl", hash = "sha256:d5663bc1b471c79f5c833cffbc9b87d7bf13f87e055a5c86c363ccd2348d7e82", size = 3485270, upload-time = "2025-04-23T01:47:36.133Z" }, - { url = "https://files.pythonhosted.org/packages/fc/14/c115516c62a7d2499781d2d3d7215218c0731b2c940753bf9f9b7b73924d/lxml-5.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:bcb7a1096b4b6b24ce1ac24d4942ad98f983cd3810f9711bcd0293f43a9d8b9f", size = 3814606, upload-time = "2025-04-23T01:47:39.028Z" }, -] - -[[package]] -name = "lxml-stubs" -version = "0.5.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/da/1a3a3e5d159b249fc2970d73437496b908de8e4716a089c69591b4ffa6fd/lxml-stubs-0.5.1.tar.gz", hash = "sha256:e0ec2aa1ce92d91278b719091ce4515c12adc1d564359dfaf81efa7d4feab79d", size = 14778, upload-time = "2024-01-10T09:37:46.521Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/c9/e0f8e4e6e8a69e5959b06499582dca6349db6769cc7fdfb8a02a7c75a9ae/lxml_stubs-0.5.1-py3-none-any.whl", hash = "sha256:1f689e5dbc4b9247cb09ae820c7d34daeb1fdbd1db06123814b856dae7787272", size = 13584, upload-time = "2024-01-10T09:37:44.931Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, -] - -[[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 = "packaging" -version = "25.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, -] - -[[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 = "plumbum" -version = "1.9.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f0/5d/49ba324ad4ae5b1a4caefafbce7a1648540129344481f2ed4ef6bb68d451/plumbum-1.9.0.tar.gz", hash = "sha256:e640062b72642c3873bd5bdc3effed75ba4d3c70ef6b6a7b907357a84d909219", size = 319083, upload-time = "2024-10-05T05:59:27.059Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/9d/d03542c93bb3d448406731b80f39c3d5601282f778328c22c77d270f4ed4/plumbum-1.9.0-py3-none-any.whl", hash = "sha256:9fd0d3b0e8d86e4b581af36edf3f3bbe9d1ae15b45b8caab28de1bcb27aaa7f5", size = 127970, upload-time = "2024-10-05T05:59:25.102Z" }, -] - -[[package]] -name = "pygments" -version = "2.19.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, -] - -[[package]] -name = "pytest" -version = "8.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, -] - -[[package]] -name = "pywin32" -version = "311" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/ab/01ea1943d4eba0f850c3c61e78e8dd59757ff815ff3ccd0a84de5f541f42/pywin32-311-cp312-cp312-win32.whl", hash = "sha256:750ec6e621af2b948540032557b10a2d43b0cee2ae9758c54154d711cc852d31", size = 8706543, upload-time = "2025-07-14T20:13:20.765Z" }, - { url = "https://files.pythonhosted.org/packages/d1/a8/a0e8d07d4d051ec7502cd58b291ec98dcc0c3fff027caad0470b72cfcc2f/pywin32-311-cp312-cp312-win_amd64.whl", hash = "sha256:b8c095edad5c211ff31c05223658e71bf7116daa0ecf3ad85f3201ea3190d067", size = 9495040, upload-time = "2025-07-14T20:13:22.543Z" }, - { url = "https://files.pythonhosted.org/packages/ba/3a/2ae996277b4b50f17d61f0603efd8253cb2d79cc7ae159468007b586396d/pywin32-311-cp312-cp312-win_arm64.whl", hash = "sha256:e286f46a9a39c4a18b319c28f59b61de793654af2f395c102b4f819e584b5852", size = 8710102, upload-time = "2025-07-14T20:13:24.682Z" }, - { url = "https://files.pythonhosted.org/packages/a5/be/3fd5de0979fcb3994bfee0d65ed8ca9506a8a1260651b86174f6a86f52b3/pywin32-311-cp313-cp313-win32.whl", hash = "sha256:f95ba5a847cba10dd8c4d8fefa9f2a6cf283b8b88ed6178fa8a6c1ab16054d0d", size = 8705700, upload-time = "2025-07-14T20:13:26.471Z" }, - { url = "https://files.pythonhosted.org/packages/e3/28/e0a1909523c6890208295a29e05c2adb2126364e289826c0a8bc7297bd5c/pywin32-311-cp313-cp313-win_amd64.whl", hash = "sha256:718a38f7e5b058e76aee1c56ddd06908116d35147e133427e59a3983f703a20d", size = 9494700, upload-time = "2025-07-14T20:13:28.243Z" }, - { url = "https://files.pythonhosted.org/packages/04/bf/90339ac0f55726dce7d794e6d79a18a91265bdf3aa70b6b9ca52f35e022a/pywin32-311-cp313-cp313-win_arm64.whl", hash = "sha256:7b4075d959648406202d92a2310cb990fea19b535c7f4a78d3f5e10b926eeb8a", size = 8709318, upload-time = "2025-07-14T20:13:30.348Z" }, - { url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" }, - { url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" }, - { url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, - { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, - { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, - { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, - { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, - { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, - { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, - { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, - { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, - { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, - { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, - { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, - { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, - { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, - { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, - { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, - { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, -] - -[[package]] -name = "rich" -version = "14.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/fe/75/af448d8e52bf1d8fa6a9d089ca6c07ff4453d86c65c145d0a300bb073b9b/rich-14.1.0.tar.gz", hash = "sha256:e497a48b844b0320d45007cdebfeaeed8db2a4f4bcf49f15e455cfc4af11eaa8", size = 224441, upload-time = "2025-07-25T07:32:58.125Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e3/30/3c4d035596d3cf444529e0b2953ad0466f6049528a879d27534700580395/rich-14.1.0-py3-none-any.whl", hash = "sha256:536f5f1785986d6dbdea3c75205c473f970777b4a0d6c6dd1b696aa05a3fa04f", size = 243368, upload-time = "2025-07-25T07:32:56.73Z" }, -] - -[[package]] -name = "rich-rst" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "docutils" }, - { name = "rich" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b0/69/5514c3a87b5f10f09a34bb011bc0927bc12c596c8dae5915604e71abc386/rich_rst-1.3.1.tar.gz", hash = "sha256:fad46e3ba42785ea8c1785e2ceaa56e0ffa32dbe5410dec432f37e4107c4f383", size = 13839, upload-time = "2024-04-30T04:40:38.125Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/bc/cc4e3dbc5e7992398dcb7a8eda0cbcf4fb792a0cdb93f857b478bf3cf884/rich_rst-1.3.1-py3-none-any.whl", hash = "sha256:498a74e3896507ab04492d326e794c3ef76e7cda078703aa592d1853d91098c1", size = 11621, upload-time = "2024-04-30T04:40:32.619Z" }, -] - -[[package]] -name = "shared-actions" -version = "1.2.2" -source = { editable = "." } -dependencies = [ - { name = "cyclopts" }, - { name = "lxml" }, - { name = "plumbum" }, - { name = "typer" }, -] - -[package.dev-dependencies] -dev = [ - { name = "cmd-mox" }, - { name = "lxml-stubs" }, - { name = "pytest" }, - { name = "pyyaml" }, - { name = "ty" }, - { name = "uuid6" }, -] - -[package.metadata] -requires-dist = [ - { name = "cyclopts", specifier = ">=2.9,<3.0" }, - { name = "lxml", specifier = ">=5.2,<6.0" }, - { name = "plumbum", specifier = ">=1.8,<2.0" }, - { name = "typer", specifier = ">=0.9,<1.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "cmd-mox", git = "https://github.com/leynos/cmd-mox.git" }, - { name = "lxml-stubs", specifier = ">=0.5.1" }, - { name = "pytest", specifier = ">=8.0,<9.0" }, - { name = "pyyaml", specifier = ">=6.0,<7.0" }, - { name = "ty", specifier = ">=0.0.1a20" }, - { name = "uuid6", specifier = ">=2025.0.1" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "ty" -version = "0.0.1a20" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7a/82/a5e3b4bc5280ec49c4b0b43d0ff727d58c7df128752c9c6f97ad0b5f575f/ty-0.0.1a20.tar.gz", hash = "sha256:933b65a152f277aa0e23ba9027e5df2c2cc09e18293e87f2a918658634db5f15", size = 4194773, upload-time = "2025-09-03T12:35:46.775Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/45/c8/f7d39392043d5c04936f6cad90e50eb661965ed092ca4bfc01db917d7b8a/ty-0.0.1a20-py3-none-linux_armv6l.whl", hash = "sha256:f73a7aca1f0d38af4d6999b375eb00553f3bfcba102ae976756cc142e14f3450", size = 8443599, upload-time = "2025-09-03T12:35:04.289Z" }, - { url = "https://files.pythonhosted.org/packages/1e/57/5aec78f9b8a677b7439ccded7d66c3361e61247e0f6b14e659b00dd01008/ty-0.0.1a20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:cad12c857ea4b97bf61e02f6796e13061ccca5e41f054cbd657862d80aa43bae", size = 8618102, upload-time = "2025-09-03T12:35:07.448Z" }, - { url = "https://files.pythonhosted.org/packages/15/20/50c9107d93cdb55676473d9dc4e2339af6af606660c9428d3b86a1b2a476/ty-0.0.1a20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f153b65c7fcb6b8b59547ddb6353761b3e8d8bb6f0edd15e3e3ac14405949f7a", size = 8192167, upload-time = "2025-09-03T12:35:09.706Z" }, - { url = "https://files.pythonhosted.org/packages/85/28/018b2f330109cee19e81c5ca9df3dc29f06c5778440eb9af05d4550c4302/ty-0.0.1a20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8c4336987a6a781d4392a9fd7b3a39edb7e4f3dd4f860e03f46c932b52aefa2", size = 8349256, upload-time = "2025-09-03T12:35:11.76Z" }, - { url = "https://files.pythonhosted.org/packages/cd/c9/2f8797a05587158f52b142278796ffd72c893bc5ad41840fce5aeb65c6f2/ty-0.0.1a20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3ff75cd4c744d09914e8c9db8d99e02f82c9379ad56b0a3fc4c5c9c923cfa84e", size = 8271214, upload-time = "2025-09-03T12:35:13.741Z" }, - { url = "https://files.pythonhosted.org/packages/30/d4/2cac5e5eb9ee51941358cb3139aadadb59520cfaec94e4fcd2b166969748/ty-0.0.1a20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e26437772be7f7808868701f2bf9e14e706a6ec4c7d02dbd377ff94d7ba60c11", size = 9264939, upload-time = "2025-09-03T12:35:16.896Z" }, - { url = "https://files.pythonhosted.org/packages/93/96/a6f2b54e484b2c6a5488f217882237dbdf10f0fdbdb6cd31333d57afe494/ty-0.0.1a20-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:83a7ee12465841619b5eb3ca962ffc7d576bb1c1ac812638681aee241acbfbbe", size = 9743137, upload-time = "2025-09-03T12:35:19.799Z" }, - { url = "https://files.pythonhosted.org/packages/6e/67/95b40dcbec3d222f3af5fe5dd1ce066d42f8a25a2f70d5724490457048e7/ty-0.0.1a20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:726d0738be4459ac7ffae312ba96c5f486d6cbc082723f322555d7cba9397871", size = 9368153, upload-time = "2025-09-03T12:35:22.569Z" }, - { url = "https://files.pythonhosted.org/packages/2c/24/689fa4c4270b9ef9a53dc2b1d6ffade259ba2c4127e451f0629e130ea46a/ty-0.0.1a20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b481f26513f38543df514189fb16744690bcba8d23afee95a01927d93b46e36", size = 9099637, upload-time = "2025-09-03T12:35:24.94Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5b/913011cbf3ea4030097fb3c4ce751856114c9e1a5e1075561a4c5242af9b/ty-0.0.1a20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7abbe3c02218c12228b1d7c5f98c57240029cc3bcb15b6997b707c19be3908c1", size = 8952000, upload-time = "2025-09-03T12:35:27.288Z" }, - { url = "https://files.pythonhosted.org/packages/df/f9/f5ba2ae455b20c5bb003f9940ef8142a8c4ed9e27de16e8f7472013609db/ty-0.0.1a20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fff51c75ee3f7cc6d7722f2f15789ef8ffe6fd2af70e7269ac785763c906688e", size = 8217938, upload-time = "2025-09-03T12:35:29.54Z" }, - { url = "https://files.pythonhosted.org/packages/eb/62/17002cf9032f0981cdb8c898d02422c095c30eefd69ca62a8b705d15bd0f/ty-0.0.1a20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b4124ab75e0e6f09fe7bc9df4a77ee43c5e0ef7e61b0c149d7c089d971437cbd", size = 8292369, upload-time = "2025-09-03T12:35:31.748Z" }, - { url = "https://files.pythonhosted.org/packages/28/d6/0879b1fb66afe1d01d45c7658f3849aa641ac4ea10679404094f3b40053e/ty-0.0.1a20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8a138fa4f74e6ed34e9fd14652d132409700c7ff57682c2fed656109ebfba42f", size = 8811973, upload-time = "2025-09-03T12:35:33.997Z" }, - { url = "https://files.pythonhosted.org/packages/60/1e/70bf0348cfe8ba5f7532983f53c508c293ddf5fa9f942ed79a3c4d576df3/ty-0.0.1a20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:8eff8871d6b88d150e2a67beba2c57048f20c090c219f38ed02eebaada04c124", size = 9010990, upload-time = "2025-09-03T12:35:36.766Z" }, - { url = "https://files.pythonhosted.org/packages/b7/ca/03d85c7650359247b1ca3f38a0d869a608ef540450151920e7014ed58292/ty-0.0.1a20-py3-none-win32.whl", hash = "sha256:3c2ace3a22fab4bd79f84c74e3dab26e798bfba7006bea4008d6321c1bd6efc6", size = 8100746, upload-time = "2025-09-03T12:35:40.007Z" }, - { url = "https://files.pythonhosted.org/packages/94/53/7a1937b8c7a66d0c8ed7493de49ed454a850396fe137d2ae12ed247e0b2f/ty-0.0.1a20-py3-none-win_amd64.whl", hash = "sha256:f41e77ff118da3385915e13c3f366b3a2f823461de54abd2e0ca72b170ba0f19", size = 8748861, upload-time = "2025-09-03T12:35:42.175Z" }, - { url = "https://files.pythonhosted.org/packages/27/36/5a3a70c5d497d3332f9e63cabc9c6f13484783b832fecc393f4f1c0c4aa8/ty-0.0.1a20-py3-none-win_arm64.whl", hash = "sha256:d8ac1c5a14cda5fad1a8b53959d9a5d979fe16ce1cc2785ea8676fed143ac85f", size = 8269906, upload-time = "2025-09-03T12:35:45.045Z" }, -] - -[[package]] -name = "typer" -version = "0.17.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/92/e8/2a73ccf9874ec4c7638f172efc8972ceab13a0e3480b389d6ed822f7a822/typer-0.17.4.tar.gz", hash = "sha256:b77dc07d849312fd2bb5e7f20a7af8985c7ec360c45b051ed5412f64d8dc1580", size = 103734, upload-time = "2025-09-05T18:14:40.746Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/72/6b3e70d32e89a5cbb6a4513726c1ae8762165b027af569289e19ec08edd8/typer-0.17.4-py3-none-any.whl", hash = "sha256:015534a6edaa450e7007eba705d5c18c3349dcea50a6ad79a5ed530967575824", size = 46643, upload-time = "2025-09-05T18:14:39.166Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.15.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, -] - -[[package]] -name = "uuid6" -version = "2025.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ca/b7/4c0f736ca824b3a25b15e8213d1bcfc15f8ac2ae48d1b445b310892dc4da/uuid6-2025.0.1.tar.gz", hash = "sha256:cd0af94fa428675a44e32c5319ec5a3485225ba2179eefcf4c3f205ae30a81bd", size = 13932, upload-time = "2025-07-04T18:30:35.186Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/b2/93faaab7962e2aa8d6e174afb6f76be2ca0ce89fde14d3af835acebcaa59/uuid6-2025.0.1-py3-none-any.whl", hash = "sha256:80530ce4d02a93cdf82e7122ca0da3ebbbc269790ec1cb902481fa3e9cc9ff99", size = 6979, upload-time = "2025-07-04T18:30:34.001Z" }, -] From a32b824ed69700ebfc4caa5d45b3a81f33081792 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Tue, 23 Sep 2025 17:26:35 +0100 Subject: [PATCH 36/41] Rebase python-lib-release-action onto origin/main; resolve conflicts preserving branch intent; drop uv.lock in favor of main; fix tests and typing/lint issues; all tests pass and linters clean --- .../release-to-pypi-uv/scripts/validate_toml_versions.py | 2 +- .../tests/test_check_github_release.py | 3 ++- conftest.py | 9 +++++++++ pyproject.toml | 1 + 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py index 146164db..bb493869 100644 --- a/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py +++ b/.github/actions/release-to-pypi-uv/scripts/validate_toml_versions.py @@ -164,7 +164,7 @@ def main( dynamic = project.get("dynamic") dynamic_set = ( {str(item) for item in dynamic} - if isinstance(dynamic, (list, tuple)) + if isinstance(dynamic, list | tuple) else set() ) if "version" in dynamic_set: diff --git a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py index 9472211a..832fe65a 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py @@ -218,7 +218,8 @@ def test_retries_then_fail( def failing_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: ANN401 _ = request, timeout - raise module.urllib.error.URLError("temporary") + message = "temporary" + raise module.urllib.error.URLError(message) monkeypatch.setattr(module.urllib.request, "urlopen", failing_urlopen) monkeypatch.setattr(module.time, "sleep", lambda _: None) diff --git a/conftest.py b/conftest.py index 3ade95aa..46397a07 100644 --- a/conftest.py +++ b/conftest.py @@ -31,6 +31,7 @@ class CmdDouble(typ.Protocol): def with_args(self, *args: str) -> CmdDouble: """Set the expected argv for the double.""" + ... def returns( self, @@ -41,9 +42,11 @@ def returns( **_: object, ) -> CmdDouble: """Provide canned output for the command invocation.""" + ... def runs(self, handler: cabc.Callable[[object], tuple[str, str, int]]) -> CmdDouble: """Execute a handler when the double is invoked.""" + ... class CmdMoxEnvironment(typ.Protocol): @@ -59,15 +62,19 @@ class CmdMox(typ.Protocol): def stub(self, command: str) -> CmdDouble: """Register a stubbed command double.""" + ... def spy(self, command: str) -> CmdDouble: """Register a spying command double.""" + ... def replay(self) -> None: """Activate the recorded doubles.""" + ... def verify(self) -> None: """Assert that recorded expectations were satisfied.""" + ... def _shim_path(cmd_mox: CmdMox, command: str) -> str: @@ -142,3 +149,5 @@ def _register_podman_info_stub( def cmd_mox() -> typ.NoReturn: # pragma: win32 no cover """Skip tests that rely on cmd-mox on Windows.""" pytest.skip("cmd-mox does not support Windows") + unreachable = "unreachable" + raise RuntimeError(unreachable) diff --git a/pyproject.toml b/pyproject.toml index 34f457bf..6897e907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,7 @@ reportUnknownVariableType = "error" reportUnknownParameterType = "error" reportUnknownMemberType = "error" reportMissingTypeStubs = "warning" +reportUnusedFunction = "none" pythonVersion = "3.12" venvPath = "." venv = ".venv" \ No newline at end of file From c44e01305e4160163f9fe81aa12ef043a82b2594 Mon Sep 17 00:00:00 2001 From: Leynos Date: Tue, 23 Sep 2025 19:11:17 +0100 Subject: [PATCH 37/41] Close cargo pipes when coverage stream missing (#137) * Close cargo pipes when missing * Ensure cargo pipes close on all paths * Ensure guard closes cargo pipes before exiting --- .../generate-coverage/scripts/run_rust.py | 220 +++++++++--------- .../generate-coverage/tests/test_scripts.py | 83 ++++++- 2 files changed, 192 insertions(+), 111 deletions(-) diff --git a/.github/actions/generate-coverage/scripts/run_rust.py b/.github/actions/generate-coverage/scripts/run_rust.py index 6b65ed6b..85d57f78 100644 --- a/.github/actions/generate-coverage/scripts/run_rust.py +++ b/.github/actions/generate-coverage/scripts/run_rust.py @@ -149,6 +149,14 @@ def get_line_coverage_percent_from_cobertura(xml_file: Path) -> str: return _format_percent(covered, total) +def _safe_close_text_stream(stream: typ.TextIO | None) -> None: + """Close ``stream`` while suppressing any cleanup errors.""" + if stream is None: + return + with contextlib.suppress(Exception): + stream.close() + + def _run_cargo(args: list[str]) -> str: """Run ``cargo`` with ``args`` streaming output and return ``stdout``.""" typer.echo(f"$ cargo {shlex.join(args)}") @@ -160,115 +168,115 @@ def _run_cargo(args: list[str]) -> str: encoding="utf-8", errors="replace", ) - if proc.stdout is None or proc.stderr is None: - missing_streams = [] - if proc.stdout is None: - missing_streams.append("stdout") - if proc.stderr is None: - missing_streams.append("stderr") - missing = ", ".join(missing_streams) - message = f"cargo output streams not captured: missing {missing}" - with contextlib.suppress(Exception): - proc.kill() - with contextlib.suppress(Exception): - proc.wait(timeout=5) - typer.echo(f"::error::{message}", err=True) - raise typer.Exit(1) - stdout_lines: list[str] = [] - - if os.name == "nt": - thread_exceptions: list[Exception] = [] - - def pump(src: typ.TextIO, *, to_stdout: bool) -> None: - dest = sys.stdout if to_stdout else sys.stderr - try: - for line in iter(src.readline, ""): - dest.write(line) - dest.flush() - if to_stdout: - stdout_lines.append(line.rstrip("\r\n")) - except Exception as exc: # noqa: BLE001 - thread_exceptions.append(exc) - if os.environ.get("RUN_RUST_DEBUG") == "1" or os.environ.get( - "DEBUG_UTF8" - ): - sys.stderr.write(f"Exception in pump thread: {exc}\n") - sys.stderr.write(traceback.format_exc()) - - threads = [ - threading.Thread( - name="cargo-stdout", - target=pump, - args=(proc.stdout,), - kwargs={"to_stdout": True}, - daemon=True, - ), - threading.Thread( - name="cargo-stderr", - target=pump, - args=(proc.stderr,), - kwargs={"to_stdout": False}, - daemon=True, - ), - ] - for thread in threads: - thread.start() - # Kill cargo promptly if a pump fails to avoid deadlocks on the other pipe. - while True: + try: + if proc.stdout is None or proc.stderr is None: + missing_streams = [] + if proc.stdout is None: + missing_streams.append("stdout") + if proc.stderr is None: + missing_streams.append("stderr") + missing = ", ".join(missing_streams) + message = f"cargo output streams not captured: missing {missing}" + with contextlib.suppress(Exception): + proc.kill() + with contextlib.suppress(Exception): + proc.wait(timeout=5) + _safe_close_text_stream(proc.stdout) + _safe_close_text_stream(proc.stderr) + typer.echo(f"::error::{message}", err=True) + raise typer.Exit(1) + stdout_lines: list[str] = [] + + if os.name == "nt": + thread_exceptions: list[Exception] = [] + + def pump(src: typ.TextIO, *, to_stdout: bool) -> None: + dest = sys.stdout if to_stdout else sys.stderr + try: + for line in iter(src.readline, ""): + dest.write(line) + dest.flush() + if to_stdout: + stdout_lines.append(line.rstrip("\r\n")) + except Exception as exc: # noqa: BLE001 + thread_exceptions.append(exc) + if os.environ.get("RUN_RUST_DEBUG") == "1" or os.environ.get( + "DEBUG_UTF8" + ): + sys.stderr.write(f"Exception in pump thread: {exc}\n") + sys.stderr.write(traceback.format_exc()) + + threads = [ + threading.Thread( + name="cargo-stdout", + target=pump, + args=(proc.stdout,), + kwargs={"to_stdout": True}, + daemon=True, + ), + threading.Thread( + name="cargo-stderr", + target=pump, + args=(proc.stderr,), + kwargs={"to_stdout": False}, + daemon=True, + ), + ] + for thread in threads: + thread.start() + # Kill cargo promptly if a pump fails to avoid deadlocks on the other pipe. + while True: + if thread_exceptions: + with contextlib.suppress(Exception): + proc.kill() + break + if not any(t.is_alive() for t in threads): + break + for t in threads: + t.join(timeout=0.1) + # Ensure all threads have finished before handling results. + for thread in threads: + thread.join() if thread_exceptions: + proc.wait() + raise thread_exceptions[0] + else: + sel = selectors.DefaultSelector() + try: + sel.register(proc.stdout, selectors.EVENT_READ, data="stdout") + sel.register(proc.stderr, selectors.EVENT_READ, data="stderr") + + while sel.get_map(): + for key, _ in sel.select(): + line = key.fileobj.readline() + if not line: + sel.unregister(key.fileobj) + continue + if key.data == "stdout": + typer.echo(line, nl=False) + stdout_lines.append(line.rstrip("\r\n")) + else: + typer.echo(line, err=True, nl=False) + except Exception: + # Ensure cargo does not outlive the parent if the selector loop fails. with contextlib.suppress(Exception): proc.kill() - break - if not any(t.is_alive() for t in threads): - break - for t in threads: - t.join(timeout=0.1) - # Ensure all threads have finished before closing streams. - for thread in threads: - thread.join() - # Streams are guaranteed non-None by earlier guard. - proc.stdout.close() - proc.stderr.close() - if thread_exceptions: - proc.wait() - raise thread_exceptions[0] - else: - sel = selectors.DefaultSelector() - try: - sel.register(proc.stdout, selectors.EVENT_READ, data="stdout") - sel.register(proc.stderr, selectors.EVENT_READ, data="stderr") - - while sel.get_map(): - for key, _ in sel.select(): - line = key.fileobj.readline() - if not line: - sel.unregister(key.fileobj) - continue - if key.data == "stdout": - typer.echo(line, nl=False) - stdout_lines.append(line.rstrip("\r\n")) - else: - typer.echo(line, err=True, nl=False) - except Exception: - # Ensure cargo does not outlive the parent if the selector loop fails. - with contextlib.suppress(Exception): - proc.kill() - proc.wait() - raise - finally: - sel.close() - # Safe due to earlier guard. - proc.stdout.close() - proc.stderr.close() - - retcode = proc.wait() - if retcode != 0: - typer.echo( - f"cargo {shlex.join(args)} failed with code {retcode}", - err=True, - ) - raise typer.Exit(code=retcode or 1) - return "\n".join(stdout_lines) + proc.wait() + raise + finally: + sel.close() + + retcode = proc.wait() + if retcode != 0: + typer.echo( + f"cargo {shlex.join(args)} failed with code {retcode}", + err=True, + ) + raise typer.Exit(code=retcode or 1) + return "\n".join(stdout_lines) + finally: + _safe_close_text_stream(proc.stdout) + _safe_close_text_stream(proc.stderr) def _merge_lcov(base: Path, extra: Path) -> None: diff --git a/.github/actions/generate-coverage/tests/test_scripts.py b/.github/actions/generate-coverage/tests/test_scripts.py index 2efb38dc..7786aa9c 100644 --- a/.github/actions/generate-coverage/tests/test_scripts.py +++ b/.github/actions/generate-coverage/tests/test_scripts.py @@ -108,7 +108,7 @@ def kill(self) -> None: if track_lifecycle: self.killed = True - def wait(self) -> int: + def wait(self, timeout: float | None = None) -> int: if track_lifecycle: self.waited = True return returncode @@ -198,6 +198,37 @@ def fake_echo(line: str, *, err: bool = False, nl: bool = True) -> None: assert res == "out-line" +def test_run_cargo_windows_closes_streams( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``_run_cargo`` closes captured streams on success.""" + mod = _load_module(monkeypatch, "run_rust") + monkeypatch.setattr(mod.os, "name", "nt") + monkeypatch.setattr(mod.typer, "echo", lambda *a, **k: None) + + class TrackingStream(io.StringIO): + def __init__(self, value: str) -> None: + super().__init__(value) + self.close_calls = 0 + + def close(self) -> None: + self.close_calls += 1 + super().close() + + stdout = TrackingStream("out-line\n") + stderr = TrackingStream("err-line\n") + fake_cargo = _make_fake_cargo(stdout, stderr) + monkeypatch.setattr(mod, "cargo", fake_cargo) + + result = mod._run_cargo(["llvm-cov"]) + + assert result == "out-line" + assert stdout.closed + assert stderr.closed + assert stdout.close_calls >= 1 + assert stderr.close_calls >= 1 + + def test_run_cargo_windows_nonzero_exit( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -249,9 +280,14 @@ def test_run_cargo_windows_none_stdout( monkeypatch.setattr(mod.os, "name", "nt") monkeypatch.setattr(mod.typer, "echo", lambda *a, **k: None) - monkeypatch.setattr(mod, "cargo", _make_fake_cargo(None, "err-line\n")) - with pytest.raises(RuntimeError): + fake_cargo = _make_fake_cargo(None, "err-line\n") + monkeypatch.setattr(mod, "cargo", fake_cargo) + with pytest.raises(mod.typer.Exit): mod._run_cargo([]) + proc = fake_cargo.last_proc + assert proc is not None + assert proc.stderr is not None + assert proc.stderr.closed def test_run_cargo_windows_none_stderr( @@ -262,9 +298,46 @@ def test_run_cargo_windows_none_stderr( monkeypatch.setattr(mod.os, "name", "nt") monkeypatch.setattr(mod.typer, "echo", lambda *a, **k: None) - monkeypatch.setattr(mod, "cargo", _make_fake_cargo("out-line\n", None)) - with pytest.raises(RuntimeError): + fake_cargo = _make_fake_cargo("out-line\n", None) + monkeypatch.setattr(mod, "cargo", fake_cargo) + with pytest.raises(mod.typer.Exit): mod._run_cargo([]) + proc = fake_cargo.last_proc + assert proc is not None + assert proc.stdout is not None + assert proc.stdout.closed + + +def test_run_cargo_stream_close_error_suppressed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Errors closing streams are suppressed during cleanup.""" + mod = _load_module(monkeypatch, "run_rust") + monkeypatch.setattr(mod.os, "name", "nt") + monkeypatch.setattr(mod.typer, "echo", lambda *a, **k: None) + + class ExplodingStream(io.StringIO): + def __init__(self, value: str) -> None: + super().__init__(value) + self.close_calls = 0 + + def close(self) -> None: + self.close_calls += 1 + super().close() + message = "close failure" + raise RuntimeError(message) + + stdout = ExplodingStream("out-line\n") + stderr = io.StringIO("err-line\n") + fake_cargo = _make_fake_cargo(stdout, stderr) + monkeypatch.setattr(mod, "cargo", fake_cargo) + + result = mod._run_cargo(["llvm-cov"]) + + assert result == "out-line" + assert stdout.close_calls >= 1 + assert stdout.closed + assert stderr.closed def test_run_rust_with_cucumber(tmp_path: Path, shell_stubs: StubManager) -> None: From dd7f7ab2790e5b15b0588f9632efa984c12437f3 Mon Sep 17 00:00:00 2001 From: Leynos Date: Tue, 23 Sep 2025 19:33:00 +0100 Subject: [PATCH 38/41] Handle release script auth failure and multiline outputs (#136) * Handle release script auth failure and multiline outputs * Harden windows toolchain setup and extend runtime tests * Handle release auth errors and tweak toolchain retries --- .../scripts/check_github_release.py | 70 ++++++----- .../scripts/determine_release.py | 2 +- .../tests/test_check_github_release.py | 30 +++++ .../tests/test_determine_release.py | 20 +++ .github/actions/rust-build-release/action.yml | 2 +- .../rust-build-release/tests/test_runtime.py | 105 ++++++---------- .github/actions/setup-windows-gnu/action.yml | 114 ++++++++++++++++-- Makefile | 1 + docs/python-native-command-mocking-design.md | 2 +- 9 files changed, 238 insertions(+), 108 deletions(-) diff --git a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py index 74d625cf..410f2827 100644 --- a/.github/actions/release-to-pypi-uv/scripts/check_github_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/check_github_release.py @@ -84,35 +84,47 @@ def _fetch_release(repo: str, tag: str, token: str) -> dict[str, object]: if hasattr(exc, "read") else "" ) - if exc.code == 404: - message = ( - "No GitHub release found for tag " - f"{tag}. Create and publish the release first." - ) - raise GithubReleaseError(message) from exc - if exc.code == 403: - permission_message = ( - "GitHub token lacks permission to read releases or has expired. " - "Use a token with contents:read scope." - ) - context = detail or exc.reason - message = f"{permission_message} ({context})" - raise GithubReleaseError(message) from exc - if attempt == max_attempts: - failure_reason = detail or exc.reason - message = ( - "GitHub API request failed with status " - f"{exc.code}: {failure_reason}" - ) - raise GithubReleaseError(message) from exc - retry_after = None - if hasattr(exc, "headers") and exc.headers is not None: - retry_after = exc.headers.get("Retry-After") - if retry_after: - with contextlib.suppress(Exception): - delay = float(retry_after) - _sleep_with_jitter(delay) - delay *= backoff_factor + match exc.code: + case 401: + context = detail or exc.reason + message = ( + "GitHub rejected the token (401 Unauthorized). " + "Verify that GH_TOKEN is correct and has not expired." + ) + if context: + message = f"{message} ({context})" + raise GithubReleaseError(message) from exc + case 403: + permission_message = ( + "GitHub token lacks permission to read releases " + "or has expired. " + "Use a token with contents:read scope." + ) + context = detail or exc.reason + message = f"{permission_message} ({context})" + raise GithubReleaseError(message) from exc + case 404: + message = ( + "No GitHub release found for tag " + f"{tag}. Create and publish the release first." + ) + raise GithubReleaseError(message) from exc + case _ if attempt == max_attempts: + failure_reason = detail or exc.reason + message = ( + "GitHub API request failed with status " + f"{exc.code}: {failure_reason}" + ) + raise GithubReleaseError(message) from exc + case _: + retry_after = None + if hasattr(exc, "headers") and exc.headers is not None: + retry_after = exc.headers.get("Retry-After") + if retry_after: + with contextlib.suppress(Exception): + delay = float(retry_after) + _sleep_with_jitter(delay) + delay *= backoff_factor except urllib.error.URLError as exc: # pragma: no cover - network failure path if attempt == max_attempts: message = f"Failed to reach GitHub API: {exc.reason}" diff --git a/.github/actions/release-to-pypi-uv/scripts/determine_release.py b/.github/actions/release-to-pypi-uv/scripts/determine_release.py index 2df6a0fb..4a529c97 100644 --- a/.github/actions/release-to-pypi-uv/scripts/determine_release.py +++ b/.github/actions/release-to-pypi-uv/scripts/determine_release.py @@ -20,7 +20,7 @@ def _emit_outputs(dest: Path, tag: str, version: str) -> None: with dest.open("a", encoding="utf-8") as fh: for key, value in (("tag", tag), ("version", version)): - fh.write(f"{key}={value}\n") + fh.write(f"{key}<<__EOF__\n{value}\n__EOF__\n") def main( diff --git a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py index 832fe65a..c2ca9250 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_check_github_release.py @@ -154,6 +154,36 @@ def fake_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: ANN assert "No GitHub release found" in captured.err +def test_authentication_failure( + monkeypatch: pytest.MonkeyPatch, + module: ModuleType, + capsys: pytest.CaptureFixture[str], + fake_token: str, +) -> None: + """Exit with guidance when GitHub rejects the authentication token.""" + detail = b"Bad credentials" + error = module.urllib.error.HTTPError( + url="https://api.github.com", + code=401, + msg="Unauthorized", + hdrs=None, + fp=io.BytesIO(detail), + ) + + def raising_urlopen(request: typ.Any, timeout: float = 30) -> typ.Any: # noqa: ANN401 + _ = request, timeout + raise error + + monkeypatch.setattr(module.urllib.request, "urlopen", raising_urlopen) + + with pytest.raises(module.typer.Exit): + module.main(tag="v1.0.0", token=fake_token, repo="owner/repo") + + captured = capsys.readouterr() + assert "Verify that GH_TOKEN" in captured.err + assert "Unauthorized" in captured.err + + def test_permission_denied( monkeypatch: pytest.MonkeyPatch, module: ModuleType, diff --git a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py index e51dbc06..f97876b0 100644 --- a/.github/actions/release-to-pypi-uv/tests/test_determine_release.py +++ b/.github/actions/release-to-pypi-uv/tests/test_determine_release.py @@ -63,6 +63,26 @@ def read_outputs(tmp_path: Path) -> dict[str, str]: return out +def test_outputs_use_multiline_format(tmp_path: Path) -> None: + """Write GitHub Action outputs using the recommended heredoc syntax.""" + env = base_env(tmp_path) + env["INPUT_TAG"] = "v3.1.4" + + script = Path(__file__).resolve().parents[1] / "scripts" / "determine_release.py" + result = run_script(script, env=env) + + assert result.returncode == 0, result.stderr + lines = (tmp_path / "out.txt").read_text(encoding="utf-8").splitlines() + assert lines == [ + "tag<<__EOF__", + "v3.1.4", + "__EOF__", + "version<<__EOF__", + "3.1.4", + "__EOF__", + ] + + def test_resolves_tag_from_ref(tmp_path: Path) -> None: """Derive the release tag from Git reference metadata.""" env = base_env(tmp_path) diff --git a/.github/actions/rust-build-release/action.yml b/.github/actions/rust-build-release/action.yml index b8f17ef0..3f4bce39 100644 --- a/.github/actions/rust-build-release/action.yml +++ b/.github/actions/rust-build-release/action.yml @@ -104,7 +104,7 @@ runs: echo "::error:: binary not found at ${bin_src}" exit 1 fi - mapfile -d '' -t man_matches < <( + mapfile -d $'\0' -t man_matches < <( find "target/${{ inputs.target }}/release/build" \ -path "*/out/${{ inputs.bin-name }}.1" \ -type f -print0 diff --git a/.github/actions/rust-build-release/tests/test_runtime.py b/.github/actions/rust-build-release/tests/test_runtime.py index 2c82f142..9ac94fc7 100644 --- a/.github/actions/rust-build-release/tests/test_runtime.py +++ b/.github/actions/rust-build-release/tests/test_runtime.py @@ -2,16 +2,17 @@ from __future__ import annotations +import importlib.util import json import subprocess import typing as typ -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace import pytest -if typ.TYPE_CHECKING: - from types import ModuleType +import pytest +if typ.TYPE_CHECKING: from .conftest import HarnessFactory, ModuleHarness @@ -271,78 +272,44 @@ def fake_run( assert call_kwargs.get("text") is True assert call_kwargs.get("check") is True assert call_kwargs.get("allowed_names") == ("rustc", "rustc.exe") -@pytest.mark.parametrize( - ("machine", "expected"), - [ - ("AMD64", "x86_64"), - ("x64", "x86_64"), - ("i386", "i686"), - ("I586", "i686"), - ("ARM64", "aarch64"), - ("armv8l", "aarch64"), - ("ARMV7L", "armv7"), - ("armv6l", "armv6"), - ("PPC64LE", "ppc64le"), - ("PowerPC64", "ppc64"), - ("sparc64", "sparc64"), - ], -) -def test_normalize_arch_unit_mappings( - runtime_module: ModuleType, machine: str, expected: str -) -> None: - """Unit test: known architecture identifiers normalize correctly.""" - - assert runtime_module._normalize_arch(machine) == expected - - -def test_normalize_arch_behavioral_fallbacks(runtime_module: ModuleType) -> None: - """Behavioural test: unknown and missing machine names are handled.""" - assert runtime_module._normalize_arch("") == "x86_64" - # Unknown identifiers are normalized to lowercase for stability. - assert runtime_module._normalize_arch("Loongson") == "loongson" - -@pytest.mark.parametrize( - ("system_name", "machine", "sys_platform", "expected"), - [ - ("Windows", "AMD64", "win32", "x86_64-pc-windows-msvc"), - ("CYGWIN_NT-10.0", "x86_64", "cygwin", "x86_64-pc-windows-gnu"), - ("MSYS_NT-10.0", "x86_64", "msys", "x86_64-pc-windows-gnu"), - ("Darwin", "arm64", "darwin", "aarch64-apple-darwin"), - ("Linux", "ppc64le", "linux", "ppc64le-unknown-linux-gnu"), - ("Linux-gnu", "armv7l", "linux-gnu", "armv7-unknown-linux-gnu"), - ("FreeBSD", "sparc64", "freebsd13", "sparc64-unknown-freebsd"), - ], -) -def test_default_host_target_for_current_platform_unit( +def test_probe_timeout_env_override( runtime_module: ModuleType, + module_harness: HarnessFactory, monkeypatch: pytest.MonkeyPatch, - system_name: str, - machine: str, - sys_platform: str, - expected: str, ) -> None: - """Unit test: platform/architecture combinations map to expected triples.""" - - monkeypatch.setattr(runtime_module.platform, "system", lambda: system_name) - monkeypatch.setattr(runtime_module.platform, "machine", lambda: machine) - monkeypatch.setattr(runtime_module.sys, "platform", sys_platform) - - assert runtime_module._default_host_target_for_current_platform() == expected - + """Respect RUNTIME_PROBE_TIMEOUT when importing the module.""" + monkeypatch.setenv("RUNTIME_PROBE_TIMEOUT", "2") + module_path = getattr(runtime_module, "__file__", None) + if module_path is None: + pytest.fail("runtime module does not expose a __file__ path") + module_spec = importlib.util.spec_from_file_location( + "rbr_runtime_reloaded", module_path + ) + if module_spec is None or module_spec.loader is None: + pytest.fail("failed to load runtime module specification") + module = importlib.util.module_from_spec(module_spec) + module_spec.loader.exec_module(module) + harness = module_harness(module) -def test_default_host_target_for_current_platform_behavioral_fallback( - runtime_module: ModuleType, monkeypatch: pytest.MonkeyPatch -) -> None: - """Behavioural test: fallbacks cover missing identifiers.""" + harness.patch_shutil_which(lambda name: "/usr/bin/rustc") + harness.patch_attr("ensure_allowed_executable", lambda path, allowed: path) - monkeypatch.setattr(runtime_module.platform, "system", lambda: "") - monkeypatch.setattr(runtime_module.platform, "machine", lambda: "") - monkeypatch.setattr(runtime_module.sys, "platform", "customos") + captured: dict[str, object] = {} - assert ( - runtime_module._default_host_target_for_current_platform() - == "x86_64-unknown-customos" - ) + def fake_run( + executable: str, + args: list[str], + *, + allowed_names: tuple[str, ...], + **kwargs: object, + ) -> subprocess.CompletedProcess[str]: + captured.update(kwargs) + return subprocess.CompletedProcess( + [executable, *args], 0, stdout="host: x86_64-unknown-linux-gnu\n" + ) + harness.monkeypatch.setattr(module, "run_validated", fake_run) + module.detect_host_target() + assert captured.get("timeout") == 2 diff --git a/.github/actions/setup-windows-gnu/action.yml b/.github/actions/setup-windows-gnu/action.yml index 4c50f4f1..f2066e8f 100644 --- a/.github/actions/setup-windows-gnu/action.yml +++ b/.github/actions/setup-windows-gnu/action.yml @@ -10,6 +10,26 @@ inputs: description: SHA-256 checksum for the llvm-mingw archive matching the selected version required: false default: "bd88084d7a3b95906fa295453399015a1fdd7b90a38baa8f78244bd234303737" + require-aarch64: + description: Fail if no aarch64 MinGW compiler is available + required: false + default: "true" + download-retry-attempts: + description: Maximum number of attempts when downloading the llvm-mingw archive + required: false + default: "5" + download-retry-initial-delay: + description: Initial retry delay in seconds before applying exponential backoff + required: false + default: "2" + download-retry-max-delay: + description: Upper bound in seconds for retry delays when downloading the archive + required: false + default: "30" + download-retry-jitter: + description: Maximum random jitter in seconds added to retry delays + required: false + default: "0" runs: using: composite steps: @@ -37,11 +57,81 @@ runs: $asset = "llvm-mingw-$version-ucrt-x86_64.zip" $url = "https://github.com/mstorsjo/llvm-mingw/releases/download/$version/$asset" $archive = Join-Path $Env:RUNNER_TEMP "llvm-mingw.zip" - try { - Invoke-WebRequest -Uri $url -OutFile $archive -ErrorAction Stop - } catch { - Write-Error "Failed to download llvm-mingw archive from $url" - throw + $culture = [System.Globalization.CultureInfo]::InvariantCulture + $numberStyles = [System.Globalization.NumberStyles]::Float + + $attempts = 5 + $attemptsParsed = 0 + $attemptsRaw = "${{ inputs['download-retry-attempts'] }}" + if ([int]::TryParse($attemptsRaw, [ref]$attemptsParsed) -and $attemptsParsed -ge 1) { + $attempts = $attemptsParsed + } + + $initialDelay = 2.0 + $initialDelayParsed = 0.0 + $initialDelayRaw = "${{ inputs['download-retry-initial-delay'] }}" + if ( + [double]::TryParse( + $initialDelayRaw, + $numberStyles, + $culture, + [ref]$initialDelayParsed + ) -and $initialDelayParsed -gt 0 + ) { + $initialDelay = $initialDelayParsed + } + + $maxDelay = 30.0 + $maxDelayParsed = 0.0 + $maxDelayRaw = "${{ inputs['download-retry-max-delay'] }}" + if ( + [double]::TryParse( + $maxDelayRaw, + $numberStyles, + $culture, + [ref]$maxDelayParsed + ) -and $maxDelayParsed -gt 0 + ) { + $maxDelay = $maxDelayParsed + } + if ($maxDelay -lt $initialDelay) { + $maxDelay = $initialDelay + } + + $jitter = 0.0 + $jitterParsed = 0.0 + $jitterRaw = "${{ inputs['download-retry-jitter'] }}" + if ( + [double]::TryParse( + $jitterRaw, + $numberStyles, + $culture, + [ref]$jitterParsed + ) -and $jitterParsed -ge 0 + ) { + $jitter = $jitterParsed + } + $retryRandom = if ($jitter -gt 0) { [System.Random]::new() } else { $null } + + for ($i = 1; $i -le $attempts; $i++) { + try { + Invoke-WebRequest -Uri $url -OutFile $archive -ErrorAction Stop + break + } catch { + if ($i -eq $attempts) { + Write-Error "Failed to download llvm-mingw archive from $url after $attempts attempts" + throw + } + $backoffExponent = $i - 1 + $backoff = [math]::Min($initialDelay * [math]::Pow(2, $backoffExponent), $maxDelay) + $sleepSeconds = $backoff + if ($retryRandom) { + $sleepSeconds = [math]::Min($backoff + ($retryRandom.NextDouble() * $jitter), $maxDelay) + } + $displayDelay = [math]::Round($sleepSeconds, 2, [System.MidpointRounding]::AwayFromZero) + Write-Warning "Download failed (attempt $i). Retrying in ${displayDelay}s..." + Start-Sleep -Seconds ([int][math]::Ceiling($sleepSeconds)) + } } $actualHash = (Get-FileHash -Path $archive -Algorithm SHA256).Hash if (-not [string]::Equals($actualHash, $expectedHash, [System.StringComparison]::OrdinalIgnoreCase)) { @@ -59,6 +149,11 @@ runs: throw "llvm-mingw bin directory not found at $binPath" } $binPath | Out-File -FilePath $Env:GITHUB_PATH -Encoding utf8 -Append + Add-Content -Path $Env:GITHUB_ENV -Value "LLVM_MINGW_ROOT=$toolRoot" + Add-Content -Path $Env:GITHUB_ENV -Value "LLVM_MINGW_BIN=$binPath" + # Toolchain hints (useful for Autotools/CMake; override as needed) + Add-Content -Path $Env:GITHUB_ENV -Value "CC_aarch64_pc_windows_gnu=aarch64-w64-mingw32-clang" + Add-Content -Path $Env:GITHUB_ENV -Value "CXX_aarch64_pc_windows_gnu=aarch64-w64-mingw32-clang++" - name: Verify GNU toolchains shell: bash run: | @@ -66,8 +161,13 @@ runs: if ! command -v x86_64-w64-mingw32-gcc >/dev/null 2>&1; then echo "::warning::x86_64 MinGW GCC not found" >&2 fi + require_a64="${{ inputs.require-aarch64 }}" if ! command -v aarch64-w64-mingw32-gcc >/dev/null 2>&1 \ && ! command -v aarch64-w64-mingw32-clang >/dev/null 2>&1; then - echo "::error::No aarch64 MinGW compiler found (neither GCC nor clang)" >&2 - exit 1 + if [ "${require_a64}" = "true" ]; then + echo "::error::No aarch64 MinGW compiler found (neither GCC nor clang)" >&2 + exit 1 + else + echo "::warning::No aarch64 MinGW compiler found; proceeding because require-aarch64=false" >&2 + fi fi diff --git a/Makefile b/Makefile index e641ed7a..b64919c1 100644 --- a/Makefile +++ b/Makefile @@ -48,6 +48,7 @@ fmt: ## Apply formatting to Python files check-fmt: ## Check Python formatting without modifying files uvx ruff format --check + uvx ruff check --select $(RUFF_FIX_RULES) markdownlint: ## Lint Markdown files find . -type f -name '*.md' -not -path './target/*' -print0 | xargs -0 -- $(MDLINT) diff --git a/docs/python-native-command-mocking-design.md b/docs/python-native-command-mocking-design.md index f1ed9034..48c77a14 100644 --- a/docs/python-native-command-mocking-design.md +++ b/docs/python-native-command-mocking-design.md @@ -88,7 +88,7 @@ Two environment variables tie the controller and shims together: controller API. These variables are injected automatically when the pytest fixture or context -manager initialises the controller. +manager initializes the controller. ## Platform Notes From 2372583f004f5f5d778ef01b483a9658d590c254 Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 24 Sep 2025 01:13:31 +0100 Subject: [PATCH 39/41] Fix cross install warning expectation (#139) * Fix cross install warning assertion * Harden cross install harness checks * Sanitize runtime probe timeout and guard actions * Refine runtime probe helpers and add timeout tests * Fix runtime probe lint findings * Share echo recorder fixture across runtime tests --- .../actions/rust-build-release/src/runtime.py | 112 ++++++++--- .../rust-build-release/tests/conftest.py | 18 ++ .../tests/test_cross_install.py | 28 ++- .../rust-build-release/tests/test_runtime.py | 176 ++++++++++++++++-- .../rust-build-release/tests/test_utils.py | 12 +- .github/actions/setup-windows-gnu/action.yml | 9 + Makefile | 2 +- conftest.py | 6 +- 8 files changed, 302 insertions(+), 61 deletions(-) diff --git a/.github/actions/rust-build-release/src/runtime.py b/.github/actions/rust-build-release/src/runtime.py index 4390da27..e19708f2 100644 --- a/.github/actions/rust-build-release/src/runtime.py +++ b/.github/actions/rust-build-release/src/runtime.py @@ -48,7 +48,73 @@ def _platform_default_host_target() -> str: DEFAULT_HOST_TARGET = _platform_default_host_target() -PROBE_TIMEOUT = int(os.environ.get("RUNTIME_PROBE_TIMEOUT", "10")) +_DEFAULT_PROBE_TIMEOUT = 10 +_MAX_PROBE_TIMEOUT = 300 + + +def _run_probe( + exec_path: str | Path, + name: str, + probe: str, + args: list[str], + *, + cwd: str | Path | None = None, + **kwargs: object, +) -> subprocess.CompletedProcess[str] | None: + """Execute a runtime probe and handle common failure modes.""" + try: + return run_validated( + exec_path, + args, + allowed_names=(name, f"{name}.exe"), + timeout=PROBE_TIMEOUT, + cwd=cwd, + **kwargs, + ) + except subprocess.TimeoutExpired: + typer.echo( + "::warning:: " + f"{name} {probe} probe exceeded {PROBE_TIMEOUT}s timeout; " + "treating runtime as unavailable", + err=True, + ) + except (OSError, subprocess.CalledProcessError): + pass + return None + + +def _get_probe_timeout() -> int: + """Return the sanitized probe timeout for runtime detection.""" + raw = os.environ.get("RUNTIME_PROBE_TIMEOUT") + if raw is None: + return _DEFAULT_PROBE_TIMEOUT + try: + value = int(raw) + except ValueError: + typer.echo( + "::warning:: Invalid RUNTIME_PROBE_TIMEOUT value" + f" {raw!r}; using {_DEFAULT_PROBE_TIMEOUT}s fallback", + err=True, + ) + return _DEFAULT_PROBE_TIMEOUT + if value <= 0: + typer.echo( + "::warning:: " + f"RUNTIME_PROBE_TIMEOUT={value}s raised to {_DEFAULT_PROBE_TIMEOUT}s", + err=True, + ) + return _DEFAULT_PROBE_TIMEOUT + if value > _MAX_PROBE_TIMEOUT: + typer.echo( + "::warning:: " + f"RUNTIME_PROBE_TIMEOUT={value}s capped to {_MAX_PROBE_TIMEOUT}s", + err=True, + ) + return _MAX_PROBE_TIMEOUT + return value + + +PROBE_TIMEOUT = _get_probe_timeout() def runtime_available(name: str, *, cwd: str | Path | None = None) -> bool: @@ -60,35 +126,33 @@ def runtime_available(name: str, *, cwd: str | Path | None = None) -> bool: exec_path = ensure_allowed_executable(path, (name, f"{name}.exe")) except UnexpectedExecutableError: return False - try: - result = run_validated( - exec_path, - ["info"], - allowed_names=(name, f"{name}.exe"), - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - timeout=PROBE_TIMEOUT, - cwd=cwd, - ) - except (OSError, subprocess.TimeoutExpired): + result = _run_probe( + exec_path, + name, + "info", + ["info"], + cwd=cwd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result is None: return False if result.returncode != 0: return False if name == "podman": - try: - security_info = run_validated( - exec_path, - ["info", "--format", "{{json .Host.Security}}"], - allowed_names=(name, f"{name}.exe"), - capture_output=True, - text=True, - check=True, - timeout=PROBE_TIMEOUT, - cwd=cwd, - ) - except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + security_info = _run_probe( + exec_path, + name, + "security", + ["info", "--format", "{{json .Host.Security}}"], + cwd=cwd, + capture_output=True, + text=True, + check=True, + ) + if security_info is None: return False try: diff --git a/.github/actions/rust-build-release/tests/conftest.py b/.github/actions/rust-build-release/tests/conftest.py index 347374ed..6161513e 100644 --- a/.github/actions/rust-build-release/tests/conftest.py +++ b/.github/actions/rust-build-release/tests/conftest.py @@ -147,6 +147,24 @@ def patch_attr(self, name: str, value: object) -> None: HarnessFactory = cabc.Callable[[ModuleType], ModuleHarness] +@pytest.fixture +def echo_recorder( + monkeypatch: pytest.MonkeyPatch, +) -> cabc.Callable[[ModuleType], list[tuple[str, bool]]]: + """Return a helper that patches ``typer.echo`` and records messages.""" + + def install(module: ModuleType) -> list[tuple[str, bool]]: + messages: list[tuple[str, bool]] = [] + + def fake_echo(message: str, *, err: bool = False) -> None: + messages.append((message, err)) + + monkeypatch.setattr(module.typer, "echo", fake_echo) + return messages + + return install + + @pytest.fixture def module_harness(monkeypatch: pytest.MonkeyPatch) -> HarnessFactory: """Return a factory that wraps a module with a harness and recorder.""" diff --git a/.github/actions/rust-build-release/tests/test_cross_install.py b/.github/actions/rust-build-release/tests/test_cross_install.py index aafbcaa5..481630eb 100644 --- a/.github/actions/rust-build-release/tests/test_cross_install.py +++ b/.github/actions/rust-build-release/tests/test_cross_install.py @@ -56,6 +56,9 @@ def fake_which(name: str) -> str | None: assert "--locked" in install idx = install.index("--version") assert install[idx + 1] == "0.2.5" + # Prove we did not take the git fallback path + assert "--git" not in install + assert "--tag" not in install def test_cross_install_failure_non_windows( @@ -111,6 +114,9 @@ def fake_which(name: str) -> str | None: assert "--locked" in install idx = install.index("--version") assert install[idx + 1] == "0.2.5" + # Ensure upgrade used crates.io, not the git fallback + assert "--git" not in install + assert "--tag" not in install @CMD_MOX_UNSUPPORTED @@ -156,10 +162,10 @@ def fake_which(name: str) -> str | None: harness.patch_shutil_which(fake_which) harness.patch_platform("win32") - release_called = {"value": False} + release_call_args: list[str] = [] def fake_release(version: str) -> bool: - release_called["value"] = True + release_call_args.append(version) return True harness.patch_attr("install_cross_release", fake_release) @@ -168,7 +174,7 @@ def fake_release(version: str) -> bool: path, ver = cross_module.ensure_cross("0.2.5") cmd_mox.verify() - assert release_called["value"] is True + assert release_call_args == ["0.2.5"] assert path == cross_path assert ver == "0.2.5" assert all(cmd[:2] != ["cargo", "install"] for cmd in harness.calls) @@ -177,6 +183,7 @@ def fake_release(version: str) -> bool: def test_install_cross_release_validates_binary( cross_module: ModuleType, module_harness: HarnessFactory, + echo_recorder: typ.Callable[[ModuleType], list[tuple[str, bool]]], tmp_path: Path, ) -> None: """Cross release installer verifies the downloaded binary executes.""" @@ -255,10 +262,7 @@ def fake_run( run_calls.append(cmd) return subprocess.CompletedProcess(cmd, 0, stdout="cross 0.2.5\n") - messages: list[tuple[str, bool]] = [] - - def fake_echo(message: str, *, err: bool = False) -> None: - messages.append((message, err)) + messages = echo_recorder(module) home_dir = tmp_path / "home" @@ -267,7 +271,6 @@ def fake_echo(message: str, *, err: bool = False) -> None: module.tempfile, "TemporaryDirectory", lambda: FakeTempDir() ) harness.monkeypatch.setattr(module, "run_validated", fake_run) - harness.monkeypatch.setattr(module.typer, "echo", fake_echo) harness.monkeypatch.setattr(module.Path, "home", lambda: home_dir) assert module.install_cross_release("0.2.5") is True @@ -391,6 +394,9 @@ def fake_which(name: str) -> str | None: build_cmd = app_env.calls[-1] assert build_cmd[0] == "cargo" assert build_cmd[1] == f"+{default_toolchain}-x86_64-unknown-linux-gnu" + # Ensure no container runtime calls were attempted + assert all(cmd[0] not in {"docker", "podman"} for cmd in app_env.calls) + assert all(cmd[0] not in {"docker", "podman"} for cmd in cross_env.calls) @CMD_MOX_UNSUPPORTED @@ -527,5 +533,7 @@ def failing_run_cmd(cmd: list[str]) -> None: assert len(harness.calls) == 2 assert path is None assert ver is None - out = capsys.readouterr().out.lower() - assert "warning" in out + io = capsys.readouterr() + msg = io.err.lower() + assert "warning" in msg + assert "cross install failed; continuing without cross" in msg diff --git a/.github/actions/rust-build-release/tests/test_runtime.py b/.github/actions/rust-build-release/tests/test_runtime.py index 9ac94fc7..8a72858e 100644 --- a/.github/actions/rust-build-release/tests/test_runtime.py +++ b/.github/actions/rust-build-release/tests/test_runtime.py @@ -5,6 +5,7 @@ import importlib.util import json import subprocess +import sys import typing as typ from types import ModuleType, SimpleNamespace @@ -45,6 +46,23 @@ def fake_run( harness.monkeypatch.setattr(runtime_module, "run_validated", fake_run) +def _reload_runtime_module(runtime_module: ModuleType, module_name: str) -> ModuleType: + """Reload the runtime module under a new name for environment-specific tests.""" + module_path = getattr(runtime_module, "__file__", None) + if module_path is None: + pytest.fail("runtime module does not expose a __file__ path") + module_spec = importlib.util.spec_from_file_location(module_name, module_path) + if module_spec is None or module_spec.loader is None: + pytest.fail("failed to load runtime module specification") + module = importlib.util.module_from_spec(module_spec) + sys.modules[module_name] = module + try: + module_spec.loader.exec_module(module) + finally: + sys.modules.pop(module_name, None) + return module + + def test_runtime_available_false_when_missing( runtime_module: ModuleType, module_harness: HarnessFactory ) -> None: @@ -69,19 +87,51 @@ def fake_ensure(path: str, allowed: tuple[str, ...]) -> str: def test_runtime_available_returns_false_on_timeout( - runtime_module: ModuleType, module_harness: HarnessFactory + runtime_module: ModuleType, + module_harness: HarnessFactory, + echo_recorder: typ.Callable[[ModuleType], list[tuple[str, bool]]], ) -> None: """Treats runtimes that hang during discovery as unavailable.""" harness = module_harness(runtime_module) harness.patch_shutil_which(lambda name: "/usr/bin/docker") harness.patch_attr("ensure_allowed_executable", lambda path, allowed: path) + messages = echo_recorder(runtime_module) _patch_run_validated_timeout(runtime_module, harness) assert runtime_module.runtime_available("docker") is False + assert any(err for _, err in messages), "expected stderr warning to be emitted" + assert any( + "docker info probe exceeded" in msg and str(runtime_module.PROBE_TIMEOUT) in msg + for msg, err in messages + if err + ), "docker info probe timeout warning missing" + + +def test_runtime_available_oserror_does_not_warn( + runtime_module: ModuleType, + module_harness: HarnessFactory, + echo_recorder: typ.Callable[[ModuleType], list[tuple[str, bool]]], +) -> None: + """OSError during runtime detection should not emit warnings.""" + harness = module_harness(runtime_module) + harness.patch_shutil_which(lambda name: "/usr/bin/docker") + harness.patch_attr("ensure_allowed_executable", lambda path, allowed: path) + messages = echo_recorder(runtime_module) + + def fake_run(*_: object, **__: object) -> subprocess.CompletedProcess[str]: + message = "simulated OSError" + raise OSError(message) + + harness.monkeypatch.setattr(runtime_module, "run_validated", fake_run) + + assert runtime_module.runtime_available("docker") is False + assert not any(err for _, err in messages), "unexpected warning for OSError" def test_podman_without_cap_sys_admin_is_unavailable( - runtime_module: ModuleType, module_harness: HarnessFactory + runtime_module: ModuleType, + module_harness: HarnessFactory, + echo_recorder: typ.Callable[[ModuleType], list[tuple[str, bool]]], ) -> None: """Podman runtimes lacking CAP_SYS_ADMIN are reported as unavailable.""" harness = module_harness(runtime_module) @@ -105,13 +155,8 @@ def fake_run( return subprocess.CompletedProcess(cmd, 0, stdout=data) return subprocess.CompletedProcess(cmd, 0, stdout="") - messages: list[tuple[str, bool]] = [] - - def fake_echo(message: str, *, err: bool = False) -> None: - messages.append((message, err)) - + messages = echo_recorder(runtime_module) harness.monkeypatch.setattr(runtime_module, "run_validated", fake_run) - harness.monkeypatch.setattr(runtime_module.typer, "echo", fake_echo) assert runtime_module.runtime_available("podman") is False assert any("CAP_SYS_ADMIN" in msg for msg, err in messages if err) @@ -147,12 +192,15 @@ def fake_run( def test_podman_security_timeout_treated_as_unavailable( - runtime_module: ModuleType, module_harness: HarnessFactory + runtime_module: ModuleType, + module_harness: HarnessFactory, + echo_recorder: typ.Callable[[ModuleType], list[tuple[str, bool]]], ) -> None: """If podman security inspection times out the runtime is skipped.""" harness = module_harness(runtime_module) harness.patch_shutil_which(lambda name: "/usr/bin/podman") harness.patch_attr("ensure_allowed_executable", lambda path, allowed: path) + messages = echo_recorder(runtime_module) _patch_run_validated_timeout( runtime_module, harness, @@ -160,6 +208,13 @@ def test_podman_security_timeout_treated_as_unavailable( ) assert runtime_module.runtime_available("podman") is False + assert any(err for _, err in messages), "expected stderr warning to be emitted" + assert any( + "podman security probe exceeded" in msg + and str(runtime_module.PROBE_TIMEOUT) in msg + for msg, err in messages + if err + ), "podman security timeout warning missing" def test_detect_host_target_returns_default_when_rustc_missing( @@ -281,16 +336,7 @@ def test_probe_timeout_env_override( ) -> None: """Respect RUNTIME_PROBE_TIMEOUT when importing the module.""" monkeypatch.setenv("RUNTIME_PROBE_TIMEOUT", "2") - module_path = getattr(runtime_module, "__file__", None) - if module_path is None: - pytest.fail("runtime module does not expose a __file__ path") - module_spec = importlib.util.spec_from_file_location( - "rbr_runtime_reloaded", module_path - ) - if module_spec is None or module_spec.loader is None: - pytest.fail("failed to load runtime module specification") - module = importlib.util.module_from_spec(module_spec) - module_spec.loader.exec_module(module) + module = _reload_runtime_module(runtime_module, "rbr_runtime_reloaded") harness = module_harness(module) harness.patch_shutil_which(lambda name: "/usr/bin/rustc") @@ -313,3 +359,95 @@ def fake_run( harness.monkeypatch.setattr(module, "run_validated", fake_run) module.detect_host_target() assert captured.get("timeout") == 2 + + +@pytest.mark.parametrize( + ("env_value", "expected_kind", "message_fragment"), + [ + pytest.param( + "not-a-number", + "default", + "Invalid RUNTIME_PROBE_TIMEOUT value", + id="invalid", + ), + pytest.param("0", "default", "0s raised to", id="zero"), + pytest.param("-5", "default", "-5s raised to", id="negative"), + pytest.param("999", "max", "999s capped to", id="capped"), + ], +) +def test_probe_timeout_sanitization_warnings( + runtime_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + echo_recorder: typ.Callable[[ModuleType], list[tuple[str, bool]]], + request: pytest.FixtureRequest, + env_value: str, + expected_kind: str, + message_fragment: str, +) -> None: + """Probe timeout overrides produce warnings when sanitized.""" + messages = echo_recorder(runtime_module) + monkeypatch.setenv("RUNTIME_PROBE_TIMEOUT", env_value) + module = _reload_runtime_module( + runtime_module, + f"rbr_runtime_timeout_{request.node.callspec.id}", + ) + if expected_kind == "default": + expected = module._DEFAULT_PROBE_TIMEOUT + elif expected_kind == "max": + expected = module._MAX_PROBE_TIMEOUT + else: + expected = int(env_value) + assert expected == module.PROBE_TIMEOUT + assert any(err for _, err in messages), "expected stderr warning for timeout" + assert any( + message_fragment in msg and str(expected) in msg for msg, err in messages if err + ) + + +@pytest.mark.parametrize( + ("env_value", "expected_kind", "message_fragment"), + [ + pytest.param(None, "default", None, id="unset"), + pytest.param("5", "value", None, id="custom-value"), + pytest.param( + "not-a-number", + "default", + "Invalid RUNTIME_PROBE_TIMEOUT value", + id="invalid", + ), + pytest.param("0", "default", "0s raised to", id="zero"), + pytest.param("-5", "default", "-5s raised to", id="negative"), + pytest.param("999", "max", "999s capped to", id="capped"), + ], +) +def test_get_probe_timeout_sanitizes_values( + runtime_module: ModuleType, + monkeypatch: pytest.MonkeyPatch, + echo_recorder: typ.Callable[[ModuleType], list[tuple[str, bool]]], + env_value: str | None, + expected_kind: str, + message_fragment: str | None, +) -> None: + """Unit tests for probe timeout sanitization helper.""" + messages = echo_recorder(runtime_module) + if env_value is None: + monkeypatch.delenv("RUNTIME_PROBE_TIMEOUT", raising=False) + else: + monkeypatch.setenv("RUNTIME_PROBE_TIMEOUT", env_value) + result = runtime_module._get_probe_timeout() + if expected_kind == "default": + expected = runtime_module._DEFAULT_PROBE_TIMEOUT + elif expected_kind == "max": + expected = runtime_module._MAX_PROBE_TIMEOUT + else: + expected = int(env_value or runtime_module._DEFAULT_PROBE_TIMEOUT) + assert result == expected + if message_fragment is None: + assert not any(err for _, err in messages), "unexpected stderr warning" + else: + assert any(err for _, err in messages), "expected stderr warning" + assert any( + message_fragment in msg and str(expected) in msg + for msg, err in messages + if err + ) diff --git a/.github/actions/rust-build-release/tests/test_utils.py b/.github/actions/rust-build-release/tests/test_utils.py index 0e96e3c9..8dad3577 100644 --- a/.github/actions/rust-build-release/tests/test_utils.py +++ b/.github/actions/rust-build-release/tests/test_utils.py @@ -59,10 +59,14 @@ def test_run_validated_invokes_subprocess_with_validated_path( ) cmd_mox.verify() - assert isinstance(result, subprocess.CompletedProcess) - assert result.args[0] == str(exe_path) - assert result.stdout == "ok" - assert spy.call_count == 1 + assert isinstance(result, subprocess.CompletedProcess), ( + "run_validated should return subprocess.CompletedProcess" + ) + assert result.args[0] == str(exe_path), ( + "subprocess should be invoked with the validated executable path" + ) + assert result.stdout == "ok", "stdout should propagate from the command double" + assert spy.call_count == 1, "command double should be invoked exactly once" def test_run_validated_raises_for_unexpected_executable( diff --git a/.github/actions/setup-windows-gnu/action.yml b/.github/actions/setup-windows-gnu/action.yml index f2066e8f..5f04f2f4 100644 --- a/.github/actions/setup-windows-gnu/action.yml +++ b/.github/actions/setup-windows-gnu/action.yml @@ -33,6 +33,14 @@ inputs: runs: using: composite steps: + - name: "Guard: Windows runner only" + shell: bash + run: | + set -euo pipefail + if [ "${RUNNER_OS}" != "Windows" ]; then + echo "::error::setup-windows-gnu action requires a Windows runner" >&2 + exit 1 + fi - name: Install MinGW toolchains # Consumers can add extra cross linkers in their workflows if they # require GCC-based aarch64 binaries. llvm-mingw provides clang by default. @@ -43,6 +51,7 @@ runs: install: | mingw-w64-x86_64-toolchain mingw-w64-x86_64-gcc-libs + mingw-w64-x86_64-pkg-config - name: Install llvm-mingw shell: pwsh run: | diff --git a/Makefile b/Makefile index b64919c1..a3f14ea4 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ typecheck: .venv ## Run static type checking with Ty --extra-search-path .github/actions/macos-package/scripts \ .github/actions/macos-package/scripts uvx pyright -fmt: ## Apply formatting to Python files +fmt: ## Format Python files and apply Ruff lint auto-fixes uvx ruff format uvx ruff check --select $(RUFF_FIX_RULES) --fix diff --git a/conftest.py b/conftest.py index 46397a07..94a0d6a2 100644 --- a/conftest.py +++ b/conftest.py @@ -29,7 +29,7 @@ class CmdDouble(typ.Protocol): call_count: int - def with_args(self, *args: str) -> CmdDouble: + def with_args(self, *args: str) -> typ.Self: """Set the expected argv for the double.""" ... @@ -40,11 +40,11 @@ def returns( stderr: str = "", exit_code: int = 0, **_: object, - ) -> CmdDouble: + ) -> typ.Self: """Provide canned output for the command invocation.""" ... - def runs(self, handler: cabc.Callable[[object], tuple[str, str, int]]) -> CmdDouble: + def runs(self, handler: cabc.Callable[[object], tuple[str, str, int]]) -> typ.Self: """Execute a handler when the double is invoked.""" ... From ed0eb1a564d08ea087a90ad85127849404fd846b Mon Sep 17 00:00:00 2001 From: Leynos Date: Wed, 24 Sep 2025 12:36:07 +0100 Subject: [PATCH 40/41] Update fmt target description (#140) --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a3f14ea4..b38510b2 100644 --- a/Makefile +++ b/Makefile @@ -42,7 +42,7 @@ typecheck: .venv ## Run static type checking with Ty --extra-search-path .github/actions/macos-package/scripts \ .github/actions/macos-package/scripts uvx pyright -fmt: ## Format Python files and apply Ruff lint auto-fixes +fmt: ## Format Python files and auto-fix selected lint rules uvx ruff format uvx ruff check --select $(RUFF_FIX_RULES) --fix From fb721b7cd313f5edae82d5d87c8ee8d06f1f2647 Mon Sep 17 00:00:00 2001 From: Payton McIntosh Date: Wed, 24 Sep 2025 12:52:39 +0100 Subject: [PATCH 41/41] Resolve rebase conflicts and align runtime tests with platform/timeouts from main while preserving branch improvements; ensure formatting passes and all tests/linters are green. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 💘 Generated with Crush Co-Authored-By: Crush --- .github/actions/rust-build-release/tests/test_runtime.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/actions/rust-build-release/tests/test_runtime.py b/.github/actions/rust-build-release/tests/test_runtime.py index 8a72858e..64045bdf 100644 --- a/.github/actions/rust-build-release/tests/test_runtime.py +++ b/.github/actions/rust-build-release/tests/test_runtime.py @@ -11,8 +11,6 @@ import pytest -import pytest - if typ.TYPE_CHECKING: from .conftest import HarnessFactory, ModuleHarness