diff --git a/eng/tools/azure-sdk-tools/azpysdk/apistub.py b/eng/tools/azure-sdk-tools/azpysdk/apistub.py index 84e2cd8c964f..e52e665f0fb7 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/apistub.py +++ b/eng/tools/azure-sdk-tools/azpysdk/apistub.py @@ -81,13 +81,27 @@ def register( dest="install_deps", default=False, action="store_true", - help=( - "Install dev requirements and apiview dependencies before running. " - "Skipped by default for faster local iteration." - ), + help="Install target package dev requirements before running.", ) p.set_defaults(func=self.run) + def ensure_apistub_dependencies(self, executable: str, package_dir: str, staging_directory: str) -> None: + try: + self.run_venv_command(executable, ["-c", "import apistub"], cwd=staging_directory, check=True) + return + except CalledProcessError: + logger.info("apistub module is not installed. Installing APIView dependencies.") + + install_into_venv( + executable, + [ + "-r", + os.path.join(REPO_ROOT, "eng", "apiview_reqs.txt"), + "--index-url=https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/", + ], + package_dir, + ) + def run(self, args: argparse.Namespace) -> int: """Run the apistub check command.""" logger.info("Running apistub check...") @@ -121,19 +135,11 @@ def run(self, args: argparse.Namespace) -> int: # install dependencies self.install_dev_reqs(executable, args, package_dir) - try: - install_into_venv( - executable, - [ - "-r", - os.path.join(REPO_ROOT, "eng", "apiview_reqs.txt"), - "--index-url=https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/", - ], - package_dir, - ) - except CalledProcessError as e: - logger.error(f"Failed to install dependencies: {e}") - return e.returncode + try: + self.ensure_apistub_dependencies(executable, package_dir, staging_directory) + except (CalledProcessError, RuntimeError) as e: + logger.error(f"Failed to install APIView dependencies: {e}") + return getattr(e, "returncode", 1) if not os.getenv("PREBUILT_WHEEL_DIR"): create_package_and_install( diff --git a/eng/tools/azure-sdk-tools/tests/test_apistub.py b/eng/tools/azure-sdk-tools/tests/test_apistub.py index 26af6468945e..21d85998eb2b 100644 --- a/eng/tools/azure-sdk-tools/tests/test_apistub.py +++ b/eng/tools/azure-sdk-tools/tests/test_apistub.py @@ -3,11 +3,11 @@ import sys import pytest +from subprocess import CalledProcessError from unittest.mock import patch, MagicMock from azpysdk.apistub import apistub, get_package_wheel_path, get_cross_language_mapping_path - # ── get_package_wheel_path() ───────────────────────────────────────────── @@ -128,7 +128,7 @@ def test_isolate_does_not_install_dependencies( def test_install_deps_installs_dependencies( self, _env, install_into_venv, _create, _get_whl, _get_mapping, tmp_path, monkeypatch ): - """When --install-deps is passed, apistub should install dependencies.""" + """When --install-deps is passed, apistub should install target package dev requirements.""" monkeypatch.chdir(os.getcwd()) stub = apistub() staging = str(tmp_path / "staging") @@ -148,13 +148,68 @@ def test_install_deps_installs_dependencies( stub.run(args) install_dev_reqs.assert_called_once_with(sys.executable, args, str(tmp_path)) - install_into_venv.assert_called_once() + install_into_venv.assert_not_called() + pip_freeze.assert_called_once_with(sys.executable) + + @patch("azpysdk.apistub.logger.error") + @patch("azpysdk.apistub.set_envvar_defaults") + def test_runtime_error_installing_apiview_dependencies_returns_one(self, _env, logger_error, tmp_path, monkeypatch): + """When APIView dependency install raises RuntimeError, run() should log and return 1.""" + monkeypatch.chdir(os.getcwd()) + stub = apistub() + staging = str(tmp_path / "staging") + os.makedirs(staging, exist_ok=True) + fake_parsed = MagicMock() + fake_parsed.folder = str(tmp_path) + fake_parsed.name = "azure-core" + + with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object( + stub, "get_executable", return_value=(sys.executable, staging) + ), patch.object(stub, "ensure_apistub_dependencies", side_effect=RuntimeError("401 auth error")): + result = stub.run(self._make_args()) + + assert result == 1 + logger_error.assert_called_once_with("Failed to install APIView dependencies: 401 auth error") + + @patch( + "azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) + ) + @patch("azpysdk.apistub.install_into_venv") + def test_apistub_dependencies_are_skipped_when_installed(self, install_into_venv, tmp_path): + """When apistub is already importable, APIView requirements should not be reinstalled.""" + stub = apistub() + + with patch.object(stub, "run_venv_command") as run_venv_command: + stub.ensure_apistub_dependencies(sys.executable, str(tmp_path), str(tmp_path)) + + run_venv_command.assert_called_once_with( + sys.executable, ["-c", "import apistub"], cwd=str(tmp_path), check=True + ) + install_into_venv.assert_not_called() + + @patch( + "azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) + ) + @patch("azpysdk.apistub.install_into_venv") + def test_missing_apistub_installs_apiview_requirements(self, install_into_venv, tmp_path): + """When apistub is missing, APIView requirements should be installed once.""" + stub = apistub() repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) + + with patch.object( + stub, + "run_venv_command", + side_effect=CalledProcessError(1, [sys.executable, "-c", "import apistub"]), + ): + stub.ensure_apistub_dependencies(sys.executable, str(tmp_path), str(tmp_path)) + + install_into_venv.assert_called_once() + assert install_into_venv.call_args.args[0] == sys.executable assert install_into_venv.call_args.args[1][0:2] == [ "-r", os.path.join(repo_root, "eng", "apiview_reqs.txt"), ] - pip_freeze.assert_called_once_with(sys.executable) + assert install_into_venv.call_args.args[2] == str(tmp_path) @patch( "azpysdk.apistub.REPO_ROOT", os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) @@ -196,6 +251,8 @@ def fake_pwsh(cmd, **kwargs): with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object( stub, "get_executable", return_value=(sys.executable, staging) ), patch.object(stub, "install_dev_reqs"), patch.object(stub, "pip_freeze"), patch.object( + stub, "ensure_apistub_dependencies" + ), patch.object( stub, "run_venv_command", side_effect=fake_apistub_run ), patch( "azpysdk.apistub.run", side_effect=fake_pwsh @@ -244,6 +301,8 @@ def fake_pwsh(cmd, **kwargs): with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object( stub, "get_executable", return_value=(sys.executable, staging) ), patch.object(stub, "install_dev_reqs"), patch.object(stub, "pip_freeze"), patch.object( + stub, "ensure_apistub_dependencies" + ), patch.object( stub, "run_venv_command", side_effect=fake_apistub_run ), patch( "azpysdk.apistub.run", side_effect=fake_pwsh @@ -293,6 +352,8 @@ def fake_pwsh(cmd, **kwargs): with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object( stub, "get_executable", return_value=(sys.executable, staging) ), patch.object(stub, "install_dev_reqs"), patch.object(stub, "pip_freeze"), patch.object( + stub, "ensure_apistub_dependencies" + ), patch.object( stub, "run_venv_command", side_effect=fake_apistub_run ), patch( "azpysdk.apistub.run", side_effect=fake_pwsh @@ -330,6 +391,8 @@ def fake_apistub_run(exe, cmds, **kwargs): with patch.object(stub, "get_targeted_directories", return_value=[fake_parsed]), patch.object( stub, "get_executable", return_value=(sys.executable, staging) ), patch.object(stub, "install_dev_reqs"), patch.object(stub, "pip_freeze"), patch.object( + stub, "ensure_apistub_dependencies" + ), patch.object( stub, "run_venv_command", side_effect=fake_apistub_run ): stub.run(self._make_args(generate_md=False))