azdev/operations/linter/util.py (107 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 copy
import re
import os
import json
import requests
from knack.log import get_logger
from azdev.utilities import get_name_index
from azdev.operations.constant import (ALLOWED_HTML_TAG, CMD_EXAMPLE_CONFIG_FILE_URL,
CMD_EXAMPLE_CONFIG_FILE_PATH, CMD_EXAMPLE_DEFAULT)
logger = get_logger(__name__)
_LOADER_CLS_RE = re.compile('.*azure/cli/command_modules/(?P<module>[^/]*)/__init__.*')
# add html tag extraction for <abd>, <lun1>, <edge zone>
# html tag search for <os_des>, <lun1_des> is enabled in cli ci but skipped in doc build cause internal issue
_HTML_TAG_RE = re.compile(r'<([^\n>]+)>')
_HTTP_LINK_RE = re.compile(r'(?<!`)(https?://[^\s`]+)(?!`)')
def filter_modules(command_loader, help_file_entries, modules=None, include_whl_extensions=False):
""" Modify the command table and help entries to only include certain modules/extensions.
: param command_loader: The CLICommandsLoader containing the command table to filter.
: help_file_entries: The dict of HelpFile entries to filter.
: modules: [str] list of module or extension names to retain.
"""
return _filter_mods(command_loader, help_file_entries, modules=modules,
include_whl_extensions=include_whl_extensions)
def exclude_commands(command_loader, help_file_entries, module_exclusions, include_whl_extensions=False):
""" Modify the command table and help entries to exclude certain modules/extensions.
: param command_loader: The CLICommandsLoader containing the command table to filter.
: help_file_entries: The dict of HelpFile entries to filter.
: modules: [str] list of module or extension names to remove.
"""
return _filter_mods(command_loader, help_file_entries, modules=module_exclusions, exclude=True,
include_whl_extensions=include_whl_extensions)
def _filter_mods(command_loader, help_file_entries, modules=None, exclude=False, include_whl_extensions=False):
modules = modules or []
# command tables and help entries must be copied to allow for seperate linter scope
command_table = command_loader.command_table.copy()
command_group_table = command_loader.command_group_table.copy()
command_loader = copy.copy(command_loader)
command_loader.command_table = command_table
command_loader.command_group_table = command_group_table
help_file_entries = help_file_entries.copy()
name_index = get_name_index(include_whl_extensions=include_whl_extensions)
for command_name in list(command_loader.command_table.keys()):
try:
source_name, _ = _get_command_source(command_name, command_loader.command_table)
except LinterError as ex:
# command is unrecognized
logger.warning(ex)
source_name = None
try:
long_name = name_index[source_name]
is_specified = source_name in modules or long_name in modules
except KeyError:
is_specified = False
if is_specified == exclude:
# brute force method of ignoring commands from a module or extension
command_loader.command_table.pop(command_name, None)
help_file_entries.pop(command_name, None)
# Remove unneeded command groups
retained_command_groups = {' '.join(x.split(' ')[:-1]) for x in command_loader.command_table}
excluded_command_groups = set(command_loader.command_group_table.keys()) - retained_command_groups
for group_name in excluded_command_groups:
command_loader.command_group_table.pop(group_name, None)
help_file_entries.pop(group_name, None)
return command_loader, help_file_entries
def share_element(first_iter, second_iter):
return any(element in first_iter for element in second_iter)
def _get_command_source(command_name, command_table):
from azure.cli.core.commands import ExtensionCommandSource # pylint: disable=import-error
command = command_table.get(command_name)
# see if command is from an extension
if isinstance(command.command_source, ExtensionCommandSource):
return command.command_source.extension_name, True
if command.command_source is None:
raise LinterError('Command: `%s`, has no command source.' % command_name)
# command is from module
return command.command_source, False
# pylint: disable=line-too-long
def merge_exclusion(left_exclusion, right_exclusion):
for command_name, value in right_exclusion.items():
for rule_name in value.get('rule_exclusions', []):
left_exclusion.setdefault(command_name, {}).setdefault('rule_exclusions', []).append(rule_name)
for param_name in value.get('parameters', {}):
for rule_name in value.get('parameters', {}).get(param_name, {}).get('rule_exclusions', []):
left_exclusion.setdefault(command_name, {}).setdefault('parameters', {}).setdefault(param_name, {}).setdefault('rule_exclusions', []).append(rule_name)
class LinterError(Exception):
"""
Exception thrown by linter for non rule violation reasons
"""
pass # pylint: disable=unnecessary-pass
# pylint: disable=line-too-long
def has_illegal_html_tag(help_message, filtered_lines=None):
"""
Detect those content wrapped with <> but illegal html tag.
Refer to rule doc: https://review.learn.microsoft.com/en-us/help/platform/validation-ref/disallowed-html-tag?branch=main
"""
html_matches = re.findall(_HTML_TAG_RE, help_message)
unbackticked_matches = [match for match in html_matches if not re.search(r'`[^`]*' + re.escape('<' + match + '>') + r'[^`]*`', help_message)]
disallowed_html_tags = set(unbackticked_matches) - set(ALLOWED_HTML_TAG)
if filtered_lines:
disallowed_html_tags = [s for s in disallowed_html_tags if any(('<' + s + '>') in diff_line for diff_line in filtered_lines)]
return ['<' + s + '>' for s in disallowed_html_tags]
def has_broken_site_links(help_message, filtered_lines=None):
"""
Detect broken link in help message.
Refer to rule doc: https://review.learn.microsoft.com/en-us/help/platform/validation-ref/other-site-link-broken?branch=main
"""
urls = re.findall(_HTTP_LINK_RE, help_message)
invalid_urls = []
for url in urls:
url = re.sub(r'[.")\'\s]*$', '', url)
try:
response = requests.get(url, timeout=5)
if response.status_code != 200:
invalid_urls.append(url)
except requests.exceptions.RequestException:
invalid_urls.append(url)
if filtered_lines:
invalid_urls = [s for s in invalid_urls if any(s in diff_line for diff_line in filtered_lines)]
return invalid_urls
def get_cmd_example_configurations():
cmd_example_threshold = {}
remote_res = requests.get(CMD_EXAMPLE_CONFIG_FILE_URL)
if remote_res.status_code != 200:
logger.warning("remote cmd example configuration fetch error, use local dict")
if not os.path.exists(CMD_EXAMPLE_CONFIG_FILE_PATH):
logger.info("cmd_example_config.json not exist, skipped")
return cmd_example_threshold
with open(CMD_EXAMPLE_CONFIG_FILE_PATH, "r") as f_in:
cmd_example_threshold = json.load(f_in)
else:
logger.info("remote cmd example configuration fetch success")
cmd_example_threshold = remote_res.json()
return cmd_example_threshold
def get_cmd_example_threshold(cmd_suffix, cmd_example_config):
for cmd_type, threshold in cmd_example_config.items():
if cmd_suffix.find(cmd_type) != -1:
return threshold
return CMD_EXAMPLE_DEFAULT