diff --git a/src/azure-cli-core/azure/cli/core/extension/__init__.py b/src/azure-cli-core/azure/cli/core/extension/__init__.py index 53afb00ecd9..a1be9284871 100644 --- a/src/azure-cli-core/azure/cli/core/extension/__init__.py +++ b/src/azure-cli-core/azure/cli/core/extension/__init__.py @@ -129,7 +129,7 @@ def __init__(self, name, path=None): super().__init__(name, 'whl', path) def get_version(self): - return self.metadata.get('version') + return self.metadata.get('version') if self.metadata else None def get_metadata(self): from glob import glob @@ -210,7 +210,7 @@ def __init__(self, name, path): super().__init__(name, 'dev', path) def get_version(self): - return self.metadata.get('version') + return self.metadata.get('version') if self.metadata else None def get_metadata(self): import pkginfo 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 e20aed58b03..d2e70687ef3 100644 --- a/src/azure-cli-core/azure/cli/core/extension/operations.py +++ b/src/azure-cli-core/azure/cli/core/extension/operations.py @@ -407,6 +407,9 @@ def update_extension(cmd=None, extension_name=None, index_url=None, pip_extra_in raise CLIError( f"Cannot update system extension {extension_name}, please wait until Cloud Shell updates it in the next release.") cur_version = ext.get_version() + if cur_version is None: + logger.warning("The '%s' extension has missing or invalid metadata. It may have been partially installed. " + "Proceeding with update to attempt to repair it.", extension_name) try: if not download_url: download_url, ext_sha256 = resolve_from_index(extension_name, cur_version=cur_version, index_url=index_url, target_version=version, cli_ctx=cmd_cli_ctx, allow_preview=allow_preview) @@ -435,7 +438,7 @@ def update_extension(cmd=None, extension_name=None, index_url=None, pip_extra_in logger.error(err) logger.debug('Copying %s to %s', backup_dir, extension_path) shutil.copytree(backup_dir, extension_path) - raise CLIError('Failed to update. Rolled {} back to {}.'.format(extension_name, cur_version)) + raise CLIError('Failed to update. Rolled {} back to {}.'.format(extension_name, cur_version or 'unknown')) CommandIndex().invalidate() except ExtensionNotInstalledException as e: raise CLIError(e) diff --git a/src/azure-cli-core/azure/cli/core/tests/test_extension.py b/src/azure-cli-core/azure/cli/core/tests/test_extension.py index 1b002025cc5..6c71632e98b 100644 --- a/src/azure-cli-core/azure/cli/core/tests/test_extension.py +++ b/src/azure-cli-core/azure/cli/core/tests/test_extension.py @@ -455,6 +455,37 @@ def test_update_extension_with_preview(self): self.assertEqual(ext['name'], extension_name) self.assertEqual(ext['version'], '1.4.1a1') + def test_update_extension_with_corrupted_metadata(self): + """Test that update_extension works when extension has missing/corrupted metadata (partial install).""" + extension_name = "extension-test-pkg" + extension2 = 'extension_test_pkg-1.2.3-py3-none-any.whl' + + mocked_index_data = { + extension_name: [ + mock_ext(extension2, version='1.2.3', download_url=_get_test_data_file(extension2)), + ] + } + with mock.patch('azure.cli.core.extension._resolve.get_index_extensions', + return_value=mocked_index_data): + add_extension(self.cmd, extension_name=extension_name) + ext = show_extension(extension_name) + self.assertEqual(ext['name'], extension_name) + self.assertEqual(ext['version'], '1.2.3') + + # Simulate corrupted/partial install by patching get_version to return None + with mock.patch('azure.cli.core.extension.operations.logger') as mock_logger: + with mock.patch.object(WheelExtension, 'get_version', return_value=None): + update_extension(self.cmd, extension_name) + + # Verify the warning about missing metadata was emitted + warning_messages = [str(call) for call in mock_logger.warning.call_args_list] + self.assertTrue(any(extension_name in msg for msg in warning_messages)) + + # Verify the extension was successfully repaired/updated + ext = show_extension(extension_name) + self.assertEqual(ext['name'], extension_name) + self.assertEqual(ext['version'], '1.2.3') + @mock.patch('sys.stdin.isatty', return_value=True) def test_ext_dynamic_install_config_tty(self, _): from azure.cli.core.extension.dynamic_install import _get_extension_use_dynamic_install_config @@ -497,6 +528,12 @@ def test_wheel_metadata2(self): # We check that we can retrieve any one of the az extension metadata values self.assertTrue(ext.metadata.get(EXT_METADATA_MINCLICOREVERSION)) + def test_wheel_get_version_with_none_metadata(self): + """Test that get_version() returns None when metadata is None (e.g. corrupted/partial installation).""" + ext = WheelExtension(EXT_NAME) + with mock.patch.object(type(ext), 'metadata', new_callable=mock.PropertyMock, return_value=None): + self.assertIsNone(ext.get_version()) + class TestWheelSystemExtension(TestExtensionsBase):