-
Notifications
You must be signed in to change notification settings - Fork 3.3k
[Extension] Support automatically installing an extension if the extension of a command is not installed #14478
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
83be3af
9b3fa62
ef4f251
3c552a7
3f800bd
e2b667b
1b6857f
3ee5415
32ccc49
72b1506
bf8f7ee
1681fb4
4bd9965
28360a0
18efb12
de3fda3
00cc9cb
1abde7c
447f63d
93dd684
e9530f5
181c3ec
2e68097
4b16148
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -280,37 +280,167 @@ def parse_known_args(self, args=None, namespace=None): | |
| self._namespace, self._raw_arguments = super().parse_known_args(args=args, namespace=namespace) | ||
| return self._namespace, self._raw_arguments | ||
|
|
||
| def _check_value(self, action, value): | ||
| def _get_extension_command_tree(self): | ||
| from azure.cli.core._session import EXT_CMD_TREE | ||
| import os | ||
| VALID_SECOND = 3600 * 24 * 10 | ||
| # self.cli_ctx is None when self.prog is beyond 'az', such as 'az iot'. | ||
| # use cli_ctx from cli_help which is not lost. | ||
| cli_ctx = self.cli_ctx or (self.cli_help.cli_ctx if self.cli_help else None) | ||
| if not cli_ctx: | ||
| return None | ||
| EXT_CMD_TREE.load(os.path.join(cli_ctx.config.config_dir, 'extensionCommandTree.json'), VALID_SECOND) | ||
| if not EXT_CMD_TREE.data: | ||
| import requests | ||
| from azure.cli.core.util import should_disable_connection_verify | ||
| try: | ||
| response = requests.get( | ||
| 'https://azurecliextensionsync.blob.core.windows.net/cmd-index/extensionCommandTree.json', | ||
| verify=(not should_disable_connection_verify()), | ||
| timeout=300) | ||
| except Exception as ex: # pylint: disable=broad-except | ||
| logger.info("Request failed for extension command tree: %s", str(ex)) | ||
| return None | ||
| if response.status_code == 200: | ||
| EXT_CMD_TREE.data = response.json() | ||
| EXT_CMD_TREE.save_with_retry() | ||
| else: | ||
| logger.info("Error when retrieving extension command tree. Response code: %s", response.status_code) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would log at warning level be better? |
||
| return None | ||
| return EXT_CMD_TREE | ||
|
|
||
| def _search_in_extension_commands(self, command_str): | ||
| """Search the command in an extension commands dict which mimics a prefix tree. | ||
| If the value of the dict item is a string, then the key represents the end of a complete command | ||
| and the value is the name of the extension that the command belongs to. | ||
| An example of the dict read from extensionCommandTree.json: | ||
| { | ||
| "aks": { | ||
| "create": "aks-preview", | ||
| "update": "aks-preview", | ||
| "app": { | ||
| "up": "deploy-to-azure" | ||
| }, | ||
| "use-dev-spaces": "dev-spaces" | ||
| }, | ||
| ... | ||
| } | ||
| """ | ||
|
|
||
| cmd_chain = self._get_extension_command_tree() | ||
| for part in command_str.split(): | ||
| try: | ||
| if isinstance(cmd_chain[part], str): | ||
| return cmd_chain[part] | ||
| cmd_chain = cmd_chain[part] | ||
| except KeyError: | ||
| return None | ||
| return None | ||
|
|
||
| def _get_extension_use_dynamic_install_config(self): | ||
| cli_ctx = self.cli_ctx or (self.cli_help.cli_ctx if self.cli_help else None) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. cli_ctx seems could be
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. line 342 has the logic |
||
| use_dynamic_install = cli_ctx.config.get( | ||
| 'extension', 'use_dynamic_install', 'no').lower() if cli_ctx else 'no' | ||
| if use_dynamic_install not in ['no', 'yes_prompt', 'yes_without_prompt']: | ||
| use_dynamic_install = 'no' | ||
| return use_dynamic_install | ||
|
|
||
| def _check_value(self, action, value): # pylint: disable=too-many-statements, too-many-locals | ||
| # Override to customize the error message when a argument is not among the available choices | ||
| # converted value must be one of the choices (if specified) | ||
| if action.choices is not None and value not in action.choices: | ||
| if action.choices is not None and value not in action.choices: # pylint: disable=too-many-nested-blocks | ||
| caused_by_extension_not_installed = False | ||
| if not self.command_source: | ||
| # parser has no `command_source`, value is part of command itself | ||
| extensions_link = 'https://docs.microsoft.com/en-us/cli/azure/azure-cli-extensions-overview' | ||
| error_msg = ("{prog}: '{value}' is not in the '{prog}' command group. See '{prog} --help'. " | ||
| "If the command is from an extension, " | ||
| "please make sure the corresponding extension is installed. " | ||
| "To learn more about extensions, please visit " | ||
| "{extensions_link}").format(prog=self.prog, value=value, extensions_link=extensions_link) | ||
| candidates = difflib.get_close_matches(value, action.choices, cutoff=0.7) | ||
| error_msg = None | ||
| # self.cli_ctx is None when self.prog is beyond 'az', such as 'az iot'. | ||
| # use cli_ctx from cli_help which is not lost. | ||
| cli_ctx = self.cli_ctx or (self.cli_help.cli_ctx if self.cli_help else None) | ||
| use_dynamic_install = self._get_extension_use_dynamic_install_config() | ||
| if use_dynamic_install != 'no' and not candidates: | ||
| # Check if the command is from an extension | ||
| from azure.cli.core.util import roughly_parse_command | ||
| cmd_list = self.prog.split() + self._raw_arguments | ||
| command_str = roughly_parse_command(cmd_list[1:]) | ||
| ext_name = self._search_in_extension_commands(command_str) | ||
| if ext_name: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what if
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If |
||
| caused_by_extension_not_installed = True | ||
| telemetry.set_command_details(command_str, | ||
| parameters=AzCliCommandInvoker._extract_parameter_names(cmd_list), # pylint: disable=protected-access | ||
| extension_name=ext_name) | ||
| run_after_extension_installed = cli_ctx.config.getboolean('extension', | ||
| 'run_after_dynamic_install', | ||
| False) if cli_ctx else False | ||
| if use_dynamic_install == 'yes_without_prompt': | ||
| logger.warning('The command requires the extension %s. ' | ||
| 'It will be installed first.', ext_name) | ||
| go_on = True | ||
| else: | ||
| from knack.prompting import prompt_y_n, NoTTYException | ||
| prompt_msg = 'The command requires the extension {}. ' \ | ||
| 'Do you want to install it now?'.format(ext_name) | ||
| if run_after_extension_installed: | ||
| prompt_msg = '{} The command will continue to run after the extension is installed.' \ | ||
| .format(prompt_msg) | ||
| NO_PROMPT_CONFIG_MSG = "Run 'az config set extension.use_dynamic_install=" \ | ||
| "yes_without_prompt' to allow installing extensions without prompt." | ||
| try: | ||
| go_on = prompt_y_n(prompt_msg, default='y') | ||
| if go_on: | ||
| logger.warning(NO_PROMPT_CONFIG_MSG) | ||
| except NoTTYException: | ||
| logger.warning("The command requires the extension %s.\n " | ||
| "Unable to prompt for extension install confirmation as no tty " | ||
| "available. %s", ext_name, NO_PROMPT_CONFIG_MSG) | ||
| go_on = False | ||
| if go_on: | ||
| from azure.cli.core.extension.operations import add_extension | ||
| add_extension(cli_ctx=cli_ctx, extension_name=ext_name) | ||
| if run_after_extension_installed: | ||
| import subprocess | ||
| import platform | ||
| exit_code = subprocess.call(cmd_list, shell=platform.system() == 'Windows') | ||
| telemetry.set_user_fault("Extension {} dynamically installed and commands will be " | ||
| "rerun automatically.".format(ext_name)) | ||
| self.exit(exit_code) | ||
| else: | ||
| error_msg = 'Extension {} installed. Please rerun your command.'.format(ext_name) | ||
| else: | ||
| error_msg = "The command requires the extension {ext_name}. " \ | ||
| "To install, run 'az extension add -n {ext_name}'.".format(ext_name=ext_name) | ||
| if not error_msg: | ||
| # parser has no `command_source`, value is part of command itself | ||
| error_msg = "{prog}: '{value}' is not in the '{prog}' command group. See '{prog} --help'." \ | ||
| .format(prog=self.prog, value=value) | ||
| if use_dynamic_install.lower() == 'no': | ||
| extensions_link = 'https://docs.microsoft.com/en-us/cli/azure/azure-cli-extensions-overview' | ||
| error_msg = ("{msg} " | ||
| "If the command is from an extension, " | ||
| "please make sure the corresponding extension is installed. " | ||
| "To learn more about extensions, please visit " | ||
| "{extensions_link}").format(msg=error_msg, extensions_link=extensions_link) | ||
| else: | ||
| # `command_source` indicates command values have been parsed, value is an argument | ||
| parameter = action.option_strings[0] if action.option_strings else action.dest | ||
| error_msg = "{prog}: '{value}' is not a valid value for '{param}'. See '{prog} --help'.".format( | ||
| prog=self.prog, value=value, param=parameter) | ||
| candidates = difflib.get_close_matches(value, action.choices, cutoff=0.7) | ||
|
|
||
| telemetry.set_user_fault(error_msg) | ||
| with CommandLoggerContext(logger): | ||
| logger.error(error_msg) | ||
| candidates = difflib.get_close_matches(value, action.choices, cutoff=0.7) | ||
| if candidates: | ||
| print_args = { | ||
| 's': 's' if len(candidates) > 1 else '', | ||
| 'verb': 'are' if len(candidates) > 1 else 'is', | ||
| 'value': value | ||
| } | ||
| self._suggestion_msg.append("\nThe most similar choice{s} to '{value}' {verb}:".format(**print_args)) | ||
| self._suggestion_msg.append('\n'.join(['\t' + candidate for candidate in candidates])) | ||
|
|
||
| failure_recovery_recommendations = self._get_failure_recovery_recommendations(action) | ||
| self._suggestion_msg.extend(failure_recovery_recommendations) | ||
| self._print_suggestion_msg(sys.stderr) | ||
| if not caused_by_extension_not_installed: | ||
| if candidates: | ||
| print_args = { | ||
| 's': 's' if len(candidates) > 1 else '', | ||
| 'verb': 'are' if len(candidates) > 1 else 'is', | ||
| 'value': value | ||
| } | ||
| self._suggestion_msg.append("\nThe most similar choice{s} to '{value}' {verb}:" | ||
| .format(**print_args)) | ||
| self._suggestion_msg.append('\n'.join(['\t' + candidate for candidate in candidates])) | ||
|
|
||
| failure_recovery_recommendations = self._get_failure_recovery_recommendations(action) | ||
| self._suggestion_msg.extend(failure_recovery_recommendations) | ||
| self._print_suggestion_msg(sys.stderr) | ||
| self.exit(2) | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would log at warning level be better?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For now, let's just fall back to previous behavior when something is wrong as the warning may confuse users when they only typed a wrong command.