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