From 9c10aef9c3ddaa7a8c4d2ac87a04b56087296f30 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 20 Sep 2025 00:17:20 +0100 Subject: [PATCH 1/4] Use cmd-mox for rust build command tests --- .../rust-build-release/tests/conftest.py | 7 - .../tests/test_cross_install.py | 236 ++++++++++-------- .../tests/test_target_install.py | 127 ++++------ .../rust-build-release/tests/test_utils.py | 27 +- conftest.py | 3 + pyproject.toml | 1 + uv.lock | 7 + 7 files changed, 201 insertions(+), 207 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 45faf91c..902ed405 100644 --- a/.github/actions/rust-build-release/tests/conftest.py +++ b/.github/actions/rust-build-release/tests/conftest.py @@ -90,13 +90,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..59882a5c 100644 --- a/.github/actions/rust-build-release/tests/test_cross_install.py +++ b/.github/actions/rust-build-release/tests/test_cross_install.py @@ -2,14 +2,21 @@ from __future__ import annotations +import collections +import collections.abc as cabc import hashlib import io import subprocess +import sys import typing as typ import zipfile import pytest +CMD_MOX_UNSUPPORTED = pytest.mark.skipif( + sys.platform == "win32", reason="cmd-mox does not support Windows" +) + if typ.TYPE_CHECKING: from pathlib import Path from types import ModuleType @@ -17,42 +24,56 @@ from .conftest import HarnessFactory -def _constant_run(stdout: str) -> typ.Callable[..., subprocess.CompletedProcess[str]]: - """Return a ``run_validated`` stub emitting *stdout*.""" +def _register_cross_version_stub( + cmd_mox, stdout: str | cabc.Iterable[str] = "cross 0.2.5\n" +) -> str: + 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 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) + def _handler(_invocation: object) -> tuple[str, str, int]: + data = outputs.popleft() if outputs else last + return data, "", 0 - return fake_run + 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: + 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: + cmd_mox.stub("docker").with_args("info").returns(exit_code=exit_code) + return str(cmd_mox.environment.shim_dir / "docker") + + +@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 +106,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 +138,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 +189,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 +376,38 @@ 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 + if name == "rustup": + return rustup_path + return 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 +420,74 @@ 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 + if name == "rustup": + return rustup_path + return 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 6e811234..b5168644 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -1,13 +1,16 @@ -"""Tests target installation fallback behavior.""" - from __future__ import annotations import os import subprocess +import sys import typing as typ import pytest +CMD_MOX_UNSUPPORTED = pytest.mark.skipif( + sys.platform == "win32", reason="cmd-mox does not support Windows" +) + if typ.TYPE_CHECKING: from pathlib import Path from types import ModuleType @@ -15,6 +18,25 @@ from .conftest import HarnessFactory +def _register_rustup_toolchain_stub( + cmd_mox, default_toolchain: str +) -> str: # pragma: no cover - helper + stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" + cmd_mox.stub("rustup").with_args("toolchain", "list").returns(stdout=stdout) + return str(cmd_mox.environment.shim_dir / "rustup") + + +def _register_cross_version_stub(cmd_mox, stdout: str = "cross 0.2.5\n") -> str: + cmd_mox.stub("cross").with_args("--version").returns(stdout=stdout) + return str(cmd_mox.environment.shim_dir / "cross") + + +def _register_docker_info_stub(cmd_mox, *, exit_code: int = 0) -> str: + cmd_mox.stub("docker").with_args("info").returns(exit_code=exit_code) + return str(cmd_mox.environment.shim_dir / "docker") + + +@CMD_MOX_UNSUPPORTED def test_skips_target_install_when_cross_available( main_module: ModuleType, cross_module: ModuleType, @@ -30,47 +52,32 @@ 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_path = _register_rustup_toolchain_stub(cmd_mox, default_toolchain) + 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 +88,15 @@ 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_path = _register_rustup_toolchain_stub(cmd_mox, default_toolchain) + 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 +105,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 +130,22 @@ 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_path = _register_rustup_toolchain_stub(cmd_mox, default_toolchain) + 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..2c69ce49 100644 --- a/.github/actions/rust-build-release/tests/test_utils.py +++ b/.github/actions/rust-build-release/tests/test_utils.py @@ -3,11 +3,16 @@ from __future__ import annotations import subprocess +import sys import typing as typ from pathlib import Path import pytest +CMD_MOX_UNSUPPORTED = pytest.mark.skipif( + sys.platform == "win32", reason="cmd-mox does not support Windows" +) + if typ.TYPE_CHECKING: from types import ModuleType @@ -34,21 +39,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 +57,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..9ee6f487 --- /dev/null +++ b/conftest.py @@ -0,0 +1,3 @@ +"""Pytest configuration for shared actions tests.""" + +pytest_plugins = ("cmd_mox.pytest_plugin",) diff --git a/pyproject.toml b/pyproject.toml index 5f8dbfbd..7f677f04 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +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", ] [tool.ruff] diff --git a/uv.lock b/uv.lock index b7dcc8d3..519811e9 100644 --- a/uv.lock +++ b/uv.lock @@ -14,6 +14,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" @@ -226,6 +231,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "cmd-mox" }, { name = "lxml-stubs" }, { name = "pytest" }, { name = "pyyaml" }, @@ -242,6 +248,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 8bfbc5994b98354679876281920bef67e64c57be Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 20 Sep 2025 11:07:37 +0100 Subject: [PATCH 2/4] Guard cmd-mox integration on Windows --- conftest.py | 17 ++++++++++++++++- docs/cmd-mox-users-guide.md | 12 +++++++++--- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/conftest.py b/conftest.py index 9ee6f487..71e3e1f0 100644 --- a/conftest.py +++ b/conftest.py @@ -1,3 +1,18 @@ """Pytest configuration for shared actions tests.""" -pytest_plugins = ("cmd_mox.pytest_plugin",) +from __future__ import annotations + +import sys + +import pytest + + +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 From 9648cd20fc31d1c74abe8484c186499a19708e23 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 20 Sep 2025 11:31:13 +0100 Subject: [PATCH 3/4] Refactor cmd-mox helpers into shared conftest --- .../tests/test_cross_install.py | 39 ++-------------- .../tests/test_target_install.py | 37 +++++---------- .../rust-build-release/tests/test_utils.py | 5 +- conftest.py | 46 +++++++++++++++++++ 4 files changed, 63 insertions(+), 64 deletions(-) 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 59882a5c..0213e501 100644 --- a/.github/actions/rust-build-release/tests/test_cross_install.py +++ b/.github/actions/rust-build-release/tests/test_cross_install.py @@ -2,19 +2,19 @@ from __future__ import annotations -import collections -import collections.abc as cabc import hashlib import io import subprocess -import sys import typing as typ import zipfile import pytest -CMD_MOX_UNSUPPORTED = pytest.mark.skipif( - sys.platform == "win32", reason="cmd-mox does not support Windows" +from shared_actions_conftest import ( + CMD_MOX_UNSUPPORTED, + _register_cross_version_stub, + _register_docker_info_stub, + _register_rustup_toolchain_stub, ) if typ.TYPE_CHECKING: @@ -22,35 +22,6 @@ from types import ModuleType from .conftest import HarnessFactory - - -def _register_cross_version_stub( - cmd_mox, stdout: str | cabc.Iterable[str] = "cross 0.2.5\n" -) -> str: - 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: - 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: - cmd_mox.stub("docker").with_args("info").returns(exit_code=exit_code) - return str(cmd_mox.environment.shim_dir / "docker") - - @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 b5168644..79e799a5 100644 --- a/.github/actions/rust-build-release/tests/test_target_install.py +++ b/.github/actions/rust-build-release/tests/test_target_install.py @@ -2,13 +2,15 @@ import os import subprocess -import sys import typing as typ import pytest -CMD_MOX_UNSUPPORTED = pytest.mark.skipif( - sys.platform == "win32", reason="cmd-mox does not support Windows" +from shared_actions_conftest import ( + CMD_MOX_UNSUPPORTED, + _register_cross_version_stub, + _register_docker_info_stub, + _register_rustup_toolchain_stub, ) if typ.TYPE_CHECKING: @@ -16,26 +18,6 @@ from types import ModuleType from .conftest import HarnessFactory - - -def _register_rustup_toolchain_stub( - cmd_mox, default_toolchain: str -) -> str: # pragma: no cover - helper - stdout = f"{default_toolchain}-x86_64-unknown-linux-gnu\n" - cmd_mox.stub("rustup").with_args("toolchain", "list").returns(stdout=stdout) - return str(cmd_mox.environment.shim_dir / "rustup") - - -def _register_cross_version_stub(cmd_mox, stdout: str = "cross 0.2.5\n") -> str: - cmd_mox.stub("cross").with_args("--version").returns(stdout=stdout) - return str(cmd_mox.environment.shim_dir / "cross") - - -def _register_docker_info_stub(cmd_mox, *, exit_code: int = 0) -> str: - cmd_mox.stub("docker").with_args("info").returns(exit_code=exit_code) - return str(cmd_mox.environment.shim_dir / "docker") - - @CMD_MOX_UNSUPPORTED def test_skips_target_install_when_cross_available( main_module: ModuleType, @@ -53,7 +35,8 @@ 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_path = _register_rustup_toolchain_stub(cmd_mox, 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) @@ -89,7 +72,8 @@ def test_errors_when_target_unsupported_without_cross( app_env = module_harness(main_module) default_toolchain = main_module.DEFAULT_TOOLCHAIN - rustup_path = _register_rustup_toolchain_stub(cmd_mox, 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 rustup_path if name == "rustup" else None @@ -131,7 +115,8 @@ 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_path = _register_rustup_toolchain_stub(cmd_mox, 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: diff --git a/.github/actions/rust-build-release/tests/test_utils.py b/.github/actions/rust-build-release/tests/test_utils.py index 2c69ce49..b46ceb69 100644 --- a/.github/actions/rust-build-release/tests/test_utils.py +++ b/.github/actions/rust-build-release/tests/test_utils.py @@ -3,15 +3,12 @@ from __future__ import annotations import subprocess -import sys import typing as typ from pathlib import Path import pytest -CMD_MOX_UNSUPPORTED = pytest.mark.skipif( - sys.platform == "win32", reason="cmd-mox does not support Windows" -) +from shared_actions_conftest import CMD_MOX_UNSUPPORTED if typ.TYPE_CHECKING: from types import ModuleType diff --git a/conftest.py b/conftest.py index 71e3e1f0..18865fc7 100644 --- a/conftest.py +++ b/conftest.py @@ -2,11 +2,57 @@ 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: From 03f83f4b6ad9b7b18d6e361c6814c6d7020ebf40 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sat, 20 Sep 2025 11:47:42 +0100 Subject: [PATCH 4/4] Apply cmd-mox fake which simplification --- .../rust-build-release/tests/test_cross_install.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) 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 0213e501..08b17eda 100644 --- a/.github/actions/rust-build-release/tests/test_cross_install.py +++ b/.github/actions/rust-build-release/tests/test_cross_install.py @@ -369,9 +369,7 @@ def fake_which(name: str) -> str | None: return cross_checks.pop(0) if cross_checks else cross_path if name in {"docker", "podman"}: return None - if name == "rustup": - return rustup_path - return None + return rustup_path if name == "rustup" else None cross_env.patch_shutil_which(fake_which) app_env.patch_shutil_which(fake_which) @@ -449,9 +447,7 @@ def fake_which(name: str) -> str | None: return docker_path if name == "cross": return cross_path - if name == "rustup": - return rustup_path - return None + return rustup_path if name == "rustup" else None cross_env.patch_shutil_which(fake_which) app_env.patch_shutil_which(fake_which)