diff --git a/eng/tools/azure-sdk-tools/azpysdk/breaking.py b/eng/tools/azure-sdk-tools/azpysdk/breaking.py index f204f1794e39..83f289963cdd 100644 --- a/eng/tools/azure-sdk-tools/azpysdk/breaking.py +++ b/eng/tools/azure-sdk-tools/azpysdk/breaking.py @@ -128,8 +128,15 @@ def run(self, args: argparse.Namespace) -> int: ) logger.info(f"Processing {package_name} for breaking check...") - # install dependencies - self.install_dev_reqs(executable, args, package_dir) + use_apistub = getattr(args, "use_apistub", False) + + # The apistub path builds the code report via static analysis (apistub generates + # api.md and it is converted to a report); the target package is never imported. + # So installing dev requirements and building/installing the package sdist is only + # needed for the default (import-based) path. Skip both in apistub mode. + if not use_apistub: + # install dependencies + self.install_dev_reqs(executable, args, package_dir) try: install_into_venv( @@ -144,17 +151,18 @@ def run(self, args: argparse.Namespace) -> int: results.append(1) continue - create_package_and_install( - distribution_directory=staging_directory, - target_setup=package_dir, - skip_install=False, - cache_dir=None, - work_dir=staging_directory, - force_create=False, - package_type="sdist", - pre_download_disabled=False, - python_executable=executable, - ) + if not use_apistub: + create_package_and_install( + distribution_directory=staging_directory, + target_setup=package_dir, + skip_install=False, + cache_dir=None, + work_dir=staging_directory, + force_create=False, + package_type="sdist", + pre_download_disabled=False, + python_executable=executable, + ) try: cmd = [ diff --git a/eng/tools/azure-sdk-tools/tests/test_breaking.py b/eng/tools/azure-sdk-tools/tests/test_breaking.py new file mode 100644 index 000000000000..15049696ee40 --- /dev/null +++ b/eng/tools/azure-sdk-tools/tests/test_breaking.py @@ -0,0 +1,100 @@ +import argparse +import os +import sys + +from unittest.mock import MagicMock, patch + +from azpysdk.breaking import breaking + + +def _make_args(use_apistub=False, changelog=True, isolate=False): + """Build an argparse.Namespace with every attribute breaking.run() reads.""" + return argparse.Namespace( + target=".", + isolate=isolate, + command="breaking", + service=None, + target_module=None, + in_venv=False, + stable_version=None, + changelog=changelog, + code_report=False, + source_report=None, + target_report=None, + latest_pypi_version=False, + use_apistub=use_apistub, + debug=False, + ) + + +def _run_breaking(args, tmp_path): + """Invoke breaking.run() with all external side effects mocked out. + + Returns a dict of the mocks that assertions can inspect. + """ + chk = breaking() + 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" + + original_cwd = os.getcwd() + with patch("azpysdk.breaking.set_envvar_defaults"), patch( + "azpysdk.breaking.install_into_venv" + ) as install_into_venv, patch("azpysdk.breaking.create_package_and_install") as create_package_and_install, patch( + "azpysdk.breaking.check_call" + ) as check_call, patch.object( + chk, "get_targeted_directories", return_value=[fake_parsed] + ), patch.object( + chk, "get_executable", return_value=(sys.executable, staging) + ), patch.object( + chk, "install_dev_reqs" + ) as install_dev_reqs: + try: + result = chk.run(args) + finally: + os.chdir(original_cwd) + + return { + "result": result, + "install_dev_reqs": install_dev_reqs, + "install_into_venv": install_into_venv, + "create_package_and_install": create_package_and_install, + "check_call": check_call, + } + + +class TestBreakingUseApistubGuard: + """The apistub path builds the report via static analysis, so it must not install the + package's dev requirements nor build/install the target package sdist.""" + + def test_use_apistub_skips_dev_reqs_and_sdist_install(self, tmp_path): + mocks = _run_breaking(_make_args(use_apistub=True), tmp_path) + + # The failing/expensive steps are skipped in apistub mode. + mocks["install_dev_reqs"].assert_not_called() + mocks["create_package_and_install"].assert_not_called() + + # jsondiff + breaking-change checker are still required by the detector. + mocks["install_into_venv"].assert_called_once() + + # The detector still runs, and it receives --use-apistub. + mocks["check_call"].assert_called_once() + detector_cmd = mocks["check_call"].call_args.args[0] + assert "--use-apistub" in detector_cmd + assert mocks["result"] == 0 + + def test_default_path_installs_dev_reqs_and_sdist(self, tmp_path): + mocks = _run_breaking(_make_args(use_apistub=False), tmp_path) + + # The import-based path needs the package (and its deps) installed. + mocks["install_dev_reqs"].assert_called_once() + mocks["create_package_and_install"].assert_called_once() + + mocks["install_into_venv"].assert_called_once() + + mocks["check_call"].assert_called_once() + detector_cmd = mocks["check_call"].call_args.args[0] + assert "--use-apistub" not in detector_cmd + assert mocks["result"] == 0