diff --git a/doc/extensions/authoring.md b/doc/extensions/authoring.md index 2258e70bda1..afd499bce4b 100644 --- a/doc/extensions/authoring.md +++ b/doc/extensions/authoring.md @@ -39,6 +39,86 @@ Periodically run the following to ensure your extension will pass CI: Address comments as appropriate and consult the Azure CLI team if something is unclear. +#### Validating extension dependency installation + +On Windows, `az extension add` and `az extension update` prefer compatible dependency +wheels over newer source distributions. The selected versions must still satisfy +the extension's requirements and the running Python's compatibility tags. This +does not pin dependencies or disable pip's build isolation. Linux and macOS retain +pip's default selection behavior. + +The Windows packages use embedded Python with a `python*._pth` file. This file +disables environment-based Python path configuration, including the `PYTHONPATH` +that pip uses to expose an isolated build environment. Consequently, a source +distribution can fail with `BackendUnavailable` even after pip successfully +installs its build backend. Installing that backend globally is not a substitute +for build isolation. Prefer an available compatible wheel; an explicit source +requirement or a dependency with no compatible wheel can still require a +source-build-capable Python installation and the package's native build tools. + +In a configured development checkout with pip and a source-build-capable Python +installation, run the offline installer regressions: + +```bash +python -m pytest \ + src/azure-cli-core/azure/cli/core/tests/test_extension_pip.py \ + src/azure-cli/azure/cli/command_modules/extension/tests/latest/test_extension_install.py +``` + +These tests use local package fixtures and real pip subprocesses, including an +embedded-style interpreter with a retained `_pth` file. They cover wheel selection, +supported isolated source builds, and installation failures without contacting +PyPI or Azure. The copied-interpreter case requires CPython 3.11 or later on Linux +or Windows, with a standalone CPython DLL layout on Windows; it is skipped on +other hosts. This case reproduces path isolation, not the complete packaged +Windows runtime. +The existing broader selectors are +`src/azure-cli-core/azure/cli/core/tests/test_extension.py` and +`src/azure-cli-core/azure/cli/core/extension/tests/latest/test_extension_commands.py`. +The command-module test selector is `extension`; the core selector is +`azure-cli-core`. Repository validation also includes: + +```bash +azdev style extension azure-cli-core +azdev linter extension +azdev test extension azure-cli-core --series +``` + +For Windows release validation, use clean x86 and x64 candidate MSI installations +and the x64 ZIP package. First run `az --version` and the bundled interpreter's +`-m pip debug --verbose` to capture the actual Python version, architecture, pip +version, and supported wheel tags. Do not infer architecture from the installation +directory. For example, the report in [#34062](https://github.com/Azure/azure-cli/issues/34062) +identifies 32-bit Python explicitly; cryptography 50.0.1 publishes Windows wheels +for `win_amd64`, not `win32`. + +In a temporary PowerShell session, use a separate extension directory: + +```powershell +$previousExtensionDir = $env:AZURE_EXTENSION_DIR +$testExtensionDir = Join-Path $env:TEMP ("az-extension-" + [guid]::NewGuid()) +try { + $env:AZURE_EXTENSION_DIR = $testExtensionDir + az extension add --name k8s-extension --version 1.8.0 --debug + if ($LASTEXITCODE -ne 0) { throw "Extension installation failed" } + az extension show --name k8s-extension + if ($LASTEXITCODE -ne 0) { throw "Installed extension was not found" } + az k8s-extension --help + if ($LASTEXITCODE -ne 0) { throw "Installed extension could not load" } +} finally { + $env:AZURE_EXTENSION_DIR = $previousExtensionDir + if (Test-Path $testExtensionDir) { Remove-Item -Recurse -Force $testExtensionDir } +} +``` + +Inspect the pip debug output for the selected dependency artifacts. Also exercise +an update and a system installation on disposable Windows installations. The +offline tests do not validate native compilation, packaged Windows DLL loading, +or current package-index contents. In particular, wheel preference cannot supply +a wheel that a dependency has not published. Candidate-package Windows runs and +repository CI remain required; this local installation smoke test needs network +access but does not provision Azure resources. + ### Publish **For the extension whose source code is hosted in [Azure/azure-cli-extensions](https://github.com/Azure/azure-cli-extensions)**, we will release for you once your code is merged into `main` branch. You must not update [index.json](https://github.com/Azure/azure-cli-extensions/blob/main/src/index.json) manually in this case. diff --git a/src/azure-cli-core/azure/cli/core/extension/operations.py b/src/azure-cli-core/azure/cli/core/extension/operations.py index 617f8d86a26..2444b7820bc 100644 --- a/src/azure-cli-core/azure/cli/core/extension/operations.py +++ b/src/azure-cli-core/azure/cli/core/extension/operations.py @@ -157,6 +157,9 @@ def _add_whl_ext(cli_ctx, source, ext_sha256=None, pip_extra_index_urls=None, pi # Install with pip extension_path = build_extension_path(extension_name, system) pip_args = ['install', '--target', extension_path, ext_file] + if IS_WINDOWS: + # Embedded Python's ._pth isolation can prevent pip from importing isolated build backends. + pip_args.append('--prefer-binary') if pip_proxy: pip_args = pip_args + ['--proxy', pip_proxy] diff --git a/src/azure-cli-core/azure/cli/core/tests/extension_pip_test_utils.py b/src/azure-cli-core/azure/cli/core/tests/extension_pip_test_utils.py new file mode 100644 index 00000000000..9ef94532e0a --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/tests/extension_pip_test_utils.py @@ -0,0 +1,177 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +"""Small, generated distributions for exercising extension installation with offline pip.""" + +import base64 +import csv +import hashlib +import io +import json +import os +from pathlib import Path +import tarfile +import tempfile +import zipfile +from unittest import mock + +from azure.cli.core import _session, extension + + +EXTENSION_NAME = 'azcli-extension-pip-test' +EXTENSION_MODULE = 'azext_pip_test' +DEPENDENCY_NAME = 'azcli-extension-test-dependency' +DEPENDENCY_MODULE = 'azcli_extension_test_dependency' +BACKEND_NAME = 'azcli-extension-test-backend' +BACKEND_MODULE = 'azcli_extension_test_backend' +BUILD_LOG_ENV = 'AZCLI_EXTENSION_TEST_BUILD_LOG' +SENTINEL_ENV = 'AZCLI_EXTENSION_TEST_SENTINEL' + +# This backend is installed only as a build requirement, never alongside the extension. +_BACKEND_SOURCE = ''' +import json +import os +from pathlib import Path +import sys +import zipfile + + +def build_wheel(wheel_directory, config_settings=None, metadata_directory=None): + fixture = json.loads(Path('fixture.json').read_text(encoding='utf-8')) + observation = { + 'backend_file': __file__, + 'executable': sys.executable, + 'path': sys.path, + 'pythonpath': os.environ.get('PYTHONPATH'), + 'sentinel': os.environ.get('AZCLI_EXTENSION_TEST_SENTINEL'), + 'http_proxy': os.environ.get('HTTP_PROXY'), + 'https_proxy': os.environ.get('HTTPS_PROXY'), + } + Path(os.environ['AZCLI_EXTENSION_TEST_BUILD_LOG']).write_text( + json.dumps(observation), encoding='utf-8') + if fixture['unsupported']: + raise RuntimeError('Synthetic source build is unsupported') + with zipfile.ZipFile(Path(wheel_directory) / fixture['wheel'], 'w') as wheel: + for path in sorted(Path('wheel').rglob('*')): + if path.is_file(): + wheel.writestr(path.relative_to('wheel').as_posix(), path.read_bytes()) + return fixture['wheel'] +''' + + +def _wheel_files(name, version, files, requirements=()): + dist_info = '{}-{}.dist-info'.format(name.replace('-', '_'), version) + contents = {path: text.encode('utf-8') for path, text in files.items()} + contents[dist_info + '/METADATA'] = ( + 'Metadata-Version: 2.1\nName: {}\nVersion: {}\n'.format(name, version) + + ''.join('Requires-Dist: {}\n'.format(requirement) for requirement in requirements) + '\n' + ).encode('utf-8') + contents[dist_info + '/WHEEL'] = ( + b'Wheel-Version: 1.0\nGenerator: extension-pip-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n' + ) + record = io.StringIO() + writer = csv.writer(record, lineterminator='\n') + for path, data in contents.items(): + digest = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b'=').decode('ascii') + writer.writerow((path, 'sha256=' + digest, len(data))) + writer.writerow((dist_info + '/RECORD', '', '')) + contents[dist_info + '/RECORD'] = record.getvalue().encode('utf-8') + return contents + + +def _write_wheel(directory, name, version, files, requirements=()): + path = directory / '{}-{}-py3-none-any.whl'.format(name.replace('-', '_'), version) + with zipfile.ZipFile(path, 'w') as wheel: + for filename, data in _wheel_files(name, version, files, requirements).items(): + wheel.writestr(filename, data) + return path + + +class ExtensionPipFixture: + """Own the local packages, extension targets and restored environment for one test.""" + + def __init__(self, testcase): + directory = tempfile.TemporaryDirectory(prefix='azure-cli-extension-pip-') + testcase.addCleanup(directory.cleanup) + self.root = Path(directory.name) + self.links = self.root / 'links' + self.links.mkdir() + self.user_dir = self.root / 'user' + self.system_dir = self.root / 'system' + self.build_log = self.root / 'build.json' + scratch = self.root / 'scratch' + scratch.mkdir() + + # Ignore caller pip configuration (including constraints and binary policy), not other environment. + environment = {key: value for key, value in os.environ.items() if not key.startswith('PIP_')} + environment.update({ + 'PIP_NO_INDEX': '1', + 'PIP_FIND_LINKS': self.links.as_uri(), + 'PIP_CONFIG_FILE': os.devnull, + 'PIP_NO_INPUT': '1', + 'PIP_PROGRESS_BAR': 'off', + 'PYTHONDONTWRITEBYTECODE': '1', + 'PYTHONNOUSERSITE': '1', + 'TMPDIR': str(scratch), + 'TEMP': str(scratch), + 'TMP': str(scratch), + 'AZURE_CONFIG_DIR': str(self.root / 'config'), + BUILD_LOG_ENV: str(self.build_log), + SENTINEL_ENV: 'inherited-by-pip-and-build-backend', + }) + for patcher in ( + mock.patch.dict(os.environ, environment, clear=True), + mock.patch.object(tempfile, 'tempdir', str(scratch)), + mock.patch.object(extension, 'EXTENSIONS_DIR', str(self.user_dir)), + mock.patch.object(extension, 'EXTENSIONS_SYS_DIR', str(self.system_dir)), + mock.patch.object(extension, 'DEV_EXTENSION_SOURCES', []), + mock.patch('azure.cli.core.util.handle_version_update'), + mock.patch('azure.cli.core.extension._homebrew_patch.is_homebrew', return_value=False), + ): + patcher.start() + testcase.addCleanup(patcher.stop) + for session in vars(_session).values(): + if isinstance(session, _session.Session): + for attribute, value in (('filename', None), ('data', {})): + patcher = mock.patch.object(session, attribute, value) + patcher.start() + testcase.addCleanup(patcher.stop) + + _write_wheel(self.links, BACKEND_NAME, '1.0', {BACKEND_MODULE + '.py': _BACKEND_SOURCE}) + self.dependency_wheel = _write_wheel( + self.links, DEPENDENCY_NAME, '1.0', {DEPENDENCY_MODULE + '/__init__.py': "__version__ = '1.0'\n"}) + self.make_sdist() + self.extension_wheel = self.make_extension() + + def make_extension(self, requirement='>=1.0'): + return _write_wheel(self.links, EXTENSION_NAME, '1.0', { + EXTENSION_MODULE + '/__init__.py': '', + EXTENSION_MODULE + '/azext_metadata.json': '{"azext.isPreview": false}', + }, [DEPENDENCY_NAME + requirement]) + + def make_sdist(self, unsupported=False): + name = DEPENDENCY_NAME.replace('-', '_') + files = { + 'wheel/' + path: data for path, data in _wheel_files( + DEPENDENCY_NAME, '2.0', {DEPENDENCY_MODULE + '/__init__.py': "__version__ = '2.0'\n"} + ).items() + } + files['pyproject.toml'] = ( + '[build-system]\nrequires = ["{}==1.0"]\nbuild-backend = "{}"\n'.format(BACKEND_NAME, BACKEND_MODULE) + ).encode('utf-8') + files['fixture.json'] = json.dumps({ + 'wheel': name + '-2.0-py3-none-any.whl', + 'unsupported': unsupported, + }).encode('utf-8') + path = self.links / (name + '-2.0.tar.gz') + with tarfile.open(path, 'w:gz') as sdist: + for filename, data in files.items(): + info = tarfile.TarInfo(name + '-2.0/' + filename) + info.size = len(data) + sdist.addfile(info, io.BytesIO(data)) + return path + + def target(self, system=False): + return (self.system_dir if system else self.user_dir) / EXTENSION_NAME diff --git a/src/azure-cli-core/azure/cli/core/tests/test_extension_pip.py b/src/azure-cli-core/azure/cli/core/tests/test_extension_pip.py new file mode 100644 index 00000000000..82ecec45ad6 --- /dev/null +++ b/src/azure-cli-core/azure/cli/core/tests/test_extension_pip.py @@ -0,0 +1,200 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import importlib.metadata +import importlib.util +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import sysconfig +import unittest +from unittest import mock + +from azure.cli.core.extension import operations +from azure.cli.core.mock import DummyCli +from azure.cli.core.tests.extension_pip_test_utils import ( + BACKEND_MODULE, BACKEND_NAME, DEPENDENCY_MODULE, DEPENDENCY_NAME, + EXTENSION_MODULE, EXTENSION_NAME, ExtensionPipFixture, SENTINEL_ENV, +) +from azure.cli.core.util import CLIError + + +class TestExtensionPip(unittest.TestCase): + + def setUp(self): + self.packages = ExtensionPipFixture(self) + self.cli_ctx = DummyCli() + self.assertIsNone(importlib.util.find_spec(BACKEND_MODULE)) + + def _assert_installed(self, dependency_version): + target = self.packages.target() + versions = { + dist.metadata['Name']: dist.version + for dist in importlib.metadata.distributions(path=[str(target)]) + } + self.assertEqual(versions, {EXTENSION_NAME: '1.0', DEPENDENCY_NAME: dependency_version}) + self.assertEqual( + (target / DEPENDENCY_MODULE / '__init__.py').read_text(encoding='utf-8'), + "__version__ = '{}'\n".format(dependency_version)) + self.assertTrue((target / EXTENSION_MODULE / '__init__.py').is_file()) + self.assertEqual( + (target / self.packages.extension_wheel.name).read_bytes(), self.packages.extension_wheel.read_bytes()) + self.assertIsNone(importlib.util.find_spec(BACKEND_MODULE)) + self.assertFalse((target / (BACKEND_MODULE + '.py')).exists()) + + def _assert_isolated_build(self): + observation = json.loads(self.packages.build_log.read_text(encoding='utf-8')) + self.assertEqual(observation['executable'], sys.executable) + self.assertEqual(observation['sentinel'], os.environ[SENTINEL_ENV]) + self.assertIn('pip-build-env-', observation['backend_file']) + self.assertIn('pip-build-env-', observation['pythonpath']) + self.assertTrue(any('pip-build-env-' in path for path in observation['path'])) + self.assertIsNone(importlib.util.find_spec(BACKEND_MODULE)) + self.assertFalse((self.packages.target() / (BACKEND_MODULE + '.py')).exists()) + return observation + + def _assert_failed_install(self): + with self.assertLogs(operations.logger, level='DEBUG') as logs: + with self.assertRaises(CLIError) as failure: + operations._add_whl_ext(self.cli_ctx, str(self.packages.extension_wheel)) + errors = [record.msg for record in logs.records if isinstance(record.msg, subprocess.CalledProcessError)] + self.assertEqual(len(errors), 1) + error = errors[0] + self.assertGreater(error.returncode, 0) + self.assertEqual( + str(failure.exception), + 'An error occurred. Pip failed with status code {}. Use --debug for more information.'.format( + error.returncode)) + self.assertTrue(any(record.msg == error.output for record in logs.records)) + self.assertFalse(self.packages.target().exists()) + self.assertTrue(self.packages.extension_wheel.is_file()) + return error + + def test_windows_prefers_compatible_wheel(self): + with mock.patch.object(operations, 'IS_WINDOWS', True), \ + mock.patch.object(operations, 'check_output', wraps=subprocess.check_output) as run: + name = operations._add_whl_ext(self.cli_ctx, str(self.packages.extension_wheel)) + + self.assertEqual(name, EXTENSION_NAME) + self._assert_installed('1.0') + self.assertFalse(self.packages.build_log.exists()) + run.assert_called_once_with( + [sys.executable, '-m', 'pip', 'install', '--target', str(self.packages.target()), + str(self.packages.extension_wheel.resolve()), + '--prefer-binary', '--disable-pip-version-check', '--no-cache-dir'], + stderr=subprocess.STDOUT, universal_newlines=True) + + def test_non_windows_selects_newer_source(self): + with mock.patch.object(operations, 'IS_WINDOWS', False), \ + mock.patch.object(operations, 'check_output', wraps=subprocess.check_output) as run: + operations._add_whl_ext(self.cli_ctx, str(self.packages.extension_wheel)) + + self._assert_installed('2.0') + self._assert_isolated_build() + self.assertNotIn('--prefer-binary', run.call_args.args[0]) + self.assertEqual(run.call_args.args[0][:3], [sys.executable, '-m', 'pip']) + + def test_windows_builds_source_only_dependency_with_isolation(self): + self.packages.dependency_wheel.unlink() + proxies = {'HTTP_PROXY': 'http://127.0.0.1:9', 'HTTPS_PROXY': 'http://127.0.0.1:10'} + with mock.patch.object(operations, 'IS_WINDOWS', True), mock.patch.dict(os.environ, proxies): + operations._add_whl_ext(self.cli_ctx, str(self.packages.extension_wheel)) + + self._assert_installed('2.0') + observation = self._assert_isolated_build() + self.assertEqual(observation['http_proxy'], proxies['HTTP_PROXY']) + self.assertEqual(observation['https_proxy'], proxies['HTTPS_PROXY']) + + def test_windows_respects_newer_source_requirement(self): + self.packages.make_extension('>=2.0') + with mock.patch.object(operations, 'IS_WINDOWS', True): + operations._add_whl_ext(self.cli_ctx, str(self.packages.extension_wheel)) + + self._assert_installed('2.0') + self._assert_isolated_build() + + def test_windows_reports_unsupported_source_build_and_cleans_target(self): + self.packages.make_extension('>=2.0') + self.packages.make_sdist(unsupported=True) + target = self.packages.target() + target.mkdir(parents=True) + (target / 'partial-install').write_text('remove on failure', encoding='utf-8') + with mock.patch.object(operations, 'IS_WINDOWS', True): + error = self._assert_failed_install() + + self.assertIn('Synthetic source build is unsupported', error.output) + self._assert_isolated_build() + + def _embedded_python(self): + if sys.implementation.name != 'cpython' or sys.version_info < (3, 11): + self.skipTest('The ._pth reproduction requires CPython 3.11 or later') + if sys.platform not in ('linux', 'win32'): + self.skipTest('Copying the ._pth test interpreter is supported only on Linux and Windows') + + directory = self.packages.root / 'embedded' + directory.mkdir() + executable = directory / ('python.exe' if sys.platform == 'win32' else 'python') + original = Path(getattr(sys, '_base_executable', sys.executable)).resolve() + shutil.copy2(original, executable) + self.assertFalse(executable.is_symlink()) + + paths = [sysconfig.get_path('stdlib'), sysconfig.get_path('platstdlib'), + sysconfig.get_config_var('DESTSHARED')] + paths.extend(path for path in sys.path if path.endswith('.zip')) + paths.append(str(Path(importlib.util.find_spec('pip').origin).parent.parent)) + if sys.platform == 'win32': + # A venv launcher alone is not a portable Windows runtime; copy the base runtime DLLs too. + for runtime in {original.parent, Path(sys.base_prefix)}: + for pattern in ('python*.dll', 'vcruntime*.dll'): + for dll in runtime.glob(pattern): + shutil.copy2(dll, directory / dll.name) + paths.extend([str(runtime), str(runtime / 'DLLs')]) + if not list(directory.glob('python{}{}*.dll'.format(*sys.version_info[:2]))): + self.skipTest('A standalone Windows CPython DLL layout is required for the copied interpreter') + (directory / 'python._pth').write_text( + '\n'.join(dict.fromkeys(path for path in paths if path)) + '\nimport site\n', encoding='utf-8') + return str(executable) + + def test_embedded_python_backend_import_requires_wheel_preference(self): + executable = self._embedded_python() + pythonpath = self.packages.root / 'pythonpath' + pythonpath.mkdir() + (pythonpath / 'azcli_ignored_pythonpath.py').write_text('present = True\n', encoding='utf-8') + with mock.patch.dict(os.environ, {'PYTHONPATH': str(pythonpath), 'PIP_VERBOSE': '1'}): + probe = json.loads(subprocess.check_output([ + executable, '-c', + "import importlib.util, json, os, pip, ssl, sys; " + "print(json.dumps({'isolated': sys.flags.isolated, " + "'ignore_environment': sys.flags.ignore_environment, 'no_site': sys.flags.no_site, " + "'path': sys.path, 'pythonpath': os.environ['PYTHONPATH'], " + "'finds_marker': importlib.util.find_spec('azcli_ignored_pythonpath') is not None, " + "'finds_backend': importlib.util.find_spec('" + BACKEND_MODULE + "') is not None}))", + ], stderr=subprocess.STDOUT, universal_newlines=True)) + self.assertEqual(probe['isolated'], 1) + self.assertEqual(probe['ignore_environment'], 1) + self.assertEqual(probe['no_site'], 0) + self.assertEqual(probe['pythonpath'], str(pythonpath)) + self.assertNotIn(str(pythonpath), probe['path']) + self.assertFalse(probe['finds_marker']) + self.assertFalse(probe['finds_backend']) + + with mock.patch.object(sys, 'executable', executable): + with mock.patch.object(operations, 'IS_WINDOWS', False): + error = self._assert_failed_install() + self.assertEqual(error.cmd[:3], [executable, '-m', 'pip']) + self.assertIn('Successfully installed {}-1.0'.format(BACKEND_NAME), error.output) + self.assertRegex(error.output, r'Installing build dependencies[^\n]*done') + self.assertIn('BackendUnavailable', error.output) + self.assertIn(BACKEND_MODULE, error.output) + self.assertFalse(self.packages.build_log.exists()) + + with mock.patch.object(operations, 'IS_WINDOWS', True): + operations._add_whl_ext(self.cli_ctx, str(self.packages.extension_wheel)) + + self._assert_installed('1.0') + self.assertFalse(self.packages.build_log.exists()) diff --git a/src/azure-cli/azure/cli/command_modules/extension/tests/latest/test_extension_install.py b/src/azure-cli/azure/cli/command_modules/extension/tests/latest/test_extension_install.py new file mode 100644 index 00000000000..74ae2346710 --- /dev/null +++ b/src/azure-cli/azure/cli/command_modules/extension/tests/latest/test_extension_install.py @@ -0,0 +1,104 @@ +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import hashlib +from importlib.metadata import distributions +import subprocess +import sys +import unittest +from unittest import mock + +from azure.cli.command_modules.extension.custom import add_extension_cmd +from azure.cli.core.extension import operations +from azure.cli.core.mock import DummyCli +from azure.cli.core.tests.extension_pip_test_utils import ( + BACKEND_MODULE, + DEPENDENCY_MODULE, + DEPENDENCY_NAME, + EXTENSION_MODULE, + EXTENSION_NAME, + ExtensionPipFixture, +) +from azure.cli.core.util import CLIError + + +class TestExtensionInstall(unittest.TestCase): + + def setUp(self): + self.fixture = ExtensionPipFixture(self) + self.cmd = mock.Mock() + self.cmd.cli_ctx = DummyCli() + for patcher in ( + mock.patch.object(operations, 'IS_WINDOWS', True), + mock.patch.object(operations, 'set_extension_management_detail'), + ): + patcher.start() + self.addCleanup(patcher.stop) + + def test_indexed_system_install_prefers_binary_and_forwards_options(self): + source = str(self.fixture.extension_wheel) + original_wheel = self.fixture.extension_wheel.read_bytes() + digest = hashlib.sha256(original_wheel).hexdigest() + target = self.fixture.target(system=True) + index_url = 'https://extensions.invalid/index.json' + proxy = 'http://127.0.0.1:9' + extra_index_urls = ['https://first.invalid/simple', 'https://second.invalid/simple'] + + with mock.patch.object(operations, 'resolve_from_index', return_value=(source, digest)) as resolve, \ + mock.patch.object(operations, 'check_output', wraps=subprocess.check_output) as check_output, \ + mock.patch.object(operations.CommandIndex, 'invalidate') as invalidate: + add_extension_cmd( + self.cmd, extension_name=EXTENSION_NAME, system=True, version='1.0', + index_url=index_url, allow_preview=True, + pip_proxy=proxy, pip_extra_index_urls=extra_index_urls) + + resolve.assert_called_once_with( + EXTENSION_NAME, index_url=index_url, target_version='1.0', + cli_ctx=self.cmd.cli_ctx, allow_preview=True) + check_output.assert_called_once_with( + [ + sys.executable, '-m', 'pip', + 'install', '--target', str(target), str(self.fixture.extension_wheel.resolve()), + '--prefer-binary', + '--proxy', proxy, + '--extra-index-url', extra_index_urls[0], + '--extra-index-url', extra_index_urls[1], + '--disable-pip-version-check', '--no-cache-dir', + ], + stderr=subprocess.STDOUT, universal_newlines=True) + invalidate.assert_called_once_with() + + versions = {dist.metadata['Name']: dist.version for dist in distributions(path=[str(target)])} + self.assertEqual(versions, {EXTENSION_NAME: '1.0', DEPENDENCY_NAME: '1.0'}) + self.assertTrue((target / EXTENSION_MODULE / '__init__.py').is_file()) + self.assertTrue((target / DEPENDENCY_MODULE / '__init__.py').is_file()) + self.assertFalse((target / (BACKEND_MODULE + '.py')).exists()) + self.assertFalse(self.fixture.build_log.exists()) + self.assertFalse(self.fixture.target().exists()) + self.assertEqual((target / self.fixture.extension_wheel.name).read_bytes(), original_wheel) + self.assertEqual(self.fixture.extension_wheel.read_bytes(), original_wheel) + + def test_indexed_install_rejects_bad_checksum_before_pip(self): + source = str(self.fixture.extension_wheel) + original_wheel = self.fixture.extension_wheel.read_bytes() + + with mock.patch.object(operations, 'resolve_from_index', return_value=(source, '0' * 64)) as resolve, \ + mock.patch.object(operations, 'check_output', wraps=subprocess.check_output) as check_output, \ + mock.patch.object(operations.CommandIndex, 'invalidate') as invalidate: + with self.assertRaises(CLIError) as raised: + add_extension_cmd(self.cmd, extension_name=EXTENSION_NAME) + + self.assertEqual( + str(raised.exception), + 'The checksum of the extension does not match the expected value. ' + 'Use --debug for more information.') + resolve.assert_called_once_with( + EXTENSION_NAME, index_url=None, target_version=None, + cli_ctx=self.cmd.cli_ctx, allow_preview=None) + check_output.assert_not_called() + invalidate.assert_not_called() + self.assertFalse(self.fixture.target().exists()) + self.assertFalse(self.fixture.target(system=True).exists()) + self.assertEqual(self.fixture.extension_wheel.read_bytes(), original_wheel)