Skip to content

Commit 91af333

Browse files
authored
[Core] az extension add: Improve feedback shown to users when installation is unsuccessful (#22941)
* Add more descriptive error messages to resolve_from_index * Present more feedback to user when `az extension add` or `az extension add --upgrade` fail * Add/update unittests for resolve_from_index * Resolve linter errors
1 parent b475906 commit 91af333

File tree

4 files changed

+163
-34
lines changed

4 files changed

+163
-34
lines changed

src/azure-cli-core/azure/cli/core/extension/_resolve.py

Lines changed: 86 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
# Licensed under the MIT License. See License.txt in the project root for license information.
44
# --------------------------------------------------------------------------------------------
55
from packaging.version import parse
6+
from typing import Callable, List, NamedTuple, Union
67

78
from azure.cli.core.extension import ext_compat_with_cli, WHEEL_INFO_RE
89
from azure.cli.core.extension._index import get_index_extensions
@@ -17,6 +18,13 @@ class NoExtensionCandidatesError(Exception):
1718
pass
1819

1920

21+
class _ExtensionFilter(NamedTuple):
22+
# A function that filters a list of extensions
23+
filter: Callable[[List[dict]], List[dict]]
24+
# Message of exception raised if a filter leaves no candidates
25+
on_empty_results_message: Union[str, Callable[[List[dict]], str]]
26+
27+
2028
def _is_not_platform_specific(item):
2129
parsed_filename = WHEEL_INFO_RE(item['filename'])
2230
p = parsed_filename.groupdict()
@@ -55,38 +63,94 @@ def filter_func(item):
5563
return filter_func
5664

5765

58-
def resolve_from_index(extension_name, cur_version=None, index_url=None, target_version=None, cli_ctx=None):
59-
"""
60-
Gets the download Url and digest for the matching extension
66+
def _get_latest_version(candidates: List[dict]) -> List[dict]:
67+
return [max(candidates, key=lambda c: parse(c['metadata']['version']))]
68+
69+
70+
def _get_version_compatibility_feedback(candidates: List[dict]) -> str:
71+
from .operations import check_version_compatibility
72+
73+
try:
74+
check_version_compatibility(_get_latest_version(candidates)[0].get("metadata"))
75+
return ""
76+
except CLIError as e:
77+
return e.args[0]
78+
6179

62-
:param cur_version: threshold verssion to filter out extensions.
80+
def resolve_from_index(extension_name, cur_version=None, index_url=None, target_version=None, cli_ctx=None):
81+
"""Gets the download Url and digest for the matching extension
82+
83+
Args:
84+
extension_name (str): Name of
85+
cur_version (str, optional): Threshold version to filter out extensions. Defaults to None.
86+
index_url (str, optional): Defaults to None.
87+
target_version (str, optional): Version of extension to install. Defaults to latest version.
88+
cli_ctx (, optional): CLI Context. Defaults to None.
89+
90+
Raises:
91+
NoExtensionCandidatesError when an extension:
92+
* Doesn't exist
93+
* Has no versions compatible with the current platform
94+
* Has no versions more recent than currently installed version
95+
* Has no versions that are compatible with the version of azure cli
96+
97+
Returns:
98+
tuple: (Download Url, SHA digest)
6399
"""
64100
candidates = get_index_extensions(index_url=index_url, cli_ctx=cli_ctx).get(extension_name, [])
65101

66102
if not candidates:
67-
raise NoExtensionCandidatesError("No extension found with name '{}'".format(extension_name))
68-
69-
filters = [_is_not_platform_specific, _is_compatible_with_cli_version]
70-
if not target_version:
71-
filters.append(_is_greater_than_cur_version(cur_version))
103+
raise NoExtensionCandidatesError(f"No extension found with name '{extension_name}'")
72104

73-
for f in filters:
74-
logger.debug("Candidates %s", [c['filename'] for c in candidates])
75-
candidates = list(filter(f, candidates))
76-
if not candidates:
77-
raise NoExtensionCandidatesError("No suitable extensions found.")
105+
# Helper to curry predicate functions
106+
def list_filter(f):
107+
return lambda cs: list(filter(f, cs))
78108

79-
candidates_sorted = sorted(candidates, key=lambda c: parse(c['metadata']['version']), reverse=True)
80-
logger.debug("Candidates %s", [c['filename'] for c in candidates_sorted])
109+
candidate_filters = [
110+
_ExtensionFilter(
111+
filter=list_filter(_is_not_platform_specific),
112+
on_empty_results_message=f"No suitable extensions found for '{extension_name}'."
113+
)
114+
]
81115

82116
if target_version:
83-
try:
84-
chosen = [c for c in candidates_sorted if c['metadata']['version'] == target_version][0]
85-
except IndexError:
86-
raise NoExtensionCandidatesError('Extension with version {} not found'.format(target_version))
117+
candidate_filters += [
118+
_ExtensionFilter(
119+
filter=list_filter(lambda c: c['metadata']['version'] == target_version),
120+
on_empty_results_message=f"Version '{target_version}' not found for extension '{extension_name}'"
121+
)
122+
]
87123
else:
88-
logger.debug("Choosing the latest of the remaining candidates.")
89-
chosen = candidates_sorted[0]
124+
candidate_filters += [
125+
_ExtensionFilter(
126+
filter=list_filter(_is_greater_than_cur_version(cur_version)),
127+
on_empty_results_message=f"Latest version of '{extension_name}' is already installed."
128+
)
129+
]
130+
131+
candidate_filters += [
132+
_ExtensionFilter(
133+
filter=list_filter(_is_compatible_with_cli_version),
134+
on_empty_results_message=_get_version_compatibility_feedback
135+
),
136+
_ExtensionFilter(
137+
filter=_get_latest_version,
138+
on_empty_results_message=f"No suitable extensions found for '{extension_name}'."
139+
)
140+
]
141+
142+
for candidate_filter, on_empty_results_message in candidate_filters:
143+
logger.debug("Candidates %s", [c['filename'] for c in candidates])
144+
filtered_candidates = candidate_filter(candidates)
145+
146+
if not filtered_candidates and (on_empty_results_message is not None):
147+
if not isinstance(on_empty_results_message, str):
148+
on_empty_results_message = on_empty_results_message(candidates)
149+
raise NoExtensionCandidatesError(on_empty_results_message)
150+
151+
candidates = filtered_candidates
152+
153+
chosen = candidates[0]
90154

91155
logger.debug("Chosen %s", chosen)
92156
download_url, digest = chosen.get('downloadUrl'), chosen.get('sha256Digest')

src/azure-cli-core/azure/cli/core/extension/operations.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -330,11 +330,7 @@ def add_extension(cmd=None, source=None, extension_name=None, index_url=None, ye
330330
source, ext_sha256 = resolve_from_index(extension_name, index_url=index_url, target_version=version, cli_ctx=cmd_cli_ctx)
331331
except NoExtensionCandidatesError as err:
332332
logger.debug(err)
333-
334-
if version:
335-
err = "No matching extensions for '{} ({})'. Use --debug for more information.".format(extension_name, version)
336-
else:
337-
err = "No matching extensions for '{}'. Use --debug for more information.".format(extension_name)
333+
err = "{}\n\nUse --debug for more information".format(err.args[0])
338334
raise CLIError(err)
339335
ext_name, ext_version = _get_extension_info_from_source(source)
340336
set_extension_management_detail(extension_name if extension_name else ext_name, ext_version)
@@ -397,7 +393,7 @@ def update_extension(cmd=None, extension_name=None, index_url=None, pip_extra_in
397393
set_extension_management_detail(extension_name, ext_version)
398394
except NoExtensionCandidatesError as err:
399395
logger.debug(err)
400-
msg = "Extension {} with version {} not found.".format(extension_name, version) if version else "No updates available for '{}'. Use --debug for more information.".format(extension_name)
396+
msg = "{}\n\nUse --debug for more information".format(err.args[0])
401397
logger.warning(msg)
402398
return
403399
# Copy current version of extension to tmp directory in case we need to restore it after a failed install.

src/azure-cli-core/azure/cli/core/extension/tests/latest/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import tempfile
99
import shutil
1010
from unittest import mock
11+
from azure.cli.core.extension import EXT_METADATA_MAXCLICOREVERSION, EXT_METADATA_MINCLICOREVERSION
1112

1213

1314
def get_test_data_file(filename):
@@ -35,18 +36,21 @@ def __exit__(self, exc_type, exc_val, exc_tb):
3536
self.patcher.stop()
3637

3738

38-
def mock_ext(filename, version=None, download_url=None, digest=None, project_url=None):
39+
def mock_ext(filename, version=None, download_url=None, digest=None, project_url=None, name=None, min_cli_version=None, max_cli_version=None):
3940
d = {
4041
'filename': filename,
4142
'metadata': {
43+
'name': name,
4244
'version': version,
4345
'extensions': {
4446
'python.details': {
4547
'project_urls': {
4648
'Home': project_url or 'https://github.com/azure/some-extension'
4749
}
4850
}
49-
}
51+
},
52+
EXT_METADATA_MINCLICOREVERSION: min_cli_version,
53+
EXT_METADATA_MAXCLICOREVERSION: max_cli_version,
5054
},
5155
'downloadUrl': download_url or 'http://contoso.com/{}'.format(filename),
5256
'sha256Digest': digest

src/azure-cli-core/azure/cli/core/extension/tests/latest/test_resolve.py

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
# Licensed under the MIT License. See License.txt in the project root for license information.
44
# --------------------------------------------------------------------------------------------
55
import unittest
6-
6+
from unittest import mock
77
from azure.cli.core.extension._resolve import (resolve_from_index, resolve_project_url_from_index,
88
NoExtensionCandidatesError, _is_not_platform_specific,
99
_is_greater_than_cur_version)
@@ -16,13 +16,13 @@ def test_no_exts_in_index(self):
1616
name = 'myext'
1717
with IndexPatch({}), self.assertRaises(NoExtensionCandidatesError) as err:
1818
resolve_from_index(name)
19-
self.assertEqual(str(err.exception), "No extension found with name '{}'".format(name))
19+
self.assertEqual(str(err.exception), "No matching extensions for '{}'.".format(name))
2020

2121
def test_ext_not_in_index(self):
2222
name = 'an_extension_b'
2323
with IndexPatch({'an_extension_a': []}), self.assertRaises(NoExtensionCandidatesError) as err:
2424
resolve_from_index(name)
25-
self.assertEqual(str(err.exception), "No extension found with name '{}'".format(name))
25+
self.assertEqual(str(err.exception), "No matching extensions for '{}'.".format(name))
2626

2727
def test_ext_resolved(self):
2828
name = 'myext'
@@ -70,9 +70,74 @@ def test_filter_target_version(self):
7070
self.assertEqual(resolve_from_index(ext_name, target_version='0.2.0')[0], index_data[ext_name][1]['downloadUrl'])
7171

7272
with IndexPatch(index_data):
73-
with self.assertRaisesRegex(NoExtensionCandidatesError, 'Extension with version 0.3.0 not found'):
73+
with self.assertRaisesRegex(NoExtensionCandidatesError, "Version '0.3.0' not found for extension 'hello'"):
7474
resolve_from_index(ext_name, target_version='0.3.0')
7575

76+
def test_ext_has_available_update(self):
77+
ext_name = 'myext'
78+
index_data = {
79+
ext_name: [
80+
mock_ext(f'{ext_name}-0.1.0-py3-none-any.whl', '0.1.0'),
81+
mock_ext(f'{ext_name}-0.2.0-py3-none-any.whl', '0.2.0')
82+
]
83+
}
84+
85+
with IndexPatch(index_data):
86+
self.assertEqual(resolve_from_index(ext_name, cur_version='0.1.0')[0], index_data[ext_name][1]['downloadUrl'])
87+
88+
89+
def test_ext_has_no_available_updates(self):
90+
ext_name = 'myext'
91+
index_data = {
92+
ext_name: [
93+
mock_ext(f'{ext_name}-0.1.0-py3-none-any.whl', '0.1.0'),
94+
mock_ext(f'{ext_name}-0.2.0-py3-none-any.whl', '0.2.0')
95+
]
96+
}
97+
98+
with IndexPatch(index_data):
99+
with self.assertRaisesRegex(NoExtensionCandidatesError, f"Latest version of '{ext_name}' is already installed."):
100+
resolve_from_index(ext_name, cur_version='0.2.0')
101+
102+
@mock.patch("azure.cli.core.__version__", "2.15.0")
103+
def test_ext_requires_later_version_of_cli_core(self):
104+
ext_name = 'myext'
105+
min_cli_version="2.16.0"
106+
index_data = {
107+
ext_name: [
108+
mock_ext(f'{ext_name}-0.1.0-py3-none-any.whl', '0.1.0', name=ext_name, min_cli_version=min_cli_version),
109+
mock_ext(f'{ext_name}-0.2.0-py3-none-any.whl', '0.2.0', name=ext_name, min_cli_version=min_cli_version)
110+
]
111+
}
112+
113+
exception_regex = '\n'.join([
114+
f'This extension requires a min of {min_cli_version} CLI core.',
115+
"Please run 'az upgrade' to upgrade to a compatible version."
116+
])
117+
with IndexPatch(index_data):
118+
with self.assertRaisesRegex(NoExtensionCandidatesError, exception_regex):
119+
resolve_from_index(ext_name)
120+
121+
122+
@mock.patch("azure.cli.core.__version__", "2.15.0")
123+
def test_ext_requires_earlier_version_of_cli_core(self):
124+
ext_name = 'myext'
125+
max_cli_version="2.14.0"
126+
index_data = {
127+
ext_name: [
128+
mock_ext(f'{ext_name}-0.1.0-py3-none-any.whl', '0.1.0', name=ext_name, max_cli_version=max_cli_version),
129+
mock_ext(f'{ext_name}-0.2.0-py3-none-any.whl', '0.2.0', name=ext_name, max_cli_version=max_cli_version)
130+
]
131+
}
132+
exception_regex = '\n'.join([
133+
f'This extension requires a max of {max_cli_version} CLI core.',
134+
f"Please run 'az extension update -n {ext_name}' to update the extension."
135+
])
136+
137+
with IndexPatch(index_data):
138+
with self.assertRaisesRegex(NoExtensionCandidatesError, exception_regex):
139+
resolve_from_index(ext_name)
140+
76141

77142
class TestResolveFilters(unittest.TestCase):
78143

0 commit comments

Comments
 (0)