azdev/operations/linter/rules/help_rules.py (143 lines of code) (raw):

# ----------------------------------------------------------------------------- # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for # license information. # ----------------------------------------------------------------------------- import shlex import re from unittest import mock from knack.log import get_logger from ..rule_decorators import HelpFileEntryRule from ..linter import RuleError, LinterSeverity from ..util import LinterError # pylint: disable=anomalous-backslash-in-string # pylint: disable=no-value-for-parameter # 'az' space then repeating runs of quoted tokens or non quoted characters _az_pattern = r'az\s*' + '(([^\"\'])*|' + r'((\"[^\"]*\"\s*)|(\'[^\']*\'\s*))' + ')' # match the two types of command substitutions _CMD_SUB_1 = re.compile(r"\$\(\s*" + "(" + _az_pattern + ")" + r"\)") _CMD_SUB_2 = re.compile(r"`\s*" + "(" + _az_pattern + ")" + "`") logger = get_logger(__name__) @HelpFileEntryRule(LinterSeverity.HIGH) def unrecognized_help_entry_rule(linter, help_entry): if help_entry not in linter.commands and help_entry not in linter.command_groups: raise RuleError('Not a recognized command or command-group') @HelpFileEntryRule(LinterSeverity.HIGH) def faulty_help_type_rule(linter, help_entry): if linter.get_help_entry_type(help_entry) != 'group' and help_entry in linter.command_groups: raise RuleError('Command-group should be of help-type `group`') if linter.get_help_entry_type(help_entry) != 'command' and help_entry in linter.commands: raise RuleError('Command should be of help-type `command`') @HelpFileEntryRule(LinterSeverity.HIGH) def unrecognized_help_parameter_rule(linter, help_entry): if help_entry not in linter.commands: return param_help_names = linter.get_help_entry_parameter_names(help_entry) violations = [] for param_help_name in param_help_names: if not linter.is_valid_parameter_help_name(help_entry, param_help_name): violations.append(param_help_name) if violations: raise RuleError('The following parameter help names are invalid: {}'.format(' | '.join(violations))) @HelpFileEntryRule(LinterSeverity.HIGH) def faulty_help_example_rule(linter, help_entry): violations = [] for index, example in enumerate(linter.get_help_entry_examples(help_entry)): if 'az ' + help_entry not in example.get('text', ''): violations.append(str(index)) if violations: raise RuleError('The following example entry indices do not include the command: {}'.format( ' | '.join(violations))) @HelpFileEntryRule(LinterSeverity.HIGH) def faulty_help_example_parameters_rule(linter, help_entry): # print(linter, help_entry) parser = linter.command_parser violations = [] for example in linter.get_help_entry_examples(help_entry): supported_profiles = example.get('supported-profiles') if supported_profiles and 'latest' not in supported_profiles: logger.warning("\n\tSKIPPING example: %s\n\tas 'latest' is not in its supported profiles." "\n\t\tsupported-profiles: %s.", example['text'], example['supported-profiles']) continue unsupported_profiles = example.get('unsupported-profiles') if unsupported_profiles and 'latest' in unsupported_profiles: logger.warning("\n\tSKIPPING example: %s\n\tas 'latest' is in its unsupported profiles." "\n\t\tunsupported-profiles: %s.", example['text'], example['unsupported-profiles']) continue example_text = example.get('text', '') commands = _extract_commands_from_example(example_text) while commands: command = commands.pop() violation, nested_commands = _lint_example_command(command, parser) commands.extend(nested_commands) # append commands that are the source of any arguments if violation: violations.append(violation) if violations: num_err = len(violations) violation_str = "\n\n".join(violations) violation_msg = "\n\tThere is a violation:\n{}.".format(violation_str) if num_err == 1 else \ "\n\tThere are {} violations:\n{}".format(num_err, violation_str) raise RuleError(violation_msg + "\n\n") # Faulty help example parameters rule helpers @mock.patch("azure.cli.core.parser.AzCliCommandParser._check_value") @mock.patch("argparse.ArgumentParser._get_value") @mock.patch("azure.cli.core.parser.AzCliCommandParser.error") def _lint_example_command(command, parser, mocked_error_method, mocked_get_value, mocked_check_value): # pylint: disable=unused-argument def get_value_side_effect(action, arg_string): # pylint: disable=unused-argument return arg_string mocked_error_method.side_effect = LinterError # mock call of parser.error so usage won't be printed. mocked_get_value.side_effect = get_value_side_effect violation = None nested_commands = [] try: command_args = shlex.split(command, comments=True)[1:] # split commands into command args, ignore comments. command_args, nested_commands = _process_command_args(command_args) parser.parse_args(command_args) except ValueError as e: # handle exception thrown by shlex. if str(e) == "No closing quotation": violation = '\t"{}"\n\thas no closing quotation. Tip: to continue an example ' \ 'command on the next line, use a "\\" followed by a newline.\n\t' \ 'If needed, you can escape the "\\", like so "\\\\"'.format(command) else: raise e except LinterError: # handle parsing failure due to invalid option violation = '\t"{}" is not a valid command'.format(command) if mocked_error_method.called: call_args = mocked_error_method.call_args violation = "{}.\n\t{}".format(violation, call_args[0][0]) return violation, nested_commands # return list of commands in the example text def _extract_commands_from_example(example_text): # fold commands spanning multiple lines into one line. Split commands that use pipes # handle single and double quotes properly lines = example_text.splitlines() example_text = "" quote = None for line in lines: for ch in line: if quote is None: if ch in ('"', "'"): quote = ch elif ch == quote: quote = None if quote is None and line.endswith("\\"): # attach this line with removed '\' and no '\n' (space at the end to keep consistent with initial algorithm) example_text += line[0:-1] + " " elif quote is not None: # attach this line without '\n' example_text += line else: # attach this line with '\n' as no quote and no continuation example_text += line + "\n" # this is also for consistency with original algorithm example_text = example_text.replace("\\ ", " ") commands = example_text.splitlines() processed_commands = [] for command in commands: # filter out commands command.strip() if command.startswith("az"): # if this is a single az command add it. processed_commands.append(command) for re_prog in [_CMD_SUB_1, _CMD_SUB_2]: start = 0 match = re_prog.search(command, start) while match: # while there is a nested az command of type 1 $( az ...) processed_commands.append(match.group(1).strip()) # add it start = match.end(1) # get index of rest of string match = re_prog.search(command, start) # attempt to get next match return processed_commands def _process_command_args(command_args): result_args = [] new_commands = [] operators = ["&&", "||", "|"] for arg in command_args: # strip unnecessary punctuation, otherwise arg validation could fail. if arg in operators: # handle cases where multiple commands are connected by control operators or pipe. idx = command_args.index(arg) maybe_new_command = " ".join(command_args[idx:]) idx = maybe_new_command.find("az ") if idx != -1: new_commands.append(maybe_new_command[idx:]) # remaining command is in fact a new command / commands. break result_args.append(arg) return result_args, new_commands