1# -------------------------------------------------------------------------------------------- 2# Copyright (c) Microsoft Corporation. All rights reserved. 3# Licensed under the MIT License. See License.txt in the project root for license information. 4# -------------------------------------------------------------------------------------------- 5 6import argparse 7 8from azure.cli.core.commands import ExtensionCommandSource 9 10from knack.help import (HelpFile as KnackHelpFile, CommandHelpFile as KnackCommandHelpFile, 11 GroupHelpFile as KnackGroupHelpFile, ArgumentGroupRegistry as KnackArgumentGroupRegistry, 12 HelpExample as KnackHelpExample, HelpParameter as KnackHelpParameter, 13 _print_indent, CLIHelp, HelpAuthoringException) 14 15from knack.log import get_logger 16from knack.util import CLIError 17 18logger = get_logger(__name__) 19 20PRIVACY_STATEMENT = """ 21Welcome to Azure CLI! 22--------------------- 23Use `az -h` to see available commands or go to https://aka.ms/cli. 24 25Telemetry 26--------- 27The Azure CLI collects usage data in order to improve your experience. 28The data is anonymous and does not include commandline argument values. 29The data is collected by Microsoft. 30 31You can change your telemetry settings with `az configure`. 32""" 33 34WELCOME_MESSAGE = r""" 35 /\ 36 / \ _____ _ _ ___ _ 37 / /\ \ |_ / | | | \'__/ _\ 38 / ____ \ / /| |_| | | | __/ 39 /_/ \_\/___|\__,_|_| \___| 40 41 42Welcome to the cool new Azure CLI! 43 44Use `az --version` to display the current version. 45Here are the base commands: 46""" 47 48 49# PrintMixin class to decouple printing functionality from AZCLIHelp class. 50# Most of these methods override print methods in CLIHelp 51class CLIPrintMixin(CLIHelp): 52 def _print_header(self, cli_name, help_file): 53 super(CLIPrintMixin, self)._print_header(cli_name, help_file) 54 55 links = help_file.links 56 if links: 57 link_text = "{} and {}".format(", ".join([link["url"] for link in links[0:-1]]), 58 links[-1]["url"]) if len(links) > 1 else links[0]["url"] 59 link_text = "For more information, see: {}\n".format(link_text) 60 _print_indent(link_text, 2, width=self.textwrap_width) 61 62 def _print_detailed_help(self, cli_name, help_file): 63 CLIPrintMixin._print_extensions_msg(help_file) 64 super(CLIPrintMixin, self)._print_detailed_help(cli_name, help_file) 65 self._print_az_find_message(help_file.command, self.cli_ctx.enable_color) 66 67 @staticmethod 68 def _get_choices_defaults_sources_str(p): 69 choice_str = ' Allowed values: {}.'.format(', '.join(sorted([str(x) for x in p.choices]))) \ 70 if p.choices else '' 71 default_value_source = p.default_value_source if p.default_value_source else 'Default' 72 default_str = ' {}: {}.'.format(default_value_source, p.default) \ 73 if p.default and p.default != argparse.SUPPRESS else '' 74 value_sources_str = CLIPrintMixin._process_value_sources(p) if p.value_sources else '' 75 return '{}{}{}'.format(choice_str, default_str, value_sources_str) 76 77 @staticmethod 78 def _print_examples(help_file): 79 indent = 0 80 _print_indent('Examples', indent) 81 for e in help_file.examples: 82 indent = 1 83 _print_indent('{0}'.format(e.short_summary), indent) 84 indent = 2 85 if e.long_summary: 86 _print_indent('{0}'.format(e.long_summary), indent) 87 _print_indent('{0}'.format(e.command), indent) 88 print('') 89 90 @staticmethod 91 def _print_az_find_message(command, enable_color): 92 from colorama import Style 93 indent = 0 94 message = 'For more specific examples, use: az find "az {}"'.format(command) 95 if enable_color: 96 message = Style.BRIGHT + message + Style.RESET_ALL 97 _print_indent(message + '\n', indent) 98 99 @staticmethod 100 def _process_value_sources(p): 101 commands, strings, urls = [], [], [] 102 103 for item in p.value_sources: 104 if "string" in item: 105 strings.append(item["string"]) 106 elif "link" in item and "command" in item["link"]: 107 commands.append(item["link"]["command"]) 108 elif "link" in item and "url" in item["link"]: 109 urls.append(item["link"]["url"]) 110 111 command_str = ' Values from: {}.'.format(", ".join(commands)) if commands else '' 112 string_str = ' {}'.format(", ".join(strings)) if strings else '' 113 string_str = string_str + "." if string_str and not string_str.endswith(".") else string_str 114 urls_str = ' For more info, go to: {}.'.format(", ".join(urls)) if urls else '' 115 return '{}{}{}'.format(command_str, string_str, urls_str) 116 117 @staticmethod 118 def _print_extensions_msg(help_file): 119 if help_file.type != 'command': 120 return 121 if isinstance(help_file.command_source, ExtensionCommandSource): 122 logger.warning(help_file.command_source.get_command_warn_msg()) 123 124 # Extension preview/experimental warning is disabled because it can be confusing when displayed together 125 # with command or command group preview/experimental warning. See #12556 126 127 # # If experimental is true, it overrides preview 128 # if help_file.command_source.experimental: 129 # logger.warning(help_file.command_source.get_experimental_warn_msg()) 130 # elif help_file.command_source.preview: 131 # logger.warning(help_file.command_source.get_preview_warn_msg()) 132 133 134class AzCliHelp(CLIPrintMixin, CLIHelp): 135 136 def __init__(self, cli_ctx): 137 super(AzCliHelp, self).__init__(cli_ctx, 138 privacy_statement=PRIVACY_STATEMENT, 139 welcome_message=WELCOME_MESSAGE, 140 command_help_cls=CliCommandHelpFile, 141 group_help_cls=CliGroupHelpFile, 142 help_cls=CliHelpFile) 143 from knack.help import HelpObject 144 145 # TODO: This workaround is used to avoid a bizarre bug in Python 2.7. It 146 # essentially reassigns Knack's HelpObject._normalize_text implementation 147 # with an identical implemenation in Az. For whatever reason, this fixes 148 # the bug in Python 2.7. 149 @staticmethod 150 def new_normalize_text(s): 151 if not s or len(s) < 2: 152 return s or '' 153 s = s.strip() 154 initial_upper = s[0].upper() + s[1:] 155 trailing_period = '' if s[-1] in '.!?' else '.' 156 return initial_upper + trailing_period 157 158 HelpObject._normalize_text = new_normalize_text # pylint: disable=protected-access 159 160 self._register_help_loaders() 161 self._name_to_content = {} 162 163 def show_help(self, cli_name, nouns, parser, is_group): 164 self.update_loaders_with_help_file_contents(nouns) 165 166 delimiters = ' '.join(nouns) 167 help_file = self.command_help_cls(self, delimiters, parser) if not is_group \ 168 else self.group_help_cls(self, delimiters, parser) 169 help_file.load(parser) 170 if not nouns: 171 help_file.command = '' 172 else: 173 AzCliHelp.update_examples(help_file) 174 self._print_detailed_help(cli_name, help_file) 175 from azure.cli.core.util import show_updates_available 176 show_updates_available(new_line_after=True) 177 show_link = self.cli_ctx.config.getboolean('output', 'show_survey_link', True) 178 from azure.cli.core.commands.constants import (SURVEY_PROMPT_STYLED, UX_SURVEY_PROMPT_STYLED) 179 from azure.cli.core.style import print_styled_text 180 if show_link: 181 print_styled_text(SURVEY_PROMPT_STYLED) 182 if not nouns: 183 print_styled_text(UX_SURVEY_PROMPT_STYLED) 184 185 def get_examples(self, command, parser, is_group): 186 """Get examples of a certain command from the help file. 187 Get the text of the example, strip the newline character and 188 return a list of commands which start with the given command name. 189 """ 190 nouns = command.split(' ')[1:] 191 self.update_loaders_with_help_file_contents(nouns) 192 193 delimiters = ' '.join(nouns) 194 help_file = self.command_help_cls(self, delimiters, parser) if not is_group \ 195 else self.group_help_cls(self, delimiters, parser) 196 help_file.load(parser) 197 198 def strip_command(command): 199 command = command.replace('\\\n', '') 200 contents = [item for item in command.split(' ') if item] 201 return ' '.join(contents).strip() 202 203 examples = [] 204 for example in help_file.examples: 205 if example.command and example.name: 206 examples.append({ 207 'command': strip_command(example.command), 208 'description': example.name 209 }) 210 211 return examples 212 213 def _register_help_loaders(self): 214 import azure.cli.core._help_loaders as help_loaders 215 import inspect 216 217 def is_loader_cls(cls): 218 return inspect.isclass(cls) and cls.__name__ != 'BaseHelpLoader' and issubclass(cls, help_loaders.BaseHelpLoader) # pylint: disable=line-too-long 219 220 versioned_loaders = {} 221 for cls_name, loader_cls in inspect.getmembers(help_loaders, is_loader_cls): 222 loader = loader_cls(self) 223 versioned_loaders[cls_name] = loader 224 225 if len(versioned_loaders) != len({ldr.version for ldr in versioned_loaders.values()}): 226 ldrs_str = " ".join("{}-version:{}".format(cls_name, ldr.version) for cls_name, ldr in versioned_loaders.items()) # pylint: disable=line-too-long 227 raise CLIError("Two loaders have the same version. Loaders:\n\t{}".format(ldrs_str)) 228 229 self.versioned_loaders = versioned_loaders 230 231 def update_loaders_with_help_file_contents(self, nouns): 232 loader_file_names_dict = {} 233 file_name_set = set() 234 for ldr_cls_name, loader in self.versioned_loaders.items(): 235 new_file_names = loader.get_noun_help_file_names(nouns) or [] 236 loader_file_names_dict[ldr_cls_name] = new_file_names 237 file_name_set.update(new_file_names) 238 239 for file_name in file_name_set: 240 if file_name not in self._name_to_content: 241 with open(file_name, 'r') as f: 242 self._name_to_content[file_name] = f.read() 243 244 for ldr_cls_name, file_names in loader_file_names_dict.items(): 245 file_contents = {} 246 for name in file_names: 247 file_contents[name] = self._name_to_content[name] 248 self.versioned_loaders[ldr_cls_name].update_file_contents(file_contents) 249 250 # This method is meant to be a hook that can be overridden by an extension or module. 251 @staticmethod 252 def update_examples(help_file): 253 pass 254 255 256class CliHelpFile(KnackHelpFile): 257 258 def __init__(self, help_ctx, delimiters): 259 # Each help file (for a command or group) has a version denoting the source of its data. 260 super(CliHelpFile, self).__init__(help_ctx, delimiters) 261 self.links = [] 262 263 def _should_include_example(self, ex): 264 supported_profiles = ex.get('supported-profiles') 265 unsupported_profiles = ex.get('unsupported-profiles') 266 267 if all((supported_profiles, unsupported_profiles)): 268 raise HelpAuthoringException("An example cannot have both supported-profiles and unsupported-profiles.") 269 270 if supported_profiles: 271 supported_profiles = [profile.strip() for profile in supported_profiles.split(',')] 272 return self.help_ctx.cli_ctx.cloud.profile in supported_profiles 273 274 if unsupported_profiles: 275 unsupported_profiles = [profile.strip() for profile in unsupported_profiles.split(',')] 276 return self.help_ctx.cli_ctx.cloud.profile not in unsupported_profiles 277 278 return True 279 280 # Needs to override base implementation to exclude unsupported examples. 281 def _load_from_data(self, data): 282 if not data: 283 return 284 285 if isinstance(data, str): 286 self.long_summary = data 287 return 288 289 if 'type' in data: 290 self.type = data['type'] 291 292 if 'short-summary' in data: 293 self.short_summary = data['short-summary'] 294 295 self.long_summary = data.get('long-summary') 296 297 if 'examples' in data: 298 self.examples = [] 299 for d in data['examples']: 300 if self._should_include_example(d): 301 self.examples.append(HelpExample(**d)) 302 303 def load(self, options): 304 ordered_loaders = sorted(self.help_ctx.versioned_loaders.values(), key=lambda ldr: ldr.version) 305 for loader in ordered_loaders: 306 loader.versioned_load(self, options) 307 308 309class CliGroupHelpFile(KnackGroupHelpFile, CliHelpFile): 310 311 def load(self, options): 312 # forces class to use this load method even if KnackGroupHelpFile overrides CliHelpFile's method. 313 CliHelpFile.load(self, options) 314 315 316class CliCommandHelpFile(KnackCommandHelpFile, CliHelpFile): 317 318 def __init__(self, help_ctx, delimiters, parser): 319 super(CliCommandHelpFile, self).__init__(help_ctx, delimiters, parser) 320 self.type = 'command' 321 self.command_source = getattr(parser, 'command_source', None) 322 323 self.parameters = [] 324 325 for action in [a for a in parser._actions if a.help != argparse.SUPPRESS]: # pylint: disable=protected-access 326 if action.option_strings: 327 self._add_parameter_help(action) 328 else: 329 # use metavar for positional parameters 330 param_kwargs = { 331 'name_source': [action.metavar or action.dest], 332 'deprecate_info': getattr(action, 'deprecate_info', None), 333 'preview_info': getattr(action, 'preview_info', None), 334 'experimental_info': getattr(action, 'experimental_info', None), 335 'default_value_source': getattr(action, 'default_value_source', None), 336 'description': action.help, 337 'choices': action.choices, 338 'required': False, 339 'default': None, 340 'group_name': 'Positional' 341 } 342 self.parameters.append(HelpParameter(**param_kwargs)) 343 344 help_param = next(p for p in self.parameters if p.name == '--help -h') 345 help_param.group_name = 'Global Arguments' 346 347 # update parameter type so we can use overriden update_from_data method to update value sources. 348 for param in self.parameters: 349 param.__class__ = HelpParameter 350 351 def _load_from_data(self, data): 352 super(CliCommandHelpFile, self)._load_from_data(data) 353 354 if isinstance(data, str) or not self.parameters or not data.get('parameters'): 355 return 356 357 loaded_params = [] 358 loaded_param = {} 359 for param in self.parameters: 360 loaded_param = next((n for n in data['parameters'] if n['name'] == param.name), None) 361 if loaded_param: 362 param.update_from_data(loaded_param) 363 loaded_params.append(param) 364 365 self.parameters = loaded_params 366 367 def load(self, options): 368 # forces class to use this load method even if KnackCommandHelpFile overrides CliHelpFile's method. 369 CliHelpFile.load(self, options) 370 371 372class ArgumentGroupRegistry(KnackArgumentGroupRegistry): # pylint: disable=too-few-public-methods 373 374 def __init__(self, group_list): 375 376 super(ArgumentGroupRegistry, self).__init__(group_list) 377 self.priorities = { 378 None: 0, 379 'Resource Id Arguments': 1, 380 'Generic Update Arguments': 998, 381 'Global Arguments': 1000, 382 } 383 priority = 2 384 # any groups not already in the static dictionary should be prioritized alphabetically 385 other_groups = [g for g in sorted(list(set(group_list))) if g not in self.priorities] 386 for group in other_groups: 387 self.priorities[group] = priority 388 priority += 1 389 390 391class HelpExample(KnackHelpExample): # pylint: disable=too-few-public-methods 392 393 def __init__(self, **_data): 394 # Old attributes 395 _data['name'] = _data.get('name', '') 396 _data['text'] = _data.get('text', '') 397 super(HelpExample, self).__init__(_data) 398 399 self.name = _data.get('summary', '') if _data.get('summary', '') else self.name 400 self.text = _data.get('command', '') if _data.get('command', '') else self.text 401 402 self.long_summary = _data.get('description', '') 403 self.supported_profiles = _data.get('supported-profiles', None) 404 self.unsupported_profiles = _data.get('unsupported-profiles', None) 405 406 # alias old params with new 407 @property 408 def short_summary(self): 409 return self.name 410 411 @short_summary.setter 412 def short_summary(self, value): 413 self.name = value 414 415 @property 416 def command(self): 417 return self.text 418 419 @command.setter 420 def command(self, value): 421 self.text = value 422 423 424class HelpParameter(KnackHelpParameter): # pylint: disable=too-many-instance-attributes 425 426 def __init__(self, **kwargs): 427 super(HelpParameter, self).__init__(**kwargs) 428 429 def update_from_data(self, data): 430 super(HelpParameter, self).update_from_data(data) 431 # original help.py value_sources are strings, update command strings to value-source dict 432 if self.value_sources: 433 self.value_sources = [str_or_dict if isinstance(str_or_dict, dict) else {"link": {"command": str_or_dict}} 434 for str_or_dict in self.value_sources] 435