[CICD] Add CLI Command Test Coverage (#323)

* add command coverage

* commit

* regex

* command coverage 1.0

* upload html

* modify report

* refactor

* add annotation

* Update test_cmdcov.py

* modify detect command function

* detect from live test file

* update func _get_all_commands

* Update __init__.py

* update sort function

* update sort function

* add level argument

* add cli own

* add EXCLUDE MODULES

* Update component.css

* delete unused code

* delete unused file

* modify regex

* add help info

* add description

* exclude command wait

* detect from recording file

* delete exception already fixed

* add newline

* add license headers

* fix tox

* refactoring

* fix bug

* exclude deprecated commands

* fix tox

* fix tox

* add network exclude commands

* add number sign exclude

* add comment

* add linter rule comcov

* update

* regex

* add log add exec_state

* add rule type command_coverage

* update diff branches

* support exclusion

* linter refactoring

* move constant and regex out

* suppport linter_exclusion.yml in cmdcov

* add license headers

* fix pylint

* fix flake8

* Update test_cmdcov.py

* fix bug for linter rule

* add resource skip commands

* update

1. Change the selected color to pink
2. Mouse hover shows percentage details
3. Modify Coverage Report description

* role commands exclusion

* modify HISTORY.rst and setup.py

* resource skip two commands - hard to test

* bug fixes: search argument

* modify command coverage to command test coverage

* bug fixes: Add three new scenarios

* update CLI own and exclude commands

* move private dns out of cli own

* add log for exclusions

* exclude vm host restart

* config

* Update test_config.py

* update

* update

* update
This commit is contained in:
ZelinWang 2023-06-19 10:06:08 +08:00 коммит произвёл GitHub
Родитель 64cd348d24
Коммит 276b24a124
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
26 изменённых файлов: 17612 добавлений и 24 удалений

1
.gitignore поставляемый
Просмотреть файл

@ -12,3 +12,4 @@ azdev.egg-info/
pip-wheel-metadata/*
build/
dist/
cmd_coverage

Просмотреть файл

@ -3,6 +3,11 @@
Release History
===============
0.1.49
++++++
* Add Command Coverage Report (#323)
* Add Linter rule missing_command_coverage and missing_parameter_coverage (#323)
0.1.48
++++++
* `azdev command-change meta-export`: Add option deprecation info and ignore `cmd` arg (#381)

Просмотреть файл

@ -4,4 +4,4 @@
# license information.
# -----------------------------------------------------------------------------
__VERSION__ = '0.1.48'
__VERSION__ = '0.1.49'

Просмотреть файл

@ -31,6 +31,9 @@ def load_command_table(self, _):
g.command('list-command-table', 'list_command_table')
g.command('diff-command-tables', 'diff_command_tables')
with CommandGroup(self, '', operation_group('cmdcov')) as g:
g.command('cmdcov', 'run_cmdcov')
with CommandGroup(self, 'verify', operation_group('pypi')) as g:
g.command('history', 'check_history')

Просмотреть файл

@ -280,3 +280,14 @@ helps['extension generate-docs'] = """
long-summary: >
This command installs the extensions in a temporary directory and sets it as the extensions dir when generating reference docs.
"""
helps['cmdcov'] = """
short-summary: Run command test coverage and generate CLI command test coverage report.
examples:
- name: Check all CLI modules command test coverage.
text: azdev cmdcov CLI
- name: Check one or serveral modules command test coverage.
text: azdev cmdcov vm storage
- name: Check CLI modules command test coverage in argument level.
text: azdev cmdcov CLI --level argument
"""

Просмотреть файл

@ -0,0 +1,138 @@
# -----------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -----------------------------------------------------------------------------
import os
import time
import yaml
from knack.log import get_logger
from knack.util import CLIError
from azdev.utilities import (
heading, display, get_path_table, require_azure_cli, filter_by_git_diff)
from azdev.utilities.path import get_cli_repo_path, get_ext_repo_paths
from .cmdcov import CmdcovManager
logger = get_logger(__name__)
try:
with open(os.path.join(get_cli_repo_path(), 'scripts', 'ci', 'cmdcov.yml'), 'r') as file:
config = yaml.safe_load(file)
EXCLUDE_MODULES = config['EXCLUDE_MODULES']
except CLIError as ex:
logger.warning('Failed to load cmdcov.yml: %s', ex)
# pylint:disable=too-many-locals, too-many-statements, too-many-branches, duplicate-code
def run_cmdcov(modules=None, git_source=None, git_target=None, git_repo=None, level='command'):
"""
:param modules:
:param git_source:
:param git_target:
:param git_target:
:return: None
"""
require_azure_cli()
from azure.cli.core import get_default_cli # pylint: disable=import-error
from azure.cli.core.file_util import ( # pylint: disable=import-error
get_all_help, create_invoker_and_load_cmds_and_args)
heading('CLI Command Test Coverage')
# allow user to run only on CLI or extensions
cli_only = modules == ['CLI']
# ext_only = modules == ['EXT']
enable_cli_own = bool(cli_only or modules is None)
if cli_only:
modules = None
# if cli_only or ext_only:
# modules = None
selected_modules = get_path_table(include_only=modules)
# filter down to only modules that have changed based on git diff
selected_modules = filter_by_git_diff(selected_modules, git_source, git_target, git_repo)
if not any(selected_modules.values()):
logger.warning('No commands selected to check.')
if EXCLUDE_MODULES:
selected_modules['mod'] = {k: v for k, v in selected_modules['mod'].items() if k not in EXCLUDE_MODULES}
selected_modules['ext'] = {k: v for k, v in selected_modules['ext'].items() if k not in EXCLUDE_MODULES}
if cli_only:
selected_mod_names = list(selected_modules['mod'].keys())
selected_mod_paths = list(selected_modules['mod'].values())
# elif ext_only:
# selected_mod_names = list(selected_modules['ext'].keys())
# selected_mod_paths = list(selected_modules['ext'].values())
else:
selected_mod_names = list(selected_modules['mod'].keys())
selected_mod_paths = list(selected_modules['mod'].values())
if selected_mod_names:
display('Modules: {}\n'.format(', '.join(selected_mod_names)))
start = time.time()
display('Initializing cmdcov with command table and help files...')
az_cli = get_default_cli()
# load commands, args, and help
create_invoker_and_load_cmds_and_args(az_cli)
loaded_help = get_all_help(az_cli)
stop = time.time()
logger.info('Commands and help loaded in %i sec', stop - start)
# format loaded help
loaded_help = {data.command: data for data in loaded_help if data.command}
linter_exclusions = {}
# collect rule exclusions from selected mod paths
for path in selected_mod_paths:
mod_exclusion_path = os.path.join(path, 'linter_exclusions.yml')
if os.path.isfile(mod_exclusion_path):
with open(mod_exclusion_path) as f:
mod_exclusions = yaml.safe_load(f)
merge_exclusions(linter_exclusions, mod_exclusions or {})
# collect rule exclusions from global exclusion paths
global_exclusion_paths = [os.path.join(get_cli_repo_path(), 'linter_exclusions.yml')]
try:
global_exclusion_paths.extend([os.path.join(path, 'linter_exclusions.yml')
for path in (get_ext_repo_paths() or [])])
except CLIError:
pass
for path in global_exclusion_paths:
if os.path.isfile(path):
with open(path) as f:
mod_exclusions = yaml.safe_load(f)
merge_exclusions(linter_exclusions, mod_exclusions or {})
cmdcov_manager = CmdcovManager(selected_mod_names=selected_mod_names,
selected_mod_paths=selected_mod_paths,
loaded_help=loaded_help,
level=level,
enable_cli_own=enable_cli_own,
exclusions=linter_exclusions)
cmdcov_manager.run()
# pylint: disable=line-too-long
def merge_exclusions(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)
if __name__ == '__main__':
pass
# _get_all_tested_commands(['a'], ['b'])
# regex2()
# regex3()

Просмотреть файл

@ -0,0 +1,465 @@
# -----------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -----------------------------------------------------------------------------
import os
import shutil
import sys
import time
import yaml
from jinja2 import FileSystemLoader, Environment
from knack.log import get_logger
from knack.util import CLIError
from azdev.operations.regex import get_all_tested_commands_from_regex
from azdev.utilities.path import get_azdev_repo_path, get_cli_repo_path, find_files
logger = get_logger(__name__)
try:
with open(os.path.join(get_cli_repo_path(), 'scripts', 'ci', 'cmdcov.yml'), 'r') as file:
config = yaml.safe_load(file)
ENCODING = config['ENCODING']
GLOBAL_PARAMETERS = config['GLOBAL_PARAMETERS']
GENERIC_UPDATE_PARAMETERS = config['GENERIC_UPDATE_PARAMETERS']
WAIT_CONDITION_PARAMETERS = config['WAIT_CONDITION_PARAMETERS']
OTHER_PARAMETERS = config['OTHER_PARAMETERS']
RED = config['RED']
ORANGE = config['ORANGE']
GREEN = config['GREEN']
BLUE = config['BLUE']
GOLD = config['GOLD']
RED_PCT = config['RED_PCT']
ORANGE_PCT = config['ORANGE_PCT']
GREEN_PCT = config['GREEN_PCT']
BLUE_PCT = config['BLUE_PCT']
CLI_OWN_MODULES = config['CLI_OWN_MODULES']
EXCLUDE_COMMANDS = config['EXCLUDE_COMMANDS']
GLOBAL_EXCLUDE_COMMANDS = config['GLOBAL_EXCLUDE_COMMANDS']
except CLIError as ex:
logger.warning('Failed to load cmdcov.yml: %s', ex)
# pylint: disable=too-many-instance-attributes
class CmdcovManager:
def __init__(self, selected_mod_names=None, selected_mod_paths=None, loaded_help=None, level=None,
enable_cli_own=None, exclusions=None):
self.selected_mod_names = selected_mod_names
self.selected_mod_paths = selected_mod_paths
self.loaded_help = loaded_help
self.level = level
self.enable_cli_own = enable_cli_own
self.all_commands = {m: [] for m in self.selected_mod_names}
self.all_tested_commands = {m: [] for m in self.selected_mod_names}
self.all_live_commands = []
self.all_untested_commands = {}
self.command_test_coverage = {'Total': [0, 0, 0]}
self.date = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
self.report_date = '-'.join(self.date.replace(':', '-').split())
self.cmdcov_path = os.path.join(get_azdev_repo_path(), 'azdev', 'operations', 'cmdcov')
self.template_path = os.path.join(self.cmdcov_path, 'template')
self.exclusions = exclusions
def run(self):
self._get_all_commands()
self._get_all_tested_commands_from_regex()
self._get_all_tested_commands_from_record()
self._run_command_test_coverage()
self._get_all_tested_commands_from_live()
self._run_command_test_coverage_enhance()
html_file = self._render_html()
if self.enable_cli_own:
command_test_coverage = {k: v for k, v in self.command_test_coverage.items() if k in CLI_OWN_MODULES}
total_tested = 0
total_untested = 0
command_test_coverage['Total'] = [0, 0, 0]
for module in command_test_coverage.keys():
total_tested += command_test_coverage[module][0] if command_test_coverage[module] else 0
total_untested += command_test_coverage[module][1] if command_test_coverage[module] else 0
command_test_coverage['Total'][0] = total_tested
command_test_coverage['Total'][1] = total_untested
command_test_coverage['Total'][2] = f'{total_tested / (total_tested + total_untested):.3%}'
self._render_cli_html(command_test_coverage)
self._browse(html_file)
def _get_all_commands(self):
"""
GLOBAL_EXCLUDE_COMMANDS: List[str]
EXCLUDE_COMMANDS: Dict[str: List[str]]
exclusions_comands: List[str]
exclude_parameters: List[List[str]]
exclusions_parameters: List[Tuple[str, str]]
get all commands from loaded_help
"""
exclude_parameters = []
exclude_parameters += GLOBAL_PARAMETERS + GENERIC_UPDATE_PARAMETERS + WAIT_CONDITION_PARAMETERS + \
OTHER_PARAMETERS
exclude_parameters = [sorted(i) for i in exclude_parameters]
# some module like vm have multiple command like vm vmss disk snapshot ...
# pylint: disable=too-many-nested-blocks, too-many-boolean-expressions
exclusions_comands = []
exclusions_parameters = []
for c, v in self.exclusions.items():
if 'parameters' in v:
for p, r in v['parameters'].items():
if 'missing_parameter_test_coverage' in r['rule_exclusions']:
exclusions_parameters.append((c, p))
elif 'rule_exclusions' in v:
if 'missing_command_test_coverage' in v['rule_exclusions']:
exclusions_comands.append(c)
for _, y in self.loaded_help.items():
if hasattr(y, 'command_source') and y.command_source in self.selected_mod_names:
module = y.command_source
elif hasattr(y, 'command_source') and hasattr(y.command_source, 'extension_name') and \
y.command_source.extension_name in self.selected_mod_names:
module = y.command_source.extension_name
else:
continue
if not y.deprecate_info:
if y.command.split()[-1] not in GLOBAL_EXCLUDE_COMMANDS and \
y.command not in EXCLUDE_COMMANDS.get(module, []) and \
y.command not in exclusions_comands:
if self.level == 'argument':
for parameter in y.parameters:
# TODO support linter_exclusions.yml
if sorted(parameter.name_source) not in exclude_parameters:
opt_list = [opt for opt in parameter.name_source if opt.startswith('-')]
if opt_list:
self.all_commands[module].append(f'{y.command} {opt_list}')
else:
self.all_commands[module].append(f'{y.command}')
def _get_all_tested_commands_from_regex(self):
"""
get all tested commands from test_*.py
"""
# pylint: disable=too-many-nested-blocks
for idx, path in enumerate(self.selected_mod_paths):
test_dir = os.path.join(path, 'tests')
files = find_files(test_dir, '*.py')
for f in files:
with open(os.path.join(test_dir, f), 'r', encoding=ENCODING) as f:
lines = f.readlines()
ref = get_all_tested_commands_from_regex(lines)
self.all_tested_commands[self.selected_mod_names[idx]] += ref
def _get_all_tested_commands_from_record(self):
"""
get all tested commands from recording files
"""
for idx, path in enumerate(self.selected_mod_paths):
test_dir = os.path.join(path, 'tests')
files = find_files(test_dir, 'test*.yaml')
for f in files:
with open(os.path.join(test_dir, f)) as f:
# safe_load can not determine a constructor for the tag: !!python/unicode
records = yaml.load(f, Loader=yaml.Loader) or {}
for record in records['interactions']:
# ['acr agentpool create']
command = record['request']['headers'].get('CommandName', [''])[0]
# ['-n -r']
argument = record['request']['headers'].get('ParameterSetName', [''])[0]
if command or argument:
cmd = command + ' ' + argument
self.all_tested_commands[self.selected_mod_names[idx]].append(cmd)
def _get_all_tested_commands_from_live(self):
with open(os.path.join(self.cmdcov_path, 'tested_command.txt'), 'r') as f:
self.all_live_commands = f.readlines()
def _run_command_test_coverage(self):
"""
all_commands: All commands that need to be test
all_tested_commands: All commands already tested
command_test_coverage: {{module1: pct}, {module2: pct}}
module: vm
pct: xx.xxx%
"""
import ast
for module in self.all_commands.keys():
self.command_test_coverage[module] = []
self.all_untested_commands[module] = []
# pylint: disable=too-many-nested-blocks
for module in self.all_commands.keys():
count = 0
for command in self.all_commands[module]:
exist_flag = False
prefix = command.rsplit('[', maxsplit=1)[0]
opt_list = ast.literal_eval('[' + command.rsplit('[', maxsplit=1)[1]) if self.level == 'argument' \
else []
for cmd in self.all_tested_commands[module]:
if prefix in cmd or \
module == 'rdbms' and prefix.split(maxsplit=1)[1] in cmd:
if self.level == 'argument':
for opt in opt_list:
if opt in cmd:
count += 1
exist_flag = True
if exist_flag:
break
else:
count += 1
exist_flag = True
if exist_flag:
break
if exist_flag:
break
else:
self.all_untested_commands[module].append(command)
try:
self.command_test_coverage[module] = [count, len(self.all_untested_commands[module]),
f'{count / len(self.all_commands[module]):.3%}']
except ZeroDivisionError:
self.command_test_coverage[module] = [0, 0, 'N/A']
self.command_test_coverage['Total'][0] += count
self.command_test_coverage['Total'][1] += len(self.all_untested_commands[module])
self.command_test_coverage['Total'][2] = f'''{self.command_test_coverage["Total"][0] /
(self.command_test_coverage["Total"][0] +
self.command_test_coverage["Total"][1]):.3%}'''
logger.warning(self.command_test_coverage)
return self.command_test_coverage
def _run_command_test_coverage_enhance(self):
"""
all_untest_commands: {[module]:[],}
all_tested_commands_from_file: []
command_test_coverage: {[module: [test, untested, pct]
module: vm
percentage: xx.xxx%
"""
import ast
total_tested = 0
total_untested = 0
# pylint: disable=too-many-nested-blocks
for module, untested_commands in self.all_untested_commands.items():
for cmd_idx, command in enumerate(untested_commands):
exist_flag = False
prefix = command.rsplit('[', maxsplit=1)[0]
opt_list = ast.literal_eval('[' + command.rsplit('[', maxsplit=1)[1]) if self.level == 'argument' \
else []
for cmd in self.all_live_commands:
if prefix in cmd:
if self.level == 'argument':
for opt in opt_list:
if opt in cmd:
self.command_test_coverage[module][0] += 1
untested_commands.pop(cmd_idx)
exist_flag = True
if exist_flag:
break
else:
self.command_test_coverage[module][0] += 1
untested_commands.pop(cmd_idx)
exist_flag = True
if exist_flag:
break
if exist_flag:
break
try:
self.command_test_coverage[module][1] = len(untested_commands)
self.command_test_coverage[module][2] = f'''{self.command_test_coverage[module][0] /
(self.command_test_coverage[module][0] +
self.command_test_coverage[module][1]):.3%}'''
except ZeroDivisionError:
self.command_test_coverage[module] = [0, 0, 'N/A']
total_tested += self.command_test_coverage[module][0] if self.command_test_coverage[module] else 0
total_untested += self.command_test_coverage[module][1] if self.command_test_coverage[module] else 0
self.command_test_coverage['Total'][0] = total_tested
self.command_test_coverage['Total'][1] = total_untested
self.command_test_coverage['Total'][2] = f'{total_tested / (total_tested + total_untested):.3%}'
logger.warning(self.command_test_coverage)
def _render_html(self):
"""
:return: Return a HTML string
"""
html_path = self.get_html_path()
description = 'Command' if self.level == 'command' else 'Command Argument'
j2_loader = FileSystemLoader(self.template_path)
env = Environment(loader=j2_loader)
j2_tmpl = env.get_template('./index.j2')
for item in self.command_test_coverage.values():
color, percentage = self._get_color(item)
item.append({'color': color, 'percentage': percentage})
total = self.command_test_coverage.pop('Total')
content = j2_tmpl.render(description=description,
enable_cli_own=self.enable_cli_own,
date=self.date,
Total=total,
command_test_coverage=self.command_test_coverage)
index_html = os.path.join(html_path, 'index.html')
with open(index_html, 'w', encoding=ENCODING) as f:
f.write(content)
# render child html
for module, coverage in self.command_test_coverage.items():
if coverage:
self._render_child_html(module, coverage, self.all_untested_commands[module])
# copy source
css_source = os.path.join(self.cmdcov_path, 'component.css')
ico_source = os.path.join(self.cmdcov_path, 'favicon.ico')
js_source = os.path.join(self.cmdcov_path, 'component.js')
try:
shutil.copy(css_source, html_path)
shutil.copy(ico_source, html_path)
shutil.copy(js_source, html_path)
except IOError as e:
logger.error("Unable to copy file %s", e)
except Exception: # pylint: disable=broad-except
logger.error("Unexpected error: %s", sys.exc_info())
return index_html
def _render_cli_html(self, command_test_coverage):
"""
render cli own html string
"""
html_path = self.get_html_path()
description = 'Command' if self.level == 'command' else 'Command Argument'
j2_loader = FileSystemLoader(self.template_path)
env = Environment(loader=j2_loader)
j2_tmpl = env.get_template('./index2.j2')
for module, item in command_test_coverage.items():
color, percentage = self._get_color(item)
command_test_coverage[module].append({'color': color, 'percentage': percentage})
total = command_test_coverage.pop('Total')
content = j2_tmpl.render(description=description,
date=self.date,
Total=total,
command_test_coverage=command_test_coverage)
index_html = os.path.join(html_path, 'index2.html')
with open(index_html, 'w', encoding=ENCODING) as f:
f.write(content)
def _render_child_html(self, module, coverage, untested_commands):
"""
render every module html
"""
html_path = self.get_html_path()
j2_loader = FileSystemLoader(self.template_path)
env = Environment(loader=j2_loader)
j2_tmpl = env.get_template('./module.j2')
content = j2_tmpl.render(module=module,
enable_cli_own=self.enable_cli_own,
date=self.date,
coverage=coverage,
untested_commands=untested_commands)
with open(f'{html_path}/{module}.html', 'w', encoding=ENCODING) as f:
f.write(content)
@staticmethod
def _get_color(coverage):
"""
:param coverage:
:return: color and percentage
"""
percentage = int(round(float(coverage[2][:-1]), 0)) if coverage[2] != 'N/A' else coverage[2]
if percentage == 'N/A':
color = 'N/A'
elif percentage < RED_PCT:
color = RED
elif percentage < ORANGE_PCT:
color = ORANGE
elif percentage < GREEN_PCT:
color = GREEN
elif percentage < BLUE_PCT:
color = BLUE
else:
color = GOLD
return color, percentage
def get_html_path(self):
"""
:return: html_path
"""
root_path = get_azdev_repo_path()
html_path = os.path.join(root_path, 'cmd_coverage', self.level, f'{self.report_date}')
if not os.path.exists(html_path):
os.makedirs(html_path)
return html_path
@staticmethod
def get_container_name():
"""
Generate container name in storage account. It is also an identifier of the pipeline run.
:return:
"""
import datetime
import random
import string
logger.warning('Enter get_container_name()')
container_time = datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
random_id = ''.join(random.choice(string.digits) for _ in range(6))
name = container_time + '-' + random_id
logger.warning('Exit get_container_name()')
return name
@staticmethod
def upload_files(container, html_path, account_key):
"""
Upload html and json files to container
"""
logger.warning('Enter upload_files()')
# Create container
cmd = 'az storage container create -n {} --account-name clitestresultstac --account-key {}' \
' --public-access container'.format(container, account_key)
os.system(cmd)
# Upload files
for root, dirs, files in os.walk(html_path):
logger.debug(dirs)
for name in files:
if name.endswith('html') or name.endswith('css'):
fullpath = os.path.join(root, name)
cmd = 'az storage blob upload -f {} -c {} -n {} --account-name clitestresultstac'
cmd = cmd.format(fullpath, container, name)
logger.warning('Running: %s', cmd)
os.system(cmd)
logger.warning('Exit upload_files()')
@staticmethod
def _browse(uri, browser_name=None): # throws ImportError, webbrowser.Error
"""Browse uri with named browser. Default browser is customizable by $BROWSER"""
def is_wsl():
# "Official" way of detecting WSL: https://github.com/Microsoft/WSL/issues/423#issuecomment-221627364
# Run `uname -a` to get 'release' without python
# - WSL 1: '4.4.0-19041-Microsoft'
# - WSL 2: '4.19.128-microsoft-standard'
import platform
uname = platform.uname()
platform_name = getattr(uname, 'system', uname[0]).lower()
release = getattr(uname, 'release', uname[2]).lower()
return platform_name == 'linux' and 'microsoft' in release
import webbrowser # Lazy import. Some distro may not have this.
if browser_name:
browser_opened = webbrowser.get(browser_name).open(uri)
else:
# This one can survive BROWSER=nonexist, while get(None).open(...) can not
browser_opened = webbrowser.open(uri)
logger.warning(uri)
# In WSL which doesn't have www-browser, try launching browser with PowerShell
if not browser_opened and is_wsl():
try:
import subprocess
# https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe
# Ampersand (&) should be quoted
exit_code = subprocess.call(
['powershell.exe', '-NoProfile', '-Command', 'Start-Process "{}"'.format(uri)])
browser_opened = exit_code == 0
except FileNotFoundError: # WSL might be too old
pass
return browser_opened

Просмотреть файл

@ -0,0 +1,235 @@
*, *:after, *:before { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box; }
body {
font-family: sans-serif;
}
/* Container styles */
.container .button a {
color: #31bc86;
text-decoration: none;
}
.container .button a:hover, a:focus {
color: #7c8d87;
}
.container > header {
margin: 0 auto;
text-align: center;
background: rgba(0,0,0,0.01);
}
.container > header h1 {
font-size: 2.625em;
line-height: 1.3;
margin: 0;
font-weight: 500;
}
.container > header span {
display: block;
font-size: 45%;
opacity: 0.6;
padding-top: 0.5em
}
/* Component styles */
.component {
line-height: 1.5em;
margin: 0 auto;
padding: 0;
width: 100%;
max-width: 1200px;
overflow: hidden;
}
.component h3 {
font-size: 1.11em;
margin-block-start: 0em;
margin-block-end: 0em;
}
table {
border: 1px solid black;
border-collapse: collapse;
margin-bottom: 3em;
width: 100%;
background: #fff;
}
td, th {
border: 1px solid black;
padding: 0.75em 1.5em;
text-align: left;
}
td.err {
background-color: #e992b9;
color: #fff;
font-size: 0.75em;
text-align: center;
line-height: 1;
}
th {
background-color: #31bc86;
font-weight: bold;
color: #fff;
white-space: nowrap;
}
tbody th {
background-color: #2ea879;
}
tbody tr:hover {
background-color: rgba(129,208,177,.3);
}
/* circle styles */
.detail {
display: none;
}
.column-percentage:hover .detail {
display: block;
text-align: center;
}
.single-chart {
width: 100%;
justify-content: space-around ;
}
.circular-chart {
display: block;
margin: 0px auto;
max-width: 100%;
max-height: 50px;
}
.circle-bg {
fill: none;
stroke: #eee;
stroke-width: 3.8;
}
.circle {
fill: none;
stroke-width: 2.8;
stroke-linecap: round;
animation: progress 1s ease-out forwards;
}
@keyframes progress {
0% {
stroke-dasharray: 0 100;
}
}
.circular-chart.red .circle {
stroke: #e71837;
}
.circular-chart.orange .circle {
stroke: #ff9f00;
}
.circular-chart.green .circle {
stroke: #4CC790;
}
.circular-chart.blue .circle {
stroke: #3c9ee5;
}
.circular-chart.gold .circle {
stroke: #ffd700;
fill: #ffd700;
}
.percentage {
fill: #666;
font-size: 0.6em;
text-anchor: middle;
}
/* medal styles */
.medal {
width: 100px;
height: 60px;
}
.ribbon {
width: 40px;
height: 35px;
margin: 0 auto;
position: relative;
}
.ribbon:before,
.ribbon:after {
content: '';
position: absolute;
width: 17.5px;
height: 100%;
top: 0;
}
.ribbon:before {
right: 0;
background: #30110E;
transform: skew(-28deg);
}
.ribbon:after {
background: #EF7E76;
transform: skew(28deg);
}
.coin {
border: 1px solid #CA5D3E;
border-radius: 50%;
background: #F0CD73;
width: 35px;
height: 35px;
position: relative;
margin: -7.5px auto 0 auto;
box-shadow: 0px 0px 3px 0px #989898;
}
.coin:after {
content: '';
position: absolute;
transform: translate(-50%, -50%);
top: 50%;
left: 50%;
width: 17.5px;
height: 17.5px;
border-radius: inherit;
box-shadow: 0 0 0 9px #D9B867;
}
/* Buttons Style */
.button {
padding-top: 1em;
font-size: 0.8em;
}
.button a {
display: inline-block;
margin: 0.5em;
padding: 0.7em 1.1em;
outline: none;
border: 2px solid #31bc86;
text-decoration: none;
text-transform: uppercase;
letter-spacing: 1px;
font-weight: 700;
color: #DB7093;
}
.button a:hover,
.button a.current-page,
.button a.current-page:hover {
border-color: #DB7093;
color: #DB7093;
}

Просмотреть файл

@ -0,0 +1,159 @@
var tag=1;
function sortNumberAS(a, b)
{
return a - b
}
function sortNumberDesc(a, b)
{
return b - a
}
function sortStrAS(a, b)
{
x = a.toUpperCase();
y = b.toUpperCase();
if (x < y) {
return -1;
}
if (x > y) {
return 1;
}
}
function sortStrDesc(a, b)
{
x = a.toUpperCase();
y = b.toUpperCase();
if (x < y) {
return 1;
}
if (x > y) {
return -1;
}
}
function sortTextAS(a, b)
{
if (a==='Not applicable'){
a = -1
} else if (a===''){
a = 100
} else {
a = parseFloat(a.substr(0,a.length-1))
}
if (b==='N/A'){
b = -1
} else if (b===''){
b = 100
} else {
b = parseFloat(b.substr(0,b.length-1))
}
if (a < b) {
return -1;
}
if (a > b) {
return 1;
}
}
function sortTextDesc(a, b)
{
if (a==='N/A'){
a = -1
} else if (a===''){
a = 100
} else {
a = parseFloat(a.substr(0,a.length-1))
}
if (b==='N/A'){
b = -1
} else if (b===''){
b = 100
} else {
b = parseFloat(b.substr(0,b.length-1))
}
if (a < b) {
return 1;
}
if (a > b) {
return -1;
}
}
function SortTable(obj){
var column=obj.id
var tdModule=document.getElementsByName("td-module");
var tdTested=document.getElementsByName("td-tested");
var tdUntested=document.getElementsByName("td-untested");
var tdPercentage=document.getElementsByName("td-percentage");
var tdDetail=document.getElementsByClassName("detail")
var tdReport=document.getElementsByName("td-report");
var tdModuleArray=[];
var tdTestedArray=[];
var tdUntestedArray=[];
var tdPercentageArray=[];
var tdReportArray=[];
for(var i=0;i<tdModule.length;i++){
tdModuleArray.push(tdModule[i].innerHTML);
}
for(var i=0;i<tdTested.length;i++){
tdTestedArray.push(tdTested[i].innerHTML);
}
for(var i=0;i<tdUntested.length;i++){
tdUntestedArray.push(tdUntested[i].innerHTML);
}
for(var i=0;i<tdPercentage.length;i++){
tdPercentageArray.push(tdPercentage[i].innerHTML);
}
for(var i=0;i<tdReport.length;i++){
tdReportArray.push(tdReport[i].innerHTML);
}
var columnArray=[];
for(var i=0;i<tdModule.length;i++){
if(column==='th-module'){
columnArray.push(tdModule[i].innerHTML);
}else if(column==='th-tested'){
columnArray.push(parseInt(tdTested[i].innerHTML));
}else if(column==='th-untested'){
columnArray.push(parseInt(tdUntested[i].innerHTML));
}else if(column==='th-percentage'){
columnArray.push(tdDetail[i].innerHTML)
}else if(column==='th-report'){
columnArray.push(tdReport[i].innerHTML);
}
}
var orginArray=[];
for(var i=0;i<columnArray.length;i++){
orginArray.push(columnArray[i]);
}
var newArray = columnArray.slice(1,)
if(obj.className=="as"){
if(column==='th-tested' || column==='th-untested'){
newArray.sort(sortNumberAS);
}else if(column==='th-module' || column==='th-report'){
newArray.sort(sortStrAS);
}else{
newArray.sort(sortTextAS);
}
obj.className="desc";
}else{
if(column==='th-tested' || column==='th-untested'){
newArray.sort(sortNumberDesc);
}else if(column==='th-module' || column==='th-report'){
newArray.sort(sortStrDesc);
}else{
newArray.sort(sortTextDesc);
}
obj.className="as";
}
columnArray = $.merge([columnArray[0]], newArray);
for(var i=1;i<columnArray.length;i++){
for(var j=1;j<orginArray.length;j++){
if(orginArray[j]==columnArray[i]){
document.getElementsByName("td-module")[i].innerHTML=tdModuleArray[j];
document.getElementsByName("td-tested")[i].innerHTML=tdTestedArray[j];
document.getElementsByName("td-untested")[i].innerHTML=tdUntestedArray[j];
document.getElementsByName("td-percentage")[i].innerHTML=tdPercentageArray[j];
document.getElementsByName("td-report")[i].innerHTML=tdReportArray[j];
orginArray[j]=null;
break;
}
}
}
}

Двоичные данные
azdev/operations/cmdcov/favicon.ico Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 2.2 KiB

Просмотреть файл

@ -0,0 +1,35 @@
{% macro render_percentage(detail, module) %}
{% if module['color'] == 'N/A' -%}
<td name="td-percentage" class="column-percentage">
<div style="text-align: center">N/A</div>
<div class="detail">Not applicable</div>
</td>
{% elif module['color'] != 'gold' -%}
<td name="td-percentage" class="column-percentage">
<div class="single-chart">
<svg viewBox="0 0 36 36" class="circular-chart {{ module['color'] }}">
<path class="circle-bg"
d="M18 2.0845
a 15.9155 15.9155 0 0 1 0 31.831
a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<path class="circle"
stroke-dasharray="{{ module['percentage'] }}, 100"
d="M18 2.0845
a 15.9155 15.9155 0 0 1 0 31.831
a 15.9155 15.9155 0 0 1 0 -31.831"
/>
<text x="18" y="20.35" class="percentage">{{ module['percentage'] }}%</text>
</svg>
<div class="detail">{{ detail }}</div>
</div>
</td>
{% else -%}
<td name="td-percentage" class="medal column-percentage">
<div class="ribbon"></div>
<div class="coin"></div>
<div class="detail">100.000%</div>
</td>
{% endif -%}
{% endmacro %}

Просмотреть файл

@ -0,0 +1,66 @@
{% from "_macros.j2" import render_percentage %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>CLI {{ description }} Test Coverage</title>
<link rel="stylesheet" type="text/css" href="component.css"/>
<link rel="shortcut icon" href="favicon.ico">
<script type="text/javascript" src="http://code.jquery.com/jquery-1.12.4.min.js"></script>
<script type="text/javascript" src="./component.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>CLI {{ description }} Test Coverage Report
<span>Please scroll down to see the every module test coverage.<br>
Any question please contact Azure Cli Team.</span>
</h1>
{% if enable_cli_own == true -%}
<nav class="button">
<a class="current-page" href="index.html">ALL</a>
<a href="index2.html">CLI OWN</a>'
</nav>
{% else -%}
<nav class="button">
<a class="current-page" href="index.html">ALL</a>
</nav>
{% endif -%}
</header>
<div class="component">
<h3>Date: {{ date }}</h3>
<table>
<thead>
<tr>
<th id="th-module" onclick="SortTable(this)" class="as">Module</th>
<th id="th-tested" onclick="SortTable(this)" class="as">Tested</th>
<th id="th-untested" onclick="SortTable(this)" class="as">Untested</th>
<th id="th-percentage" onclick="SortTable(this)" class="as">Percentage</th>
<th id="th-report" onclick="SortTable(this)" class="as">Reports</th>
</tr>
</thead>
<tbody>
<tr>
<td name="td-module">Total</td>
<td name="td-tested">{{ Total[0] }}</td>
<td name="td-untested">{{ Total[1] }}</td>
{{ render_percentage(Total[2], Total[3]) }}
<td name="td-report">N/A</td>
</tr>
{% for module, cov in command_test_coverage.items() %}
<tr>
<td name="td-module">{{ module }}</td>
<td name="td-tested">{{ cov[0] }}</td>
<td name="td-untested">{{ cov[1] }}</td>
{{ render_percentage(cov[2], cov[3]) }}
<td name="td-report"><a href="{{ module }}.html">{{ module }} test coverage report</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="contact">This is the {{ description.lower() }} test coverage report of CLI.<br>
Any question please contact Azure Cli Team.</p>
</div>
</div><!-- /container -->
</body>
</html>

Просмотреть файл

@ -0,0 +1,60 @@
{% from "_macros.j2" import render_percentage %}
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>CLI Own {{ description }} Test Coverage</title>
<link rel="stylesheet" type="text/css" href="component.css"/>
<link rel="shortcut icon" href="favicon.ico">
<script type="text/javascript" src="http://code.jquery.com/jquery-1.12.4.min.js"></script>
<script type="text/javascript" src="./component.js"></script>
</head>
<body>
<div class="container">
<header>
<h1>CLI Own {{ description }} Test Coverage Report
<span>Please scroll down to see the every module test coverage.<br>
Any question please contact Azure Cli Team.</span>
</h1>
<nav class="button">
<a href="index.html">ALL</a>
<a class="current-page" href="index2.html">CLI OWN</a>'
</nav>
</header>
<div class="component">
<h3>Date: {{ date }}</h3>
<table>
<thead>
<tr>
<th id="th-module" onclick="SortTable(this)" class="as">Module</th>
<th id="th-tested" onclick="SortTable(this)" class="as">Tested</th>
<th id="th-untested" onclick="SortTable(this)" class="as">Untested</th>
<th id="th-percentage" onclick="SortTable(this)" class="as">Percentage</th>
<th id="th-report" onclick="SortTable(this)" class="as">Reports</th>
</tr>
</thead>
<tbody>
<tr>
<td name="td-module">Total</td>
<td name="td-tested">{{ Total[0] }}</td>
<td name="td-untested">{{ Total[1] }}</td>
{{ render_percentage(Total[2], Total[3]) }}
<td name="td-report">N/A</td>
</tr>
{% for module, cov in command_test_coverage.items() %}
<tr>
<td name="td-module">{{ module }}</td>
<td name="td-tested">{{ cov[0] }}</td>
<td name="td-untested">{{ cov[1] }}</td>
{{ render_percentage(cov[2], cov[3]) }}
<td name="td-report"><a href="{{ module }}.html">{{ module }} test coverage report</a></td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="contact">This is the {{ description.lower() }} test coverage report of CLI Own.<br>
Any question please contact Azure Cli Team.</p>
</div>
</div><!-- /container -->
</body>
</html>

Просмотреть файл

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ module }} Test Coverage Detail</title>
<link rel="stylesheet" type="text/css" href="component.css"/>
<link rel="shortcut icon" href="favicon.ico">
</head>
<body>
<div class="container">
<header>
<h1>{{ module }} Test Coverage Report
<span>This is the test coverage report of {{ module }}. Please scroll down to see the untested details.<br>
Any question please contact Azure Cli Team.</span>
</h1>
{% if enable_cli_own == true -%}
<nav class="button">
<a class="current-page" href="index.html">ALL</a>
<a href="index2.html">CLI OWN</a>
</nav>
{% else -%}
<nav class="button">
<a href="index.html">ALL</a>
</nav>
{% endif -%}
</header>
<div class="component">
<h3>Date: {{ date }}</h3>
<h3>Tested: {{ coverage[0] }}, Untested: {{ coverage[1] }}, Percentage: {{ coverage[2] }}</h3>
<table>
<thead>
<tr>
<th>Module</th>
<th>Untested</th>
</tr>
</thead>
<tbody>
{% for cmd in untested_commands %}
<tr>
<td>{{ module }}</td>
<td>{{ cmd }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<p class="contact">This is the test coverage report of {{ module }} module.<br>
Any question please contact Azure Cli Team.<br>
</p>
</div>
</div><!-- /container -->
</body>
</html>

Разница между файлами не показана из-за своего большого размера Загрузить разницу

Просмотреть файл

@ -152,7 +152,10 @@ def run_linter(modules=None, rule_types=None, rules=None, ci_exclusions=None,
rule_inclusions=rules,
use_ci_exclusions=ci_exclusions,
min_severity=min_severity,
update_global_exclusion=update_global_exclusion)
update_global_exclusion=update_global_exclusion,
git_source=git_source,
git_target=git_target,
git_repo=git_repo)
subheading('Results')
logger.info('Running linter: %i commands, %i help entries',
@ -161,9 +164,12 @@ def run_linter(modules=None, rule_types=None, rules=None, ci_exclusions=None,
run_params=not rule_types or 'params' in rule_types,
run_commands=not rule_types or 'commands' in rule_types,
run_command_groups=not rule_types or 'command_groups' in rule_types,
run_help_files_entries=not rule_types or 'help_entries' in rule_types)
run_help_files_entries=not rule_types or 'help_entries' in rule_types,
run_command_test_coverage=not rule_types or 'command_test_coverage' in rule_types,
)
display(os.linesep + 'Run custom pylint rules.')
exit_code += pylint_rules(selected_modules)
print(exit_code)
sys.exit(exit_code)

Просмотреть файл

@ -4,18 +4,26 @@
# license information.
# -----------------------------------------------------------------------------
import os
import inspect
from importlib import import_module
from pkgutil import iter_modules
from difflib import context_diff
from enum import Enum
from importlib import import_module
import inspect
import os
import re
from pkgutil import iter_modules
import yaml
from knack.log import get_logger
from azdev.operations.regex import (
get_all_tested_commands_from_regex,
search_argument,
search_argument_context,
search_command,
search_command_group)
from azdev.utilities import diff_branches_detail
from azdev.utilities.path import get_cli_repo_path, get_ext_repo_paths
from .util import share_element, exclude_commands, LinterError
PACKAGE_NAME = 'azdev.operations.linter'
_logger = get_logger(__name__)
@ -37,8 +45,9 @@ class LinterSeverity(Enum):
return sorted(LinterSeverity, key=lambda sev: sev.value)
class Linter: # pylint: disable=too-many-public-methods
def __init__(self, command_loader=None, help_file_entries=None, loaded_help=None):
class Linter: # pylint: disable=too-many-public-methods, too-many-instance-attributes
def __init__(self, command_loader=None, help_file_entries=None, loaded_help=None, git_source=None, git_target=None,
git_repo=None, exclusions=None):
self._all_yaml_help = help_file_entries
self._loaded_help = loaded_help
self._command_loader = command_loader
@ -50,6 +59,10 @@ class Linter: # pylint: disable=too-many-public-methods
self._parameters[command_name] = set()
for name in command.arguments:
self._parameters[command_name].add(name)
self.git_source = git_source
self.git_target = git_target
self.git_repo = git_repo
self.exclusions = exclusions
@property
def commands(self):
@ -180,19 +193,163 @@ class Linter: # pylint: disable=too-many-public-methods
return help_entry.short_summary or help_entry.long_summary
return help_entry
def get_command_test_coverage(self):
diff_index = diff_branches_detail(repo=self.git_repo, target=self.git_target, source=self.git_source)
commands, parameters = self._detect_new_command(diff_index)
all_tested_command = self._detect_tested_command(diff_index)
return self._run_command_test_coverage(commands, parameters, all_tested_command)
# pylint: disable=too-many-locals, too-many-nested-blocks, too-many-branches, too-many-statements
def _detect_new_command(self, diff_index):
"""
exclude_comands: List[str]
exclude_parameters: List[tuple[str, str]]
commands: List[str]
parameters: List[str, List[str]]
"""
YELLOW = '\x1b[33m'
parameters = []
commands = []
lines = []
exclude_comands = []
exclude_parameters = []
for c, v in self.exclusions.items():
if 'parameters' in v:
for p, r in v['parameters'].items():
if 'missing_parameter_test_coverage' in r['rule_exclusions']:
exclude_parameters.append((c, p))
elif 'rule_exclusions' in v:
if 'missing_command_test_coverage' in v['rule_exclusions']:
exclude_comands.append(c)
_logger.debug('exclude_parameters: %s', exclude_parameters)
_logger.debug('exclude_comands: %s', exclude_comands)
for diff in diff_index:
filename = diff.a_path.split('/')[-1]
if 'params' in filename or 'commands' in filename:
lines = list(
context_diff(diff.a_blob.data_stream.read().decode("utf-8").splitlines(True) if diff.a_blob else [],
diff.b_blob.data_stream.read().decode("utf-8").splitlines(True) if diff.b_blob else [],
'Original', 'Current'))
for row_num, line in enumerate(lines):
if 'params.py' in filename:
params, param_name = search_argument(line)
if params:
offset = -1
while row_num > 0:
row_num -= 1
# Match row num '--- 156,163 ----'
sub_pattern = r'--- (\d{0,}),(?:\d{0,}) ----'
idx = re.findall(sub_pattern, lines[row_num])
offset += 1
if idx:
idx = int(idx[0]) + offset
break
with open(os.path.join(get_cli_repo_path(), diff.a_path), encoding='utf-8') as f:
param_lines = f.readlines()
cmds = search_argument_context(idx, param_lines)
for cmd in cmds:
if cmd not in exclude_comands and \
not list(filter(lambda x, c=cmd, p=param_name: c in x[0] and p in x[1], exclude_parameters)): # pylint: disable=line-too-long
parameters.append([cmd, params])
else:
print('%sCommand [%s, %s] not test and exclude in linter_exclusions.yml' % (
YELLOW, cmd, params))
if 'commands.py' in filename:
command = search_command(line)
if command:
offset = -1
while row_num > 0:
row_num -= 1
# Match row num '--- 156,163 ----'
sub_pattern = r'--- (\d{0,}),(?:\d{0,}) ----'
idx = re.findall(sub_pattern, lines[row_num])
offset += 1
if idx:
idx = int(idx[0]) + offset
break
with open(os.path.join(get_cli_repo_path(), diff.a_path), encoding='utf-8') as f:
cmd_lines = f.readlines()
cmd = search_command_group(idx, cmd_lines, command)
if cmd:
if cmd in exclude_comands:
print('%sCommand %s not test and exclude in linter_exclusions.yml' % (YELLOW, cmd))
else:
commands.append(cmd)
_logger.debug('New add parameters: %s', parameters)
_logger.debug('New add commands: %s', commands)
return commands, parameters
def _detect_tested_command(self, diff_index):
all_tested_command = []
# get tested command by regex
for diff in diff_index:
filename = diff.a_path.split('/')[-1]
if re.findall(r'test_.*.py', filename) and os.path.exists(os.path.join(get_cli_repo_path(), diff.a_path)):
with open(os.path.join(self.git_repo, diff.a_path), encoding='utf-8') as f:
lines = f.readlines()
ref = get_all_tested_commands_from_regex(lines)
all_tested_command += ref
# get tested command by recording file
if re.findall(r'test_.*.yaml', filename) and os.path.exists(os.path.join(get_cli_repo_path(), diff.a_path)):
with open(os.path.join(self.git_repo, diff.a_path)) as f:
records = yaml.load(f, Loader=yaml.Loader) or {}
for record in records['interactions']:
# parse command ['acr agentpool create']
command = record['request']['headers'].get('CommandName', [''])[0]
# parse argument ['-n -r']
argument = record['request']['headers'].get('ParameterSetName', [''])[0]
if command or argument:
all_tested_command.append(command + ' ' + argument)
_logger.debug('All tested command: %s', all_tested_command)
return all_tested_command
@staticmethod
def _run_command_test_coverage(commands, parameters, all_tested_command):
flag = False
exec_state = True
for command, opt_list in parameters:
for opt in opt_list:
for code in all_tested_command:
if command in code and opt in code:
_logger.debug("Find '%s' test case in '%s'", command + ' ' + opt, code)
flag = True
break
else:
_logger.error("Can not find '%s' test case", command + ' ' + opt)
_logger.error("Please add some scenario tests for the new parameter")
_logger.error(
"Or add the parameter with missing_parameter_test_coverage rule in linter_exclusions.yml")
exec_state = False
if flag:
break
for command in commands:
for code in all_tested_command:
if command in code:
_logger.debug("Find '%s' test case in '%s'", command, code)
break
else:
_logger.error("Can not find '%s' test case", command)
_logger.error("Please add some scenario tests for the new command")
_logger.error("Or add the command with missing_command_test_coverage rule in linter_exclusions.yml")
exec_state = False
return exec_state
# pylint: disable=too-many-instance-attributes
class LinterManager:
_RULE_TYPES = {'help_file_entries', 'command_groups', 'commands', 'params'}
_RULE_TYPES = {'help_file_entries', 'command_groups', 'commands', 'params', 'command_test_coverage'}
def __init__(self, command_loader=None, help_file_entries=None, loaded_help=None, exclusions=None,
rule_inclusions=None, use_ci_exclusions=None, min_severity=None, update_global_exclusion=None):
rule_inclusions=None, use_ci_exclusions=None, min_severity=None, update_global_exclusion=None,
git_source=None, git_target=None, git_repo=None):
# default to running only rules of the highest severity
self.min_severity = min_severity or LinterSeverity.get_ordered_members()[-1]
self.linter = Linter(command_loader=command_loader, help_file_entries=help_file_entries,
loaded_help=loaded_help)
self._exclusions = exclusions or {}
self.linter = Linter(command_loader=command_loader, help_file_entries=help_file_entries,
loaded_help=loaded_help, git_source=git_source, git_target=git_target, git_repo=git_repo,
exclusions=self._exclusions)
self._rules = {rule_type: {} for rule_type in LinterManager._RULE_TYPES} # initialize empty rules
self._ci_exclusions = {}
self._rule_inclusions = rule_inclusions
@ -234,7 +391,8 @@ class LinterManager:
def exit_code(self):
return self._exit_code
def run(self, run_params=None, run_commands=None, run_command_groups=None, run_help_files_entries=None):
def run(self, run_params=None, run_commands=None, run_command_groups=None,
run_help_files_entries=None, run_command_test_coverage=None):
paths = import_module('{}.rules'.format(PACKAGE_NAME)).__path__
if paths:
@ -256,9 +414,11 @@ class LinterManager:
# run all rule-checks
if run_help_files_entries and self._rules.get('help_file_entries'):
# print('help_file_entries')
self._run_rules('help_file_entries')
if run_command_groups and self._rules.get('command_groups'):
# print('command_groups')
self._run_rules('command_groups')
if run_commands and self._rules.get('commands'):
@ -267,6 +427,9 @@ class LinterManager:
if run_params and self._rules.get('params'):
self._run_rules('params')
if run_command_test_coverage and self._rules.get('command_test_coverage'):
self._run_rules('command_test_coverage')
if not self.exit_code:
print(os.linesep + 'No violations found for linter rules.')
@ -297,13 +460,18 @@ class LinterManager:
YELLOW = '\x1b[33m'
CYAN = '\x1b[36m'
RESET = '\x1b[39m'
# print('enter _run_rules')
for rule_name, (rule_func, linter_callable, rule_severity) in self._rules.get(rule_group).items():
# print('enter_items')
severity_str = rule_severity.name
# use new linter if needed
with LinterScope(self, linter_callable):
# print('enter_with')
# if the rule's severity is lower than the linter's severity skip it.
if self._linter_severity_is_applicable(rule_severity, rule_name):
# print('enter violations', rule_func)
violations = sorted(rule_func()) or []
# print('enter to find')
if violations:
if rule_severity == LinterSeverity.HIGH:
sev_color = RED
@ -314,13 +482,14 @@ class LinterManager:
# pylint: disable=duplicate-string-formatting-argument
print('- {} FAIL{} - {}{}{} severity: {}'.format(RED, RESET, sev_color,
severity_str, RESET, rule_name,))
severity_str, RESET, rule_name, ))
for violation_msg, entity_name, name in violations:
print(violation_msg)
self._save_violations(entity_name, name)
print()
else:
print('- {} pass{}: {} '.format(GREEN, RESET, rule_name))
# print('enter_end')
def _linter_severity_is_applicable(self, rule_severity, rule_name):
if self.min_severity.value > rule_severity.value:
@ -336,7 +505,9 @@ class LinterManager:
self._violiations.setdefault(command_name, {}).setdefault('rule_exclusions', []).append(rule_name)
else:
command_name, param_name = entity_name
self._violiations.setdefault(command_name, {}).setdefault('parameters', {}).setdefault(param_name, {}).setdefault('rule_exclusions', []).append(rule_name)
self._violiations.setdefault(command_name, {}).setdefault('parameters', {}).setdefault(param_name,
{}).setdefault(
'rule_exclusions', []).append(rule_name)
class RuleError(Exception):
@ -351,6 +522,7 @@ class LinterScope:
Linter Context manager. used when calling a rule function. Allows substitution of main linter for a linter
that takes into account any applicable exclusions, if applicable.
"""
def __init__(self, linter_manager, linter_callable):
self.linter_manager = linter_manager
self.linter = linter_callable()

Просмотреть файл

@ -17,6 +17,28 @@ class BaseRule:
self.severity = severity
# command_test_rule run once
class CommandCoverageRule(BaseRule):
def __call__(self, func):
def add_to_linter(linter_manager):
def wrapper():
linter = linter_manager.linter
try:
func(linter)
except RuleError as ex:
linter_manager.mark_rule_failure(self.severity)
yield (_create_violation_msg(ex, 'repo: {}, src: {}, tgt: {}',
linter.git_repo, linter.git_source, linter.git_target),
(linter.git_source, linter.git_target),
func.__name__)
linter_manager.add_rule('command_test_coverage', func.__name__, wrapper, self.severity)
add_to_linter.linter_rule = True
return add_to_linter
# help_file_entry_rule
class HelpFileEntryRule(BaseRule):
@ -68,7 +90,7 @@ def _get_decorator(func, rule_group, print_format, severity):
def add_to_linter(linter_manager):
def wrapper():
linter = linter_manager.linter
# print('enter add to linter', len(getattr(linter, rule_group)))
for iter_entity in getattr(linter, rule_group):
exclusions = linter_manager.exclusions.get(iter_entity, {}).get('rule_exclusions', [])
if func.__name__ not in exclusions:

Просмотреть файл

@ -0,0 +1,14 @@
# -----------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -----------------------------------------------------------------------------
from ..rule_decorators import CommandCoverageRule
from ..linter import RuleError, LinterSeverity
@CommandCoverageRule(LinterSeverity.HIGH)
def missing_command_test_coverage(linter):
if not linter.get_command_test_coverage():
raise RuleError('Missing Command Test Coverage')

Просмотреть файл

@ -70,6 +70,7 @@ def faulty_help_example_rule(linter, help_entry):
@HelpFileEntryRule(LinterSeverity.HIGH)
def faulty_help_example_parameters_rule(linter, help_entry):
# print(linter, help_entry)
parser = linter.command_parser
violations = []

158
azdev/operations/regex.py Normal file
Просмотреть файл

@ -0,0 +1,158 @@
# -----------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -----------------------------------------------------------------------------
import json
import os
import re
import yaml
from knack.log import get_logger
from knack.util import CLIError
from azdev.utilities.path import get_cli_repo_path
logger = get_logger(__name__)
try:
with open(os.path.join(get_cli_repo_path(), 'scripts', 'ci', 'cmdcov.yml'), 'r') as file:
config = yaml.safe_load(file)
CMD_PATTERN = config['CMD_PATTERN']
QUO_PATTERN = config['QUO_PATTERN']
END_PATTERN = config['END_PATTERN']
DOCS_END_PATTERN = config['DOCS_END_PATTERN']
NOT_END_PATTERN = config['NOT_END_PATTERN']
NUMBER_SIGN_PATTERN = config['NUMBER_SIGN_PATTERN']
except CLIError as ex:
logger.warning('Failed to load cmdcov.yml: %s', ex)
def get_all_tested_commands_from_regex(lines):
"""
get all tested commands from test_*.py
"""
# pylint: disable=too-many-nested-blocks
ref = []
total_lines = len(lines)
row_num = 0
count = 1
while row_num < total_lines:
re_idx = None
if re.findall(NUMBER_SIGN_PATTERN, lines[row_num]):
row_num += 1
continue
if re.findall(CMD_PATTERN[0], lines[row_num]):
re_idx = 0
if re_idx is None and re.findall(CMD_PATTERN[1], lines[row_num]):
re_idx = 1
if re_idx is None and re.findall(CMD_PATTERN[2], lines[row_num]):
re_idx = 2
if re_idx is None and re.findall(CMD_PATTERN[3], lines[row_num]):
re_idx = 3
if re_idx is not None:
command = re.findall(CMD_PATTERN[re_idx], lines[row_num])[0]
while row_num < total_lines:
if (re_idx in [0, 1] and not re.findall(END_PATTERN, lines[row_num])) or \
(re_idx == 2 and (row_num + 1) < total_lines and
re.findall(NOT_END_PATTERN, lines[row_num + 1])):
row_num += 1
cmd = re.findall(QUO_PATTERN, lines[row_num])
if cmd:
command += cmd[0][1]
elif re_idx == 3 and (row_num + 1) < total_lines \
and not re.findall(DOCS_END_PATTERN, lines[row_num]):
row_num += 1
command += lines[row_num][:-1]
else:
command = command + ' ' + str(count)
ref.append(command)
row_num += 1
count += 1
break
else:
command = command + ' ' + str(count)
ref.append(command)
row_num += 1
count += 1
break
else:
row_num += 1
return ref
def search_argument_context(row_num, lines):
cmds = []
while row_num > 0:
row_num -= 1
# Match `with self.argument_context('') as c:`
sub_pattern0 = r'with self.argument_context\(\'(.*?)\'[\),]'
# Match `with self.argument_context(scope) as c:`
sub_pattern1 = r'with self.argument_context\(scope[\),]'
# Match `with self.argument_context(\'{} stop\'.format(scope)) as c:',
sub_pattern2 = r'with self.argument_context\(\'(.*)\'.format\(scope\)\)'
ref0 = re.findall(sub_pattern0, lines[row_num])
ref1 = re.findall(sub_pattern1, lines[row_num])
ref2 = re.findall(sub_pattern2, lines[row_num])
# Match `with self.argument_context('') as c:`
if ref0:
cmds = ref0
break
# Match `with self.argument_context(scope) as c:`
if ref1:
sub_pattern = r'for scope in (.*):'
cmds = json.loads(
re.findall(sub_pattern, lines[row_num - 1])[0].replace('\'', '"'))
break
# Match `with self.argument_context(\'{} stop\'.format(scope)) as c:',
if ref2:
sub_pattern = r'for scope in (.*):'
format_strings = json.loads(
re.findall(sub_pattern, lines[row_num - 1])[0].replace('\'', '"'))
for c in ref2:
for f in format_strings:
cmds.append(c.replace('{}', f))
break
return cmds
def search_argument(line):
params = []
param_name = ''
# Match ` + c.argument('xxx')?`
pattern = r'\+\s+c.argument\((.*)\)?'
ref = re.findall(pattern, line)
if ref:
# strip ' and \' and )
param_name = ref[0].split(',')[0].strip(r"'\'\)")
if 'options_list' in ref[0]:
# Match ` options_list=xxx, or options_list=xxx)`
sub_pattern = r'options_list=\[(.*?)\]'
params = re.findall(sub_pattern, ref[0])[0].replace('\'', '').replace('"', '').split()
else:
# if options_list not exist, generate by parameter name
params = ['--' + param_name.replace('_', '-')]
return params, param_name
def search_command_group(row_num, lines, command):
cmd = ''
while row_num > 0:
row_num -= 1
# Match `with self.command_group('local-context',`
sub_pattern = r'with self.command_group\(\'(.*?)\','
group = re.findall(sub_pattern, lines[row_num])
if group:
cmd = group[0] + ' ' + command
break
return cmd
def search_command(line):
command = ''
# Match `+ g.*command(xxx)`
pattern = r'\+\s+g.(?:\w+)?command\((.*)\)'
ref = re.findall(pattern, line)
if ref:
command = ref[0].split(',')[0].strip("'")
return command

Просмотреть файл

@ -0,0 +1,277 @@
# -----------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License. See License.txt in the project root for
# license information.
# -----------------------------------------------------------------------------
from pprint import pprint
from azdev.operations.regex import (
get_all_tested_commands_from_regex,
search_argument,
search_argument_context,
search_command,
search_command_group)
# pylint: disable=line-too-long
# one line test
def test_one_line_regex():
lines = [
# start with self.cmd.
'self.cmd(\'image builder create -n {tmpl_02} -g {rg} --identity {ide} --scripts {script} --image-source {img_src} --build-timeout 22\')\n',
# start with blanks, match self.cmd("").
' self.cmd("role assignment create --assignee {assignee} --role {role} --scope {scope}")\n',
# start with blanks, match self.cmd('').
' self.cmd(\'role assignment create --assignee {assignee} --role {role} --scope {scope}\')\n',
# start with multiple blanks, characters, self.cmd
' identity_id = self.cmd(\'identity create -g {rg} -n {ide}\').get_output_in_json()[\'clientId\']\n',
# start with blanks, match self.cmd, use fstring, ''
' self.cmd(f\'afd profile usage -g {resource_group} --profile-name {profile_name}\', checks=usage_checks)',
# start with blanks, match self.cmd, use fstring, ""
' self.cmd(f"afd profile usage -g {resource_group} --profile-name {profile_name}", checks=usage_checks)',
# one line docstring '''
' self.cmd("""afd profile usage -g {resource_group} --profile-name {profile_name}""", checks=usage_checks)',
# one line docstring """
' self.cmd("""afd profile usage -g {resource_group} --profile-name {profile_name}""", checks=usage_checks)',
# .format
' self.cmd("afd profile usage -g {} --profile-name {}".format(group, name)',
# %s
' self.cmd("afd profile usage -g %s --profile-name %s", group, name)',
# end with hashtag, should match.
' self.cmd(f"afd profile usage -g {resource_group} --profile-name {profile_name}", checks=usage_checks) # xxx',
# start with hashtag, shouldn't match.
' # self.cmd(f"afd profile usage -g {resource_group} --profile-name {profile_name}", checks=usage_checks)',
# start with blanks, match *_cmd = ''.
' stop_cmd = \'aks stop --resource-group={resource_group} --name={name}\'\n',
# start with blanks, match *_cmd = "".
' enable_cmd = "aks enable-addons --addons confcom --resource-group={resource_group} --name={name} -o json"\n',
# start with blanks, match *_cmd = f''.
' disable_cmd = f\'aks disable-addons --addons confcom --resource-group={resource_group} --name={name} -o json\'\n',
# start with blanks, match *_cmd = f"".
' browse_cmd = f"aks browse --resource-group={resource_group} --name={name} --listen-address=127.0.0.1 --listen-port=8080 --disable-browser"\n',
]
ref = get_all_tested_commands_from_regex(lines)
pprint(ref, width=1000)
assert len(ref) == 15
# multiple lines test
def test_multiple_lines_regex():
lines = [
# start with blanks, self.cmd, one cmd line, multiple checks.
' self.cmd(\'aks list -g {resource_group}\', checks=[\n',
' self.check(\'[0].type\', \'{resource_type}\'),\n',
' StringContainCheck(aks_name),\n',
' StringContainCheck(resource_group)\n',
' ])\n',
# start with blanks, self.cmd, multiple cmd lines, multiple checks.
' self.cmd(\'image builder create -n {tmpl_02} -g {rg} --identity {ide} --scripts {script} --image-source {img_src} --build-timeout 22\'\n',
' \' --managed-image-destinations img_1=westus \' + out_3,\n',
' checks=[\n',
' self.check(\'name\', \'{tmpl_02}\'), self.check(\'provisioningState\', \'Succeeded\'),\n',
' self.check(\'length(distribute)\', 2),\n',
' self.check(\'distribute[0].imageId\', \'/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/images/img_1\'),\n',
' self.check(\'distribute[0].location\', \'westus\'),\n',
' self.check(\'distribute[0].runOutputName\', \'img_1\'),\n',
' self.check(\'distribute[0].type\', \'ManagedImage\'),\n',
' self.check(\'distribute[1].imageId\', \'/subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Compute/images/img_2\'),\n',
' self.check(\'distribute[1].location\', \'centralus\'),\n',
' self.check(\'distribute[1].runOutputName\', \'img_2\'),\n',
' self.check(\'distribute[1].type\', \'ManagedImage\'),\n',
' self.check(\'buildTimeoutInMinutes\', 22)\n',
' ])\n',
# start with blanks, characters, self.cmd, but have line break at self.cmd(
' ipprefix_id = self.cmd(\n',
' \'az network public-ip prefix create -g {rg} -n {ipprefix_name} --location {location} --length 29\'). \\n',
' get_output_in_json().get("id")\n',
# start with blanks, match *_cmd = '', multiple lines.
' create_cmd = \'aks create -g {resource_group} -n {name} -p {dns_name_prefix} --ssh-key-value {ssh_key_value} \' \\n',
' \'-l {location} --service-principal {service_principal} --client-secret {client_secret} -k {k8s_version} \' \\n',
' \'--node-vm-size {vm_size} \' \\n',
' \'--tags scenario_test -c 1 --no-wait\'\n',
' update_cmd = \'aks update --resource-group={resource_group} --name={name} \' \\n',
' \'--aad-admin-group-object-ids 00000000-0000-0000-0000-000000000002 \' \\n',
' \'--aad-tenant-id 00000000-0000-0000-0000-000000000003 -o json\'\n',
' enable_autoscaler_cmd = \'aks update --resource-group={resource_group} --name={name} \' \\n',
' \'--tags {tags} --enable-cluster-autoscaler --min-count 2 --max-count 5\'\n',
' disable_autoscaler_cmd = \'aks update --resource-group={resource_group} --name={name} \' \\n',
' \'--tags {tags} --disable-cluster-autoscaler\'\n',
' create_spot_node_pool_cmd = \'aks nodepool add \' \\n',
' \'--resource-group={resource_group} \' \\n',
' \'--cluster-name={name} \' \\n',
' \'-n {spot_node_pool_name} \' \\n',
' \'--priority Spot \' \\n',
' \'--spot-max-price {spot_max_price} \' \\n',
' \'-c 1\'\n',
' create_ppg_node_pool_cmd = \'aks nodepool add \' \\n',
' \'--resource-group={resource_group} \' \\n',
' \'--cluster-name={name} \' \\n',
' \'-n {node_pool_name} \' \\n',
' \'--ppg={ppg} \'\n',
' upgrade_node_image_only_cluster_cmd = \'aks upgrade \' \\n',
' \'-g {resource_group} \' \\n',
' \'-n {name} \' \\n',
' \'--node-image-only \' \\n',
' \'--yes\'\n',
' upgrade_node_image_only_nodepool_cmd = \'aks nodepool upgrade \' \\n',
' \'--resource-group={resource_group} \' \\n',
' \'--cluster-name={name} \' \\n',
' \'-n {node_pool_name} \' \\n',
' \'--node-image-only \' \\n',
' \'--no-wait\'\n',
' get_nodepool_cmd = \'aks nodepool show \' \\n',
' \'--resource-group={resource_group} \' \\n',
' \'--cluster-name={name} \' \\n',
' \'-n {node_pool_name} \'\n',
# start with blanks, match *_cmd = '', multiple lines, use .format
' install_cmd = \'aks install-cli --client-version={} --install-location={} --base-src-url={} \' \\n',
' \'--kubelogin-version={} --kubelogin-install-location={} --kubelogin-base-src-url={}\'.format(version,\n',
' ctl_temp_file,\n',
' "",\n',
' version,\n',
' login_temp_file,\n',
' "")\n',
# start with blanks, match *_cmd, use docstring """
' create_cmd = """storage account create -n {sc} -g {rg} -l eastus2euap --enable-files-adds --domain-name\n',
' {domain_name} --net-bios-domain-name {net_bios_domain_name} --forest-name {forest_name} --domain-guid\n',
' {domain_guid} --domain-sid {domain_sid} --azure-storage-sid {azure_storage_sid}"""\n',
' update_cmd = """storage account update -n {sc} -g {rg} --enable-files-adds --domain-name {domain_name}\n',
' --net-bios-domain-name {net_bios_domain_name} --forest-name {forest_name} --domain-guid {domain_guid}\n',
' --domain-sid {domain_sid} --azure-storage-sid {azure_storage_sid}"""\n',
# start with blanks, match *_cmd, use docstring '''
' update_cmd = \'\'\'storage account update -n {sc} -g {rg} --enable-files-adds --domain-name {domain_name}\n',
' --net-bios-domain-name {net_bios_domain_name} --forest-name {forest_name} --domain-guid {domain_guid}\n',
' --domain-sid {domain_sid} --azure-storage-sid {azure_storage_sid}\'\'\'\n',
# start with blanks, match *_cmd*, .format
' create_cmd1 = \'az storage account create -n {} -g {} --routing-choice MicrosoftRouting --publish-microsoft-endpoint true\'.format('
' name1, resource_group)',
# start with blanks, match .cmd
' test.cmd(\'az billing account list \'',
' \'--expand "soldTo,billingProfiles,billingProfiles/invoiceSections"\',',
' checks=[])',
# start with blanks, match *Command
' runCommand = \'aks command invoke -g {resource_group} -n {name} -o json -c "kubectl get pods -A"\'',
' self.cmd(runCommand, [',
' self.check(\'provisioningState\', \'Succeeded\'),',
' self.check(\'exitCode\', 0),',
' ])',
# start with blanks, match *command, use fstring, single quotation marks
' command = f\'afd origin-group update -g {resource_group_name} --profile-name {profile_name} \' ',
' f\'--origin-group-name {origin_group_name}\'',
# string splicing (+)
' self.cmd(\'spring-cloud app deployment create -g {resourceGroup} -s {serviceName} --app {app} -n green\'\n',
' + \' --container-image {containerImage} --registry-username PLACEHOLDER --registry-password PLACEHOLDER\',\n',
' checks=[\n',
' self.check(\'name\', \'green\'),\n',
' ])\n',
# --format vs .format
' self.cmd(\n',
' \'appconfig kv import -n {config_store_name} -s {import_source} --path "{strict_import_file_path}" --format {imported_format} --profile {profile} --strict -y\')',
]
ref = get_all_tested_commands_from_regex(lines)
pprint(ref, width=1000)
assert len(ref) == 22
def test_detect_new_command():
commands = []
lines = [
'with self.command_group(\'disk\', compute_disk_sdk, operation_group=\'disks\', min_api=\'2017-03-30\') as g:',
# 1.`+ g.command(xxx)`
'+ g.command(\'list-instances\', \'list\', command_type=compute_vmss_vm_sdk)',
# 2.`+ g.custom_command(xxx)`
'+ g.custom_command(\'create\', \'create_managed_disk\', supports_no_wait=True, table_transformer=transform_disk_show_table_output, validator=process_disk_or_snapshot_create_namespace)',
# 3.`+ g.custom_show_command(xxx)`
'+ g.custom_show_command(\'show\', \'get_vmss\', table_transformer=get_vmss_table_output_transformer(self, False))',
# 4.`+ g.wait_command(xxx)`
'+ g.wait_command(\'wait\', getter_name=\'get_vmss\', getter_type=compute_custom)',
]
for row_num, line in enumerate(lines):
command = search_command(line)
if command:
cmd = search_command_group(row_num, lines, command)
if cmd:
commands.append(cmd)
pprint(commands)
assert commands == ['disk list-instances', 'disk create', 'disk show', 'disk wait']
def test_detect_new_params():
parameters = []
lines = [
# without scope
' with self.argument_context(\'disk\') as c:',
'+ c.argument(\'network_policy\')',
'+ c.argument(\'zone\', zone_type, min_api=\'2017-03-30\', options_list=[\'--zone\']) ',
# scope
' for scope in [\'disk\', \'snapshot\']:',
' with self.argument_context(scope) as c:',
'+ c.argument(\'size_gb\', options_list=[\'--size-gb\', \'-z\'], help=\'size in GB. Max size: 4095 GB (certain preview disks can be larger).\', type=int)',
# scope with multi args
' for scope in [\'signalr create\', \'signalr update\']:',
' with self.argument_context(scope, arg_group=\'Network Rule\') as c:',
'+ c.argument(\'default_action\', arg_type=get_enum_type([\'Allow\', \'Deny\']), help=\'Default action to apply when no rule matches.\', required=False)',
# scope AND format
' for scope in [\'create\', \'update\']:',
' with self.argument_context(\'vmss run-command {}\'.format(scope)) as c:',
'+ c.argument(\'vmss_name\', run_cmd_vmss_name)',
# scope AND format
' for scope in [\'vm\', \'vmss\']:',
' with self.argument_context(\'{} stop\'.format(scope)) as c:',
'+ c.argument(\'skip_shutdown\', action=\'store_true\', help=\'Skip shutdown and power-off immediately.\', min_api=\'2019-03-01\')',
# multiple `[]`
' with self.argument_context(\'acr connected-registry create\') as c:',
'+ c.argument(\'client_token_list\', options_list=[\'--client-tokens\'], nargs=\'+\', help=\'Specify the client access to the repositories in the connected registry. It can be in the format [TOKEN_NAME01] [TOKEN_NAME02]...\')',
'+ c.argument(\'notifications\', options_list=[\'--notifications\'], nargs=\'+\', help=\'List of artifact pattern for which notifications need to be generated. Use the format "--notifications [PATTERN1 PATTERN2 ...]".\')',
# multiple lines
' with self.argument_context(\'webapp update\') as c:',
'+ c.argument(\'skip_custom_domain_verification\',',
'+ help="If true, custom (non *.azurewebsites.net) domains associated with web app are not verified",',
'+ arg_type=get_three_state_flag(return_label=True), deprecate_info=c.deprecate(expiration=\'3.0.0\'))',
# options_list=[""] double quotes
' with self.argument_context(\'webapp update\') as c:',
'+ c.argument(\'minimum_elastic_instance_count\', options_list=["--minimum-elastic-instance-count", "-i"], type=int, is_preview=True, help="Minimum number of instances. App must be in an elastic scale App Service Plan.")',
'+ c.argument(\'prewarmed_instance_count\', options_list=["--prewarmed-instance-count", "-w"], type=int, is_preview=True, help="Number of preWarmed instances. App must be in an elastic scale App Service Plan.")',
# self.argument_context with multi args
' with self.argument_context(\'appconfig kv import\', arg_group=\'File\') as c:',
'+ c.argument(\'strict\', validator=validate_strict_import, arg_type=get_three_state_flag(), help="Delete all other key-values in the store with specified prefix and label", is_preview=True)',
' with self.argument_context(\'snapshot\', resource_type=ResourceType.MGMT_COMPUTE, operation_group=\'snapshots\') as c:',
'+ c.argument(\'snapshot_name\', existing_snapshot_name, id_part=\'name\', completer=get_resource_name_completion_list(\'Microsoft.Compute/snapshots\'))',
]
# pattern = r'\+\s+c.argument\((.*)\)?'
for row_num, line in enumerate(lines):
params, _ = search_argument(line)
if params:
cmds = search_argument_context(row_num, lines)
# print(cmds)
for cmd in cmds:
parameters.append([cmd, params])
continue
pprint(parameters)
assert parameters == [
['disk', ['--network-policy']],
['disk', ['--zone']],
['disk', ['--size-gb,', '-z']],
['snapshot', ['--size-gb,', '-z']],
['signalr create', ['--default-action']],
['signalr update', ['--default-action']],
['vmss run-command create', ['--vmss-name']],
['vmss run-command update', ['--vmss-name']],
['vm stop', ['--skip-shutdown']],
['vmss stop', ['--skip-shutdown']],
['acr connected-registry create', ['--client-tokens']],
['acr connected-registry create', ['--notifications']],
['webapp update', ['--skip-custom-domain-verification']],
['webapp update', ['--minimum-elastic-instance-count,', '-i']],
['webapp update', ['--prewarmed-instance-count,', '-w']],
['appconfig kv import', ['--strict']],
['snapshot', ['--snapshot-name']],
]
if __name__ == '__main__':
test_one_line_regex()
test_multiple_lines_regex()
test_detect_new_command()
test_detect_new_params()

Просмотреть файл

@ -83,7 +83,7 @@ def load_arguments(self, _):
with ArgumentsContext(self, 'linter') as c:
c.positional('modules', modules_type)
c.argument('rules', options_list=['--rules', '-r'], nargs='+', help='Space-separated list of rules to run. Omit to run all rules.')
c.argument('rule_types', options_list=['--rule-types', '-t'], nargs='+', choices=['params', 'commands', 'command_groups', 'help_entries'], help='Space-separated list of rule types to run. Omit to run all.')
c.argument('rule_types', options_list=['--rule-types', '-t'], nargs='+', choices=['params', 'commands', 'command_groups', 'help_entries', 'command_test_coverage'], help='Space-separated list of rule types to run. Omit to run all.')
c.argument('ci_exclusions', action='store_true', help='Force application of CI exclusions list when run locally.')
c.argument('include_whl_extensions',
action='store_true',
@ -113,7 +113,6 @@ def load_arguments(self, _):
with ArgumentsContext(self, 'statistics diff-command-tables') as c:
c.argument('table_path', help='command table json file')
c.argument('diff_table_path', help='command table json file to diff')
# endregion
with ArgumentsContext(self, 'command-change meta-export') as c:
@ -130,6 +129,12 @@ def load_arguments(self, _):
help='format to print diff and suggest message')
c.argument('output_file', help='command meta diff json file path to store')
# region cmdcov
with ArgumentsContext(self, 'cmdcov') as c:
c.positional('modules', modules_type)
c.argument('level', choices=['command', 'argument'], help='Run command test coverage in command level or argument level.')
# endregion
with ArgumentsContext(self, 'perf') as c:
c.argument('runs', type=int, help='Number of runs to average performance over.')

Просмотреть файл

@ -42,7 +42,8 @@ from .display import (
)
from .git_util import (
diff_branches,
filter_by_git_diff
filter_by_git_diff,
diff_branches_detail
)
from .path import (
extract_module_name,
@ -106,5 +107,6 @@ __all__ = [
'get_path_table',
'get_name_index',
'require_virtual_env',
'require_azure_cli'
'require_azure_cli',
'diff_branches_detail',
]

Просмотреть файл

@ -90,5 +90,40 @@ def diff_branches(repo, target, source):
logger.info('git --no-pager diff %s..%s --name-only -- .\n', target_commit, source_commit)
diff_index = target_commit.diff(source_commit)
return [diff.b_path for diff in diff_index]
def diff_branches_detail(repo, target, source):
""" Returns compare results of files that have changed in a given repo between two branches.
Only focus on these files: _params.py, commands.py, test_*.py """
try:
import git # pylint: disable=unused-import,unused-variable
import git.exc as git_exc
import gitdb
except ImportError as ex:
raise CLIError(ex)
from git import Repo
try:
git_repo = Repo(repo)
except (git_exc.NoSuchPathError, git_exc.InvalidGitRepositoryError):
raise CLIError('invalid git repo: {}'.format(repo))
def get_commit(branch):
try:
return git_repo.commit(branch)
except gitdb.exc.BadName:
raise CLIError('usage error, invalid branch: {}'.format(branch))
if source:
source_commit = get_commit(source)
else:
source_commit = git_repo.head.commit
target_commit = get_commit(target)
logger.info('Filtering down to modules which have changed based on:')
logger.info('cd %s', repo)
logger.info('git --no-pager diff %s..%s --name-only -- .\n', target_commit, source_commit)
diff_index = target_commit.diff(source_commit)
return diff_index

Просмотреть файл

@ -62,6 +62,7 @@ setup(
'azdev.operations.extensions',
'azdev.operations.statistics',
'azdev.operations.command_change',
'azdev.operations.cmdcov',
'azdev.utilities',
],
install_requires=[