"""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()