"""Shared fixtures for scripts.helpers.build regression tests."""
from __future__ import annotations
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any
import pytest
from scripts.helpers.build import main as build_main
from scripts.helpers.build.argv import BuildOptions
if TYPE_CHECKING:
from collections.abc import Callable
_FAKE_UV = "/fake/uv"
_BUILD_MAIN = "scripts.helpers.build.main"
def _completed(cmd: list[str], returncode: int, stdout: str = "", stderr: str = "") -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(cmd, returncode, stdout, stderr)
def _raise_if_check(cmd: list[str], completed: subprocess.CompletedProcess[str], **kwargs: Any) -> None:
if kwargs.get("check") and completed.returncode != 0:
raise subprocess.CalledProcessError(
completed.returncode,
cmd,
output=completed.stdout,
stderr=completed.stderr,
)
@dataclass
class SubprocessRunCapture:
"""Records boundary subprocess invocations from build.main."""
version_calls: list[str] = field(default_factory=list)
shell_calls: list[dict[str, Any]] = field(default_factory=list)
merged_output_calls: list[dict[str, Any]] = field(default_factory=list)
on_uv_version: Callable[[str], subprocess.CompletedProcess[str] | None] | None = None
on_bash: Callable[[list[str], dict[str, Any]], subprocess.CompletedProcess[str] | None] | None = None
on_merged_output: Callable[..., int] | None = None
def _run_uv_version(self, cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
version = cmd[2]
self.version_calls.append(version)
if self.on_uv_version is not None:
custom = self.on_uv_version(version)
if custom is not None:
_raise_if_check(cmd, custom, **kwargs)
return custom
completed = _completed(cmd, 0)
_raise_if_check(cmd, completed, **kwargs)
return completed
def _run_bash(self, cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
call = {"cmd": cmd, "kwargs": kwargs}
self.shell_calls.append(call)
if self.on_bash is not None:
custom = self.on_bash(cmd, kwargs)
if custom is not None:
_raise_if_check(cmd, custom, **kwargs)
return custom
completed = _completed(cmd, 0)
_raise_if_check(cmd, completed, **kwargs)
return completed
def run(self, cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
if len(cmd) >= 3 and Path(cmd[0]).name == "uv" and cmd[1] == "version":
return self._run_uv_version(cmd, **kwargs)
if cmd and cmd[0] == "bash":
return self._run_bash(cmd, **kwargs)
msg = f"unexpected subprocess.run command: {cmd!r}"
raise AssertionError(msg)
def run_merged_output(
self,
cmd: list[str],
*,
cwd: Path,
env: dict[str, str],
timeout: float,
tee_path: Path | None = None,
**kwargs: Any,
) -> int:
call = {
"cmd": cmd,
"cwd": cwd,
"env": env,
"timeout": timeout,
"tee_path": tee_path,
**kwargs,
}
self.merged_output_calls.append(call)
if self.on_merged_output is not None:
return self.on_merged_output(
cmd,
cwd=cwd,
env=env,
timeout=timeout,
tee_path=tee_path,
**kwargs,
)
if tee_path is not None:
tee_path.parent.mkdir(parents=True, exist_ok=True)
tee_path.write_text("gate output\n", encoding="utf-8")
return 0
def build_options(**overrides: Any) -> BuildOptions:
defaults: dict[str, Any] = {
"is_test": False,
"is_local": False,
"version": None,
"version_explicit": False,
"extras": {},
}
defaults.update(overrides)
return BuildOptions(**defaults)
def patch_uv_in_path(monkeypatch: pytest.MonkeyPatch, *, uv_path: str | None = _FAKE_UV) -> None:
def which(name: str) -> str | None:
if name == "uv":
return uv_path
return None
monkeypatch.setattr(f"{_BUILD_MAIN}.shutil.which", which)
def patch_subprocess_run(
monkeypatch: pytest.MonkeyPatch,
capture: SubprocessRunCapture,
) -> SubprocessRunCapture:
monkeypatch.setattr(f"{_BUILD_MAIN}.subprocess.run", capture.run)
monkeypatch.setattr(f"{_BUILD_MAIN}.run_merged_output", capture.run_merged_output)
return capture
def fake_build_bash(
repo_root: Path,
*,
wheel_name: str,
stdout: str = "",
) -> Callable[[list[str], dict[str, Any]], subprocess.CompletedProcess[str]]:
def handler(cmd: list[str], _kwargs: dict[str, Any]) -> subprocess.CompletedProcess[str]:
wheel_dir = repo_root / "artifacts"
wheel_dir.mkdir(parents=True, exist_ok=True)
wheel_path = wheel_dir / wheel_name
wheel_path.write_bytes(b"wheel-bytes")
message = stdout or f"Successfully built {wheel_path}\n"
return _completed(cmd, 0, message)
return handler
@pytest.fixture
def repo_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
scripts_dir = tmp_path / "scripts"
scripts_dir.mkdir(parents=True)
(scripts_dir / "build.sh").write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
(scripts_dir / "run_ci_gate.sh").write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8")
(tmp_path / "pyproject.toml").write_text(
'[project]\nname = "msmodeling"\nversion = "0.2.0"\n',
encoding="utf-8",
)
monkeypatch.setattr(build_main, "REPO_ROOT", tmp_path)
monkeypatch.setattr(build_main, "_BUILD_SCRIPT", scripts_dir / "build.sh")
monkeypatch.setattr(build_main, "_CI_GATE_SCRIPT", scripts_dir / "run_ci_gate.sh")
monkeypatch.setattr(build_main, "_ARTIFACTS_DIR", tmp_path / "artifacts")
monkeypatch.setattr(build_main, "_WHEEL_OUTPUT_DIR", tmp_path / "artifacts")
monkeypatch.setattr(build_main, "_TEST_REPORTS_DIR", tmp_path / "artifacts" / "test-reports")
return tmp_path
@pytest.fixture
def with_uv(monkeypatch: pytest.MonkeyPatch) -> None:
patch_uv_in_path(monkeypatch)
@pytest.fixture
def subprocess_capture(
monkeypatch: pytest.MonkeyPatch,
with_uv: None,
) -> SubprocessRunCapture:
return patch_subprocess_run(monkeypatch, SubprocessRunCapture())