Files
devx/tests/unit/test_setup.py
T
kiretoandemo ed3bd75367
Post-merge / detect-and-configure (push) Successful in 30s
Post-merge / release-and-maintain (push) Successful in 2m22s
DEVX-149: fix: unique molecule container names per CI runner
Co-authored-by: kireto <kireto@oblachno.com>
2026-08-05 19:01:54 +00:00

366 lines
15 KiB
Python

"""Unit tests for devx.tools.setup."""
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.setup import (
_configure_tea_login,
_install_ansible_collections,
_install_pre_commit_hooks,
_install_python_deps,
_run,
_verify,
main,
)
class TestRun:
@patch("devx.tools.setup.subprocess.run")
def test_run_success(self, mock_run: MagicMock) -> None:
_run(["echo", "hello"])
mock_run.assert_called_once_with(["echo", "hello"], check=True)
@patch("devx.tools.setup.subprocess.run")
def test_run_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.CalledProcessError(1, ["echo", "hello"])
with pytest.raises(subprocess.CalledProcessError):
_run(["echo", "hello"])
class TestInstallPythonDeps:
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_dev(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "dev")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], check=False)
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_ci(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], check=False)
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_custom_extras(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=0)
_install_python_deps(".venv/bin", "ci,lint")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"], check=False)
@patch("devx.tools.setup.subprocess.run")
def test_install_with_break_system_packages(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0)
with patch.dict(os.environ, {"PIP_BREAK_SYSTEM_PACKAGES": "1"}):
_install_python_deps(".venv/bin", "ci")
mock_run.assert_called_once_with(
[".venv/bin/pip", "install", "-e", ".[ci]", "--break-system-packages"], check=False
)
@patch("devx.tools.setup._run")
@patch("devx.tools.setup.subprocess.run")
def test_install_retry_with_ignore_installed(self, mock_subprocess: MagicMock, mock_run: MagicMock) -> None:
mock_subprocess.return_value = MagicMock(returncode=1)
with patch.dict(os.environ, {"PIP_BREAK_SYSTEM_PACKAGES": "1"}):
_install_python_deps(".venv/bin", "ci")
mock_run.assert_called_once_with(
[".venv/bin/pip", "install", "-e", ".[ci]", "--break-system-packages", "--ignore-installed"]
)
@patch("devx.tools.setup.subprocess.run")
@patch.dict(os.environ, {}, clear=False)
def test_install_failure_without_break_system(self, mock_run: MagicMock) -> None:
os.environ.pop("PIP_BREAK_SYSTEM_PACKAGES", None)
mock_run.return_value = MagicMock(returncode=1)
with pytest.raises(subprocess.CalledProcessError):
_install_python_deps(".venv/bin", "ci")
class TestInstallPreCommitHooks:
@patch("devx.tools.setup._run")
def test_installs_all_hook_types(self, mock_run: MagicMock) -> None:
_install_pre_commit_hooks(".venv/bin")
assert mock_run.call_count == 3
hook_types = [call.args[0][3] for call in mock_run.call_args_list]
assert "pre-commit" in hook_types
assert "commit-msg" in hook_types
assert "pre-push" in hook_types
class TestInstallAnsibleCollections:
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/ansible-galaxy")
@patch("devx.tools.setup._run")
def test_installs_from_requirements(self, mock_run: MagicMock, mock_which: MagicMock, tmp_path: Path) -> None:
req = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True)
req.write_text("collections: []")
with patch("devx.tools.setup.Path") as mock_path:
mock_path.return_value.exists.return_value = True
mock_path.return_value.__str__ = lambda _: str(req)
_install_ansible_collections(".venv/bin")
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
assert "--no-cache" in args, "ansible-galaxy must use --no-cache to avoid concurrent cache corruption"
@patch("devx.tools.setup._run")
def test_skips_when_no_requirements(self, mock_run: MagicMock) -> None:
_install_ansible_collections(".venv/bin")
mock_run.assert_not_called()
class TestConfigureTeaLogin:
@patch("devx.tools.setup.shutil.which", return_value=None)
def test_tea_not_installed(self, mock_which: MagicMock) -> None:
_configure_tea_login()
mock_which.assert_called_once_with("tea")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict("os.environ", {}, clear=True)
def test_no_token(self, mock_which: MagicMock) -> None:
_configure_tea_login()
@patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_login_already_exists(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="devx\ngrm\n", stderr="")
_configure_tea_login()
# Should only call login list, not login add
assert mock_run.call_count == 1
assert "list" in mock_run.call_args[0][0]
@patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_login_add_success(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
list_result = MagicMock(returncode=0, stdout="", stderr="")
add_result = MagicMock(returncode=0, stdout="", stderr="")
default_result = MagicMock(returncode=0, stdout="", stderr="")
mock_run.side_effect = [list_result, add_result, default_result]
_configure_tea_login()
assert mock_run.call_count == 3
assert "add" in mock_run.call_args_list[1][0][0]
assert "default" in mock_run.call_args_list[2][0][0]
@patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_login_add_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
list_result = MagicMock(returncode=0, stdout="", stderr="")
add_result = MagicMock(returncode=1, stdout="", stderr="auth failed")
mock_run.side_effect = [list_result, add_result]
_configure_tea_login()
assert mock_run.call_count == 2
@patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict(
"os.environ",
{"CI_GITEA_TOKEN": "tok123", "DEVX_GITEA_API_URL": "https://custom.example.com/api/v1"},
clear=True,
)
def test_custom_gitea_url(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
list_result = MagicMock(returncode=0, stdout="", stderr="")
add_result = MagicMock(returncode=0, stdout="", stderr="")
default_result = MagicMock(returncode=0, stdout="", stderr="")
mock_run.side_effect = [list_result, add_result, default_result]
_configure_tea_login()
add_call = mock_run.call_args_list[1][0][0]
assert "https://custom.example.com" in add_call
class TestVerify:
@patch("devx.tools.setup.subprocess.run")
def test_verify_runs_tools(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="devx 0.1.0", stderr="")
_verify(".venv/bin")
# Should try to run --version for devx and pre-commit
assert mock_run.call_count >= 1
@patch("devx.tools.setup.subprocess.run")
def test_verify_handles_missing_tools(self, mock_run: MagicMock) -> None:
mock_run.side_effect = FileNotFoundError("not found")
_verify(".venv/bin") # Should not raise
@patch("devx.tools.setup.subprocess.run")
def test_verify_handles_timeout(self, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.TimeoutExpired(cmd="devx", timeout=10)
_verify(".venv/bin") # Should not raise
@patch("devx.tools.setup.subprocess.run")
def test_verify_handles_nonzero_returncode(self, mock_run: MagicMock) -> None:
"""When a tool returns non-zero, it is skipped without raising."""
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
_verify(".venv/bin") # Should not raise
class TestMain:
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_success(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
mock_install_deps.assert_called_once()
mock_install_ansible.assert_called_once()
mock_install_hooks.assert_called_once()
mock_verify.assert_called_once()
mock_tea.assert_called_once()
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_no_pre_commit(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--no-pre-commit"])
assert result.exit_code == 0
mock_install_deps.assert_called_once()
mock_install_hooks.assert_not_called()
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_no_ansible_collections(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--no-ansible-collections"])
assert result.exit_code == 0
mock_install_ansible.assert_not_called()
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_custom_extras(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--extras", "ci"])
assert result.exit_code == 0
mock_install_deps.assert_called_once_with(str(bin_dir), "ci")
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_no_tea_login(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--no-tea-login"])
assert result.exit_code == 0
mock_tea.assert_not_called()
@patch("devx.tools.setup._run")
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._install_python_deps")
@patch("devx.tools.setup._verify")
def test_main_missing_bin_dir(
self, mock_tea: MagicMock, mock_deps: MagicMock, mock_verify: MagicMock, mock_run: MagicMock
) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--bin", "/nonexistent/path"])
assert result.exit_code != 0
assert "Bin directory not found" in result.output
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_skip_install(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--skip-install"])
assert result.exit_code == 0
mock_install_deps.assert_not_called()
assert "Skipping pip install" in result.output
def test_main_module_block(tmp_path: Path) -> None:
"""Test the __main__ block execution."""
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
with patch.dict("os.environ", {}, clear=True):
with patch("devx.tools.setup._install_python_deps") as mock_deps:
with patch("devx.tools.setup._install_pre_commit_hooks"):
with patch("devx.tools.setup._install_ansible_collections"):
with patch("devx.tools.setup._configure_tea_login"):
with patch("devx.tools.setup._verify"):
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
mock_deps.assert_called_once()